Contact Us!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.

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