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.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/misc.ts
Views: 687
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. <[email protected]> is valid and understood as [email protected]
1358
// (Note that <<[email protected]> will be <[email protected] 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 protected]", "[email protected]"]
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>"[email protected]
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(n ?? 0, d ?? (Math.abs(n) < 0.0095 ? 3 : 2))}`;
1802
if (d == null || d <= 2) {
1803
return s;
1804
}
1805
// strip excessive 0's off the end
1806
const i = s.indexOf(".");
1807
while (s[s.length - 1] == "0" && i <= s.length - 2) {
1808
s = s.slice(0, s.length - 1);
1809
}
1810
return s;
1811
}
1812
1813
export function stripeAmount(
1814
unitPrice: number,
1815
currency: string,
1816
units = 1,
1817
): string {
1818
// input is in pennies
1819
if (currency !== "usd") {
1820
// TODO: need to make this look nice with symbols for other currencies...
1821
return `${currency == "eur" ? "€" : ""}${to_money(
1822
(units * unitPrice) / 100,
1823
)} ${currency.toUpperCase()}`;
1824
}
1825
return `$${to_money((units * unitPrice) / 100)} USD`;
1826
}
1827
1828
export function planInterval(
1829
interval: string,
1830
interval_count: number = 1,
1831
): string {
1832
return `${interval_count} ${plural(interval_count, interval)}`;
1833
}
1834
1835
// get a subarray of all values between the two given values inclusive,
1836
// provided in either order
1837
export function get_array_range(arr: any[], value1: any, value2: any): any[] {
1838
let index1 = arr.indexOf(value1);
1839
let index2 = arr.indexOf(value2);
1840
if (index1 > index2) {
1841
[index1, index2] = [index2, index1];
1842
}
1843
return arr.slice(index1, +index2 + 1 || undefined);
1844
}
1845
1846
function seconds2hms_days(
1847
d: number,
1848
h: number,
1849
m: number,
1850
longform: boolean,
1851
): string {
1852
h = h % 24;
1853
const s = h * 60 * 60 + m * 60;
1854
const x = s > 0 ? seconds2hms(s, longform, false) : "";
1855
if (longform) {
1856
return `${d} ${plural(d, "day")} ${x}`.trim();
1857
} else {
1858
return `${d}d${x}`;
1859
}
1860
}
1861
1862
// like seconds2hms, but only up to minute-resultion
1863
export function seconds2hm(secs: number, longform: boolean = false): string {
1864
return seconds2hms(secs, longform, false);
1865
}
1866
1867
// dear future developer: look into test/misc-test.coffee to see how the expected output is defined.
1868
export function seconds2hms(
1869
secs: number,
1870
longform: boolean = false,
1871
show_seconds: boolean = true,
1872
): string {
1873
let s;
1874
if (!longform && secs < 10) {
1875
s = round2(secs % 60);
1876
} else if (!longform && secs < 60) {
1877
s = round1(secs % 60);
1878
} else {
1879
s = Math.round(secs % 60);
1880
}
1881
const m = Math.floor(secs / 60) % 60;
1882
const h = Math.floor(secs / 60 / 60);
1883
const d = Math.floor(secs / 60 / 60 / 24);
1884
// for more than one day, special routine (ignoring seconds altogether)
1885
if (d > 0) {
1886
return seconds2hms_days(d, h, m, longform);
1887
}
1888
if (h === 0 && m === 0 && show_seconds) {
1889
if (longform) {
1890
return `${s} ${plural(s, "second")}`;
1891
} else {
1892
return `${s}s`;
1893
}
1894
}
1895
if (h > 0) {
1896
if (longform) {
1897
let ret = `${h} ${plural(h, "hour")}`;
1898
if (m > 0) {
1899
ret += ` ${m} ${plural(m, "minute")}`;
1900
}
1901
return ret;
1902
} else {
1903
if (show_seconds) {
1904
return `${h}h${m}m${s}s`;
1905
} else {
1906
return `${h}h${m}m`;
1907
}
1908
}
1909
}
1910
if (m > 0 || !show_seconds) {
1911
if (show_seconds) {
1912
if (longform) {
1913
let ret = `${m} ${plural(m, "minute")}`;
1914
if (s > 0) {
1915
ret += ` ${s} ${plural(s, "second")}`;
1916
}
1917
return ret;
1918
} else {
1919
return `${m}m${s}s`;
1920
}
1921
} else {
1922
if (longform) {
1923
return `${m} ${plural(m, "minute")}`;
1924
} else {
1925
return `${m}m`;
1926
}
1927
}
1928
}
1929
return "";
1930
}
1931
1932
export function range(n: number): number[] {
1933
const v: number[] = [];
1934
for (let i = 0; i < n; i++) {
1935
v.push(i);
1936
}
1937
return v;
1938
}
1939
1940
// Like Python's enumerate
1941
export function enumerate(v: any[]) {
1942
const w: [number, any][] = [];
1943
let i = 0;
1944
for (let x of Array.from(v)) {
1945
w.push([i, x]);
1946
i += 1;
1947
}
1948
return w;
1949
}
1950
1951
// converts an array to a "human readable" array
1952
export function to_human_list(arr: any[]): string {
1953
arr = lodash.map(arr, (x) => `${x}`);
1954
if (arr.length > 1) {
1955
return arr.slice(0, -1).join(", ") + " and " + arr.slice(-1);
1956
} else if (arr.length === 1) {
1957
return arr[0].toString();
1958
} else {
1959
return "";
1960
}
1961
}
1962
1963
// derive the console initialization filename from the console's filename
1964
// used in webapp and console_server_child
1965
export function console_init_filename(path: string): string {
1966
const x = path_split(path);
1967
x.tail = `.${x.tail}.init`;
1968
if (x.head === "") {
1969
return x.tail;
1970
}
1971
return [x.head, x.tail].join("/");
1972
}
1973
1974
export function has_null_leaf(obj: object): boolean {
1975
for (const k in obj) {
1976
const v = obj[k];
1977
if (v === null || (typeof v === "object" && has_null_leaf(v))) {
1978
return true;
1979
}
1980
}
1981
return false;
1982
}
1983
1984
// Peer Grading
1985
// This function takes a list of student_ids,
1986
// and a number N of the desired number of peers per student.
1987
// It returns an object, mapping each student to a list of N peers.
1988
export function peer_grading(
1989
students: string[],
1990
N: number = 2,
1991
): { [student_id: string]: string[] } {
1992
if (N <= 0) {
1993
throw Error("Number of peer assigments must be at least 1");
1994
}
1995
if (students.length <= N) {
1996
throw Error(`You need at least ${N + 1} students`);
1997
}
1998
1999
const assignment: { [student_id: string]: string[] } = {};
2000
2001
// make output dict keys sorted like students input array
2002
for (const s of students) {
2003
assignment[s] = [];
2004
}
2005
2006
// randomize peer assignments
2007
const s_random = lodash.shuffle(students);
2008
2009
// the peer grading groups are set here. Think of nodes in
2010
// a circular graph, and node i is associated with grading
2011
// nodes i+1 up to i+N.
2012
const L = students.length;
2013
for (let i = 0; i < L; i++) {
2014
for (let j = i + 1; j <= i + N; j++) {
2015
assignment[s_random[i]].push(s_random[j % L]);
2016
}
2017
}
2018
2019
// sort each peer group by the order of the `student` input list
2020
for (let k in assignment) {
2021
const v = assignment[k];
2022
assignment[k] = lodash.sortBy(v, (s) => students.indexOf(s));
2023
}
2024
return assignment;
2025
}
2026
2027
// Checks if the string only makes sense (heuristically) as downloadable url
2028
export function is_only_downloadable(s: string): boolean {
2029
return s.indexOf("://") !== -1 || startswith(s, "[email protected]");
2030
}
2031
2032
export function ensure_bound(x: number, min: number, max: number): number {
2033
return x < min ? min : x > max ? max : x;
2034
}
2035
2036
export const EDITOR_PREFIX = "editor-";
2037
2038
// convert a file path to the "name" of the underlying editor tab.
2039
// needed because otherwise filenames like 'log' would cause problems
2040
export function path_to_tab(name: string): string {
2041
return `${EDITOR_PREFIX}${name}`;
2042
}
2043
2044
// assumes a valid editor tab name...
2045
// If invalid or undefined, returns undefined
2046
export function tab_to_path(name: string): string | undefined {
2047
if (name?.substring(0, 7) === EDITOR_PREFIX) {
2048
return name.substring(7);
2049
}
2050
return;
2051
}
2052
2053
// suggest a new filename when duplicating it as follows:
2054
// strip extension, split at '_' or '-' if it exists
2055
// try to parse a number, if it works, increment it, etc.
2056
// Handle leading zeros for the number (see https://github.com/sagemathinc/cocalc/issues/2973)
2057
export function suggest_duplicate_filename(name: string): string {
2058
let ext;
2059
({ name, ext } = separate_file_extension(name));
2060
const idx_dash = name.lastIndexOf("-");
2061
const idx_under = name.lastIndexOf("_");
2062
const idx = Math.max(idx_dash, idx_under);
2063
let new_name: string | undefined = undefined;
2064
if (idx > 0) {
2065
const [prefix, ending] = Array.from([
2066
name.slice(0, idx + 1),
2067
name.slice(idx + 1),
2068
]);
2069
// Pad the number with leading zeros to maintain the original length
2070
const paddedEnding = ending.padStart(ending.length, "0");
2071
const num = parseInt(paddedEnding);
2072
if (!Number.isNaN(num)) {
2073
// Increment the number and pad it back to the original length
2074
const newNum = (num + 1).toString().padStart(ending.length, "0");
2075
new_name = `${prefix}${newNum}`;
2076
}
2077
}
2078
if (new_name == null) {
2079
new_name = `${name}-1`;
2080
}
2081
if (ext.length > 0) {
2082
new_name += "." + ext;
2083
}
2084
return new_name;
2085
}
2086
2087
// Takes an object representing a directed graph shaped as follows:
2088
// DAG =
2089
// node1 : []
2090
// node2 : ["node1"]
2091
// node3 : ["node1", "node2"]
2092
//
2093
// Which represents the following graph:
2094
// node1 ----> node2
2095
// | |
2096
// \|/ |
2097
// node3 <-------|
2098
//
2099
// Returns a topological ordering of the DAG
2100
// object = ["node1", "node2", "node3"]
2101
//
2102
// Throws an error if cyclic
2103
// Runs in O(N + E) where N is the number of nodes and E the number of edges
2104
// Kahn, Arthur B. (1962), "Topological sorting of large networks", Communications of the ACM
2105
export function top_sort(
2106
DAG: { [node: string]: string[] },
2107
opts: { omit_sources?: boolean } = { omit_sources: false },
2108
): string[] {
2109
const { omit_sources } = opts;
2110
const source_names: string[] = [];
2111
let num_edges = 0;
2112
const graph_nodes = {};
2113
2114
// Ready the nodes for top sort
2115
for (const name in DAG) {
2116
const parents = DAG[name];
2117
if (graph_nodes[name] == null) {
2118
graph_nodes[name] = {};
2119
}
2120
const node = graph_nodes[name];
2121
node.name = name;
2122
if (node.children == null) {
2123
node.children = [];
2124
}
2125
node.parent_set = {};
2126
for (const parent_name of parents) {
2127
// include element in "parent_set" (see https://github.com/sagemathinc/cocalc/issues/1710)
2128
node.parent_set[parent_name] = true;
2129
if (graph_nodes[parent_name] == null) {
2130
graph_nodes[parent_name] = {};
2131
// Cover implicit nodes which are assumed to be source nodes
2132
if (DAG[parent_name] == null) {
2133
source_names.push(parent_name);
2134
}
2135
}
2136
if (graph_nodes[parent_name].children == null) {
2137
graph_nodes[parent_name].children = [];
2138
}
2139
2140
graph_nodes[parent_name].children.push(node);
2141
}
2142
2143
if (parents.length === 0) {
2144
source_names.push(name);
2145
} else {
2146
num_edges += parents.length;
2147
}
2148
}
2149
2150
// Top sort! Non-recursive method since recursion is way slow in javascript
2151
// https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm
2152
const path: string[] = [];
2153
const num_sources = source_names.length;
2154
let walked_edges = 0;
2155
2156
while (source_names.length !== 0) {
2157
const curr_name = source_names.shift();
2158
if (curr_name == null) throw Error("BUG -- can't happen"); // TS :-)
2159
path.push(curr_name);
2160
2161
for (const child of graph_nodes[curr_name].children) {
2162
delete child.parent_set[curr_name];
2163
walked_edges++;
2164
if (Object.keys(child.parent_set).length === 0) {
2165
source_names.push(child.name);
2166
}
2167
}
2168
}
2169
2170
// Detect lack of sources
2171
if (num_sources === 0) {
2172
throw new Error("No sources were detected");
2173
}
2174
2175
// Detect cycles
2176
if (num_edges !== walked_edges) {
2177
/*// uncomment this when debugging problems.
2178
if (typeof window != "undefined") {
2179
(window as any)._DAG = DAG;
2180
} // so it's possible to debug in browser
2181
*/
2182
throw new Error("Store has a cycle in its computed values");
2183
}
2184
2185
if (omit_sources) {
2186
return path.slice(num_sources);
2187
} else {
2188
return path;
2189
}
2190
}
2191
2192
// Takes an object obj with keys and values where
2193
// the values are functions and keys are the names
2194
// of the functions.
2195
// Dependency graph is created from the property
2196
// `dependency_names` found on the values
2197
// Returns an object shaped
2198
// DAG =
2199
// func_name1 : []
2200
// func_name2 : ["func_name1"]
2201
// func_name3 : ["func_name1", "func_name2"]
2202
//
2203
// Which represents the following graph:
2204
// func_name1 ----> func_name2
2205
// | |
2206
// \|/ |
2207
// func_name3 <-------|
2208
export function create_dependency_graph(obj: {
2209
[name: string]: Function & { dependency_names?: string };
2210
}): { [name: string]: string[] } {
2211
const DAG = {};
2212
for (const name in obj) {
2213
const written_func = obj[name];
2214
DAG[name] = written_func.dependency_names ?? [];
2215
}
2216
return DAG;
2217
}
2218
2219
// modify obj in place substituting as specified in subs recursively,
2220
// both for keys *and* values of obj. E.g.,
2221
// obj ={a:{b:'d',d:5}}; obj_key_subs(obj, {d:'x'})
2222
// then obj --> {a:{b:'x',x:5}}.
2223
// This is actually used in user queries to replace {account_id}, {project_id},
2224
// and {now}, but special strings or the time in queries.
2225
export function obj_key_subs(obj: object, subs: { [key: string]: any }): void {
2226
for (const k in obj) {
2227
const v = obj[k];
2228
const s: any = subs[k];
2229
if (typeof s == "string") {
2230
// key substitution for strings
2231
delete obj[k];
2232
obj[s] = v;
2233
}
2234
if (typeof v === "object") {
2235
obj_key_subs(v, subs);
2236
} else if (typeof v === "string") {
2237
// value substitution
2238
const s2: any = subs[v];
2239
if (s2 != null) {
2240
obj[k] = s2;
2241
}
2242
}
2243
}
2244
}
2245
2246
// this is a helper for sanitizing html. It is used in
2247
// * packages/backend/misc_node → sanitize_html
2248
// * packages/frontend/misc-page → sanitize_html
2249
export function sanitize_html_attributes($, node): void {
2250
$.each(node.attributes, function () {
2251
// sometimes, "this" is undefined -- #2823
2252
// @ts-ignore -- no implicit this
2253
if (this == null) {
2254
return;
2255
}
2256
// @ts-ignore -- no implicit this
2257
const attrName = this.name;
2258
// @ts-ignore -- no implicit this
2259
const attrValue = this.value;
2260
// remove attribute name start with "on", possible
2261
// unsafe, e.g.: onload, onerror...
2262
// remove attribute value start with "javascript:" pseudo
2263
// protocol, possible unsafe, e.g. href="javascript:alert(1)"
2264
if (
2265
attrName?.indexOf("on") === 0 ||
2266
attrValue?.indexOf("javascript:") === 0
2267
) {
2268
$(node).removeAttr(attrName);
2269
}
2270
});
2271
}
2272
2273
// cocalc analytics cookie name
2274
export const analytics_cookie_name = "CC_ANA";
2275
2276
// convert a jupyter kernel language (i.e. "python" or "r", usually short and lowercase)
2277
// to a canonical name.
2278
export function jupyter_language_to_name(lang: string): string {
2279
if (lang === "python") {
2280
return "Python";
2281
} else if (lang === "gap") {
2282
return "GAP";
2283
} else if (lang === "sage" || exports.startswith(lang, "sage-")) {
2284
return "SageMath";
2285
} else {
2286
return capitalize(lang);
2287
}
2288
}
2289
2290
// Find the kernel whose name is closest to the given name.
2291
export function closest_kernel_match(
2292
name: string,
2293
kernel_list: immutable.List<immutable.Map<string, string>>,
2294
): immutable.Map<string, string> {
2295
name = name.toLowerCase().replace("matlab", "octave");
2296
name = name === "python" ? "python3" : name;
2297
let bestValue = -1;
2298
let bestMatch: immutable.Map<string, string> | undefined = undefined;
2299
for (let i = 0; i < kernel_list.size; i++) {
2300
const k = kernel_list.get(i);
2301
if (k == null) {
2302
// This happened to Harald once when using the "mod sim py" custom image.
2303
continue;
2304
}
2305
// filter out kernels with negative priority (using the priority
2306
// would be great, though)
2307
if ((k.getIn(["metadata", "cocalc", "priority"], 0) as number) < 0)
2308
continue;
2309
const kernel_name = k.get("name")?.toLowerCase();
2310
if (!kernel_name) continue;
2311
let v = 0;
2312
for (let j = 0; j < name.length; j++) {
2313
if (name[j] === kernel_name[j]) {
2314
v++;
2315
} else {
2316
break;
2317
}
2318
}
2319
if (
2320
v > bestValue ||
2321
(v === bestValue &&
2322
bestMatch &&
2323
compareVersionStrings(
2324
k.get("name") ?? "",
2325
bestMatch.get("name") ?? "",
2326
) === 1)
2327
) {
2328
bestValue = v;
2329
bestMatch = k;
2330
}
2331
}
2332
if (bestMatch == null) {
2333
// kernel list could be empty...
2334
return kernel_list.get(0) ?? immutable.Map<string, string>();
2335
}
2336
return bestMatch;
2337
}
2338
2339
// compareVersionStrings takes two strings "a","b"
2340
// and returns 1 is "a" is bigger, 0 if they are the same, and -1 if "a" is smaller.
2341
// By "bigger" we compare the integer and non-integer parts of the strings separately.
2342
// Examples:
2343
// - "sage.10" is bigger than "sage.9" (because 10 > 9)
2344
// - "python.1" is bigger than "sage.9" (because "python" > "sage")
2345
// - "sage.1.23" is bigger than "sage.0.456" (because 1 > 0)
2346
// - "sage.1.2.3" is bigger than "sage.1.2" (because "." > "")
2347
function compareVersionStrings(a: string, b: string): -1 | 0 | 1 {
2348
const av: string[] = a.split(/(\d+)/);
2349
const bv: string[] = b.split(/(\d+)/);
2350
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
2351
const l = av[i] ?? "";
2352
const r = bv[i] ?? "";
2353
if (/\d/.test(l) && /\d/.test(r)) {
2354
const vA = parseInt(l);
2355
const vB = parseInt(r);
2356
if (vA > vB) {
2357
return 1;
2358
}
2359
if (vA < vB) {
2360
return -1;
2361
}
2362
} else {
2363
if (l > r) {
2364
return 1;
2365
}
2366
if (l < r) {
2367
return -1;
2368
}
2369
}
2370
}
2371
return 0;
2372
}
2373
2374
// 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
2375
2376
export function count(str: string, strsearch: string): number {
2377
let index = -1;
2378
let count = -1;
2379
while (true) {
2380
index = str.indexOf(strsearch, index + 1);
2381
count++;
2382
if (index === -1) {
2383
break;
2384
}
2385
}
2386
return count;
2387
}
2388
2389
// right pad a number using html's &nbsp;
2390
// by default, rounds number to a whole integer
2391
export function rpad_html(num: number, width: number, round_fn?: Function) {
2392
num = (round_fn ?? Math.round)(num);
2393
const s = "&nbsp;";
2394
if (num == 0) return lodash.repeat(s, width - 1) + "0";
2395
if (num < 0) return num; // TODO not implemented
2396
const str = `${num}`;
2397
const pad = Math.max(0, width - str.length);
2398
return lodash.repeat(s, pad) + str;
2399
}
2400
2401
// Remove key:value's from objects in obj
2402
// recursively, where value is undefined or null.
2403
export function removeNulls(obj) {
2404
if (typeof obj != "object") {
2405
return obj;
2406
}
2407
if (is_array(obj)) {
2408
for (const x of obj) {
2409
removeNulls(x);
2410
}
2411
return obj;
2412
}
2413
const obj2: any = {};
2414
for (const field in obj) {
2415
if (obj[field] != null) {
2416
obj2[field] = removeNulls(obj[field]);
2417
}
2418
}
2419
return obj2;
2420
}
2421
2422
const academicCountry = new RegExp(/\.(ac|edu)\...$/);
2423
2424
// test if a domain belongs to an academic instition
2425
// TODO: an exhaustive test must probably use the list at https://github.com/Hipo/university-domains-list
2426
export function isAcademic(s?: string): boolean {
2427
if (!s) return false;
2428
const domain = s.split("@")[1];
2429
if (!domain) return false;
2430
if (domain.endsWith(".edu")) return true;
2431
if (academicCountry.test(domain)) return true;
2432
return false;
2433
}
2434
2435
/**
2436
* Test, if the given object is a valid list of JSON-Patch operations.
2437
* @returns boolean
2438
*/
2439
export function test_valid_jsonpatch(patch: any): boolean {
2440
if (!is_array(patch)) {
2441
return false;
2442
}
2443
for (const op of patch) {
2444
if (!is_object(op)) return false;
2445
if (op.op == null) return false;
2446
if (!["add", "remove", "replace", "move", "copy", "test"].includes(op.op)) {
2447
return false;
2448
}
2449
if (op.path == null) return false;
2450
if (op.from != null && typeof op.from !== "string") return false;
2451
// we don't test on value
2452
}
2453
return true;
2454
}
2455
2456
export function rowBackground({
2457
index,
2458
checked,
2459
}: {
2460
index: number;
2461
checked?: boolean;
2462
}): string {
2463
if (checked) {
2464
if (index % 2 === 0) {
2465
return "#a3d4ff";
2466
} else {
2467
return "#a3d4f0";
2468
}
2469
} else if (index % 2 === 0) {
2470
return "#f4f4f4";
2471
} else {
2472
return "white";
2473
}
2474
}
2475
2476
export function firstLetterUppercase(str: string | undefined) {
2477
if (str == null) return "";
2478
return str.charAt(0).toUpperCase() + str.slice(1);
2479
}
2480
2481
const randomColorCache = new LRU<string, string>({ max: 100 });
2482
2483
/**
2484
* For a given string s, return a random bright color, but not too bright.
2485
* Use a hash to make this random, but deterministic.
2486
*
2487
* opts:
2488
* - min: minimum value for each channel
2489
* - max: maxium value for each channel
2490
* - diff: mimimum difference across channels (increase, to avoid dull gray colors)
2491
* - seed: seed for the random number generator
2492
*/
2493
export function getRandomColor(
2494
s: string,
2495
opts?: { min?: number; max?: number; diff?: number; seed?: number },
2496
): string {
2497
const diff = opts?.diff ?? 0;
2498
const min = clip(opts?.min ?? 120, 0, 254);
2499
const max = Math.max(min, clip(opts?.max ?? 230, 1, 255));
2500
const seed = opts?.seed ?? 0;
2501
2502
const key = `${s}-${min}-${max}-${diff}-${seed}`;
2503
const cached = randomColorCache.get(key);
2504
if (cached) {
2505
return cached;
2506
}
2507
2508
let iter = 0;
2509
const iterLimit = "z".charCodeAt(0) - "A".charCodeAt(0);
2510
const mod = max - min;
2511
2512
while (true) {
2513
// seed + s + String.fromCharCode("A".charCodeAt(0) + iter)
2514
const val = `${seed}-${s}-${String.fromCharCode("A".charCodeAt(0) + iter)}`;
2515
const hash = sha1(val)
2516
.split("")
2517
.reduce((a, b) => ((a << 6) - a + b.charCodeAt(0)) | 0, 0);
2518
const r = (((hash >> 0) & 0xff) % mod) + min;
2519
const g = (((hash >> 8) & 0xff) % mod) + min;
2520
const b = (((hash >> 16) & 0xff) % mod) + min;
2521
2522
iter += 1;
2523
if (iter <= iterLimit && diff) {
2524
const diffVal = Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
2525
if (diffVal < diff) continue;
2526
}
2527
const col = `rgb(${r}, ${g}, ${b})`;
2528
randomColorCache.set(key, col);
2529
return col;
2530
}
2531
}
2532
2533
export function hexColorToRGBA(col: string, opacity?: number): string {
2534
const r = parseInt(col.slice(1, 3), 16);
2535
const g = parseInt(col.slice(3, 5), 16);
2536
const b = parseInt(col.slice(5, 7), 16);
2537
2538
if (opacity && opacity <= 1 && opacity >= 0) {
2539
return `rgba(${r},${g},${b},${opacity})`;
2540
} else {
2541
return `rgb(${r},${g},${b})`;
2542
}
2543
}
2544
2545
// returns an always positive integer, not negative ones. useful for "scrolling backwards", etc.
2546
export function strictMod(a: number, b: number): number {
2547
return ((a % b) + b) % b;
2548
}
2549
2550
export function clip(val: number, min: number, max: number): number {
2551
return Math.min(Math.max(val, min), max);
2552
}
2553
2554
/**
2555
* Converts an integer to an English word, but only for small numbers and reverts to a digit for larger numbers
2556
*/
2557
export function smallIntegerToEnglishWord(val: number): string | number {
2558
if (!Number.isInteger(val)) return val;
2559
switch (val) {
2560
case 0:
2561
return "zero";
2562
case 1:
2563
return "one";
2564
case 2:
2565
return "two";
2566
case 3:
2567
return "three";
2568
case 4:
2569
return "four";
2570
case 5:
2571
return "five";
2572
case 6:
2573
return "six";
2574
case 7:
2575
return "seven";
2576
case 8:
2577
return "eight";
2578
case 9:
2579
return "nine";
2580
case 10:
2581
return "ten";
2582
case 11:
2583
return "eleven";
2584
case 12:
2585
return "twelve";
2586
case 13:
2587
return "thirteen";
2588
case 14:
2589
return "fourteen";
2590
case 15:
2591
return "fifteen";
2592
case 16:
2593
return "sixteen";
2594
case 17:
2595
return "seventeen";
2596
case 18:
2597
return "eighteen";
2598
case 19:
2599
return "nineteen";
2600
case 20:
2601
return "twenty";
2602
}
2603
return val;
2604
}
2605
2606
export function numToOrdinal(val: number): string {
2607
// 1 → 1st, 2 → 2nd, 3 → 3rd, 4 → 4th, ... 21 → 21st, ... 101 → 101st, ...
2608
if (!Number.isInteger(val)) return `${val}th`;
2609
const mod100 = val % 100;
2610
if (mod100 >= 11 && mod100 <= 13) {
2611
return `${val}th`;
2612
}
2613
const mod10 = val % 10;
2614
switch (mod10) {
2615
case 1:
2616
return `${val}st`;
2617
case 2:
2618
return `${val}nd`;
2619
case 3:
2620
return `${val}rd`;
2621
default:
2622
return `${val}th`;
2623
}
2624
}
2625
2626
export function hoursToTimeIntervalHuman(num: number): string {
2627
if (num < 24) {
2628
const n = round1(num);
2629
return `${n} ${plural(n, "hour")}`;
2630
} else if (num < 24 * 7) {
2631
const n = round1(num / 24);
2632
return `${n} ${plural(n, "day")}`;
2633
} else {
2634
const n = round1(num / (24 * 7));
2635
return `${n} ${plural(n, "week")}`;
2636
}
2637
}
2638
2639
/**
2640
* Return the last @lines lines of string s, in an efficient way. (e.g. long stdout, and return last 3 lines)
2641
*/
2642
export function tail(s: string, lines: number) {
2643
if (lines < 1) return "";
2644
2645
let lineCount = 0;
2646
let lastIndex = s.length - 1;
2647
2648
// Iterate backwards through the string, searching for newline characters
2649
while (lastIndex >= 0 && lineCount < lines) {
2650
lastIndex = s.lastIndexOf("\n", lastIndex);
2651
if (lastIndex === -1) {
2652
// No more newlines found, return the entire string
2653
return s;
2654
}
2655
lineCount++;
2656
lastIndex--;
2657
}
2658
2659
// Return the substring starting from the next character after the last newline
2660
return s.slice(lastIndex + 2);
2661
}
2662
2663