Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/misc.ts
5738 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
export { get_start_time_ts, get_uptime, log, wrap_log } from "./log";
7
8
export * from "./misc-path";
9
10
import LRU from "lru-cache";
11
12
import {
13
is_array,
14
is_integer,
15
is_object,
16
is_string,
17
is_date,
18
is_set,
19
} from "./type-checking";
20
21
export { is_array, is_integer, is_object, is_string, is_date, is_set };
22
23
export {
24
map_limit,
25
map_max,
26
map_min,
27
sum,
28
is_zero_map,
29
map_without_undefined_and_null,
30
map_mutate_out_undefined_and_null,
31
} from "./maps";
32
33
export { done, done1, done2 } from "./done";
34
35
export {
36
cmp,
37
cmp_Date,
38
cmp_dayjs,
39
cmp_moment,
40
cmp_array,
41
timestamp_cmp,
42
field_cmp,
43
is_different,
44
is_different_array,
45
shallowCompare,
46
all_fields_equal,
47
} from "./cmp";
48
49
export {
50
server_time,
51
server_milliseconds_ago,
52
server_seconds_ago,
53
server_minutes_ago,
54
server_hours_ago,
55
server_days_ago,
56
server_weeks_ago,
57
server_months_ago,
58
milliseconds_before,
59
seconds_before,
60
minutes_before,
61
hours_before,
62
days_before,
63
weeks_before,
64
months_before,
65
expire_time,
66
YEAR,
67
} from "./relative-time";
68
69
import sha1 from "sha1";
70
export { sha1 };
71
72
function base16ToBase64(hex) {
73
return Buffer.from(hex, "hex").toString("base64");
74
// let bytes: number[] = [];
75
// for (let c = 0; c < hex.length; c += 2) {
76
// bytes.push(parseInt(hex.substr(c, 2), 16));
77
// }
78
// return btoa(String.fromCharCode.apply(null, bytes));
79
}
80
81
export function sha1base64(s) {
82
return base16ToBase64(sha1(s));
83
}
84
85
import * as lodash from "lodash";
86
import * as immutable from "immutable";
87
88
export const keys: (any) => string[] = lodash.keys;
89
90
import { required, defaults, types } from "./opts";
91
export { required, defaults, types };
92
93
interface SplittedPath {
94
head: string;
95
tail: string;
96
}
97
98
export function path_split(path: string): SplittedPath {
99
const v = path.split("/");
100
return { head: v.slice(0, -1).join("/"), tail: v[v.length - 1] };
101
}
102
103
// NOTE: as of right now, there is definitely some code somewhere
104
// in cocalc that calls this sometimes with s undefined, and
105
// typescript doesn't catch it, hence allowing s to be undefined.
106
export function capitalize(s?: string): string {
107
if (!s) return "";
108
return s.charAt(0).toUpperCase() + s.slice(1);
109
}
110
111
// turn an arbitrary string into a nice clean identifier that can safely be used in an URL
112
export function make_valid_name(s: string): string {
113
// for now we just delete anything that isn't alphanumeric.
114
// See http://stackoverflow.com/questions/9364400/remove-not-alphanumeric-characters-from-string-having-trouble-with-the-char/9364527#9364527
115
// whose existence surprised me!
116
return s.replace(/\W/g, "_").toLowerCase();
117
}
118
119
const filename_extension_re = /(?:\.([^.]+))?$/;
120
export function filename_extension(filename: string): string {
121
filename = path_split(filename).tail;
122
const match = filename_extension_re.exec(filename);
123
if (!match) {
124
return "";
125
}
126
const ext = match[1];
127
return ext ? ext : "";
128
}
129
130
export function filename_extension_notilde(filename: string): string {
131
let ext = filename_extension(filename);
132
while (ext && ext[ext.length - 1] === "~") {
133
// strip tildes from the end of the extension -- put there by rsync --backup, and other backup systems in UNIX.
134
ext = ext.slice(0, ext.length - 1);
135
}
136
return ext;
137
}
138
139
// If input name foo.bar, returns object {name:'foo', ext:'bar'}.
140
// If there is no . in input name, returns {name:name, ext:''}
141
export function separate_file_extension(name: string): {
142
name: string;
143
ext: string;
144
} {
145
const ext: string = filename_extension(name);
146
if (ext !== "") {
147
name = name.slice(0, name.length - ext.length - 1); // remove the ext and the .
148
}
149
return { name, ext };
150
}
151
152
// change the filename's extension to the new one.
153
// if there is no extension, add it.
154
export function change_filename_extension(
155
path: string,
156
new_ext: string,
157
): string {
158
const { name } = separate_file_extension(path);
159
return `${name}.${new_ext}`;
160
}
161
162
// Check if a filename contains characters that are problematic for LaTeX compilation.
163
// Returns true if the filename contains:
164
// - Two or more consecutive spaces (breaks LaTeX processing, see #3230)
165
// - Single quotes (breaks bash string interpolation in build commands)
166
export function is_bad_latex_filename(path: string): boolean {
167
return /\s\s+|'/.test(path);
168
}
169
170
// Takes parts to a path and intelligently merges them on '/'.
171
// Continuous non-'/' portions of each part will have at most
172
// one '/' on either side.
173
// Each part will have exactly one '/' between it and adjacent parts
174
// Does NOT resolve up-level references
175
// See misc-tests for examples.
176
export function normalized_path_join(...parts): string {
177
const sep = "/";
178
const replace = new RegExp(sep + "{1,}", "g");
179
const result: string[] = [];
180
for (let x of Array.from(parts)) {
181
if (x != null && `${x}`.length > 0) {
182
result.push(`${x}`);
183
}
184
}
185
return result.join(sep).replace(replace, sep);
186
}
187
188
// Like Python splitlines.
189
// WARNING -- this is actually NOT like Python splitlines, since it just deletes whitespace lines. TODO: audit usage and fix.
190
export function splitlines(s: string): string[] {
191
const r = s.match(/[^\r\n]+/g);
192
return r ? r : [];
193
}
194
195
// Like Python's string split -- splits on whitespace
196
export function split(s: string): string[] {
197
const r = s.match(/\S+/g);
198
if (r) {
199
return r;
200
} else {
201
return [];
202
}
203
}
204
205
// Modifies in place the object dest so that it
206
// includes all values in objs and returns dest.
207
// This is a *shallow* copy.
208
// Rightmost object overwrites left.
209
export function merge(dest, ...objs) {
210
for (const obj of objs) {
211
for (const k in obj) {
212
dest[k] = obj[k];
213
}
214
}
215
return dest;
216
}
217
218
// Makes new object that is *shallow* copy merge of all objects.
219
export function merge_copy(...objs): object {
220
return merge({}, ...Array.from(objs));
221
}
222
223
// copy of map but only with some keys
224
// I.e., restrict a function to a subset of the domain.
225
export function copy_with<T>(obj: T, w: string | string[]): Partial<T> {
226
if (typeof w === "string") {
227
w = [w];
228
}
229
const obj2: any = {};
230
let key: string;
231
for (key of w) {
232
const y = obj[key];
233
if (y !== undefined) {
234
obj2[key] = y;
235
}
236
}
237
return obj2;
238
}
239
240
// copy of map but without some keys
241
// I.e., restrict a function to the complement of a subset of the domain.
242
export function copy_without(obj: object, w: string | string[]): object {
243
if (typeof w === "string") {
244
w = [w];
245
}
246
const r = {};
247
for (let key in obj) {
248
const y = obj[key];
249
if (!Array.from(w).includes(key)) {
250
r[key] = y;
251
}
252
}
253
return r;
254
}
255
256
import { cloneDeep } from "lodash";
257
export const deep_copy = cloneDeep;
258
259
// Very poor man's set.
260
export function set(v: string[]): { [key: string]: true } {
261
const s: { [key: string]: true } = {};
262
for (const x of v) {
263
s[x] = true;
264
}
265
return s;
266
}
267
268
// see https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object/30042948#30042948
269
export function copy<T>(obj: T): T {
270
return lodash.clone(obj);
271
}
272
273
// startswith(s, x) is true if s starts with the string x or any of the strings in x.
274
// It is false if s is not a string.
275
export function startswith(s: any, x: string | string[]): boolean {
276
if (typeof s != "string") {
277
return false;
278
}
279
if (typeof x === "string") {
280
return s.startsWith(x);
281
}
282
for (const v of x) {
283
if (s.indexOf(v) === 0) {
284
return true;
285
}
286
}
287
return false;
288
}
289
290
export function endswith(s: any, t: any): boolean {
291
if (typeof s != "string" || typeof t != "string") {
292
return false;
293
}
294
return s.endsWith(t);
295
}
296
297
import { v4 as v4uuid } from "uuid";
298
export const uuid: () => string = v4uuid;
299
300
const uuid_regexp = new RegExp(
301
/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/i,
302
);
303
export function is_valid_uuid_string(uuid?: any): boolean {
304
return (
305
typeof uuid === "string" && uuid.length === 36 && uuid_regexp.test(uuid)
306
);
307
}
308
export function assert_valid_account_id(uuid?: any): void {
309
if (!is_valid_uuid_string(uuid)) {
310
throw new Error(`Invalid Account ID: ${uuid}`);
311
}
312
}
313
export const isValidUUID = is_valid_uuid_string;
314
315
// this should work for IP addresses, also short IPv6, and any UUIDs
316
export function isValidAnonymousID(id: unknown) {
317
return typeof id === "string" && id.length >= 3;
318
}
319
320
export function assertValidAccountID(account_id?: any) {
321
if (!isValidUUID(account_id)) {
322
throw Error("account_id is invalid");
323
}
324
}
325
326
export function assert_uuid(uuid: string): void {
327
if (!is_valid_uuid_string(uuid)) {
328
throw Error(`invalid uuid='${uuid}'`);
329
}
330
}
331
332
// Compute a uuid v4 from the Sha-1 hash of data.
333
// NOTE: If on backend, you should instead import
334
// the version in misc_node, which is faster.
335
export function uuidsha1(data: string): string {
336
const s = sha1(data);
337
let i = -1;
338
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
339
i += 1;
340
switch (c) {
341
case "x":
342
return s[i];
343
case "y":
344
// take 8 + low order 3 bits of hex number.
345
return ((parseInt(`0x${s[i]}`, 16) & 0x3) | 0x8).toString(16);
346
}
347
});
348
}
349
350
// returns the number of keys of an object, e.g., {a:5, b:7, d:'hello'} --> 3
351
export function len(obj: object | undefined | null): number {
352
if (obj == null) {
353
return 0;
354
}
355
return Object.keys(obj).length;
356
}
357
358
// Specific, easy to read: describe amount of time before right now
359
// Use negative input for after now (i.e., in the future).
360
export function milliseconds_ago(ms: number): Date {
361
return new Date(Date.now() - ms);
362
}
363
export function seconds_ago(s: number) {
364
return milliseconds_ago(1000 * s);
365
}
366
export function minutes_ago(m: number) {
367
return seconds_ago(60 * m);
368
}
369
export function hours_ago(h: number) {
370
return minutes_ago(60 * h);
371
}
372
export function days_ago(d: number) {
373
return hours_ago(24 * d);
374
}
375
export function weeks_ago(w: number) {
376
return days_ago(7 * w);
377
}
378
export function months_ago(m: number) {
379
return days_ago(30.5 * m);
380
}
381
382
// Here, we want to know how long ago a certain timestamp was
383
export function how_long_ago_ms(ts: Date | number): number {
384
const ts_ms = typeof ts === "number" ? ts : ts.getTime();
385
return Date.now() - ts_ms;
386
}
387
export function how_long_ago_s(ts: Date | number): number {
388
return how_long_ago_ms(ts) / 1000;
389
}
390
export function how_long_ago_m(ts: Date | number): number {
391
return how_long_ago_s(ts) / 60;
392
}
393
394
// Current time in milliseconds since epoch or t.
395
export function mswalltime(t?: number): number {
396
return Date.now() - (t ?? 0);
397
}
398
399
// Current time in seconds since epoch, as a floating point
400
// number (so much more precise than just seconds), or time
401
// since t.
402
export function walltime(t?: number): number {
403
return mswalltime() / 1000.0 - (t ?? 0);
404
}
405
406
// encode a UNIX path, which might have # and % in it.
407
// Maybe alternatively, (encodeURIComponent(p) for p in path.split('/')).join('/') ?
408
export function encode_path(path) {
409
// doesn't escape # and ?, since they are special for urls (but not unix paths)
410
path = encodeURI(path);
411
return path.replace(/#/g, "%23").replace(/\?/g, "%3F");
412
}
413
414
const reValidEmail = (function () {
415
const sQtext = "[^\\x0d\\x22\\x5c\\x80-\\xff]";
416
const sDtext = "[^\\x0d\\x5b-\\x5d\\x80-\\xff]";
417
const sAtom =
418
"[^\\x00-\\x20\\x22\\x28\\x29\\x2c\\x2e\\x3a-\\x3c\\x3e\\x40\\x5b-\\x5d\\x7f-\\xff]+";
419
const sQuotedPair = "\\x5c[\\x00-\\x7f]";
420
const sDomainLiteral = `\\x5b(${sDtext}|${sQuotedPair})*\\x5d`;
421
const sQuotedString = `\\x22(${sQtext}|${sQuotedPair})*\\x22`;
422
const sDomain_ref = sAtom;
423
const sSubDomain = `(${sDomain_ref}|${sDomainLiteral})`;
424
const sWord = `(${sAtom}|${sQuotedString})`;
425
const sDomain = sSubDomain + "(\\x2e" + sSubDomain + ")*";
426
const sLocalPart = sWord + "(\\x2e" + sWord + ")*";
427
const sAddrSpec = sLocalPart + "\\x40" + sDomain; // complete RFC822 email address spec
428
const sValidEmail = `^${sAddrSpec}$`; // as whole string
429
return new RegExp(sValidEmail);
430
})();
431
432
export function is_valid_email_address(email?: unknown): boolean {
433
// From http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
434
// but converted to Javascript; it's near the middle but claims to be exactly RFC822.
435
if (typeof email === "string" && reValidEmail.test(email)) {
436
return true;
437
} else {
438
return false;
439
}
440
}
441
442
export function assert_valid_email_address(email: string): void {
443
if (!is_valid_email_address(email)) {
444
throw Error(`Invalid email address: ${email}`);
445
}
446
}
447
448
export const to_json = JSON.stringify;
449
450
// gives the plural form of the word if the number should be plural
451
export function plural(
452
number: number = 0,
453
singular: string,
454
plural: string = `${singular}s`,
455
) {
456
if (["GB", "G", "MB"].includes(singular)) {
457
return singular;
458
}
459
if (number === 1) {
460
return singular;
461
} else {
462
return plural;
463
}
464
}
465
466
const ELLIPSIS = "…";
467
// "foobar" --> "foo…"
468
export function trunc<T>(
469
sArg: T,
470
max_length = 1024,
471
ellipsis = ELLIPSIS,
472
): string | T {
473
if (sArg == null) {
474
return sArg;
475
}
476
const s = typeof sArg !== "string" ? `${sArg}` : sArg;
477
if (s.length > max_length) {
478
if (max_length < 1) {
479
throw new Error("ValueError: max_length must be >= 1");
480
}
481
return s.slice(0, max_length - 1) + ellipsis;
482
} else {
483
return s;
484
}
485
}
486
487
// "foobar" --> "fo…ar"
488
export function trunc_middle<T>(
489
sArg: T,
490
max_length = 1024,
491
ellipsis = ELLIPSIS,
492
): T | string {
493
if (sArg == null) {
494
return sArg;
495
}
496
const s = typeof sArg !== "string" ? `${sArg}` : sArg;
497
if (s.length <= max_length) {
498
return s;
499
}
500
if (max_length < 1) {
501
throw new Error("ValueError: max_length must be >= 1");
502
}
503
const n = Math.floor(max_length / 2);
504
return (
505
s.slice(0, n - 1 + (max_length % 2 ? 1 : 0)) +
506
ellipsis +
507
s.slice(s.length - n)
508
);
509
}
510
511
// "foobar" --> "…bar"
512
export function trunc_left<T>(
513
sArg: T,
514
max_length = 1024,
515
ellipsis = ELLIPSIS,
516
): T | string {
517
if (sArg == null) {
518
return sArg;
519
}
520
const s = typeof sArg !== "string" ? `${sArg}` : sArg;
521
if (s.length > max_length) {
522
if (max_length < 1) {
523
throw new Error("ValueError: max_length must be >= 1");
524
}
525
return ellipsis + s.slice(s.length - max_length + 1);
526
} else {
527
return s;
528
}
529
}
530
531
/*
532
Like the immutable.js getIn, but on the thing x.
533
*/
534
535
export function getIn(x: any, path: string[], default_value?: any): any {
536
for (const key of path) {
537
if (x !== undefined) {
538
try {
539
x = x[key];
540
} catch (err) {
541
return default_value;
542
}
543
} else {
544
return default_value;
545
}
546
}
547
return x === undefined ? default_value : x;
548
}
549
550
// see http://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript
551
export function replace_all(
552
s: string,
553
search: string,
554
replace: string,
555
): string {
556
return s.split(search).join(replace);
557
}
558
559
// Similar to replace_all, except it takes as input a function replace_f, which
560
// returns what to replace the i-th copy of search in string with.
561
export function replace_all_function(
562
s: string,
563
search: string,
564
replace_f: (i: number) => string,
565
): string {
566
const v = s.split(search);
567
const w: string[] = [];
568
for (let i = 0; i < v.length; i++) {
569
w.push(v[i]);
570
if (i < v.length - 1) {
571
w.push(replace_f(i));
572
}
573
}
574
return w.join("");
575
}
576
577
export function path_to_title(path: string): string {
578
const subtitle = separate_file_extension(path_split(path).tail).name;
579
return capitalize(replace_all(replace_all(subtitle, "-", " "), "_", " "));
580
}
581
582
// names is a Set<string>
583
export function list_alternatives(names): string {
584
names = names.map((x) => x.toUpperCase()).toJS();
585
if (names.length == 1) {
586
return names[0];
587
} else if (names.length == 2) {
588
return `${names[0]} or ${names[1]}`;
589
}
590
return names.join(", ");
591
}
592
593
// convert x to a useful string to show to a user.
594
export function to_user_string(x: any): string {
595
switch (typeof x) {
596
case "undefined":
597
return "undefined";
598
case "number":
599
case "symbol":
600
case "boolean":
601
return x.toString();
602
case "function":
603
return x.toString();
604
case "object":
605
if (typeof x.toString !== "function") {
606
return JSON.stringify(x);
607
}
608
const a = x.toString(); // is much better than stringify for exceptions (etc.).
609
if (a === "[object Object]") {
610
return JSON.stringify(x);
611
} else {
612
return a;
613
}
614
default:
615
return JSON.stringify(x);
616
}
617
}
618
619
// delete any null fields, to avoid wasting space.
620
export function delete_null_fields(obj: object): void {
621
for (const k in obj) {
622
if (obj[k] == null) {
623
delete obj[k];
624
}
625
}
626
}
627
628
// for switch/case -- https://www.typescriptlang.org/docs/handbook/advanced-types.html
629
export function unreachable(x: never) {
630
// if this fails a typecheck here, go back to your switch/case.
631
// you either made a typo in one of the cases or you missed one.
632
const tmp: never = x;
633
tmp;
634
}
635
636
// Get *all* methods of an object (including from base classes!).
637
// See https://flaviocopes.com/how-to-list-object-methods-javascript/
638
// This is used by bind_methods below to bind all methods
639
// of an instance of an object, all the way up the
640
// prototype chain, just to be 100% sure!
641
function get_methods(obj: object): string[] {
642
let properties = new Set<string>();
643
let current_obj = obj;
644
do {
645
Object.getOwnPropertyNames(current_obj).map((item) => properties.add(item));
646
} while ((current_obj = Object.getPrototypeOf(current_obj)));
647
return [...properties.keys()].filter(
648
(item) => typeof obj[item] === "function",
649
);
650
}
651
652
// Bind all or specified methods of the object. If method_names
653
// is not given, binds **all** methods.
654
// For example, in a base class constructor, you can do
655
// bind_methods(this);
656
// and every method will always be bound even for derived classes
657
// (assuming they call super if they overload the constructor!).
658
// Do this for classes that don't get created in a tight inner
659
// loop and for which you want 'safer' semantics.
660
export function bind_methods<T extends object>(
661
obj: T,
662
method_names: undefined | string[] = undefined,
663
): T {
664
if (method_names === undefined) {
665
method_names = get_methods(obj);
666
method_names.splice(method_names.indexOf("constructor"), 1);
667
}
668
for (const method_name of method_names) {
669
obj[method_name] = obj[method_name].bind(obj);
670
}
671
return obj;
672
}
673
674
export function human_readable_size(
675
bytes: number | null | undefined,
676
short = false,
677
): string {
678
if (bytes == null) {
679
return "?";
680
}
681
if (bytes < 1000) {
682
return `${bytes} ${short ? "b" : "bytes"}`;
683
}
684
if (bytes < 1000000) {
685
const b = Math.floor(bytes / 100);
686
return `${b / 10} KB`;
687
}
688
if (bytes < 1000000000) {
689
const b = Math.floor(bytes / 100000);
690
return `${b / 10} MB`;
691
}
692
const b = Math.floor(bytes / 100000000);
693
return `${b / 10} GB`;
694
}
695
696
// Regexp used to test for URLs in a string.
697
// We just use a simple one that was a top Google search when I searched: https://www.regextester.com/93652
698
// We don't use a complicated one like https://www.npmjs.com/package/url-regex, since
699
// (1) it is heavy and doesn't work on Edge -- https://github.com/sagemathinc/cocalc/issues/4056
700
// (2) it's not bad if we are extra conservative. E.g., url-regex "matches the TLD against a list of valid TLDs."
701
// which is really overkill for preventing abuse, and is clearly more aimed at highlighting URL's
702
// properly (not our use case).
703
export const re_url =
704
/(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?/gi;
705
706
export function contains_url(str: string): boolean {
707
return !!str.toLowerCase().match(re_url);
708
}
709
710
export function hidden_meta_file(path: string, ext: string): string {
711
const p = path_split(path);
712
let head: string = p.head;
713
if (head !== "") {
714
head += "/";
715
}
716
return head + "." + p.tail + "." + ext;
717
}
718
719
export function history_path(path: string): string {
720
return hidden_meta_file(path, "time-travel");
721
}
722
723
export function meta_file(path: string, ext: string): string {
724
return hidden_meta_file(path, "sage-" + ext);
725
}
726
727
// helps with converting an array of strings to a union type of strings.
728
// usage: 1. const foo : string[] = tuple(["bar", "baz"]);
729
// 2. type Foo = typeof foo[number]; // bar | baz;
730
//
731
// NOTE: in newer TS versions, it's fine to define the string[] list with "as const", then step 2.
732
export function tuple<T extends string[]>(o: T) {
733
return o;
734
}
735
736
export function aux_file(path: string, ext: string): string {
737
const s = path_split(path);
738
s.tail += "." + ext;
739
if (s.head) {
740
return s.head + "/." + s.tail;
741
} else {
742
return "." + s.tail;
743
}
744
}
745
746
export function auxFileToOriginal(path: string): string {
747
const { head, tail } = path_split(path);
748
const i = tail.lastIndexOf(".");
749
const filename = tail.slice(1, i);
750
if (!head) {
751
return filename;
752
}
753
return head + "/" + filename;
754
}
755
756
/*
757
Generate a cryptographically safe secure random string with
758
16 characters chosen to be reasonably unambiguous to look at.
759
That is 93 bits of randomness, and there is an argument here
760
that 64 bits is enough:
761
762
https://security.stackexchange.com/questions/1952/how-long-should-a-random-nonce-be
763
*/
764
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
765
export function secure_random_token(
766
length: number = 16,
767
alphabet: string = BASE58, // default is this crypto base58 less ambiguous numbers/letters
768
): string {
769
let s = "";
770
if (length == 0) return s;
771
if (alphabet.length == 0) {
772
throw Error("impossible, since alphabet is empty");
773
}
774
const v = new Uint8Array(length);
775
globalThis.crypto.getRandomValues(v); // secure random numbers
776
for (const i of v) {
777
s += alphabet[i % alphabet.length];
778
}
779
return s;
780
}
781
782
// Return a random element of an array.
783
// If array has length 0 will return undefined.
784
export function random_choice(v: any[]): any {
785
return v[Math.floor(Math.random() * v.length)];
786
}
787
788
// Called when an object will not be used further, to avoid
789
// it references anything that could lead to memory leaks.
790
791
export function close(obj: object, omit?: Set<string>): void {
792
if (omit != null) {
793
Object.keys(obj).forEach(function (key) {
794
if (omit.has(key)) return;
795
if (typeof obj[key] == "function") return;
796
delete obj[key];
797
});
798
} else {
799
Object.keys(obj).forEach(function (key) {
800
if (typeof obj[key] == "function") return;
801
delete obj[key];
802
});
803
}
804
}
805
806
// return true if the word contains the substring
807
export function contains(word: string, sub: string): boolean {
808
return word.indexOf(sub) !== -1;
809
}
810
811
export function assertDefined<T>(val: T): asserts val is NonNullable<T> {
812
if (val === undefined || val === null) {
813
throw new Error(`Expected 'val' to be defined, but received ${val}`);
814
}
815
}
816
817
export function round1(num: number): number {
818
return Math.round(num * 10) / 10;
819
}
820
821
// Round given number to 2 decimal places
822
export function round2(num: number): number {
823
// padding to fix floating point issue (see http://stackoverflow.com/questions/11832914/round-to-at-most-2-decimal-places-in-javascript)
824
return Math.round((num + 0.00001) * 100) / 100;
825
}
826
827
export function round3(num: number): number {
828
return Math.round((num + 0.000001) * 1000) / 1000;
829
}
830
831
export function round4(num: number): number {
832
return Math.round((num + 0.0000001) * 10000) / 10000;
833
}
834
835
// Round given number up to 2 decimal places, for the
836
// purposes of dealing with money. We use toFixed to
837
// accomplish this, because we care about the decimal
838
// representation, not the exact internal binary number.
839
// Doing ' Math.ceil(num * 100) / 100', is wrong because
840
// e.g., numbers like 4.73 are not representable in binary, e.g.,
841
// > 4.73 = 100.101110101110000101000111101011100001010001111011... forever
842
export function round2up(num: number): number {
843
// This rounds the number to the closest 2-digit decimal representation.
844
// It can be LESS than num, e.g., (0.356).toFixed(2) == '0.36'
845
const rnd = parseFloat(num.toFixed(2));
846
if (rnd >= num) {
847
// it rounded up.
848
return rnd;
849
}
850
// It rounded down, so we add a penny to num first,
851
// to ensure that rounding is up.
852
return parseFloat((num + 0.01).toFixed(2));
853
}
854
855
// Round given number down to 2 decimal places, suitable for
856
// dealing with money.
857
export function round2down(num: number): number {
858
// This rounds the number to the closest 2-digit decimal representation.
859
// It can be LESS than num, e.g., (0.356).toFixed(2) == '0.36'
860
const rnd = parseFloat(num.toFixed(2));
861
if (rnd <= num) {
862
// it rounded down: good.
863
return rnd;
864
}
865
// It rounded up, so we subtract a penny to num first,
866
// to ensure that rounding is down.
867
return parseFloat((num - 0.01).toFixed(2));
868
}
869
870
// returns the number parsed from the input text, or undefined if invalid
871
// rounds to the nearest 0.01 if round_number is true (default : true)
872
// allows negative numbers if allow_negative is true (default : false)
873
export function parse_number_input(
874
input: any,
875
round_number: boolean = true,
876
allow_negative: boolean = false,
877
): number | undefined {
878
if (typeof input == "boolean") {
879
return input ? 1 : 0;
880
}
881
882
if (typeof input == "number") {
883
// easy to parse
884
if (!isFinite(input)) {
885
return;
886
}
887
if (!allow_negative && input < 0) {
888
return;
889
}
890
return input;
891
}
892
893
if (input == null || !input) return 0;
894
895
let val;
896
const v = `${input}`.split("/"); // fraction?
897
if (v.length !== 1 && v.length !== 2) {
898
return undefined;
899
}
900
if (v.length === 2) {
901
// a fraction
902
val = parseFloat(v[0]) / parseFloat(v[1]);
903
}
904
if (v.length === 1) {
905
val = parseFloat(v[0]);
906
if (isNaN(val) || v[0].trim() === "") {
907
// Shockingly, whitespace returns false for isNaN!
908
return undefined;
909
}
910
}
911
if (round_number) {
912
val = round2(val);
913
}
914
if (isNaN(val) || val === Infinity || (val < 0 && !allow_negative)) {
915
return undefined;
916
}
917
return val;
918
}
919
920
// MUTATE map by coercing each element of codomain to a number,
921
// with false->0 and true->1
922
// Non finite values coerce to 0.
923
// Also, returns map.
924
export function coerce_codomain_to_numbers(map: { [k: string]: any }): {
925
[k: string]: number;
926
} {
927
for (const k in map) {
928
const x = map[k];
929
if (typeof x === "boolean") {
930
map[k] = x ? 1 : 0;
931
} else {
932
try {
933
const t = parseFloat(x);
934
if (isFinite(t)) {
935
map[k] = t;
936
} else {
937
map[k] = 0;
938
}
939
} catch (_) {
940
map[k] = 0;
941
}
942
}
943
}
944
return map;
945
}
946
947
// arithmetic of maps with codomain numbers; missing values
948
// default to 0. Despite the typing being that codomains are
949
// all numbers, we coerce null values to 0 as well, and all codomain
950
// values to be numbers, since definitely some client code doesn't
951
// pass in properly typed inputs.
952
export function map_sum(
953
a?: { [k: string]: number },
954
b?: { [k: string]: number },
955
): { [k: string]: number } {
956
if (a == null) {
957
return coerce_codomain_to_numbers(b ?? {});
958
}
959
if (b == null) {
960
return coerce_codomain_to_numbers(a ?? {});
961
}
962
a = coerce_codomain_to_numbers(a);
963
b = coerce_codomain_to_numbers(b);
964
const c: { [k: string]: number } = {};
965
for (const k in a) {
966
c[k] = (a[k] ?? 0) + (b[k] ?? 0);
967
}
968
for (const k in b) {
969
if (c[k] == null) {
970
// anything in iteration above will be a number; also,
971
// we know a[k] is null, since it was definintely not
972
// iterated through above.
973
c[k] = b[k] ?? 0;
974
}
975
}
976
return c;
977
}
978
979
export function map_diff(
980
a?: { [k: string]: number },
981
b?: { [k: string]: number },
982
): { [k: string]: number } {
983
if (b == null) {
984
return coerce_codomain_to_numbers(a ?? {});
985
}
986
b = coerce_codomain_to_numbers(b);
987
const c: { [k: string]: number } = {};
988
if (a == null) {
989
for (const k in b) {
990
c[k] = -(b[k] ?? 0);
991
}
992
return c;
993
}
994
a = coerce_codomain_to_numbers(a);
995
for (const k in a) {
996
c[k] = (a[k] ?? 0) - (b[k] ?? 0);
997
}
998
for (const k in b) {
999
if (c[k] == null) {
1000
// anything in iteration above will be a number; also,
1001
// we know a[k] is null, since it was definintely not
1002
// iterated through above.
1003
c[k] = -(b[k] ?? 0);
1004
}
1005
}
1006
return c;
1007
}
1008
1009
// Like the split method, but quoted terms are grouped
1010
// together for an exact search. Terms that start and end in
1011
// a forward slash '/' are converted to regular expressions.
1012
export function search_split(
1013
search: string,
1014
allowRegexp: boolean = true,
1015
regexpOptions: string = "i",
1016
): (string | RegExp)[] {
1017
search = search.trim();
1018
if (
1019
allowRegexp &&
1020
search.length > 2 &&
1021
search[0] == "/" &&
1022
search[search.length - 1] == "/"
1023
) {
1024
// in case when entire search is clearly meant to be a regular expression,
1025
// we directly try for that first. This is one thing that is documented
1026
// to work regarding regular expressions, and a search like '/a b/' with
1027
// whitespace in it would work. That wouldn't work below unless you explicitly
1028
// put quotes around it.
1029
const t = stringOrRegExp(search, regexpOptions);
1030
if (typeof t != "string") {
1031
return [t];
1032
}
1033
}
1034
1035
// Now we split on whitespace, allowing for quotes, and get all the search
1036
// terms and possible regexps.
1037
const terms: (string | RegExp)[] = [];
1038
const v = search.split('"');
1039
const { length } = v;
1040
for (let i = 0; i < v.length; i++) {
1041
let element = v[i];
1042
element = element.trim();
1043
if (element.length == 0) continue;
1044
if (i % 2 === 0 || (i === length - 1 && length % 2 === 0)) {
1045
// The even elements lack quotation
1046
// if there are an even number of elements that means there is
1047
// an unclosed quote, so the last element shouldn't be grouped.
1048
for (const s of split(element)) {
1049
terms.push(allowRegexp ? stringOrRegExp(s, regexpOptions) : s);
1050
}
1051
} else {
1052
terms.push(
1053
allowRegexp ? stringOrRegExp(element, regexpOptions) : element,
1054
);
1055
}
1056
}
1057
return terms;
1058
}
1059
1060
// Convert a string that starts and ends in / to a regexp,
1061
// if it is a VALID regular expression. Otherwise, returns
1062
// string.
1063
function stringOrRegExp(s: string, options: string): string | RegExp {
1064
if (s.length < 2 || s[0] != "/" || s[s.length - 1] != "/")
1065
return s.toLowerCase();
1066
try {
1067
return new RegExp(s.slice(1, -1), options);
1068
} catch (_err) {
1069
// if there is an error, then we just use the string itself
1070
// in the search. We assume anybody using regexp's in a search
1071
// is reasonably sophisticated, so they don't need hand holding
1072
// error messages (CodeMirror doesn't give any indication when
1073
// a regexp is invalid).
1074
return s.toLowerCase();
1075
}
1076
}
1077
1078
function isMatch(s: string, x: string | RegExp): boolean {
1079
if (typeof x == "string") {
1080
if (x[0] == "-") {
1081
// negate
1082
if (x.length == 1) {
1083
// special case of empty -- no-op, since when you type -foo, you first type "-" and it
1084
// is disturbing for everything to immediately vanish.
1085
return true;
1086
}
1087
return !isMatch(s, x.slice(1));
1088
}
1089
if (x[0] === "#") {
1090
// only match hashtag at end of word (the \b), so #fo does not match #foo.
1091
return s.search(new RegExp(x + "\\b")) != -1;
1092
}
1093
return s.includes(x);
1094
} else {
1095
// regular expression instead of string
1096
return x.test?.(s);
1097
}
1098
return false;
1099
}
1100
1101
// s = lower case string
1102
// v = array of search terms as output by search_split above
1103
export function search_match(s: string, v: (string | RegExp)[]): boolean {
1104
if (typeof s != "string" || !is_array(v)) {
1105
// be safe against non Typescript clients
1106
return false;
1107
}
1108
s = s.toLowerCase();
1109
// we also make a version with no backslashes, since our markdown slate editor does a lot
1110
// of escaping, e.g., of dashes, and this is confusing when doing searches, e.g., see
1111
// https://github.com/sagemathinc/cocalc/issues/6915
1112
const s1 = s.replace(/\\/g, "");
1113
for (let x of v) {
1114
if (!isMatch(s, x) && !isMatch(s1, x)) return false;
1115
}
1116
// no term doesn't match, so we have a match.
1117
return true;
1118
}
1119
1120
export let RUNNING_IN_NODE: boolean;
1121
try {
1122
RUNNING_IN_NODE = process?.title == "node";
1123
} catch (_err) {
1124
// error since process probably not defined at all (unless there is a node polyfill).
1125
RUNNING_IN_NODE = false;
1126
}
1127
1128
/*
1129
The functions to_json_socket and from_json_socket are for sending JSON data back
1130
and forth in serialized form over a socket connection. They replace Date objects by the
1131
object {DateEpochMS:ms_since_epoch} *only* during transit. This is much better than
1132
converting to ISO, then using a regexp, since then all kinds of strings will get
1133
converted that were never meant to be date objects at all, e.g., a filename that is
1134
a ISO time string. Also, ms since epoch is less ambiguous regarding old/different
1135
browsers, and more compact.
1136
1137
If you change SOCKET_DATE_KEY, then all clients and servers and projects must be
1138
simultaneously restarted. And yes, I perhaps wish I had made this key more obfuscated.
1139
That said, we also check the object length when translating back so only objects
1140
exactly of the form {DateEpochMS:value} get transformed to a date.
1141
*/
1142
const SOCKET_DATE_KEY = "DateEpochMS";
1143
1144
function socket_date_replacer(key: string, value: any): any {
1145
// @ts-ignore
1146
const x = this[key];
1147
return x instanceof Date ? { [SOCKET_DATE_KEY]: x.valueOf() } : value;
1148
}
1149
1150
export function to_json_socket(x: any): string {
1151
return JSON.stringify(x, socket_date_replacer);
1152
}
1153
1154
function socket_date_parser(_key: string, value: any): any {
1155
const x = value?.[SOCKET_DATE_KEY];
1156
return x != null && len(value) == 1 ? new Date(x) : value;
1157
}
1158
1159
export function from_json_socket(x: string): any {
1160
try {
1161
return JSON.parse(x, socket_date_parser);
1162
} catch (err) {
1163
console.debug(`from_json: error parsing ${x} (=${to_json(x)}) from JSON`);
1164
throw err;
1165
}
1166
}
1167
1168
// convert object x to a JSON string, removing any keys that have "pass" in them and
1169
// any values that are potentially big -- this is meant to only be used for logging.
1170
export function to_safe_str(x: any): string {
1171
if (typeof x === "string") {
1172
// nothing we can do at this point -- already a string.
1173
return x;
1174
}
1175
const obj = {};
1176
for (const key in x) {
1177
let value = x[key];
1178
let sanitize = false;
1179
1180
if (
1181
key.indexOf("pass") !== -1 ||
1182
key.indexOf("token") !== -1 ||
1183
key.indexOf("secret") !== -1
1184
) {
1185
sanitize = true;
1186
} else if (typeof value === "string" && value.slice(0, 7) === "sha512$") {
1187
sanitize = true;
1188
}
1189
1190
if (sanitize) {
1191
obj[key] = "(unsafe)";
1192
} else {
1193
if (typeof value === "object") {
1194
value = "[object]"; // many objects, e.g., buffers can block for seconds to JSON...
1195
} else if (typeof value === "string") {
1196
value = trunc(value, 1000); // long strings are not SAFE -- since JSON'ing them for logging blocks for seconds!
1197
}
1198
obj[key] = value;
1199
}
1200
}
1201
1202
return JSON.stringify(obj);
1203
}
1204
1205
// convert from a JSON string to Javascript (properly dealing with ISO dates)
1206
// e.g., 2016-12-12T02:12:03.239Z and 2016-12-12T02:02:53.358752
1207
const reISO =
1208
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*))(?:Z|(\+|-)([\d|:]*))?$/;
1209
export function date_parser(_key: string | undefined, value: any) {
1210
if (typeof value === "string" && value.length >= 20 && reISO.exec(value)) {
1211
return ISO_to_Date(value);
1212
} else {
1213
return value;
1214
}
1215
}
1216
1217
export function ISO_to_Date(s: string): Date {
1218
if (s.indexOf("Z") === -1) {
1219
// Firefox assumes local time rather than UTC if there is no Z. However,
1220
// our backend might possibly send a timestamp with no Z and it should be
1221
// interpretted as UTC anyways.
1222
// That said, with the to_json_socket/from_json_socket code, the browser
1223
// shouldn't be running this parser anyways.
1224
// In particular: TODO -- completely get rid of using this in from_json... if possible!
1225
s += "Z";
1226
}
1227
return new Date(s);
1228
}
1229
1230
export function from_json(x: string): any {
1231
try {
1232
return JSON.parse(x, date_parser);
1233
} catch (err) {
1234
console.debug(`from_json: error parsing ${x} (=${to_json(x)}) from JSON`);
1235
throw err;
1236
}
1237
}
1238
1239
// Returns modified version of obj with any string
1240
// that look like ISO dates to actual Date objects. This mutates
1241
// obj in place as part of the process.
1242
// date_keys = 'all' or list of keys in nested object whose values
1243
// should be considered. Nothing else is considered!
1244
export function fix_json_dates(obj: any, date_keys?: "all" | string[]) {
1245
if (date_keys == null) {
1246
// nothing to do
1247
return obj;
1248
}
1249
if (is_object(obj)) {
1250
for (let k in obj) {
1251
const v = obj[k];
1252
if (typeof v === "object") {
1253
fix_json_dates(v, date_keys);
1254
} else if (
1255
typeof v === "string" &&
1256
v.length >= 20 &&
1257
reISO.exec(v) &&
1258
(date_keys === "all" || Array.from(date_keys).includes(k))
1259
) {
1260
obj[k] = new Date(v);
1261
}
1262
}
1263
} else if (is_array(obj)) {
1264
for (let i in obj) {
1265
const x = obj[i];
1266
obj[i] = fix_json_dates(x, date_keys);
1267
}
1268
} else if (
1269
typeof obj === "string" &&
1270
obj.length >= 20 &&
1271
reISO.exec(obj) &&
1272
date_keys === "all"
1273
) {
1274
return new Date(obj);
1275
}
1276
return obj;
1277
}
1278
1279
// converts a Date object to an ISO string in UTC.
1280
// NOTE -- we remove the +0000 (or whatever) timezone offset, since *all* machines within
1281
// the CoCalc servers are assumed to be on UTC.
1282
function to_iso(d: Date): string {
1283
return new Date(d.valueOf() - d.getTimezoneOffset() * 60 * 1000)
1284
.toISOString()
1285
.slice(0, -5);
1286
}
1287
1288
// turns a Date object into a more human readable more friendly directory name in the local timezone
1289
export function to_iso_path(d: Date): string {
1290
return to_iso(d).replace("T", "-").replace(/:/g, "");
1291
}
1292
1293
// does the given object (first arg) have the given key (second arg)?
1294
export const has_key: (obj: object, path: string[] | string) => boolean =
1295
lodash.has;
1296
1297
// returns the values of a map
1298
export const values = lodash.values;
1299
1300
// as in python, makes a map from an array of pairs [(x,y),(z,w)] --> {x:y, z:w}
1301
export function dict(v: [string, any][]): { [key: string]: any } {
1302
const obj: { [key: string]: any } = {};
1303
for (let a of Array.from(v)) {
1304
if (a.length !== 2) {
1305
throw new Error("ValueError: unexpected length of tuple");
1306
}
1307
obj[a[0]] = a[1];
1308
}
1309
return obj;
1310
}
1311
1312
// remove first occurrence of value (just like in python);
1313
// throws an exception if val not in list.
1314
// mutates arr.
1315
export function remove(arr: any[], val: any): void {
1316
for (
1317
let i = 0, end = arr.length, asc = 0 <= end;
1318
asc ? i < end : i > end;
1319
asc ? i++ : i--
1320
) {
1321
if (arr[i] === val) {
1322
arr.splice(i, 1);
1323
return;
1324
}
1325
}
1326
throw new Error("ValueError -- item not in array");
1327
}
1328
1329
export const max: (x: any[]) => any = lodash.max;
1330
export const min: (x: any[]) => any = lodash.min;
1331
1332
// Takes a path string and file name and gives the full path to the file
1333
export function path_to_file(path: string = "", file: string): string {
1334
if (path === "") {
1335
return file;
1336
}
1337
return path + "/" + file;
1338
}
1339
1340
// Given a path of the form foo/bar/.baz.ext.something returns foo/bar/baz.ext.
1341
// For example:
1342
// .example.ipynb.sage-jupyter --> example.ipynb
1343
// tmp/.example.ipynb.sage-jupyter --> tmp/example.ipynb
1344
// .foo.txt.sage-chat --> foo.txt
1345
// tmp/.foo.txt.sage-chat --> tmp/foo.txt
1346
1347
export function original_path(path: string): string {
1348
const s = path_split(path);
1349
if (s.tail[0] != "." || s.tail.indexOf(".sage-") == -1) {
1350
return path;
1351
}
1352
const ext = filename_extension(s.tail);
1353
let x = s.tail.slice(
1354
s.tail[0] === "." ? 1 : 0,
1355
s.tail.length - (ext.length + 1),
1356
);
1357
if (s.head !== "") {
1358
x = s.head + "/" + x;
1359
}
1360
return x;
1361
}
1362
1363
export function lower_email_address(email_address: any): string {
1364
if (email_address == null) {
1365
return "";
1366
}
1367
if (typeof email_address !== "string") {
1368
// silly, but we assume it is a string, and I'm concerned
1369
// about an attack involving badly formed messages
1370
email_address = JSON.stringify(email_address);
1371
}
1372
// make email address lower case
1373
return email_address.toLowerCase();
1374
}
1375
1376
// Parses a string representing a search of users by email or non-email
1377
// Expects the string to be delimited by commas or semicolons
1378
// between multiple users
1379
//
1380
// Non-email strings are ones without an '@' and will be split on whitespace
1381
//
1382
// Emails may be wrapped by angle brackets.
1383
// ie. <[email protected]> is valid and understood as [email protected]
1384
// (Note that <<[email protected]> will be <[email protected] which is not valid)
1385
// Emails must be legal as specified by RFC822
1386
//
1387
// returns an object with the queries in lowercase
1388
// eg.
1389
// {
1390
// string_queries: [["firstname", "lastname"], ["somestring"]]
1391
// email_queries: ["[email protected]", "[email protected]"]
1392
// }
1393
export function parse_user_search(query: string): {
1394
string_queries: string[][];
1395
email_queries: string[];
1396
} {
1397
const r = { string_queries: [] as string[][], email_queries: [] as string[] };
1398
if (typeof query !== "string") {
1399
// robustness against bad input from non-TS client.
1400
return r;
1401
}
1402
const queries = query
1403
.split("\n")
1404
.map((q1) => q1.split(/,|;/))
1405
.reduce((acc, val) => acc.concat(val), []) // flatten
1406
.map((q) => q.trim().toLowerCase());
1407
const email_re = /<(.*)>/;
1408
for (const x of queries) {
1409
if (x) {
1410
if (x.indexOf("@") === -1 || x.startsWith("@")) {
1411
// Is obviously not an email, e.g., no @ or starts with @ = username, e.g., @wstein.
1412
r.string_queries.push(x.split(/\s+/g));
1413
} else {
1414
// Might be an email address:
1415
// extract just the email address out
1416
for (let a of split(x)) {
1417
// Ensures that we don't throw away emails like
1418
// "<validEmail>"[email protected]
1419
if (a[0] === "<") {
1420
const match = email_re.exec(a);
1421
a = match != null ? match[1] : a;
1422
}
1423
if (is_valid_email_address(a)) {
1424
r.email_queries.push(a);
1425
}
1426
}
1427
}
1428
}
1429
}
1430
return r;
1431
}
1432
1433
// Delete trailing whitespace in the string s.
1434
export function delete_trailing_whitespace(s: string): string {
1435
return s.replace(/[^\S\n]+$/gm, "");
1436
}
1437
1438
export function retry_until_success(opts: {
1439
f: Function;
1440
start_delay?: number;
1441
max_delay?: number;
1442
factor?: number;
1443
max_tries?: number;
1444
max_time?: number;
1445
log?: Function;
1446
warn?: Function;
1447
name?: string;
1448
cb?: Function;
1449
}): void {
1450
let start_time;
1451
opts = defaults(opts, {
1452
f: required, // f((err) => )
1453
start_delay: 100, // milliseconds
1454
max_delay: 20000, // milliseconds -- stop increasing time at this point
1455
factor: 1.4, // multiply delay by this each time
1456
max_tries: undefined, // maximum number of times to call f
1457
max_time: undefined, // milliseconds -- don't call f again if the call would start after this much time from first call
1458
log: undefined,
1459
warn: undefined,
1460
name: "",
1461
cb: undefined, // called with cb() on *success*; cb(error, last_error) if max_tries is exceeded
1462
});
1463
let delta = opts.start_delay as number;
1464
let tries = 0;
1465
if (opts.max_time != null) {
1466
start_time = new Date();
1467
}
1468
const g = function () {
1469
tries += 1;
1470
if (opts.log != null) {
1471
if (opts.max_tries != null) {
1472
opts.log(
1473
`retry_until_success(${opts.name}) -- try ${tries}/${opts.max_tries}`,
1474
);
1475
}
1476
if (opts.max_time != null) {
1477
opts.log(
1478
`retry_until_success(${opts.name}) -- try ${tries} (started ${
1479
Date.now() - start_time
1480
}ms ago; will stop before ${opts.max_time}ms max time)`,
1481
);
1482
}
1483
if (opts.max_tries == null && opts.max_time == null) {
1484
opts.log(`retry_until_success(${opts.name}) -- try ${tries}`);
1485
}
1486
}
1487
opts.f(function (err) {
1488
if (err) {
1489
if (err === "not_public") {
1490
opts.cb?.("not_public");
1491
return;
1492
}
1493
if (err && opts.warn != null) {
1494
opts.warn(`retry_until_success(${opts.name}) -- err=${err}`);
1495
}
1496
if (opts.log != null) {
1497
opts.log(`retry_until_success(${opts.name}) -- err=${err}`);
1498
}
1499
if (opts.max_tries != null && opts.max_tries <= tries) {
1500
opts.cb?.(
1501
`maximum tries (=${opts.max_tries}) exceeded - last error ${err}`,
1502
err,
1503
);
1504
return;
1505
}
1506
delta = Math.min(
1507
opts.max_delay as number,
1508
(opts.factor as number) * delta,
1509
);
1510
if (
1511
opts.max_time != null &&
1512
Date.now() - start_time + delta > opts.max_time
1513
) {
1514
opts.cb?.(
1515
`maximum time (=${opts.max_time}ms) exceeded - last error ${err}`,
1516
err,
1517
);
1518
return;
1519
}
1520
return setTimeout(g, delta);
1521
} else {
1522
if (opts.log != null) {
1523
opts.log(`retry_until_success(${opts.name}) -- success`);
1524
}
1525
opts.cb?.();
1526
}
1527
});
1528
};
1529
g();
1530
}
1531
1532
// Class to use for mapping a collection of strings to characters (e.g., for use with diff/patch/match).
1533
export class StringCharMapping {
1534
private _to_char: { [s: string]: string } = {};
1535
private _next_char: string = "A";
1536
public _to_string: { [s: string]: string } = {}; // yes, this is publicly accessed (TODO: fix)
1537
1538
constructor(opts?) {
1539
let ch, st;
1540
this.find_next_char = this.find_next_char.bind(this);
1541
this.to_string = this.to_string.bind(this);
1542
this.to_array = this.to_array.bind(this);
1543
if (opts == null) {
1544
opts = {};
1545
}
1546
opts = defaults(opts, {
1547
to_char: undefined,
1548
to_string: undefined,
1549
});
1550
if (opts.to_string != null) {
1551
for (ch in opts.to_string) {
1552
st = opts.to_string[ch];
1553
this._to_string[ch] = st;
1554
this._to_char[st] = ch;
1555
}
1556
}
1557
if (opts.to_char != null) {
1558
for (st in opts.to_char) {
1559
ch = opts.to_char[st];
1560
this._to_string[ch] = st;
1561
this._to_char[st] = ch;
1562
}
1563
}
1564
this.find_next_char();
1565
}
1566
1567
private find_next_char(): void {
1568
while (true) {
1569
this._next_char = String.fromCharCode(this._next_char.charCodeAt(0) + 1);
1570
if (this._to_string[this._next_char] == null) {
1571
// found it!
1572
break;
1573
}
1574
}
1575
}
1576
1577
public to_string(strings: string[]): string {
1578
let t = "";
1579
for (const s of strings) {
1580
const a = this._to_char[s];
1581
if (a != null) {
1582
t += a;
1583
} else {
1584
t += this._next_char;
1585
this._to_char[s] = this._next_char;
1586
this._to_string[this._next_char] = s;
1587
this.find_next_char();
1588
}
1589
}
1590
return t;
1591
}
1592
1593
public to_array(x: string): string[] {
1594
return Array.from(x).map((s) => this.to_string[s]);
1595
}
1596
1597
// for testing
1598
public _debug_get_to_char() {
1599
return this._to_char;
1600
}
1601
public _debug_get_next_char() {
1602
return this._next_char;
1603
}
1604
}
1605
1606
// Used in the database, etc., for different types of users of a project
1607
export const PROJECT_GROUPS: string[] = [
1608
"owner",
1609
"collaborator",
1610
"viewer",
1611
"invited_collaborator",
1612
"invited_viewer",
1613
];
1614
1615
// format is 2014-04-04-061502
1616
export function parse_bup_timestamp(s: string): Date {
1617
const v = [
1618
s.slice(0, 4),
1619
s.slice(5, 7),
1620
s.slice(8, 10),
1621
s.slice(11, 13),
1622
s.slice(13, 15),
1623
s.slice(15, 17),
1624
"0",
1625
];
1626
return new Date(`${v[1]}/${v[2]}/${v[0]} ${v[3]}:${v[4]}:${v[5]} UTC`);
1627
}
1628
1629
// NOTE: this hash works, but the crypto hashes in nodejs, eg.,
1630
// sha1 (as used here packages/backend/sha1.ts) are MUCH faster
1631
// for large strings. If there is some way to switch to one of those,
1632
// it would be better, but we have to worry about how this is already deployed
1633
// e.g., hashes in the database.
1634
export function hash_string(s: string): number {
1635
if (typeof s != "string") {
1636
return 0; // just in case non-typescript code tries to use this
1637
}
1638
// see http://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery
1639
let hash = 0;
1640
if (s.length === 0) {
1641
return hash;
1642
}
1643
const n = s.length;
1644
for (let i = 0; i < n; i++) {
1645
const chr = s.charCodeAt(i);
1646
hash = (hash << 5) - hash + chr;
1647
hash |= 0; // convert to 32-bit integer
1648
}
1649
return hash;
1650
}
1651
1652
export function parse_hashtags(t?: string): [number, number][] {
1653
// return list of pairs (i,j) such that t.slice(i,j) is a hashtag (starting with #).
1654
const v: [number, number][] = [];
1655
if (typeof t != "string") {
1656
// in case of non-Typescript user
1657
return v;
1658
}
1659
let base = 0;
1660
while (true) {
1661
let i: number = t.indexOf("#");
1662
if (i === -1 || i === t.length - 1) {
1663
return v;
1664
}
1665
base += i + 1;
1666
if (t[i + 1] === "#" || !(i === 0 || t[i - 1].match(/\s/))) {
1667
t = t.slice(i + 1);
1668
continue;
1669
}
1670
t = t.slice(i + 1);
1671
// find next whitespace or non-alphanumeric or dash
1672
// TODO: this lines means hashtags must be US ASCII --
1673
// see http://stackoverflow.com/questions/1661197/valid-characters-for-javascript-variable-names
1674
const m = t.match(/\s|[^A-Za-z0-9_\-]/);
1675
if (m && m.index != null) {
1676
i = m.index;
1677
} else {
1678
i = -1;
1679
}
1680
if (i === 0) {
1681
// hash followed immediately by whitespace -- markdown desc
1682
base += i + 1;
1683
t = t.slice(i + 1);
1684
} else {
1685
// a hash tag
1686
if (i === -1) {
1687
// to the end
1688
v.push([base - 1, base + t.length]);
1689
return v;
1690
} else {
1691
v.push([base - 1, base + i]);
1692
base += i + 1;
1693
t = t.slice(i + 1);
1694
}
1695
}
1696
}
1697
}
1698
1699
// Return true if (1) path is contained in one
1700
// of the given paths (a list of strings) -- or path without
1701
// zip extension is in paths.
1702
// Always returns false if path is undefined/null (since
1703
// that might be dangerous, right)?
1704
export function path_is_in_public_paths(
1705
path: string | undefined | null,
1706
paths: string[] | Set<string> | object | undefined | null,
1707
): boolean {
1708
return containing_public_path(path, paths) != null;
1709
}
1710
1711
// returns a string in paths if path is public because of that string
1712
// Otherwise, returns undefined.
1713
// IMPORTANT: a possible returned string is "", which is falsey but defined!
1714
// paths can be an array or object (with keys the paths) or a Set
1715
export function containing_public_path(
1716
path: string | undefined | null,
1717
paths: string[] | Set<string> | object | undefined | null,
1718
): undefined | string {
1719
if (paths == null || path == null) {
1720
// just in case of non-typescript clients
1721
return;
1722
}
1723
if (path.indexOf("../") !== -1) {
1724
// just deny any potentially trickiery involving relative
1725
// path segments (TODO: maybe too restrictive?)
1726
return;
1727
}
1728
if (is_array(paths) || is_set(paths)) {
1729
// array so "of"
1730
// @ts-ignore
1731
for (const p of paths) {
1732
if (p == null) continue; // the typescript typings evidently aren't always exactly right
1733
if (p === "") {
1734
// the whole project is public, which matches everything
1735
return "";
1736
}
1737
if (path === p) {
1738
// exact match
1739
return p;
1740
}
1741
if (path.slice(0, p.length + 1) === p + "/") {
1742
return p;
1743
}
1744
}
1745
} else if (is_object(paths)) {
1746
for (const p in paths) {
1747
// object and want keys, so *of*
1748
if (p === "") {
1749
// the whole project is public, which matches everything
1750
return "";
1751
}
1752
if (path === p) {
1753
// exact match
1754
return p;
1755
}
1756
if (path.slice(0, p.length + 1) === p + "/") {
1757
return p;
1758
}
1759
}
1760
} else {
1761
throw Error("paths must be undefined, an array, or a map");
1762
}
1763
if (filename_extension(path) === "zip") {
1764
// is path something_public.zip ?
1765
return containing_public_path(path.slice(0, path.length - 4), paths);
1766
}
1767
return undefined;
1768
}
1769
1770
export const is_equal = lodash.isEqual;
1771
1772
export function is_whitespace(s?: string): boolean {
1773
return (s?.trim().length ?? 0) == 0;
1774
}
1775
1776
export function lstrip(s: string): string {
1777
return s.replace(/^\s*/g, "");
1778
}
1779
1780
export function date_to_snapshot_format(
1781
d: Date | undefined | null | number,
1782
): string {
1783
if (d == null) {
1784
d = 0;
1785
}
1786
if (typeof d === "number") {
1787
d = new Date(d);
1788
}
1789
let s = d.toJSON();
1790
s = s.replace("T", "-").replace(/:/g, "");
1791
const i = s.lastIndexOf(".");
1792
return s.slice(0, i);
1793
}
1794
1795
export function stripeDate(d: number): string {
1796
// https://github.com/sagemathinc/cocalc/issues/3254
1797
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_negotiation
1798
return new Date(d * 1000).toLocaleDateString(undefined, {
1799
year: "numeric",
1800
month: "long",
1801
day: "numeric",
1802
});
1803
}
1804
1805
export function to_money(n: number, d = 2): string {
1806
// see http://stackoverflow.com/questions/149055/how-can-i-format-numbers-as-money-in-javascript
1807
// TODO: replace by using react-intl...
1808
return n.toFixed(d).replace(/(\d)(?=(\d{3})+\.)/g, "$1,");
1809
}
1810
1811
// numbers with commas -- https://stackoverflow.com/questions/2901102/how-to-format-a-number-with-commas-as-thousands-separators
1812
export function commas(n: number): string {
1813
if (n == null) {
1814
// in case of bugs, at least fail with empty in prod
1815
return "";
1816
}
1817
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1818
}
1819
1820
// Display currency with a dollar sign, rounded to *nearest*.
1821
// If d is not given and n is less than 1 cent, will show 3 digits
1822
// instead of 2.
1823
export function currency(n: number, d?: number) {
1824
if (n == 0) {
1825
return `$0.00`;
1826
}
1827
let s = `$${to_money(Math.abs(n) ?? 0, d ?? (Math.abs(n) < 0.0095 ? 3 : 2))}`;
1828
if (n < 0) {
1829
s = `-${s}`;
1830
}
1831
if (d == null || d <= 2) {
1832
return s;
1833
}
1834
// strip excessive 0's off the end
1835
const i = s.indexOf(".");
1836
while (s[s.length - 1] == "0" && i <= s.length - (d ?? 2)) {
1837
s = s.slice(0, s.length - 1);
1838
}
1839
return s;
1840
}
1841
1842
export function stripeAmount(
1843
unitPrice: number,
1844
currency: string,
1845
units = 1,
1846
): string {
1847
// input is in pennies
1848
if (currency !== "usd") {
1849
// TODO: need to make this look nice with symbols for other currencies...
1850
return `${currency == "eur" ? "€" : ""}${to_money(
1851
(units * unitPrice) / 100,
1852
)} ${currency.toUpperCase()}`;
1853
}
1854
return `$${to_money((units * unitPrice) / 100)} USD`;
1855
}
1856
1857
export function planInterval(
1858
interval: string,
1859
interval_count: number = 1,
1860
): string {
1861
return `${interval_count} ${plural(interval_count, interval)}`;
1862
}
1863
1864
// get a subarray of all values between the two given values inclusive,
1865
// provided in either order
1866
export function get_array_range(arr: any[], value1: any, value2: any): any[] {
1867
let index1 = arr.indexOf(value1);
1868
let index2 = arr.indexOf(value2);
1869
if (index1 > index2) {
1870
[index1, index2] = [index2, index1];
1871
}
1872
return arr.slice(index1, +index2 + 1 || undefined);
1873
}
1874
1875
function seconds2hms_years(
1876
y: number,
1877
d: number,
1878
h: number,
1879
m: number,
1880
s: number,
1881
longform: boolean,
1882
show_seconds: boolean,
1883
show_minutes: boolean = true,
1884
): string {
1885
// Get remaining days after years
1886
const remaining_days = d % 365;
1887
1888
// When show_minutes is false, show only years and days
1889
if (!show_minutes) {
1890
if (remaining_days === 0) {
1891
if (longform) {
1892
return `${y} ${plural(y, "year")}`;
1893
} else {
1894
return `${y}y`;
1895
}
1896
}
1897
if (longform) {
1898
return `${y} ${plural(y, "year")} ${remaining_days} ${plural(
1899
remaining_days,
1900
"day",
1901
)}`;
1902
} else {
1903
return `${y}y${remaining_days}d`;
1904
}
1905
}
1906
1907
// When show_minutes is true, include hours and minutes for sub-day portion
1908
// Use seconds2hms_days for the remaining days
1909
if (remaining_days > 0) {
1910
const sub_str = seconds2hms_days(
1911
remaining_days,
1912
h,
1913
m,
1914
s,
1915
longform,
1916
show_seconds,
1917
show_minutes,
1918
);
1919
if (longform) {
1920
return `${y} ${plural(y, "year")} ${sub_str}`;
1921
} else {
1922
return `${y}y${sub_str}`;
1923
}
1924
} else {
1925
// Only years, no remaining days - but may have hours/minutes/seconds
1926
// Calculate seconds for just the sub-day portion
1927
const h_within_day = h % 24;
1928
const sub_day_seconds = h_within_day * 3600 + m * 60 + s;
1929
if (sub_day_seconds > 0) {
1930
// Call seconds2hms_days with 0 days to get just the hours/minutes/seconds formatting
1931
const sub_str = seconds2hms_days(
1932
0,
1933
h_within_day,
1934
m,
1935
s,
1936
longform,
1937
show_seconds,
1938
show_minutes,
1939
);
1940
if (sub_str) {
1941
if (longform) {
1942
return `${y} ${plural(y, "year")} ${sub_str}`;
1943
} else {
1944
return `${y}y${sub_str}`;
1945
}
1946
}
1947
}
1948
// Only years, nothing else
1949
if (longform) {
1950
return `${y} ${plural(y, "year")}`;
1951
} else {
1952
return `${y}y`;
1953
}
1954
}
1955
}
1956
1957
function seconds2hms_days(
1958
d: number,
1959
h: number,
1960
m: number,
1961
s: number,
1962
longform: boolean,
1963
show_seconds: boolean,
1964
show_minutes: boolean = true,
1965
): string {
1966
h = h % 24;
1967
// When show_minutes is false and h is 0, don't show anything for the sub-day part
1968
if (!show_minutes && h === 0) {
1969
if (d === 0) {
1970
// No days to show, return empty
1971
return "";
1972
}
1973
if (longform) {
1974
return `${d} ${plural(d, "day")}`;
1975
} else {
1976
return `${d}d`;
1977
}
1978
}
1979
// Calculate total seconds for the sub-day portion
1980
const total_secs = h * 60 * 60 + m * 60 + s;
1981
// When there are days, use show_seconds for shortform but false for longform (original behavior)
1982
const use_show_seconds = d > 0 && longform ? false : show_seconds;
1983
const x =
1984
total_secs > 0
1985
? seconds2hms(total_secs, longform, use_show_seconds, show_minutes)
1986
: "";
1987
if (d === 0) {
1988
// No days, just return the sub-day portion
1989
return x;
1990
}
1991
if (longform) {
1992
return `${d} ${plural(d, "day")} ${x}`.trim();
1993
} else {
1994
return `${d}d${x}`;
1995
}
1996
}
1997
1998
// like seconds2hms, but only up to minute-resultion
1999
export function seconds2hm(secs: number, longform: boolean = false): string {
2000
return seconds2hms(secs, longform, false);
2001
}
2002
2003
// dear future developer: look into test/misc-test.coffee to see how the expected output is defined.
2004
export function seconds2hms(
2005
secs: number,
2006
longform: boolean = false,
2007
show_seconds: boolean = true,
2008
show_minutes: boolean = true,
2009
): string {
2010
if (show_minutes === false) {
2011
show_seconds = false;
2012
}
2013
let s;
2014
if (!longform && secs < 10) {
2015
s = round2(secs % 60);
2016
} else if (!longform && secs < 60) {
2017
s = round1(secs % 60);
2018
} else {
2019
s = Math.round(secs % 60);
2020
}
2021
const m = Math.floor(secs / 60) % 60;
2022
const h = Math.floor(secs / 60 / 60);
2023
const d = Math.floor(secs / 60 / 60 / 24);
2024
const y = Math.floor(d / 365);
2025
// for more than one year, special routine
2026
if (y > 0) {
2027
return seconds2hms_years(
2028
y,
2029
d,
2030
h,
2031
m,
2032
s,
2033
longform,
2034
show_seconds,
2035
show_minutes,
2036
);
2037
}
2038
// for more than one day, special routine (ignoring seconds altogether)
2039
if (d > 0) {
2040
return seconds2hms_days(d, h, m, s, longform, show_seconds, show_minutes);
2041
}
2042
if (h === 0 && m === 0 && show_seconds) {
2043
if (longform) {
2044
return `${s} ${plural(s, "second")}`;
2045
} else {
2046
return `${s}s`;
2047
}
2048
}
2049
if (h > 0) {
2050
if (longform) {
2051
let ret = `${h} ${plural(h, "hour")}`;
2052
if (m > 0 && show_minutes) {
2053
ret += ` ${m} ${plural(m, "minute")}`;
2054
}
2055
// In longform, don't show seconds when there are hours (original behavior)
2056
return ret;
2057
} else {
2058
if (show_minutes) {
2059
if (show_seconds) {
2060
return `${h}h${m}m${s}s`;
2061
} else {
2062
return `${h}h${m}m`;
2063
}
2064
} else {
2065
return `${h}h`;
2066
}
2067
}
2068
}
2069
if ((m > 0 || !show_seconds) && show_minutes) {
2070
if (show_seconds) {
2071
if (longform) {
2072
let ret = `${m} ${plural(m, "minute")}`;
2073
if (s > 0) {
2074
ret += ` ${s} ${plural(s, "second")}`;
2075
}
2076
return ret;
2077
} else {
2078
return `${m}m${s}s`;
2079
}
2080
} else {
2081
if (longform) {
2082
return `${m} ${plural(m, "minute")}`;
2083
} else {
2084
return `${m}m`;
2085
}
2086
}
2087
}
2088
// If neither minutes nor seconds are shown, use fallback logic
2089
if (!show_minutes && !show_seconds) {
2090
// If we have hours, show hours
2091
if (h > 0) {
2092
if (longform) {
2093
return `${h} ${plural(h, "hour")}`;
2094
} else {
2095
return `${h}h`;
2096
}
2097
}
2098
// If less than 1 hour, fall back to showing minutes
2099
if (m > 0) {
2100
if (longform) {
2101
return `${m} ${plural(m, "minute")}`;
2102
} else {
2103
return `${m}m`;
2104
}
2105
}
2106
// If less than 1 minute, fall back to showing seconds
2107
if (longform) {
2108
return `${s} ${plural(s, "second")}`;
2109
} else {
2110
return `${s}s`;
2111
}
2112
}
2113
return "";
2114
}
2115
2116
export function range(n: number): number[] {
2117
const v: number[] = [];
2118
for (let i = 0; i < n; i++) {
2119
v.push(i);
2120
}
2121
return v;
2122
}
2123
2124
// Like Python's enumerate
2125
export function enumerate(v: any[]) {
2126
const w: [number, any][] = [];
2127
let i = 0;
2128
for (let x of Array.from(v)) {
2129
w.push([i, x]);
2130
i += 1;
2131
}
2132
return w;
2133
}
2134
2135
// converts an array to a "human readable" array
2136
export function to_human_list(arr: any[]): string {
2137
arr = lodash.map(arr, (x) => `${x}`);
2138
if (arr.length > 1) {
2139
return arr.slice(0, -1).join(", ") + " and " + arr.slice(-1);
2140
} else if (arr.length === 1) {
2141
return arr[0].toString();
2142
} else {
2143
return "";
2144
}
2145
}
2146
2147
// derive the console initialization filename from the console's filename
2148
// used in webapp and console_server_child
2149
export function console_init_filename(path: string): string {
2150
const x = path_split(path);
2151
x.tail = `.${x.tail}.init`;
2152
if (x.head === "") {
2153
return x.tail;
2154
}
2155
return [x.head, x.tail].join("/");
2156
}
2157
2158
export function has_null_leaf(obj: object): boolean {
2159
for (const k in obj) {
2160
const v = obj[k];
2161
if (v === null || (typeof v === "object" && has_null_leaf(v))) {
2162
return true;
2163
}
2164
}
2165
return false;
2166
}
2167
2168
// mutate obj and delete any undefined leafs.
2169
// was used for MsgPack -- but the ignoreUndefined:true option
2170
// to the encoder is a much better fix.
2171
// export function removeUndefinedLeafs(obj: object) {
2172
// for (const k in obj) {
2173
// const v = obj[k];
2174
// if (v === undefined) {
2175
// delete obj[k];
2176
// } else if (is_object(v)) {
2177
// removeUndefinedLeafs(v);
2178
// }
2179
// }
2180
// }
2181
2182
// Peer Grading
2183
// This function takes a list of student_ids,
2184
// and a number N of the desired number of peers per student.
2185
// It returns an object, mapping each student to a list of N peers.
2186
export function peer_grading(
2187
students: string[],
2188
N: number = 2,
2189
): { [student_id: string]: string[] } {
2190
if (N <= 0) {
2191
throw Error("Number of peer assigments must be at least 1");
2192
}
2193
if (students.length <= N) {
2194
throw Error(`You need at least ${N + 1} students`);
2195
}
2196
2197
const assignment: { [student_id: string]: string[] } = {};
2198
2199
// make output dict keys sorted like students input array
2200
for (const s of students) {
2201
assignment[s] = [];
2202
}
2203
2204
// randomize peer assignments
2205
const s_random = lodash.shuffle(students);
2206
2207
// the peer grading groups are set here. Think of nodes in
2208
// a circular graph, and node i is associated with grading
2209
// nodes i+1 up to i+N.
2210
const L = students.length;
2211
for (let i = 0; i < L; i++) {
2212
for (let j = i + 1; j <= i + N; j++) {
2213
assignment[s_random[i]].push(s_random[j % L]);
2214
}
2215
}
2216
2217
// sort each peer group by the order of the `student` input list
2218
for (let k in assignment) {
2219
const v = assignment[k];
2220
assignment[k] = lodash.sortBy(v, (s) => students.indexOf(s));
2221
}
2222
return assignment;
2223
}
2224
2225
// Checks if the string only makes sense (heuristically) as downloadable url
2226
export function is_only_downloadable(s: string): boolean {
2227
return s.indexOf("://") !== -1 || startswith(s, "[email protected]");
2228
}
2229
2230
export function ensure_bound(x: number, min: number, max: number): number {
2231
return x < min ? min : x > max ? max : x;
2232
}
2233
2234
export const EDITOR_PREFIX = "editor-";
2235
2236
// convert a file path to the "name" of the underlying editor tab.
2237
// needed because otherwise filenames like 'log' would cause problems
2238
export function path_to_tab(name: string): string {
2239
return `${EDITOR_PREFIX}${name}`;
2240
}
2241
2242
// assumes a valid editor tab name...
2243
// If invalid or undefined, returns undefined
2244
export function tab_to_path(name?: string): string | undefined {
2245
if (name?.substring(0, 7) === EDITOR_PREFIX) {
2246
return name.substring(7);
2247
}
2248
return;
2249
}
2250
2251
// suggest a new filename when duplicating it as follows:
2252
// strip extension, split at '_' or '-' if it exists
2253
// try to parse a number, if it works, increment it, etc.
2254
// Handle leading zeros for the number (see https://github.com/sagemathinc/cocalc/issues/2973)
2255
export function suggest_duplicate_filename(name: string): string {
2256
let ext;
2257
({ name, ext } = separate_file_extension(name));
2258
const idx_dash = name.lastIndexOf("-");
2259
const idx_under = name.lastIndexOf("_");
2260
const idx = Math.max(idx_dash, idx_under);
2261
let new_name: string | undefined = undefined;
2262
if (idx > 0) {
2263
const [prefix, ending] = Array.from([
2264
name.slice(0, idx + 1),
2265
name.slice(idx + 1),
2266
]);
2267
// Pad the number with leading zeros to maintain the original length
2268
const paddedEnding = ending.padStart(ending.length, "0");
2269
const num = parseInt(paddedEnding);
2270
if (!Number.isNaN(num)) {
2271
// Increment the number and pad it back to the original length
2272
const newNum = (num + 1).toString().padStart(ending.length, "0");
2273
new_name = `${prefix}${newNum}`;
2274
}
2275
}
2276
if (new_name == null) {
2277
new_name = `${name}-1`;
2278
}
2279
if (ext.length > 0) {
2280
new_name += "." + ext;
2281
}
2282
return new_name;
2283
}
2284
2285
// Takes an object representing a directed graph shaped as follows:
2286
// DAG =
2287
// node1 : []
2288
// node2 : ["node1"]
2289
// node3 : ["node1", "node2"]
2290
//
2291
// Which represents the following graph:
2292
// node1 ----> node2
2293
// | |
2294
// \|/ |
2295
// node3 <-------|
2296
//
2297
// Returns a topological ordering of the DAG
2298
// object = ["node1", "node2", "node3"]
2299
//
2300
// Throws an error if cyclic
2301
// Runs in O(N + E) where N is the number of nodes and E the number of edges
2302
// Kahn, Arthur B. (1962), "Topological sorting of large networks", Communications of the ACM
2303
export function top_sort(
2304
DAG: { [node: string]: string[] },
2305
opts: { omit_sources?: boolean } = { omit_sources: false },
2306
): string[] {
2307
const { omit_sources } = opts;
2308
const source_names: string[] = [];
2309
let num_edges = 0;
2310
const graph_nodes = {};
2311
2312
// Ready the nodes for top sort
2313
for (const name in DAG) {
2314
const parents = DAG[name];
2315
if (graph_nodes[name] == null) {
2316
graph_nodes[name] = {};
2317
}
2318
const node = graph_nodes[name];
2319
node.name = name;
2320
if (node.children == null) {
2321
node.children = [];
2322
}
2323
node.parent_set = {};
2324
for (const parent_name of parents) {
2325
// include element in "parent_set" (see https://github.com/sagemathinc/cocalc/issues/1710)
2326
node.parent_set[parent_name] = true;
2327
if (graph_nodes[parent_name] == null) {
2328
graph_nodes[parent_name] = {};
2329
// Cover implicit nodes which are assumed to be source nodes
2330
if (DAG[parent_name] == null) {
2331
source_names.push(parent_name);
2332
}
2333
}
2334
if (graph_nodes[parent_name].children == null) {
2335
graph_nodes[parent_name].children = [];
2336
}
2337
2338
graph_nodes[parent_name].children.push(node);
2339
}
2340
2341
if (parents.length === 0) {
2342
source_names.push(name);
2343
} else {
2344
num_edges += parents.length;
2345
}
2346
}
2347
2348
// Top sort! Non-recursive method since recursion is way slow in javascript
2349
// https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm
2350
const path: string[] = [];
2351
const num_sources = source_names.length;
2352
let walked_edges = 0;
2353
2354
while (source_names.length !== 0) {
2355
const curr_name = source_names.shift();
2356
if (curr_name == null) throw Error("BUG -- can't happen"); // TS :-)
2357
path.push(curr_name);
2358
2359
for (const child of graph_nodes[curr_name].children) {
2360
delete child.parent_set[curr_name];
2361
walked_edges++;
2362
if (Object.keys(child.parent_set).length === 0) {
2363
source_names.push(child.name);
2364
}
2365
}
2366
}
2367
2368
// Detect lack of sources
2369
if (num_sources === 0) {
2370
throw new Error("No sources were detected");
2371
}
2372
2373
// Detect cycles
2374
if (num_edges !== walked_edges) {
2375
/*// uncomment this when debugging problems.
2376
if (typeof window != "undefined") {
2377
(window as any)._DAG = DAG;
2378
} // so it's possible to debug in browser
2379
*/
2380
throw new Error("Store has a cycle in its computed values");
2381
}
2382
2383
if (omit_sources) {
2384
return path.slice(num_sources);
2385
} else {
2386
return path;
2387
}
2388
}
2389
2390
// Takes an object obj with keys and values where
2391
// the values are functions and keys are the names
2392
// of the functions.
2393
// Dependency graph is created from the property
2394
// `dependency_names` found on the values
2395
// Returns an object shaped
2396
// DAG =
2397
// func_name1 : []
2398
// func_name2 : ["func_name1"]
2399
// func_name3 : ["func_name1", "func_name2"]
2400
//
2401
// Which represents the following graph:
2402
// func_name1 ----> func_name2
2403
// | |
2404
// \|/ |
2405
// func_name3 <-------|
2406
export function create_dependency_graph(obj: {
2407
[name: string]: Function & { dependency_names?: string[] };
2408
}): { [name: string]: string[] } {
2409
const DAG = {};
2410
for (const name in obj) {
2411
const written_func = obj[name];
2412
DAG[name] = written_func.dependency_names ?? [];
2413
}
2414
return DAG;
2415
}
2416
2417
// modify obj in place substituting as specified in subs recursively,
2418
// both for keys *and* values of obj. E.g.,
2419
// obj ={a:{b:'d',d:5}}; obj_key_subs(obj, {d:'x'})
2420
// then obj --> {a:{b:'x',x:5}}.
2421
// This is actually used in user queries to replace {account_id}, {project_id},
2422
// and {now}, but special strings or the time in queries.
2423
export function obj_key_subs(obj: object, subs: { [key: string]: any }): void {
2424
for (const k in obj) {
2425
const v = obj[k];
2426
const s: any = subs[k];
2427
if (typeof s == "string") {
2428
// key substitution for strings
2429
delete obj[k];
2430
obj[s] = v;
2431
}
2432
if (typeof v === "object") {
2433
obj_key_subs(v, subs);
2434
} else if (typeof v === "string") {
2435
// value substitution
2436
const s2: any = subs[v];
2437
if (s2 != null) {
2438
obj[k] = s2;
2439
}
2440
}
2441
}
2442
}
2443
2444
// this is a helper for sanitizing html. It is used in
2445
// * packages/backend/misc_node → sanitize_html
2446
// * packages/frontend/misc-page → sanitize_html
2447
export function sanitize_html_attributes($, node): void {
2448
// Use Array.from to snapshot node.attributes (a live NamedNodeMap).
2449
// Iterating a live collection while removing attributes shifts indices
2450
// and causes elements to be skipped — an XSS vulnerability.
2451
for (const attr of Array.from(node.attributes)) {
2452
if (attr == null) {
2453
continue;
2454
}
2455
const attrName = (attr as Attr).name;
2456
const attrValue = (attr as Attr).value;
2457
const lowerName = attrName?.toLowerCase() ?? "";
2458
// Remove whitespace and control characters (ASCII 0-31) from value for checking
2459
const normalizedValue =
2460
attrValue?.replace(/[\s\x00-\x1f]/g, "").toLowerCase() ?? "";
2461
// remove attribute name start with "on", possible
2462
// unsafe, e.g.: onload, onerror...
2463
// remove attribute value start with "javascript:" pseudo
2464
// protocol, possible unsafe, e.g. href="javascript:alert(1)"
2465
if (
2466
lowerName.startsWith("on") ||
2467
normalizedValue.startsWith("javascript:") ||
2468
normalizedValue.startsWith("vbscript:") ||
2469
// Prevent XSS via data URIs (e.g. data:text/html) while allowing legitimate images (data:image/) in src.
2470
(normalizedValue.startsWith("data:") &&
2471
(lowerName !== "src" || !normalizedValue.startsWith("data:image/")))
2472
) {
2473
$(node).removeAttr(attrName);
2474
}
2475
}
2476
}
2477
2478
// convert a jupyter kernel language (i.e. "python" or "r", usually short and lowercase)
2479
// to a canonical name.
2480
export function jupyter_language_to_name(lang: string): string {
2481
if (lang === "python") {
2482
return "Python";
2483
} else if (lang === "gap") {
2484
return "GAP";
2485
} else if (lang === "sage" || exports.startswith(lang, "sage-")) {
2486
return "SageMath";
2487
} else {
2488
return capitalize(lang);
2489
}
2490
}
2491
2492
// Find the kernel whose name is closest to the given name.
2493
export function closest_kernel_match(
2494
name: string,
2495
kernel_list: immutable.List<immutable.Map<string, any>>,
2496
): immutable.Map<string, any> {
2497
name = name.toLowerCase().replace("matlab", "octave");
2498
name = name === "python" ? "python3" : name;
2499
let bestValue = -1;
2500
let bestMatch: immutable.Map<string, any> | undefined = undefined;
2501
for (let i = 0; i < kernel_list.size; i++) {
2502
const k = kernel_list.get(i);
2503
if (k == null) {
2504
// This happened to Harald once when using the "mod sim py" custom image.
2505
continue;
2506
}
2507
// filter out kernels with negative priority (using the priority
2508
// would be great, though)
2509
if ((k.getIn(["metadata", "cocalc", "priority"], 0) as number) < 0)
2510
continue;
2511
const kernel_name = k.get("name")?.toLowerCase();
2512
if (!kernel_name) continue;
2513
let v = 0;
2514
for (let j = 0; j < name.length; j++) {
2515
if (name[j] === kernel_name[j]) {
2516
v++;
2517
} else {
2518
break;
2519
}
2520
}
2521
if (
2522
v > bestValue ||
2523
(v === bestValue &&
2524
bestMatch &&
2525
compareVersionStrings(
2526
k.get("name") ?? "",
2527
bestMatch.get("name") ?? "",
2528
) === 1)
2529
) {
2530
bestValue = v;
2531
bestMatch = k;
2532
}
2533
}
2534
if (bestMatch == null) {
2535
// kernel list could be empty...
2536
return kernel_list.get(0) ?? immutable.Map<string, string>();
2537
}
2538
return bestMatch;
2539
}
2540
2541
// compareVersionStrings takes two strings "a","b"
2542
// and returns 1 is "a" is bigger, 0 if they are the same, and -1 if "a" is smaller.
2543
// By "bigger" we compare the integer and non-integer parts of the strings separately.
2544
// Examples:
2545
// - "sage.10" is bigger than "sage.9" (because 10 > 9)
2546
// - "python.1" is bigger than "sage.9" (because "python" > "sage")
2547
// - "sage.1.23" is bigger than "sage.0.456" (because 1 > 0)
2548
// - "sage.1.2.3" is bigger than "sage.1.2" (because "." > "")
2549
function compareVersionStrings(a: string, b: string): -1 | 0 | 1 {
2550
const av: string[] = a.split(/(\d+)/);
2551
const bv: string[] = b.split(/(\d+)/);
2552
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
2553
const l = av[i] ?? "";
2554
const r = bv[i] ?? "";
2555
if (/\d/.test(l) && /\d/.test(r)) {
2556
const vA = parseInt(l);
2557
const vB = parseInt(r);
2558
if (vA > vB) {
2559
return 1;
2560
}
2561
if (vA < vB) {
2562
return -1;
2563
}
2564
} else {
2565
if (l > r) {
2566
return 1;
2567
}
2568
if (l < r) {
2569
return -1;
2570
}
2571
}
2572
}
2573
return 0;
2574
}
2575
2576
// Count number of occurrences of m in s-- see http://stackoverflow.com/questions/881085/count-the-number-of-occurences-of-a-character-in-a-string-in-javascript
2577
2578
export function count(str: string, strsearch: string): number {
2579
let index = -1;
2580
let count = -1;
2581
while (true) {
2582
index = str.indexOf(strsearch, index + 1);
2583
count++;
2584
if (index === -1) {
2585
break;
2586
}
2587
}
2588
return count;
2589
}
2590
2591
// right pad a number using html's &nbsp;
2592
// by default, rounds number to a whole integer
2593
export function rpad_html(num: number, width: number, round_fn?: Function) {
2594
num = (round_fn ?? Math.round)(num);
2595
const s = "&nbsp;";
2596
if (num == 0) return lodash.repeat(s, width - 1) + "0";
2597
if (num < 0) return num; // TODO not implemented
2598
const str = `${num}`;
2599
const pad = Math.max(0, width - str.length);
2600
return lodash.repeat(s, pad) + str;
2601
}
2602
2603
// Remove key:value's from objects in obj
2604
// recursively, where value is undefined or null.
2605
export function removeNulls(obj) {
2606
if (typeof obj != "object") {
2607
return obj;
2608
}
2609
if (is_array(obj)) {
2610
for (const x of obj) {
2611
removeNulls(x);
2612
}
2613
return obj;
2614
}
2615
const obj2: any = {};
2616
for (const field in obj) {
2617
if (obj[field] != null) {
2618
obj2[field] = removeNulls(obj[field]);
2619
}
2620
}
2621
return obj2;
2622
}
2623
2624
const academicCountry = new RegExp(/\.(ac|edu)\...$/);
2625
2626
// test if a domain belongs to an academic instition
2627
// TODO: an exhaustive test must probably use the list at https://github.com/Hipo/university-domains-list
2628
export function isAcademic(s?: string): boolean {
2629
if (!s) return false;
2630
const domain = s.split("@")[1];
2631
if (!domain) return false;
2632
if (domain.endsWith(".edu")) return true;
2633
if (academicCountry.test(domain)) return true;
2634
return false;
2635
}
2636
2637
/**
2638
* Test, if the given object is a valid list of JSON-Patch operations.
2639
* @returns boolean
2640
*/
2641
export function test_valid_jsonpatch(patch: any): boolean {
2642
if (!is_array(patch)) {
2643
return false;
2644
}
2645
for (const op of patch) {
2646
if (!is_object(op)) {
2647
return false;
2648
}
2649
if (op["op"] == null) {
2650
return false;
2651
}
2652
if (
2653
!["add", "remove", "replace", "move", "copy", "test"].includes(op["op"])
2654
) {
2655
return false;
2656
}
2657
if (op["path"] == null) {
2658
return false;
2659
}
2660
if (op["from"] != null && typeof op["from"] !== "string") {
2661
return false;
2662
}
2663
// we don't test on value
2664
}
2665
return true;
2666
}
2667
2668
export function rowBackground({
2669
index,
2670
checked,
2671
}: {
2672
index: number;
2673
checked?: boolean;
2674
}): string {
2675
if (checked) {
2676
if (index % 2 === 0) {
2677
return "#a3d4ff";
2678
} else {
2679
return "#a3d4f0";
2680
}
2681
} else if (index % 2 === 0) {
2682
return "#f4f4f4";
2683
} else {
2684
return "white";
2685
}
2686
}
2687
2688
export function firstLetterUppercase(str: string | undefined) {
2689
if (str == null) return "";
2690
return str.charAt(0).toUpperCase() + str.slice(1);
2691
}
2692
2693
const randomColorCache = new LRU<string, string>({ max: 100 });
2694
2695
/**
2696
* For a given string s, return a random bright color, but not too bright.
2697
* Use a hash to make this random, but deterministic.
2698
*
2699
* opts:
2700
* - min: minimum value for each channel
2701
* - max: maxium value for each channel
2702
* - diff: mimimum difference across channels (increase, to avoid dull gray colors)
2703
* - seed: seed for the random number generator
2704
*/
2705
export function getRandomColor(
2706
s: string,
2707
opts?: { min?: number; max?: number; diff?: number; seed?: number },
2708
): string {
2709
const diff = opts?.diff ?? 0;
2710
const min = clip(opts?.min ?? 120, 0, 254);
2711
const max = Math.max(min, clip(opts?.max ?? 230, 1, 255));
2712
const seed = opts?.seed ?? 0;
2713
2714
const key = `${s}-${min}-${max}-${diff}-${seed}`;
2715
const cached = randomColorCache.get(key);
2716
if (cached) {
2717
return cached;
2718
}
2719
2720
let iter = 0;
2721
const iterLimit = "z".charCodeAt(0) - "A".charCodeAt(0);
2722
const mod = max - min;
2723
2724
while (true) {
2725
// seed + s + String.fromCharCode("A".charCodeAt(0) + iter)
2726
const val = `${seed}-${s}-${String.fromCharCode("A".charCodeAt(0) + iter)}`;
2727
const hash = sha1(val)
2728
.split("")
2729
.reduce((a, b) => ((a << 6) - a + b.charCodeAt(0)) | 0, 0);
2730
const r = (((hash >> 0) & 0xff) % mod) + min;
2731
const g = (((hash >> 8) & 0xff) % mod) + min;
2732
const b = (((hash >> 16) & 0xff) % mod) + min;
2733
2734
iter += 1;
2735
if (iter <= iterLimit && diff) {
2736
const diffVal = Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
2737
if (diffVal < diff) continue;
2738
}
2739
const col = `rgb(${r}, ${g}, ${b})`;
2740
randomColorCache.set(key, col);
2741
return col;
2742
}
2743
}
2744
2745
export function hexColorToRGBA(col: string, opacity?: number): string {
2746
const r = parseInt(col.slice(1, 3), 16);
2747
const g = parseInt(col.slice(3, 5), 16);
2748
const b = parseInt(col.slice(5, 7), 16);
2749
2750
if (opacity && opacity <= 1 && opacity >= 0) {
2751
return `rgba(${r},${g},${b},${opacity})`;
2752
} else {
2753
return `rgb(${r},${g},${b})`;
2754
}
2755
}
2756
2757
// returns an always positive integer, not negative ones. useful for "scrolling backwards", etc.
2758
export function strictMod(a: number, b: number): number {
2759
return ((a % b) + b) % b;
2760
}
2761
2762
export function clip(val: number, min: number, max: number): number {
2763
return Math.min(Math.max(val, min), max);
2764
}
2765
2766
/**
2767
* Converts an integer to an English word, but only for small numbers and reverts to a digit for larger numbers
2768
*/
2769
export function smallIntegerToEnglishWord(val: number): string | number {
2770
if (!Number.isInteger(val)) return val;
2771
switch (val) {
2772
case 0:
2773
return "zero";
2774
case 1:
2775
return "one";
2776
case 2:
2777
return "two";
2778
case 3:
2779
return "three";
2780
case 4:
2781
return "four";
2782
case 5:
2783
return "five";
2784
case 6:
2785
return "six";
2786
case 7:
2787
return "seven";
2788
case 8:
2789
return "eight";
2790
case 9:
2791
return "nine";
2792
case 10:
2793
return "ten";
2794
case 11:
2795
return "eleven";
2796
case 12:
2797
return "twelve";
2798
case 13:
2799
return "thirteen";
2800
case 14:
2801
return "fourteen";
2802
case 15:
2803
return "fifteen";
2804
case 16:
2805
return "sixteen";
2806
case 17:
2807
return "seventeen";
2808
case 18:
2809
return "eighteen";
2810
case 19:
2811
return "nineteen";
2812
case 20:
2813
return "twenty";
2814
}
2815
return val;
2816
}
2817
2818
export function numToOrdinal(val: number): string {
2819
// 1 → 1st, 2 → 2nd, 3 → 3rd, 4 → 4th, ... 21 → 21st, ... 101 → 101st, ...
2820
if (!Number.isInteger(val)) return `${val}th`;
2821
const mod100 = val % 100;
2822
if (mod100 >= 11 && mod100 <= 13) {
2823
return `${val}th`;
2824
}
2825
const mod10 = val % 10;
2826
switch (mod10) {
2827
case 1:
2828
return `${val}st`;
2829
case 2:
2830
return `${val}nd`;
2831
case 3:
2832
return `${val}rd`;
2833
default:
2834
return `${val}th`;
2835
}
2836
}
2837
2838
export function hoursToTimeIntervalHuman(num: number): string {
2839
if (num < 24) {
2840
const n = round1(num);
2841
return `${n} ${plural(n, "hour")}`;
2842
} else if (num < 24 * 7) {
2843
const n = round1(num / 24);
2844
return `${n} ${plural(n, "day")}`;
2845
} else {
2846
const n = round1(num / (24 * 7));
2847
return `${n} ${plural(n, "week")}`;
2848
}
2849
}
2850
2851
/**
2852
* Return the last @lines lines of string s, in an efficient way. (e.g. long stdout, and return last 3 lines)
2853
*/
2854
export function tail(s: string, lines: number) {
2855
if (lines < 1) return "";
2856
2857
let lineCount = 0;
2858
let lastIndex = s.length - 1;
2859
2860
// Iterate backwards through the string, searching for newline characters
2861
while (lastIndex >= 0 && lineCount < lines) {
2862
lastIndex = s.lastIndexOf("\n", lastIndex);
2863
if (lastIndex === -1) {
2864
// No more newlines found, return the entire string
2865
return s;
2866
}
2867
lineCount++;
2868
lastIndex--;
2869
}
2870
2871
// Return the substring starting from the next character after the last newline
2872
return s.slice(lastIndex + 2);
2873
}
2874
2875
export function basePathCookieName({
2876
basePath,
2877
name,
2878
}: {
2879
basePath: string;
2880
name: string;
2881
}): string {
2882
return `${basePath.length <= 1 ? "" : encodeURIComponent(basePath)}${name}`;
2883
}
2884
2885
export function isNumericString(str: string): boolean {
2886
// https://stackoverflow.com/questions/175739/how-can-i-check-if-a-string-is-a-valid-number
2887
if (typeof str != "string") {
2888
return false; // we only process strings!
2889
}
2890
return (
2891
// @ts-ignore
2892
!isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
2893
!isNaN(parseFloat(str))
2894
); // ...and ensure strings of whitespace fail
2895
}
2896
2897
// This is needed in browsers, where toString('base64') doesn't work
2898
// and .toBase64(). This also works on buffers. In nodejs there is
2899
// toString('base64'), but that seems broken in some cases and a bit
2900
// dangerous since toString('base64') in the browser is just toString(),
2901
// which is very different.
2902
export function uint8ArrayToBase64(uint8Array: Uint8Array) {
2903
let binaryString = "";
2904
for (let i = 0; i < uint8Array.length; i++) {
2905
binaryString += String.fromCharCode(uint8Array[i]);
2906
}
2907
return btoa(binaryString);
2908
}
2909
2910