Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/util/misc.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45export { get_start_time_ts, get_uptime, log, wrap_log } from "./log";67export * from "./misc-path";89import LRU from "lru-cache";1011import {12is_array,13is_integer,14is_object,15is_string,16is_date,17is_set,18} from "./type-checking";1920export { is_array, is_integer, is_object, is_string, is_date, is_set };2122export {23map_limit,24map_max,25map_min,26sum,27is_zero_map,28map_without_undefined_and_null,29map_mutate_out_undefined_and_null,30} from "./maps";3132export { done, done1, done2 } from "./done";3334export {35cmp,36cmp_Date,37cmp_dayjs,38cmp_moment,39cmp_array,40timestamp_cmp,41field_cmp,42is_different,43is_different_array,44shallowCompare,45all_fields_equal,46} from "./cmp";4748export {49server_time,50server_milliseconds_ago,51server_seconds_ago,52server_minutes_ago,53server_hours_ago,54server_days_ago,55server_weeks_ago,56server_months_ago,57milliseconds_before,58seconds_before,59minutes_before,60hours_before,61days_before,62weeks_before,63months_before,64expire_time,65YEAR,66} from "./relative-time";6768import sha1 from "sha1";69export { sha1 };7071import getRandomValues from "get-random-values";72import * as lodash from "lodash";73import * as immutable from "immutable";7475export const keys: (any) => string[] = lodash.keys;7677import { required, defaults, types } from "./opts";78export { required, defaults, types };7980interface SplittedPath {81head: string;82tail: string;83}8485export function path_split(path: string): SplittedPath {86const v = path.split("/");87return { head: v.slice(0, -1).join("/"), tail: v[v.length - 1] };88}8990// NOTE: as of right now, there is definitely some code somewhere91// in cocalc that calls this sometimes with s undefined, and92// typescript doesn't catch it, hence allowing s to be undefined.93export function capitalize(s?: string): string {94if (!s) return "";95return s.charAt(0).toUpperCase() + s.slice(1);96}9798// turn an arbitrary string into a nice clean identifier that can safely be used in an URL99export function make_valid_name(s: string): string {100// for now we just delete anything that isn't alphanumeric.101// See http://stackoverflow.com/questions/9364400/remove-not-alphanumeric-characters-from-string-having-trouble-with-the-char/9364527#9364527102// whose existence surprised me!103return s.replace(/\W/g, "_").toLowerCase();104}105106const filename_extension_re = /(?:\.([^.]+))?$/;107export function filename_extension(filename: string): string {108filename = path_split(filename).tail;109const match = filename_extension_re.exec(filename);110if (!match) {111return "";112}113const ext = match[1];114return ext ? ext : "";115}116117export function filename_extension_notilde(filename: string): string {118let ext = filename_extension(filename);119while (ext && ext[ext.length - 1] === "~") {120// strip tildes from the end of the extension -- put there by rsync --backup, and other backup systems in UNIX.121ext = ext.slice(0, ext.length - 1);122}123return ext;124}125126// If input name foo.bar, returns object {name:'foo', ext:'bar'}.127// If there is no . in input name, returns {name:name, ext:''}128export function separate_file_extension(name: string): {129name: string;130ext: string;131} {132const ext: string = filename_extension(name);133if (ext !== "") {134name = name.slice(0, name.length - ext.length - 1); // remove the ext and the .135}136return { name, ext };137}138139// change the filename's extension to the new one.140// if there is no extension, add it.141export function change_filename_extension(142path: string,143new_ext: string,144): string {145const { name } = separate_file_extension(path);146return `${name}.${new_ext}`;147}148149// Takes parts to a path and intelligently merges them on '/'.150// Continuous non-'/' portions of each part will have at most151// one '/' on either side.152// Each part will have exactly one '/' between it and adjacent parts153// Does NOT resolve up-level references154// See misc-tests for examples.155export function normalized_path_join(...parts): string {156const sep = "/";157const replace = new RegExp(sep + "{1,}", "g");158const result: string[] = [];159for (let x of Array.from(parts)) {160if (x != null && `${x}`.length > 0) {161result.push(`${x}`);162}163}164return result.join(sep).replace(replace, sep);165}166167// Like Python splitlines.168// WARNING -- this is actually NOT like Python splitlines, since it just deletes whitespace lines. TODO: audit usage and fix.169export function splitlines(s: string): string[] {170const r = s.match(/[^\r\n]+/g);171return r ? r : [];172}173174// Like Python's string split -- splits on whitespace175export function split(s: string): string[] {176const r = s.match(/\S+/g);177if (r) {178return r;179} else {180return [];181}182}183184// Modifies in place the object dest so that it185// includes all values in objs and returns dest.186// This is a *shallow* copy.187// Rightmost object overwrites left.188export function merge(dest, ...objs) {189for (const obj of objs) {190for (const k in obj) {191dest[k] = obj[k];192}193}194return dest;195}196197// Makes new object that is *shallow* copy merge of all objects.198export function merge_copy(...objs): object {199return merge({}, ...Array.from(objs));200}201202// copy of map but only with some keys203// I.e., restrict a function to a subset of the domain.204export function copy_with<T>(obj: T, w: string | string[]): Partial<T> {205if (typeof w === "string") {206w = [w];207}208const obj2: any = {};209let key: string;210for (key of w) {211const y = obj[key];212if (y !== undefined) {213obj2[key] = y;214}215}216return obj2;217}218219// copy of map but without some keys220// I.e., restrict a function to the complement of a subset of the domain.221export function copy_without(obj: object, w: string | string[]): object {222if (typeof w === "string") {223w = [w];224}225const r = {};226for (let key in obj) {227const y = obj[key];228if (!Array.from(w).includes(key)) {229r[key] = y;230}231}232return r;233}234235import { cloneDeep } from "lodash";236export const deep_copy = cloneDeep;237238// Very poor man's set.239export function set(v: string[]): { [key: string]: true } {240const s: { [key: string]: true } = {};241for (const x of v) {242s[x] = true;243}244return s;245}246247// see https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object/30042948#30042948248export function copy<T>(obj: T): T {249return lodash.clone(obj);250}251252// startswith(s, x) is true if s starts with the string x or any of the strings in x.253// It is false if s is not a string.254export function startswith(s: any, x: string | string[]): boolean {255if (typeof s != "string") {256return false;257}258if (typeof x === "string") {259return s.startsWith(x);260}261for (const v of x) {262if (s.indexOf(v) === 0) {263return true;264}265}266return false;267}268269export function endswith(s: any, t: any): boolean {270if (typeof s != "string" || typeof t != "string") {271return false;272}273return s.endsWith(t);274}275276import { v4 as v4uuid } from "uuid";277export const uuid: () => string = v4uuid;278279const uuid_regexp = new RegExp(280/[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,281);282export function is_valid_uuid_string(uuid?: any): boolean {283return (284typeof uuid === "string" && uuid.length === 36 && uuid_regexp.test(uuid)285);286}287export function assert_valid_account_id(uuid?: any): void {288if (!is_valid_uuid_string(uuid)) {289throw new Error(`Invalid Account ID: ${uuid}`);290}291}292export const isValidUUID = is_valid_uuid_string;293294export function assertValidAccountID(account_id?: any) {295if (!isValidUUID(account_id)) {296throw Error("account_id is invalid");297}298}299300export function assert_uuid(uuid: string): void {301if (!is_valid_uuid_string(uuid)) {302throw Error(`invalid uuid='${uuid}'`);303}304}305306// Compute a uuid v4 from the Sha-1 hash of data.307// NOTE: If on backend, you should instead import308// the version in misc_node, which is faster.309export function uuidsha1(data: string): string {310const s = sha1(data);311let i = -1;312return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {313i += 1;314switch (c) {315case "x":316return s[i];317case "y":318// take 8 + low order 3 bits of hex number.319return ((parseInt(`0x${s[i]}`, 16) & 0x3) | 0x8).toString(16);320}321});322}323324// returns the number of keys of an object, e.g., {a:5, b:7, d:'hello'} --> 3325export function len(obj: object | undefined | null): number {326if (obj == null) {327return 0;328}329return Object.keys(obj).length;330}331332// Specific, easy to read: describe amount of time before right now333// Use negative input for after now (i.e., in the future).334export function milliseconds_ago(ms: number): Date {335return new Date(Date.now() - ms);336}337export function seconds_ago(s: number) {338return milliseconds_ago(1000 * s);339}340export function minutes_ago(m: number) {341return seconds_ago(60 * m);342}343export function hours_ago(h: number) {344return minutes_ago(60 * h);345}346export function days_ago(d: number) {347return hours_ago(24 * d);348}349export function weeks_ago(w: number) {350return days_ago(7 * w);351}352export function months_ago(m: number) {353return days_ago(30.5 * m);354}355356// Here, we want to know how long ago a certain timestamp was357export function how_long_ago_ms(ts: Date | number): number {358const ts_ms = typeof ts === "number" ? ts : ts.getTime();359return Date.now() - ts_ms;360}361export function how_long_ago_s(ts: Date | number): number {362return how_long_ago_ms(ts) / 1000;363}364export function how_long_ago_m(ts: Date | number): number {365return how_long_ago_s(ts) / 60;366}367368// Current time in milliseconds since epoch or t.369export function mswalltime(t?: number): number {370return Date.now() - (t ?? 0);371}372373// Current time in seconds since epoch, as a floating point374// number (so much more precise than just seconds), or time375// since t.376export function walltime(t?: number): number {377return mswalltime() / 1000.0 - (t ?? 0);378}379380// encode a UNIX path, which might have # and % in it.381// Maybe alternatively, (encodeURIComponent(p) for p in path.split('/')).join('/') ?382export function encode_path(path) {383path = encodeURI(path); // doesn't escape # and ?, since they are special for urls (but not unix paths)384return path.replace(/#/g, "%23").replace(/\?/g, "%3F");385}386387const reValidEmail = (function () {388const sQtext = "[^\\x0d\\x22\\x5c\\x80-\\xff]";389const sDtext = "[^\\x0d\\x5b-\\x5d\\x80-\\xff]";390const sAtom =391"[^\\x00-\\x20\\x22\\x28\\x29\\x2c\\x2e\\x3a-\\x3c\\x3e\\x40\\x5b-\\x5d\\x7f-\\xff]+";392const sQuotedPair = "\\x5c[\\x00-\\x7f]";393const sDomainLiteral = `\\x5b(${sDtext}|${sQuotedPair})*\\x5d`;394const sQuotedString = `\\x22(${sQtext}|${sQuotedPair})*\\x22`;395const sDomain_ref = sAtom;396const sSubDomain = `(${sDomain_ref}|${sDomainLiteral})`;397const sWord = `(${sAtom}|${sQuotedString})`;398const sDomain = sSubDomain + "(\\x2e" + sSubDomain + ")*";399const sLocalPart = sWord + "(\\x2e" + sWord + ")*";400const sAddrSpec = sLocalPart + "\\x40" + sDomain; // complete RFC822 email address spec401const sValidEmail = `^${sAddrSpec}$`; // as whole string402return new RegExp(sValidEmail);403})();404405export function is_valid_email_address(email: string): boolean {406// From http://stackoverflow.com/questions/46155/validate-email-address-in-javascript407// but converted to Javascript; it's near the middle but claims to be exactly RFC822.408if (reValidEmail.test(email)) {409return true;410} else {411return false;412}413}414415export function assert_valid_email_address(email: string): void {416if (!is_valid_email_address(email)) {417throw Error(`Invalid email address: ${email}`);418}419}420421export const to_json = JSON.stringify;422423// gives the plural form of the word if the number should be plural424export function plural(425number: number = 0,426singular: string,427plural: string = `${singular}s`,428) {429if (["GB", "G", "MB"].includes(singular)) {430return singular;431}432if (number === 1) {433return singular;434} else {435return plural;436}437}438439const ELLIPSIS = "…";440// "foobar" --> "foo…"441export function trunc<T>(442sArg: T,443max_length = 1024,444ellipsis = ELLIPSIS,445): string | T {446if (sArg == null) {447return sArg;448}449const s = typeof sArg !== "string" ? `${sArg}` : sArg;450if (s.length > max_length) {451if (max_length < 1) {452throw new Error("ValueError: max_length must be >= 1");453}454return s.slice(0, max_length - 1) + ellipsis;455} else {456return s;457}458}459460// "foobar" --> "fo…ar"461export function trunc_middle<T>(462sArg: T,463max_length = 1024,464ellipsis = ELLIPSIS,465): T | string {466if (sArg == null) {467return sArg;468}469const s = typeof sArg !== "string" ? `${sArg}` : sArg;470if (s.length <= max_length) {471return s;472}473if (max_length < 1) {474throw new Error("ValueError: max_length must be >= 1");475}476const n = Math.floor(max_length / 2);477return (478s.slice(0, n - 1 + (max_length % 2 ? 1 : 0)) +479ellipsis +480s.slice(s.length - n)481);482}483484// "foobar" --> "…bar"485export function trunc_left<T>(486sArg: T,487max_length = 1024,488ellipsis = ELLIPSIS,489): T | string {490if (sArg == null) {491return sArg;492}493const s = typeof sArg !== "string" ? `${sArg}` : sArg;494if (s.length > max_length) {495if (max_length < 1) {496throw new Error("ValueError: max_length must be >= 1");497}498return ellipsis + s.slice(s.length - max_length + 1);499} else {500return s;501}502}503504/*505Like the immutable.js getIn, but on the thing x.506*/507508export function getIn(x: any, path: string[], default_value?: any): any {509for (const key of path) {510if (x !== undefined) {511try {512x = x[key];513} catch (err) {514return default_value;515}516} else {517return default_value;518}519}520return x === undefined ? default_value : x;521}522523// see http://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript524export function replace_all(525s: string,526search: string,527replace: string,528): string {529return s.split(search).join(replace);530}531532// Similar to replace_all, except it takes as input a function replace_f, which533// returns what to replace the i-th copy of search in string with.534export function replace_all_function(535s: string,536search: string,537replace_f: (i: number) => string,538): string {539const v = s.split(search);540const w: string[] = [];541for (let i = 0; i < v.length; i++) {542w.push(v[i]);543if (i < v.length - 1) {544w.push(replace_f(i));545}546}547return w.join("");548}549550export function path_to_title(path: string): string {551const subtitle = separate_file_extension(path_split(path).tail).name;552return capitalize(replace_all(replace_all(subtitle, "-", " "), "_", " "));553}554555// names is a Set<string>556export function list_alternatives(names): string {557names = names.map((x) => x.toUpperCase()).toJS();558if (names.length == 1) {559return names[0];560} else if (names.length == 2) {561return `${names[0]} or ${names[1]}`;562}563return names.join(", ");564}565566// convert x to a useful string to show to a user.567export function to_user_string(x: any): string {568switch (typeof x) {569case "undefined":570return "undefined";571case "number":572case "symbol":573case "boolean":574return x.toString();575case "function":576return x.toString();577case "object":578if (typeof x.toString !== "function") {579return JSON.stringify(x);580}581const a = x.toString(); // is much better than stringify for exceptions (etc.).582if (a === "[object Object]") {583return JSON.stringify(x);584} else {585return a;586}587default:588return JSON.stringify(x);589}590}591592// delete any null fields, to avoid wasting space.593export function delete_null_fields(obj: object): void {594for (const k in obj) {595if (obj[k] == null) {596delete obj[k];597}598}599}600601// for switch/case -- https://www.typescriptlang.org/docs/handbook/advanced-types.html602export function unreachable(x: never) {603// if this fails a typecheck here, go back to your switch/case.604// you either made a typo in one of the cases or you missed one.605const tmp: never = x;606tmp;607}608609// Get *all* methods of an object (including from base classes!).610// See https://flaviocopes.com/how-to-list-object-methods-javascript/611// This is used by bind_methods below to bind all methods612// of an instance of an object, all the way up the613// prototype chain, just to be 100% sure!614function get_methods(obj: object): string[] {615let properties = new Set<string>();616let current_obj = obj;617do {618Object.getOwnPropertyNames(current_obj).map((item) => properties.add(item));619} while ((current_obj = Object.getPrototypeOf(current_obj)));620return [...properties.keys()].filter(621(item) => typeof obj[item] === "function",622);623}624625// Bind all or specified methods of the object. If method_names626// is not given, binds **all** methods.627// For example, in a base class constructor, you can do628// bind_methods(this);629// and every method will always be bound even for derived classes630// (assuming they call super if they overload the constructor!).631// Do this for classes that don't get created in a tight inner632// loop and for which you want 'safer' semantics.633export function bind_methods<T extends object>(634obj: T,635method_names: undefined | string[] = undefined,636): T {637if (method_names === undefined) {638method_names = get_methods(obj);639method_names.splice(method_names.indexOf("constructor"), 1);640}641for (const method_name of method_names) {642obj[method_name] = obj[method_name].bind(obj);643}644return obj;645}646647export function human_readable_size(648bytes: number | null | undefined,649short = false,650): string {651if (bytes == null) {652return "?";653}654if (bytes < 1000) {655return `${bytes} ${short ? "b" : "bytes"}`;656}657if (bytes < 1000000) {658const b = Math.floor(bytes / 100);659return `${b / 10} KB`;660}661if (bytes < 1000000000) {662const b = Math.floor(bytes / 100000);663return `${b / 10} MB`;664}665const b = Math.floor(bytes / 100000000);666return `${b / 10} GB`;667}668669// Regexp used to test for URLs in a string.670// We just use a simple one that was a top Google search when I searched: https://www.regextester.com/93652671// We don't use a complicated one like https://www.npmjs.com/package/url-regex, since672// (1) it is heavy and doesn't work on Edge -- https://github.com/sagemathinc/cocalc/issues/4056673// (2) it's not bad if we are extra conservative. E.g., url-regex "matches the TLD against a list of valid TLDs."674// which is really overkill for preventing abuse, and is clearly more aimed at highlighting URL's675// properly (not our use case).676export const re_url =677/(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?/gi;678679export function contains_url(str: string): boolean {680return !!str.toLowerCase().match(re_url);681}682683export function hidden_meta_file(path: string, ext: string): string {684const p = path_split(path);685let head: string = p.head;686if (head !== "") {687head += "/";688}689return head + "." + p.tail + "." + ext;690}691692export function history_path(path: string): string {693return hidden_meta_file(path, "time-travel");694}695696export function meta_file(path: string, ext: string): string {697return hidden_meta_file(path, "sage-" + ext);698}699700// helps with converting an array of strings to a union type of strings.701// usage: 1. const foo : string[] = tuple(["bar", "baz"]);702// 2. type Foo = typeof foo[number]; // bar | baz;703//704// NOTE: in newer TS versions, it's fine to define the string[] list with "as const", then step 2.705export function tuple<T extends string[]>(o: T) {706return o;707}708709export function aux_file(path: string, ext: string): string {710const s = path_split(path);711s.tail += "." + ext;712if (s.head) {713return s.head + "/." + s.tail;714} else {715return "." + s.tail;716}717}718719export function auxFileToOriginal(path: string): string {720const { head, tail } = path_split(path);721const i = tail.lastIndexOf(".");722const filename = tail.slice(1, i);723if (!head) {724return filename;725}726return head + "/" + filename;727}728729/*730Generate a cryptographically safe secure random string with73116 characters chosen to be reasonably unambiguous to look at.732That is 93 bits of randomness, and there is an argument here733that 64 bits is enough:734735https://security.stackexchange.com/questions/1952/how-long-should-a-random-nonce-be736*/737const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";738export function secure_random_token(739length: number = 16,740alphabet: string = BASE58, // default is this crypto base58 less ambiguous numbers/letters741): string {742let s = "";743if (length == 0) return s;744if (alphabet.length == 0) {745throw Error("impossible, since alphabet is empty");746}747const v = new Uint8Array(length);748getRandomValues(v); // secure random numbers749for (const i of v) {750s += alphabet[i % alphabet.length];751}752return s;753}754755// Return a random element of an array.756// If array has length 0 will return undefined.757export function random_choice(v: any[]): any {758return v[Math.floor(Math.random() * v.length)];759}760761// Called when an object will not be used further, to avoid762// it references anything that could lead to memory leaks.763764export function close(obj: object, omit?: Set<string>): void {765if (omit != null) {766Object.keys(obj).forEach(function (key) {767if (omit.has(key)) return;768if (typeof obj[key] == "function") return;769delete obj[key];770});771} else {772Object.keys(obj).forEach(function (key) {773if (typeof obj[key] == "function") return;774delete obj[key];775});776}777}778779// return true if the word contains the substring780export function contains(word: string, sub: string): boolean {781return word.indexOf(sub) !== -1;782}783784export function assertDefined<T>(val: T): asserts val is NonNullable<T> {785if (val === undefined || val === null) {786throw new Error(`Expected 'val' to be defined, but received ${val}`);787}788}789790export function round1(num: number): number {791return Math.round(num * 10) / 10;792}793794// Round given number to 2 decimal places795export function round2(num: number): number {796// padding to fix floating point issue (see http://stackoverflow.com/questions/11832914/round-to-at-most-2-decimal-places-in-javascript)797return Math.round((num + 0.00001) * 100) / 100;798}799800export function round3(num: number): number {801return Math.round((num + 0.000001) * 1000) / 1000;802}803804export function round4(num: number): number {805return Math.round((num + 0.0000001) * 10000) / 10000;806}807808// Round given number up to 2 decimal places, for the809// purposes of dealing with money. We use toFixed to810// accomplish this, because we care about the decimal811// representation, not the exact internal binary number.812// Doing ' Math.ceil(num * 100) / 100', is wrong because813// e.g., numbers like 4.73 are not representable in binary, e.g.,814// > 4.73 = 100.101110101110000101000111101011100001010001111011... forever815export function round2up(num: number): number {816// This rounds the number to the closest 2-digit decimal representation.817// It can be LESS than num, e.g., (0.356).toFixed(2) == '0.36'818const rnd = parseFloat(num.toFixed(2));819if (rnd >= num) {820// it rounded up.821return rnd;822}823// It rounded down, so we add a penny to num first,824// to ensure that rounding is up.825return parseFloat((num + 0.01).toFixed(2));826}827828// Round given number down to 2 decimal places, suitable for829// dealing with money.830export function round2down(num: number): number {831// This rounds the number to the closest 2-digit decimal representation.832// It can be LESS than num, e.g., (0.356).toFixed(2) == '0.36'833const rnd = parseFloat(num.toFixed(2));834if (rnd <= num) {835// it rounded down: good.836return rnd;837}838// It rounded up, so we subtract a penny to num first,839// to ensure that rounding is down.840return parseFloat((num - 0.01).toFixed(2));841}842843// returns the number parsed from the input text, or undefined if invalid844// rounds to the nearest 0.01 if round_number is true (default : true)845// allows negative numbers if allow_negative is true (default : false)846export function parse_number_input(847input: any,848round_number: boolean = true,849allow_negative: boolean = false,850): number | undefined {851if (typeof input == "boolean") {852return input ? 1 : 0;853}854855if (typeof input == "number") {856// easy to parse857if (!isFinite(input)) {858return;859}860if (!allow_negative && input < 0) {861return;862}863return input;864}865866if (input == null || !input) return 0;867868let val;869const v = `${input}`.split("/"); // fraction?870if (v.length !== 1 && v.length !== 2) {871return undefined;872}873if (v.length === 2) {874// a fraction875val = parseFloat(v[0]) / parseFloat(v[1]);876}877if (v.length === 1) {878val = parseFloat(v[0]);879if (isNaN(val) || v[0].trim() === "") {880// Shockingly, whitespace returns false for isNaN!881return undefined;882}883}884if (round_number) {885val = round2(val);886}887if (isNaN(val) || val === Infinity || (val < 0 && !allow_negative)) {888return undefined;889}890return val;891}892893// MUTATE map by coercing each element of codomain to a number,894// with false->0 and true->1895// Non finite values coerce to 0.896// Also, returns map.897export function coerce_codomain_to_numbers(map: { [k: string]: any }): {898[k: string]: number;899} {900for (const k in map) {901const x = map[k];902if (typeof x === "boolean") {903map[k] = x ? 1 : 0;904} else {905try {906const t = parseFloat(x);907if (isFinite(t)) {908map[k] = t;909} else {910map[k] = 0;911}912} catch (_) {913map[k] = 0;914}915}916}917return map;918}919920// arithmetic of maps with codomain numbers; missing values921// default to 0. Despite the typing being that codomains are922// all numbers, we coerce null values to 0 as well, and all codomain923// values to be numbers, since definitely some client code doesn't924// pass in properly typed inputs.925export function map_sum(926a?: { [k: string]: number },927b?: { [k: string]: number },928): { [k: string]: number } {929if (a == null) {930return coerce_codomain_to_numbers(b ?? {});931}932if (b == null) {933return coerce_codomain_to_numbers(a ?? {});934}935a = coerce_codomain_to_numbers(a);936b = coerce_codomain_to_numbers(b);937const c: { [k: string]: number } = {};938for (const k in a) {939c[k] = (a[k] ?? 0) + (b[k] ?? 0);940}941for (const k in b) {942if (c[k] == null) {943// anything in iteration above will be a number; also,944// we know a[k] is null, since it was definintely not945// iterated through above.946c[k] = b[k] ?? 0;947}948}949return c;950}951952export function map_diff(953a?: { [k: string]: number },954b?: { [k: string]: number },955): { [k: string]: number } {956if (b == null) {957return coerce_codomain_to_numbers(a ?? {});958}959b = coerce_codomain_to_numbers(b);960const c: { [k: string]: number } = {};961if (a == null) {962for (const k in b) {963c[k] = -(b[k] ?? 0);964}965return c;966}967a = coerce_codomain_to_numbers(a);968for (const k in a) {969c[k] = (a[k] ?? 0) - (b[k] ?? 0);970}971for (const k in b) {972if (c[k] == null) {973// anything in iteration above will be a number; also,974// we know a[k] is null, since it was definintely not975// iterated through above.976c[k] = -(b[k] ?? 0);977}978}979return c;980}981982// Like the split method, but quoted terms are grouped983// together for an exact search. Terms that start and end in984// a forward slash '/' are converted to regular expressions.985export function search_split(986search: string,987allowRegexp: boolean = true,988regexpOptions: string = "i",989): (string | RegExp)[] {990search = search.trim();991if (992allowRegexp &&993search.length > 2 &&994search[0] == "/" &&995search[search.length - 1] == "/"996) {997// in case when entire search is clearly meant to be a regular expression,998// we directly try for that first. This is one thing that is documented999// to work regarding regular expressions, and a search like '/a b/' with1000// whitespace in it would work. That wouldn't work below unless you explicitly1001// put quotes around it.1002const t = stringOrRegExp(search, regexpOptions);1003if (typeof t != "string") {1004return [t];1005}1006}10071008// Now we split on whitespace, allowing for quotes, and get all the search1009// terms and possible regexps.1010const terms: (string | RegExp)[] = [];1011const v = search.split('"');1012const { length } = v;1013for (let i = 0; i < v.length; i++) {1014let element = v[i];1015element = element.trim();1016if (element.length == 0) continue;1017if (i % 2 === 0 || (i === length - 1 && length % 2 === 0)) {1018// The even elements lack quotation1019// if there are an even number of elements that means there is1020// an unclosed quote, so the last element shouldn't be grouped.1021for (const s of split(element)) {1022terms.push(allowRegexp ? stringOrRegExp(s, regexpOptions) : s);1023}1024} else {1025terms.push(1026allowRegexp ? stringOrRegExp(element, regexpOptions) : element,1027);1028}1029}1030return terms;1031}10321033// Convert a string that starts and ends in / to a regexp,1034// if it is a VALID regular expression. Otherwise, returns1035// string.1036function stringOrRegExp(s: string, options: string): string | RegExp {1037if (s.length < 2 || s[0] != "/" || s[s.length - 1] != "/")1038return s.toLowerCase();1039try {1040return new RegExp(s.slice(1, -1), options);1041} catch (_err) {1042// if there is an error, then we just use the string itself1043// in the search. We assume anybody using regexp's in a search1044// is reasonably sophisticated, so they don't need hand holding1045// error messages (CodeMirror doesn't give any indication when1046// a regexp is invalid).1047return s.toLowerCase();1048}1049}10501051function isMatch(s: string, x: string | RegExp): boolean {1052if (typeof x == "string") {1053if (x[0] == "-") {1054// negate1055if (x.length == 1) {1056// special case of empty -- no-op, since when you type -foo, you first type "-" and it1057// is disturbing for everything to immediately vanish.1058return true;1059}1060return !isMatch(s, x.slice(1));1061}1062if (x[0] === "#") {1063// only match hashtag at end of word (the \b), so #fo does not match #foo.1064return s.search(new RegExp(x + "\\b")) != -1;1065}1066return s.includes(x);1067} else {1068// regular expression instead of string1069return x.test?.(s);1070}1071return false;1072}10731074// s = lower case string1075// v = array of search terms as output by search_split above1076export function search_match(s: string, v: (string | RegExp)[]): boolean {1077if (typeof s != "string" || !is_array(v)) {1078// be safe against non Typescript clients1079return false;1080}1081s = s.toLowerCase();1082// we also make a version with no backslashes, since our markdown slate editor does a lot1083// of escaping, e.g., of dashes, and this is confusing when doing searches, e.g., see1084// https://github.com/sagemathinc/cocalc/issues/69151085const s1 = s.replace(/\\/g, "");1086for (let x of v) {1087if (!isMatch(s, x) && !isMatch(s1, x)) return false;1088}1089// no term doesn't match, so we have a match.1090return true;1091}10921093export let RUNNING_IN_NODE: boolean;1094try {1095RUNNING_IN_NODE = process?.title == "node";1096} catch (_err) {1097// error since process probably not defined at all (unless there is a node polyfill).1098RUNNING_IN_NODE = false;1099}11001101/*1102The functions to_json_socket and from_json_socket are for sending JSON data back1103and forth in serialized form over a socket connection. They replace Date objects by the1104object {DateEpochMS:ms_since_epoch} *only* during transit. This is much better than1105converting to ISO, then using a regexp, since then all kinds of strings will get1106converted that were never meant to be date objects at all, e.g., a filename that is1107a ISO time string. Also, ms since epoch is less ambiguous regarding old/different1108browsers, and more compact.11091110If you change SOCKET_DATE_KEY, then all clients and servers and projects must be1111simultaneously restarted. And yes, I perhaps wish I had made this key more obfuscated.1112That said, we also check the object length when translating back so only objects1113exactly of the form {DateEpochMS:value} get transformed to a date.1114*/1115const SOCKET_DATE_KEY = "DateEpochMS";11161117function socket_date_replacer(key: string, value: any): any {1118// @ts-ignore1119const x = this[key];1120return x instanceof Date ? { [SOCKET_DATE_KEY]: x.valueOf() } : value;1121}11221123export function to_json_socket(x: any): string {1124return JSON.stringify(x, socket_date_replacer);1125}11261127function socket_date_parser(_key: string, value: any): any {1128const x = value?.[SOCKET_DATE_KEY];1129return x != null && len(value) == 1 ? new Date(x) : value;1130}11311132export function from_json_socket(x: string): any {1133try {1134return JSON.parse(x, socket_date_parser);1135} catch (err) {1136console.debug(`from_json: error parsing ${x} (=${to_json(x)}) from JSON`);1137throw err;1138}1139}11401141// convert object x to a JSON string, removing any keys that have "pass" in them and1142// any values that are potentially big -- this is meant to only be used for logging.1143export function to_safe_str(x: any): string {1144if (typeof x === "string") {1145// nothing we can do at this point -- already a string.1146return x;1147}1148const obj = {};1149for (const key in x) {1150let value = x[key];1151let sanitize = false;11521153if (1154key.indexOf("pass") !== -1 ||1155key.indexOf("token") !== -1 ||1156key.indexOf("secret") !== -11157) {1158sanitize = true;1159} else if (typeof value === "string" && value.slice(0, 7) === "sha512$") {1160sanitize = true;1161}11621163if (sanitize) {1164obj[key] = "(unsafe)";1165} else {1166if (typeof value === "object") {1167value = "[object]"; // many objects, e.g., buffers can block for seconds to JSON...1168} else if (typeof value === "string") {1169value = trunc(value, 1000); // long strings are not SAFE -- since JSON'ing them for logging blocks for seconds!1170}1171obj[key] = value;1172}1173}11741175return JSON.stringify(obj);1176}11771178// convert from a JSON string to Javascript (properly dealing with ISO dates)1179// e.g., 2016-12-12T02:12:03.239Z and 2016-12-12T02:02:53.3587521180const reISO =1181/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*))(?:Z|(\+|-)([\d|:]*))?$/;1182export function date_parser(_key: string, value) {1183if (typeof value === "string" && value.length >= 20 && reISO.exec(value)) {1184return ISO_to_Date(value);1185} else {1186return value;1187}1188}11891190export function ISO_to_Date(s: string): Date {1191if (s.indexOf("Z") === -1) {1192// Firefox assumes local time rather than UTC if there is no Z. However,1193// our backend might possibly send a timestamp with no Z and it should be1194// interpretted as UTC anyways.1195// That said, with the to_json_socket/from_json_socket code, the browser1196// shouldn't be running this parser anyways.1197// In particular: TODO -- completely get rid of using this in from_json... if possible!1198s += "Z";1199}1200return new Date(s);1201}12021203export function from_json(x: string): any {1204try {1205return JSON.parse(x, date_parser);1206} catch (err) {1207console.debug(`from_json: error parsing ${x} (=${to_json(x)}) from JSON`);1208throw err;1209}1210}12111212// Returns modified version of obj with any string1213// that look like ISO dates to actual Date objects. This mutates1214// obj in place as part of the process.1215// date_keys = 'all' or list of keys in nested object whose values1216// should be considered. Nothing else is considered!1217export function fix_json_dates(obj: any, date_keys?: "all" | string[]) {1218if (date_keys == null) {1219// nothing to do1220return obj;1221}1222if (is_object(obj)) {1223for (let k in obj) {1224const v = obj[k];1225if (typeof v === "object") {1226fix_json_dates(v, date_keys);1227} else if (1228typeof v === "string" &&1229v.length >= 20 &&1230reISO.exec(v) &&1231(date_keys === "all" || Array.from(date_keys).includes(k))1232) {1233obj[k] = new Date(v);1234}1235}1236} else if (is_array(obj)) {1237for (let i in obj) {1238const x = obj[i];1239obj[i] = fix_json_dates(x, date_keys);1240}1241} else if (1242typeof obj === "string" &&1243obj.length >= 20 &&1244reISO.exec(obj) &&1245date_keys === "all"1246) {1247return new Date(obj);1248}1249return obj;1250}12511252// converts a Date object to an ISO string in UTC.1253// NOTE -- we remove the +0000 (or whatever) timezone offset, since *all* machines within1254// the CoCalc servers are assumed to be on UTC.1255function to_iso(d: Date): string {1256return new Date(d.valueOf() - d.getTimezoneOffset() * 60 * 1000)1257.toISOString()1258.slice(0, -5);1259}12601261// turns a Date object into a more human readable more friendly directory name in the local timezone1262export function to_iso_path(d: Date): string {1263return to_iso(d).replace("T", "-").replace(/:/g, "");1264}12651266// does the given object (first arg) have the given key (second arg)?1267export const has_key: (obj: object, path: string[] | string) => boolean =1268lodash.has;12691270// returns the values of a map1271export const values = lodash.values;12721273// as in python, makes a map from an array of pairs [(x,y),(z,w)] --> {x:y, z:w}1274export function dict(v: [string, any][]): { [key: string]: any } {1275const obj: { [key: string]: any } = {};1276for (let a of Array.from(v)) {1277if (a.length !== 2) {1278throw new Error("ValueError: unexpected length of tuple");1279}1280obj[a[0]] = a[1];1281}1282return obj;1283}12841285// remove first occurrence of value (just like in python);1286// throws an exception if val not in list.1287// mutates arr.1288export function remove(arr: any[], val: any): void {1289for (1290let i = 0, end = arr.length, asc = 0 <= end;1291asc ? i < end : i > end;1292asc ? i++ : i--1293) {1294if (arr[i] === val) {1295arr.splice(i, 1);1296return;1297}1298}1299throw new Error("ValueError -- item not in array");1300}13011302export const max: (x: any[]) => any = lodash.max;1303export const min: (x: any[]) => any = lodash.min;13041305// Takes a path string and file name and gives the full path to the file1306export function path_to_file(path: string = "", file: string): string {1307if (path === "") {1308return file;1309}1310return path + "/" + file;1311}13121313// Given a path of the form foo/bar/.baz.ext.something returns foo/bar/baz.ext.1314// For example:1315// .example.ipynb.sage-jupyter --> example.ipynb1316// tmp/.example.ipynb.sage-jupyter --> tmp/example.ipynb1317// .foo.txt.sage-chat --> foo.txt1318// tmp/.foo.txt.sage-chat --> tmp/foo.txt13191320export function original_path(path: string): string {1321const s = path_split(path);1322if (s.tail[0] != "." || s.tail.indexOf(".sage-") == -1) {1323return path;1324}1325const ext = filename_extension(s.tail);1326let x = s.tail.slice(1327s.tail[0] === "." ? 1 : 0,1328s.tail.length - (ext.length + 1),1329);1330if (s.head !== "") {1331x = s.head + "/" + x;1332}1333return x;1334}13351336export function lower_email_address(email_address: any): string {1337if (email_address == null) {1338return "";1339}1340if (typeof email_address !== "string") {1341// silly, but we assume it is a string, and I'm concerned1342// about an attack involving badly formed messages1343email_address = JSON.stringify(email_address);1344}1345// make email address lower case1346return email_address.toLowerCase();1347}13481349// Parses a string representing a search of users by email or non-email1350// Expects the string to be delimited by commas or semicolons1351// between multiple users1352//1353// Non-email strings are ones without an '@' and will be split on whitespace1354//1355// Emails may be wrapped by angle brackets.1356// ie. <[email protected]> is valid and understood as [email protected]1357// (Note that <<[email protected]> will be <[email protected] which is not valid)1358// Emails must be legal as specified by RFC8221359//1360// returns an object with the queries in lowercase1361// eg.1362// {1363// string_queries: [["firstname", "lastname"], ["somestring"]]1364// email_queries: ["[email protected]", "[email protected]"]1365// }1366export function parse_user_search(query: string): {1367string_queries: string[][];1368email_queries: string[];1369} {1370const r = { string_queries: [] as string[][], email_queries: [] as string[] };1371if (typeof query !== "string") {1372// robustness against bad input from non-TS client.1373return r;1374}1375const queries = query1376.split("\n")1377.map((q1) => q1.split(/,|;/))1378.reduce((acc, val) => acc.concat(val), []) // flatten1379.map((q) => q.trim().toLowerCase());1380const email_re = /<(.*)>/;1381for (const x of queries) {1382if (x) {1383if (x.indexOf("@") === -1 || x.startsWith("@")) {1384// Is obviously not an email, e.g., no @ or starts with @ = username, e.g., @wstein.1385r.string_queries.push(x.split(/\s+/g));1386} else {1387// Might be an email address:1388// extract just the email address out1389for (let a of split(x)) {1390// Ensures that we don't throw away emails like1391// "<validEmail>"[email protected]1392if (a[0] === "<") {1393const match = email_re.exec(a);1394a = match != null ? match[1] : a;1395}1396if (is_valid_email_address(a)) {1397r.email_queries.push(a);1398}1399}1400}1401}1402}1403return r;1404}14051406// Delete trailing whitespace in the string s.1407export function delete_trailing_whitespace(s: string): string {1408return s.replace(/[^\S\n]+$/gm, "");1409}14101411export function retry_until_success(opts: {1412f: Function;1413start_delay?: number;1414max_delay?: number;1415factor?: number;1416max_tries?: number;1417max_time?: number;1418log?: Function;1419warn?: Function;1420name?: string;1421cb?: Function;1422}): void {1423let start_time;1424opts = defaults(opts, {1425f: required, // f((err) => )1426start_delay: 100, // milliseconds1427max_delay: 20000, // milliseconds -- stop increasing time at this point1428factor: 1.4, // multiply delay by this each time1429max_tries: undefined, // maximum number of times to call f1430max_time: undefined, // milliseconds -- don't call f again if the call would start after this much time from first call1431log: undefined,1432warn: undefined,1433name: "",1434cb: undefined, // called with cb() on *success*; cb(error, last_error) if max_tries is exceeded1435});1436let delta = opts.start_delay as number;1437let tries = 0;1438if (opts.max_time != null) {1439start_time = new Date();1440}1441const g = function () {1442tries += 1;1443if (opts.log != null) {1444if (opts.max_tries != null) {1445opts.log(1446`retry_until_success(${opts.name}) -- try ${tries}/${opts.max_tries}`,1447);1448}1449if (opts.max_time != null) {1450opts.log(1451`retry_until_success(${opts.name}) -- try ${tries} (started ${1452Date.now() - start_time1453}ms ago; will stop before ${opts.max_time}ms max time)`,1454);1455}1456if (opts.max_tries == null && opts.max_time == null) {1457opts.log(`retry_until_success(${opts.name}) -- try ${tries}`);1458}1459}1460opts.f(function (err) {1461if (err) {1462if (err === "not_public") {1463opts.cb?.("not_public");1464return;1465}1466if (err && opts.warn != null) {1467opts.warn(1468`retry_until_success(${opts.name}) -- err=${JSON.stringify(err)}`,1469);1470}1471if (opts.log != null) {1472opts.log(1473`retry_until_success(${opts.name}) -- err=${JSON.stringify(err)}`,1474);1475}1476if (opts.max_tries != null && opts.max_tries <= tries) {1477opts.cb?.(1478`maximum tries (=${1479opts.max_tries1480}) exceeded - last error ${JSON.stringify(err)}`,1481err,1482);1483return;1484}1485delta = Math.min(1486opts.max_delay as number,1487(opts.factor as number) * delta,1488);1489if (1490opts.max_time != null &&1491Date.now() - start_time + delta > opts.max_time1492) {1493opts.cb?.(1494`maximum time (=${1495opts.max_time1496}ms) exceeded - last error ${JSON.stringify(err)}`,1497err,1498);1499return;1500}1501return setTimeout(g, delta);1502} else {1503if (opts.log != null) {1504opts.log(`retry_until_success(${opts.name}) -- success`);1505}1506opts.cb?.();1507}1508});1509};1510g();1511}15121513// Class to use for mapping a collection of strings to characters (e.g., for use with diff/patch/match).1514export class StringCharMapping {1515private _to_char: { [s: string]: string } = {};1516private _next_char: string = "A";1517public _to_string: { [s: string]: string } = {}; // yes, this is publicly accessed (TODO: fix)15181519constructor(opts?) {1520let ch, st;1521this.find_next_char = this.find_next_char.bind(this);1522this.to_string = this.to_string.bind(this);1523this.to_array = this.to_array.bind(this);1524if (opts == null) {1525opts = {};1526}1527opts = defaults(opts, {1528to_char: undefined,1529to_string: undefined,1530});1531if (opts.to_string != null) {1532for (ch in opts.to_string) {1533st = opts.to_string[ch];1534this._to_string[ch] = st;1535this._to_char[st] = ch;1536}1537}1538if (opts.to_char != null) {1539for (st in opts.to_char) {1540ch = opts.to_char[st];1541this._to_string[ch] = st;1542this._to_char[st] = ch;1543}1544}1545this.find_next_char();1546}15471548private find_next_char(): void {1549while (true) {1550this._next_char = String.fromCharCode(this._next_char.charCodeAt(0) + 1);1551if (this._to_string[this._next_char] == null) {1552// found it!1553break;1554}1555}1556}15571558public to_string(strings: string[]): string {1559let t = "";1560for (const s of strings) {1561const a = this._to_char[s];1562if (a != null) {1563t += a;1564} else {1565t += this._next_char;1566this._to_char[s] = this._next_char;1567this._to_string[this._next_char] = s;1568this.find_next_char();1569}1570}1571return t;1572}15731574public to_array(x: string): string[] {1575return Array.from(x).map((s) => this.to_string[s]);1576}1577}15781579// Used in the database, etc., for different types of users of a project1580export const PROJECT_GROUPS: string[] = [1581"owner",1582"collaborator",1583"viewer",1584"invited_collaborator",1585"invited_viewer",1586];15871588// format is 2014-04-04-0615021589export function parse_bup_timestamp(s: string): Date {1590const v = [1591s.slice(0, 4),1592s.slice(5, 7),1593s.slice(8, 10),1594s.slice(11, 13),1595s.slice(13, 15),1596s.slice(15, 17),1597"0",1598];1599return new Date(`${v[1]}/${v[2]}/${v[0]} ${v[3]}:${v[4]}:${v[5]} UTC`);1600}16011602// NOTE: this hash works, but the crypto hashes in nodejs, eg.,1603// sha1 (as used here packages/backend/sha1.ts) are MUCH faster1604// for large strings. If there is some way to switch to one of those,1605// it would be better, but we have to worry about how this is already deployed1606// e.g., hashes in the database.1607export function hash_string(s: string): number {1608if (typeof s != "string") {1609return 0; // just in case non-typescript code tries to use this1610}1611// see http://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery1612let hash = 0;1613if (s.length === 0) {1614return hash;1615}1616const n = s.length;1617for (let i = 0; i < n; i++) {1618const chr = s.charCodeAt(i);1619hash = (hash << 5) - hash + chr;1620hash |= 0; // convert to 32-bit integer1621}1622return hash;1623}16241625export function parse_hashtags(t: string): [number, number][] {1626// return list of pairs (i,j) such that t.slice(i,j) is a hashtag (starting with #).1627const v: [number, number][] = [];1628if (typeof t != "string") {1629// in case of non-Typescript user1630return v;1631}1632let base = 0;1633while (true) {1634let i: number = t.indexOf("#");1635if (i === -1 || i === t.length - 1) {1636return v;1637}1638base += i + 1;1639if (t[i + 1] === "#" || !(i === 0 || t[i - 1].match(/\s/))) {1640t = t.slice(i + 1);1641continue;1642}1643t = t.slice(i + 1);1644// find next whitespace or non-alphanumeric or dash1645// TODO: this lines means hashtags must be US ASCII --1646// see http://stackoverflow.com/questions/1661197/valid-characters-for-javascript-variable-names1647const m = t.match(/\s|[^A-Za-z0-9_\-]/);1648if (m && m.index != null) {1649i = m.index;1650} else {1651i = -1;1652}1653if (i === 0) {1654// hash followed immediately by whitespace -- markdown desc1655base += i + 1;1656t = t.slice(i + 1);1657} else {1658// a hash tag1659if (i === -1) {1660// to the end1661v.push([base - 1, base + t.length]);1662return v;1663} else {1664v.push([base - 1, base + i]);1665base += i + 1;1666t = t.slice(i + 1);1667}1668}1669}1670}16711672// Return true if (1) path is contained in one1673// of the given paths (a list of strings) -- or path without1674// zip extension is in paths.1675// Always returns false if path is undefined/null (since1676// that might be dangerous, right)?1677export function path_is_in_public_paths(1678path: string,1679paths: string[] | Set<string> | object | undefined,1680): boolean {1681return containing_public_path(path, paths) != null;1682}16831684// returns a string in paths if path is public because of that string1685// Otherwise, returns undefined.1686// IMPORTANT: a possible returned string is "", which is falsey but defined!1687// paths can be an array or object (with keys the paths) or a Set1688export function containing_public_path(1689path: string,1690paths: string[] | Set<string> | object | undefined,1691): undefined | string {1692if (paths == null || path == null) {1693// just in case of non-typescript clients1694return;1695}1696if (path.indexOf("../") !== -1) {1697// just deny any potentially trickiery involving relative1698// path segments (TODO: maybe too restrictive?)1699return;1700}1701if (is_array(paths) || is_set(paths)) {1702// array so "of"1703// @ts-ignore1704for (const p of paths) {1705if (p == null) continue; // the typescript typings evidently aren't always exactly right1706if (p === "") {1707// the whole project is public, which matches everything1708return "";1709}1710if (path === p) {1711// exact match1712return p;1713}1714if (path.slice(0, p.length + 1) === p + "/") {1715return p;1716}1717}1718} else if (is_object(paths)) {1719for (const p in paths) {1720// object and want keys, so *of*1721if (p === "") {1722// the whole project is public, which matches everything1723return "";1724}1725if (path === p) {1726// exact match1727return p;1728}1729if (path.slice(0, p.length + 1) === p + "/") {1730return p;1731}1732}1733} else {1734throw Error("paths must be undefined, an array, or a map");1735}1736if (filename_extension(path) === "zip") {1737// is path something_public.zip ?1738return containing_public_path(path.slice(0, path.length - 4), paths);1739}1740return undefined;1741}17421743export const is_equal = lodash.isEqual;17441745export function is_whitespace(s?: string): boolean {1746return (s?.trim().length ?? 0) == 0;1747}17481749export function lstrip(s: string): string {1750return s.replace(/^\s*/g, "");1751}17521753export function date_to_snapshot_format(1754d: Date | undefined | null | number,1755): string {1756if (d == null) {1757d = 0;1758}1759if (typeof d === "number") {1760d = new Date(d);1761}1762let s = d.toJSON();1763s = s.replace("T", "-").replace(/:/g, "");1764const i = s.lastIndexOf(".");1765return s.slice(0, i);1766}17671768export function stripeDate(d: number): string {1769// https://github.com/sagemathinc/cocalc/issues/32541770// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_negotiation1771return new Date(d * 1000).toLocaleDateString(undefined, {1772year: "numeric",1773month: "long",1774day: "numeric",1775});1776}17771778export function to_money(n: number, d = 2): string {1779// see http://stackoverflow.com/questions/149055/how-can-i-format-numbers-as-money-in-javascript1780// TODO: replace by using react-intl...1781return n.toFixed(d).replace(/(\d)(?=(\d{3})+\.)/g, "$1,");1782}17831784// numbers with commas -- https://stackoverflow.com/questions/2901102/how-to-format-a-number-with-commas-as-thousands-separators1785export function commas(n: number): string {1786if (n == null) {1787// in case of bugs, at least fail with empty in prod1788return "";1789}1790return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");1791}17921793// Display currency with a dollar sign, rounded to *nearest*.1794// If d is not given and n is less than 1 cent, will show 3 digits1795// instead of 2.1796export function currency(n: number, d?: number) {1797if (n == 0) {1798return `$0.00`;1799}1800let s = `$${to_money(n ?? 0, d ?? (Math.abs(n) < 0.0095 ? 3 : 2))}`;1801if (d == null || d <= 2) {1802return s;1803}1804// strip excessive 0's off the end1805const i = s.indexOf(".");1806while (s[s.length - 1] == "0" && i <= s.length - 2) {1807s = s.slice(0, s.length - 1);1808}1809return s;1810}18111812export function stripeAmount(1813unitPrice: number,1814currency: string,1815units = 1,1816): string {1817// input is in pennies1818if (currency !== "usd") {1819// TODO: need to make this look nice with symbols for other currencies...1820return `${currency == "eur" ? "€" : ""}${to_money(1821(units * unitPrice) / 100,1822)} ${currency.toUpperCase()}`;1823}1824return `$${to_money((units * unitPrice) / 100)} USD`;1825}18261827export function planInterval(1828interval: string,1829interval_count: number = 1,1830): string {1831return `${interval_count} ${plural(interval_count, interval)}`;1832}18331834// get a subarray of all values between the two given values inclusive,1835// provided in either order1836export function get_array_range(arr: any[], value1: any, value2: any): any[] {1837let index1 = arr.indexOf(value1);1838let index2 = arr.indexOf(value2);1839if (index1 > index2) {1840[index1, index2] = [index2, index1];1841}1842return arr.slice(index1, +index2 + 1 || undefined);1843}18441845function seconds2hms_days(1846d: number,1847h: number,1848m: number,1849longform: boolean,1850): string {1851h = h % 24;1852const s = h * 60 * 60 + m * 60;1853const x = s > 0 ? seconds2hms(s, longform, false) : "";1854if (longform) {1855return `${d} ${plural(d, "day")} ${x}`.trim();1856} else {1857return `${d}d${x}`;1858}1859}18601861// like seconds2hms, but only up to minute-resultion1862export function seconds2hm(secs: number, longform: boolean = false): string {1863return seconds2hms(secs, longform, false);1864}18651866// dear future developer: look into test/misc-test.coffee to see how the expected output is defined.1867export function seconds2hms(1868secs: number,1869longform: boolean = false,1870show_seconds: boolean = true,1871): string {1872let s;1873if (!longform && secs < 10) {1874s = round2(secs % 60);1875} else if (!longform && secs < 60) {1876s = round1(secs % 60);1877} else {1878s = Math.round(secs % 60);1879}1880const m = Math.floor(secs / 60) % 60;1881const h = Math.floor(secs / 60 / 60);1882const d = Math.floor(secs / 60 / 60 / 24);1883// for more than one day, special routine (ignoring seconds altogether)1884if (d > 0) {1885return seconds2hms_days(d, h, m, longform);1886}1887if (h === 0 && m === 0 && show_seconds) {1888if (longform) {1889return `${s} ${plural(s, "second")}`;1890} else {1891return `${s}s`;1892}1893}1894if (h > 0) {1895if (longform) {1896let ret = `${h} ${plural(h, "hour")}`;1897if (m > 0) {1898ret += ` ${m} ${plural(m, "minute")}`;1899}1900return ret;1901} else {1902if (show_seconds) {1903return `${h}h${m}m${s}s`;1904} else {1905return `${h}h${m}m`;1906}1907}1908}1909if (m > 0 || !show_seconds) {1910if (show_seconds) {1911if (longform) {1912let ret = `${m} ${plural(m, "minute")}`;1913if (s > 0) {1914ret += ` ${s} ${plural(s, "second")}`;1915}1916return ret;1917} else {1918return `${m}m${s}s`;1919}1920} else {1921if (longform) {1922return `${m} ${plural(m, "minute")}`;1923} else {1924return `${m}m`;1925}1926}1927}1928return "";1929}19301931export function range(n: number): number[] {1932const v: number[] = [];1933for (let i = 0; i < n; i++) {1934v.push(i);1935}1936return v;1937}19381939// Like Python's enumerate1940export function enumerate(v: any[]) {1941const w: [number, any][] = [];1942let i = 0;1943for (let x of Array.from(v)) {1944w.push([i, x]);1945i += 1;1946}1947return w;1948}19491950// converts an array to a "human readable" array1951export function to_human_list(arr: any[]): string {1952arr = lodash.map(arr, (x) => `${x}`);1953if (arr.length > 1) {1954return arr.slice(0, -1).join(", ") + " and " + arr.slice(-1);1955} else if (arr.length === 1) {1956return arr[0].toString();1957} else {1958return "";1959}1960}19611962// derive the console initialization filename from the console's filename1963// used in webapp and console_server_child1964export function console_init_filename(path: string): string {1965const x = path_split(path);1966x.tail = `.${x.tail}.init`;1967if (x.head === "") {1968return x.tail;1969}1970return [x.head, x.tail].join("/");1971}19721973export function has_null_leaf(obj: object): boolean {1974for (const k in obj) {1975const v = obj[k];1976if (v === null || (typeof v === "object" && has_null_leaf(v))) {1977return true;1978}1979}1980return false;1981}19821983// Peer Grading1984// This function takes a list of student_ids,1985// and a number N of the desired number of peers per student.1986// It returns an object, mapping each student to a list of N peers.1987export function peer_grading(1988students: string[],1989N: number = 2,1990): { [student_id: string]: string[] } {1991if (N <= 0) {1992throw Error("Number of peer assigments must be at least 1");1993}1994if (students.length <= N) {1995throw Error(`You need at least ${N + 1} students`);1996}19971998const assignment: { [student_id: string]: string[] } = {};19992000// make output dict keys sorted like students input array2001for (const s of students) {2002assignment[s] = [];2003}20042005// randomize peer assignments2006const s_random = lodash.shuffle(students);20072008// the peer grading groups are set here. Think of nodes in2009// a circular graph, and node i is associated with grading2010// nodes i+1 up to i+N.2011const L = students.length;2012for (let i = 0; i < L; i++) {2013for (let j = i + 1; j <= i + N; j++) {2014assignment[s_random[i]].push(s_random[j % L]);2015}2016}20172018// sort each peer group by the order of the `student` input list2019for (let k in assignment) {2020const v = assignment[k];2021assignment[k] = lodash.sortBy(v, (s) => students.indexOf(s));2022}2023return assignment;2024}20252026// Checks if the string only makes sense (heuristically) as downloadable url2027export function is_only_downloadable(s: string): boolean {2028return s.indexOf("://") !== -1 || startswith(s, "[email protected]");2029}20302031export function ensure_bound(x: number, min: number, max: number): number {2032return x < min ? min : x > max ? max : x;2033}20342035export const EDITOR_PREFIX = "editor-";20362037// convert a file path to the "name" of the underlying editor tab.2038// needed because otherwise filenames like 'log' would cause problems2039export function path_to_tab(name: string): string {2040return `${EDITOR_PREFIX}${name}`;2041}20422043// assumes a valid editor tab name...2044// If invalid or undefined, returns undefined2045export function tab_to_path(name: string): string | undefined {2046if (name?.substring(0, 7) === EDITOR_PREFIX) {2047return name.substring(7);2048}2049return;2050}20512052// suggest a new filename when duplicating it as follows:2053// strip extension, split at '_' or '-' if it exists2054// try to parse a number, if it works, increment it, etc.2055// Handle leading zeros for the number (see https://github.com/sagemathinc/cocalc/issues/2973)2056export function suggest_duplicate_filename(name: string): string {2057let ext;2058({ name, ext } = separate_file_extension(name));2059const idx_dash = name.lastIndexOf("-");2060const idx_under = name.lastIndexOf("_");2061const idx = Math.max(idx_dash, idx_under);2062let new_name: string | undefined = undefined;2063if (idx > 0) {2064const [prefix, ending] = Array.from([2065name.slice(0, idx + 1),2066name.slice(idx + 1),2067]);2068// Pad the number with leading zeros to maintain the original length2069const paddedEnding = ending.padStart(ending.length, "0");2070const num = parseInt(paddedEnding);2071if (!Number.isNaN(num)) {2072// Increment the number and pad it back to the original length2073const newNum = (num + 1).toString().padStart(ending.length, "0");2074new_name = `${prefix}${newNum}`;2075}2076}2077if (new_name == null) {2078new_name = `${name}-1`;2079}2080if (ext.length > 0) {2081new_name += "." + ext;2082}2083return new_name;2084}20852086// Takes an object representing a directed graph shaped as follows:2087// DAG =2088// node1 : []2089// node2 : ["node1"]2090// node3 : ["node1", "node2"]2091//2092// Which represents the following graph:2093// node1 ----> node22094// | |2095// \|/ |2096// node3 <-------|2097//2098// Returns a topological ordering of the DAG2099// object = ["node1", "node2", "node3"]2100//2101// Throws an error if cyclic2102// Runs in O(N + E) where N is the number of nodes and E the number of edges2103// Kahn, Arthur B. (1962), "Topological sorting of large networks", Communications of the ACM2104export function top_sort(2105DAG: { [node: string]: string[] },2106opts: { omit_sources?: boolean } = { omit_sources: false },2107): string[] {2108const { omit_sources } = opts;2109const source_names: string[] = [];2110let num_edges = 0;2111const graph_nodes = {};21122113// Ready the nodes for top sort2114for (const name in DAG) {2115const parents = DAG[name];2116if (graph_nodes[name] == null) {2117graph_nodes[name] = {};2118}2119const node = graph_nodes[name];2120node.name = name;2121if (node.children == null) {2122node.children = [];2123}2124node.parent_set = {};2125for (const parent_name of parents) {2126// include element in "parent_set" (see https://github.com/sagemathinc/cocalc/issues/1710)2127node.parent_set[parent_name] = true;2128if (graph_nodes[parent_name] == null) {2129graph_nodes[parent_name] = {};2130// Cover implicit nodes which are assumed to be source nodes2131if (DAG[parent_name] == null) {2132source_names.push(parent_name);2133}2134}2135if (graph_nodes[parent_name].children == null) {2136graph_nodes[parent_name].children = [];2137}21382139graph_nodes[parent_name].children.push(node);2140}21412142if (parents.length === 0) {2143source_names.push(name);2144} else {2145num_edges += parents.length;2146}2147}21482149// Top sort! Non-recursive method since recursion is way slow in javascript2150// https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm2151const path: string[] = [];2152const num_sources = source_names.length;2153let walked_edges = 0;21542155while (source_names.length !== 0) {2156const curr_name = source_names.shift();2157if (curr_name == null) throw Error("BUG -- can't happen"); // TS :-)2158path.push(curr_name);21592160for (const child of graph_nodes[curr_name].children) {2161delete child.parent_set[curr_name];2162walked_edges++;2163if (Object.keys(child.parent_set).length === 0) {2164source_names.push(child.name);2165}2166}2167}21682169// Detect lack of sources2170if (num_sources === 0) {2171throw new Error("No sources were detected");2172}21732174// Detect cycles2175if (num_edges !== walked_edges) {2176/*// uncomment this when debugging problems.2177if (typeof window != "undefined") {2178(window as any)._DAG = DAG;2179} // so it's possible to debug in browser2180*/2181throw new Error("Store has a cycle in its computed values");2182}21832184if (omit_sources) {2185return path.slice(num_sources);2186} else {2187return path;2188}2189}21902191// Takes an object obj with keys and values where2192// the values are functions and keys are the names2193// of the functions.2194// Dependency graph is created from the property2195// `dependency_names` found on the values2196// Returns an object shaped2197// DAG =2198// func_name1 : []2199// func_name2 : ["func_name1"]2200// func_name3 : ["func_name1", "func_name2"]2201//2202// Which represents the following graph:2203// func_name1 ----> func_name22204// | |2205// \|/ |2206// func_name3 <-------|2207export function create_dependency_graph(obj: {2208[name: string]: Function & { dependency_names?: string };2209}): { [name: string]: string[] } {2210const DAG = {};2211for (const name in obj) {2212const written_func = obj[name];2213DAG[name] = written_func.dependency_names ?? [];2214}2215return DAG;2216}22172218// modify obj in place substituting as specified in subs recursively,2219// both for keys *and* values of obj. E.g.,2220// obj ={a:{b:'d',d:5}}; obj_key_subs(obj, {d:'x'})2221// then obj --> {a:{b:'x',x:5}}.2222// This is actually used in user queries to replace {account_id}, {project_id},2223// and {now}, but special strings or the time in queries.2224export function obj_key_subs(obj: object, subs: { [key: string]: any }): void {2225for (const k in obj) {2226const v = obj[k];2227const s: any = subs[k];2228if (typeof s == "string") {2229// key substitution for strings2230delete obj[k];2231obj[s] = v;2232}2233if (typeof v === "object") {2234obj_key_subs(v, subs);2235} else if (typeof v === "string") {2236// value substitution2237const s2: any = subs[v];2238if (s2 != null) {2239obj[k] = s2;2240}2241}2242}2243}22442245// this is a helper for sanitizing html. It is used in2246// * packages/backend/misc_node → sanitize_html2247// * packages/frontend/misc-page → sanitize_html2248export function sanitize_html_attributes($, node): void {2249$.each(node.attributes, function () {2250// sometimes, "this" is undefined -- #28232251// @ts-ignore -- no implicit this2252if (this == null) {2253return;2254}2255// @ts-ignore -- no implicit this2256const attrName = this.name;2257// @ts-ignore -- no implicit this2258const attrValue = this.value;2259// remove attribute name start with "on", possible2260// unsafe, e.g.: onload, onerror...2261// remove attribute value start with "javascript:" pseudo2262// protocol, possible unsafe, e.g. href="javascript:alert(1)"2263if (2264attrName?.indexOf("on") === 0 ||2265attrValue?.indexOf("javascript:") === 02266) {2267$(node).removeAttr(attrName);2268}2269});2270}22712272// cocalc analytics cookie name2273export const analytics_cookie_name = "CC_ANA";22742275// convert a jupyter kernel language (i.e. "python" or "r", usually short and lowercase)2276// to a canonical name.2277export function jupyter_language_to_name(lang: string): string {2278if (lang === "python") {2279return "Python";2280} else if (lang === "gap") {2281return "GAP";2282} else if (lang === "sage" || exports.startswith(lang, "sage-")) {2283return "SageMath";2284} else {2285return capitalize(lang);2286}2287}22882289// Find the kernel whose name is closest to the given name.2290export function closest_kernel_match(2291name: string,2292kernel_list: immutable.List<immutable.Map<string, string>>,2293): immutable.Map<string, string> {2294name = name.toLowerCase().replace("matlab", "octave");2295name = name === "python" ? "python3" : name;2296let bestValue = -1;2297let bestMatch: immutable.Map<string, string> | undefined = undefined;2298for (let i = 0; i < kernel_list.size; i++) {2299const k = kernel_list.get(i);2300if (k == null) {2301// This happened to Harald once when using the "mod sim py" custom image.2302continue;2303}2304// filter out kernels with negative priority (using the priority2305// would be great, though)2306if ((k.getIn(["metadata", "cocalc", "priority"], 0) as number) < 0)2307continue;2308const kernel_name = k.get("name")?.toLowerCase();2309if (!kernel_name) continue;2310let v = 0;2311for (let j = 0; j < name.length; j++) {2312if (name[j] === kernel_name[j]) {2313v++;2314} else {2315break;2316}2317}2318if (2319v > bestValue ||2320(v === bestValue &&2321bestMatch &&2322compareVersionStrings(2323k.get("name") ?? "",2324bestMatch.get("name") ?? "",2325) === 1)2326) {2327bestValue = v;2328bestMatch = k;2329}2330}2331if (bestMatch == null) {2332// kernel list could be empty...2333return kernel_list.get(0) ?? immutable.Map<string, string>();2334}2335return bestMatch;2336}23372338// compareVersionStrings takes two strings "a","b"2339// and returns 1 is "a" is bigger, 0 if they are the same, and -1 if "a" is smaller.2340// By "bigger" we compare the integer and non-integer parts of the strings separately.2341// Examples:2342// - "sage.10" is bigger than "sage.9" (because 10 > 9)2343// - "python.1" is bigger than "sage.9" (because "python" > "sage")2344// - "sage.1.23" is bigger than "sage.0.456" (because 1 > 0)2345// - "sage.1.2.3" is bigger than "sage.1.2" (because "." > "")2346function compareVersionStrings(a: string, b: string): -1 | 0 | 1 {2347const av: string[] = a.split(/(\d+)/);2348const bv: string[] = b.split(/(\d+)/);2349for (let i = 0; i < Math.max(av.length, bv.length); i++) {2350const l = av[i] ?? "";2351const r = bv[i] ?? "";2352if (/\d/.test(l) && /\d/.test(r)) {2353const vA = parseInt(l);2354const vB = parseInt(r);2355if (vA > vB) {2356return 1;2357}2358if (vA < vB) {2359return -1;2360}2361} else {2362if (l > r) {2363return 1;2364}2365if (l < r) {2366return -1;2367}2368}2369}2370return 0;2371}23722373// 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-javascript23742375export function count(str: string, strsearch: string): number {2376let index = -1;2377let count = -1;2378while (true) {2379index = str.indexOf(strsearch, index + 1);2380count++;2381if (index === -1) {2382break;2383}2384}2385return count;2386}23872388// right pad a number using html's 2389// by default, rounds number to a whole integer2390export function rpad_html(num: number, width: number, round_fn?: Function) {2391num = (round_fn ?? Math.round)(num);2392const s = " ";2393if (num == 0) return lodash.repeat(s, width - 1) + "0";2394if (num < 0) return num; // TODO not implemented2395const str = `${num}`;2396const pad = Math.max(0, width - str.length);2397return lodash.repeat(s, pad) + str;2398}23992400// Remove key:value's from objects in obj2401// recursively, where value is undefined or null.2402export function removeNulls(obj) {2403if (typeof obj != "object") {2404return obj;2405}2406if (is_array(obj)) {2407for (const x of obj) {2408removeNulls(x);2409}2410return obj;2411}2412const obj2: any = {};2413for (const field in obj) {2414if (obj[field] != null) {2415obj2[field] = removeNulls(obj[field]);2416}2417}2418return obj2;2419}24202421const academicCountry = new RegExp(/\.(ac|edu)\...$/);24222423// test if a domain belongs to an academic instition2424// TODO: an exhaustive test must probably use the list at https://github.com/Hipo/university-domains-list2425export function isAcademic(s?: string): boolean {2426if (!s) return false;2427const domain = s.split("@")[1];2428if (!domain) return false;2429if (domain.endsWith(".edu")) return true;2430if (academicCountry.test(domain)) return true;2431return false;2432}24332434/**2435* Test, if the given object is a valid list of JSON-Patch operations.2436* @returns boolean2437*/2438export function test_valid_jsonpatch(patch: any): boolean {2439if (!is_array(patch)) {2440return false;2441}2442for (const op of patch) {2443if (!is_object(op)) return false;2444if (op.op == null) return false;2445if (!["add", "remove", "replace", "move", "copy", "test"].includes(op.op)) {2446return false;2447}2448if (op.path == null) return false;2449if (op.from != null && typeof op.from !== "string") return false;2450// we don't test on value2451}2452return true;2453}24542455export function rowBackground({2456index,2457checked,2458}: {2459index: number;2460checked?: boolean;2461}): string {2462if (checked) {2463if (index % 2 === 0) {2464return "#a3d4ff";2465} else {2466return "#a3d4f0";2467}2468} else if (index % 2 === 0) {2469return "#f4f4f4";2470} else {2471return "white";2472}2473}24742475export function firstLetterUppercase(str: string | undefined) {2476if (str == null) return "";2477return str.charAt(0).toUpperCase() + str.slice(1);2478}24792480const randomColorCache = new LRU<string, string>({ max: 100 });24812482/**2483* For a given string s, return a random bright color, but not too bright.2484* Use a hash to make this random, but deterministic.2485*2486* opts:2487* - min: minimum value for each channel2488* - max: maxium value for each channel2489* - diff: mimimum difference across channels (increase, to avoid dull gray colors)2490* - seed: seed for the random number generator2491*/2492export function getRandomColor(2493s: string,2494opts?: { min?: number; max?: number; diff?: number; seed?: number },2495): string {2496const diff = opts?.diff ?? 0;2497const min = clip(opts?.min ?? 120, 0, 254);2498const max = Math.max(min, clip(opts?.max ?? 230, 1, 255));2499const seed = opts?.seed ?? 0;25002501const key = `${s}-${min}-${max}-${diff}-${seed}`;2502const cached = randomColorCache.get(key);2503if (cached) {2504return cached;2505}25062507let iter = 0;2508const iterLimit = "z".charCodeAt(0) - "A".charCodeAt(0);2509const mod = max - min;25102511while (true) {2512// seed + s + String.fromCharCode("A".charCodeAt(0) + iter)2513const val = `${seed}-${s}-${String.fromCharCode("A".charCodeAt(0) + iter)}`;2514const hash = sha1(val)2515.split("")2516.reduce((a, b) => ((a << 6) - a + b.charCodeAt(0)) | 0, 0);2517const r = (((hash >> 0) & 0xff) % mod) + min;2518const g = (((hash >> 8) & 0xff) % mod) + min;2519const b = (((hash >> 16) & 0xff) % mod) + min;25202521iter += 1;2522if (iter <= iterLimit && diff) {2523const diffVal = Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);2524if (diffVal < diff) continue;2525}2526const col = `rgb(${r}, ${g}, ${b})`;2527randomColorCache.set(key, col);2528return col;2529}2530}25312532export function hexColorToRGBA(col: string, opacity?: number): string {2533const r = parseInt(col.slice(1, 3), 16);2534const g = parseInt(col.slice(3, 5), 16);2535const b = parseInt(col.slice(5, 7), 16);25362537if (opacity && opacity <= 1 && opacity >= 0) {2538return `rgba(${r},${g},${b},${opacity})`;2539} else {2540return `rgb(${r},${g},${b})`;2541}2542}25432544// returns an always positive integer, not negative ones. useful for "scrolling backwards", etc.2545export function strictMod(a: number, b: number): number {2546return ((a % b) + b) % b;2547}25482549export function clip(val: number, min: number, max: number): number {2550return Math.min(Math.max(val, min), max);2551}25522553/**2554* Converts an integer to an English word, but only for small numbers and reverts to a digit for larger numbers2555*/2556export function smallIntegerToEnglishWord(val: number): string | number {2557if (!Number.isInteger(val)) return val;2558switch (val) {2559case 0:2560return "zero";2561case 1:2562return "one";2563case 2:2564return "two";2565case 3:2566return "three";2567case 4:2568return "four";2569case 5:2570return "five";2571case 6:2572return "six";2573case 7:2574return "seven";2575case 8:2576return "eight";2577case 9:2578return "nine";2579case 10:2580return "ten";2581case 11:2582return "eleven";2583case 12:2584return "twelve";2585case 13:2586return "thirteen";2587case 14:2588return "fourteen";2589case 15:2590return "fifteen";2591case 16:2592return "sixteen";2593case 17:2594return "seventeen";2595case 18:2596return "eighteen";2597case 19:2598return "nineteen";2599case 20:2600return "twenty";2601}2602return val;2603}26042605export function numToOrdinal(val: number): string {2606// 1 → 1st, 2 → 2nd, 3 → 3rd, 4 → 4th, ... 21 → 21st, ... 101 → 101st, ...2607if (!Number.isInteger(val)) return `${val}th`;2608const mod100 = val % 100;2609if (mod100 >= 11 && mod100 <= 13) {2610return `${val}th`;2611}2612const mod10 = val % 10;2613switch (mod10) {2614case 1:2615return `${val}st`;2616case 2:2617return `${val}nd`;2618case 3:2619return `${val}rd`;2620default:2621return `${val}th`;2622}2623}26242625export function hoursToTimeIntervalHuman(num: number): string {2626if (num < 24) {2627const n = round1(num);2628return `${n} ${plural(n, "hour")}`;2629} else if (num < 24 * 7) {2630const n = round1(num / 24);2631return `${n} ${plural(n, "day")}`;2632} else {2633const n = round1(num / (24 * 7));2634return `${n} ${plural(n, "week")}`;2635}2636}26372638/**2639* Return the last @lines lines of string s, in an efficient way. (e.g. long stdout, and return last 3 lines)2640*/2641export function tail(s: string, lines: number) {2642if (lines < 1) return "";26432644let lineCount = 0;2645let lastIndex = s.length - 1;26462647// Iterate backwards through the string, searching for newline characters2648while (lastIndex >= 0 && lineCount < lines) {2649lastIndex = s.lastIndexOf("\n", lastIndex);2650if (lastIndex === -1) {2651// No more newlines found, return the entire string2652return s;2653}2654lineCount++;2655lastIndex--;2656}26572658// Return the substring starting from the next character after the last newline2659return s.slice(lastIndex + 2);2660}266126622663