import {delay} from 'app/common/delay'; import {Listener, Observable} from 'grainjs'; import {Observable as KoObservable} from 'knockout'; import constant = require('lodash/constant'); import identity = require('lodash/identity'); import times = require('lodash/times'); export const UP_TRIANGLE = '\u25B2'; export const DOWN_TRIANGLE = '\u25BC'; const EMAIL_RE = new RegExp("^\\w[\\w%+/='-]*(\\.[\\w%+/='-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z" + "0-9])?\\.)+[A-Za-z]{2,6}$", "u"); // Returns whether str starts with prefix. (Note that this implementation avoids creating a new // string, and only checks a single location.) export function startsWith(str: string, prefix: string): boolean { return str.lastIndexOf(prefix, 0) === 0; } // Returns whether str ends with suffix. export function endsWith(str: string, suffix: string): boolean { return str.indexOf(suffix, str.length - suffix.length) !== -1; } // If str starts with prefix, removes it and returns what remains. Otherwise, returns null. export function removePrefix(str: string, prefix: string): string|null { return startsWith(str, prefix) ? str.slice(prefix.length) : null; } // If str ends with suffix, removes it and returns what remains. Otherwise, returns null. export function removeSuffix(str: string, suffix: string): string|null { return endsWith(str, suffix) ? str.slice(0, str.length - suffix.length) : null; } export function removeTrailingSlash(str: string): string { const result = removeSuffix(str, '/'); return result === null ? str : result; } // Expose .padStart. The version of node we use has it, but they typings // need the es2017 typescript target. TODO: replace once typings in place. export function padStart(str: string, targetLength: number, padString: string) { return (str as any).padStart(targetLength, padString); } // Capitalizes every word in a string. export function capitalize(str: string): string { return str.replace(/\b[a-z]/gi, c => c.toUpperCase()); } // Returns whether the string n represents a valid number. // http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric export function isNumber(n: string): boolean { // This wasn't right for a long time: isFinite() is key to failing on strings like "5a". return !isNaN(parseFloat(n)) && isFinite(n as any); } /** * Returns a value clamped to the given min-max range. * @param {Number} value - some numeric value. * @param {Number} min - minimum value allowed. * @param {Number} max - maximum value allowed. Must have min <= max. * @returns {Number} - value restricted to the given range. */ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } /** * Checks if ele is contained within the given bounds. * @param {Number} value * @param {Number} bound1 - does not have to be less than/eqal to bound2 * @param {Number} bound2 * @returns {Boolean} - True/False */ export function between(value: number, bound1: number, bound2: number): boolean { const lower = Math.min(bound1, bound2); const upper = Math.max(bound1, bound2); return lower <= value && value <= upper; } /** * Returns the positive modulo of x by n. (Javascript default allows negatives) */ export function mod(x: number, n: number): number { return ((x % n) + n) % n; } /** * Returns a number that is n rounded down to the next nearest number divisible by m */ export function roundDownToMultiple(n: number, m: number): number { return Math.floor(n / m) * m; } /** * Returns the first argument unless it's undefined, in which case returns the second one. */ export function undefDefault(x: T|undefined, y: T): T { return (x !== void 0) ? x : y; } /** * Parses json and returns the result, or returns defaultVal if parsing fails. */ export function safeJsonParse(json: string, defaultVal: any): any { try { return JSON.parse(json); } catch (e) { return defaultVal; } } /** * Just like encodeURIComponent, but does not encode slashes. Slashes don't hurt to be included in * URL parameters, and look much friendlier not encoded. */ export function encodeQueryParam(str: string|number|undefined): string { return encodeURIComponent(String(str === undefined ? null : str)).replace(/%2F/g, '/'); } /** * Encode an object into a querystring ("key=value&key2=value2"). * This is similar to JQuery's $.param, but only works on shallow objects. */ export function encodeQueryParams(obj: {[key: string]: string|number|undefined}): string { return Object.keys(obj).map((k: string) => encodeQueryParam(k) + '=' + encodeQueryParam(obj[k])).join('&'); } /** * Return a list of the words in the string, using the given separator string. At most * maxNumSplits splits are done, so the result will have at most maxNumSplits + 1 elements (this * is the main difference from how JS built-in string.split() works, and similar to Python split). * @param {String} str: String to split. * @param {String} sep: Separator to split on. * @param {Number} maxNumSplits: Maximum number of splits to do. * @return {Array[String]} Array of words, of length at most maxNumSplits + 1. */ export function maxsplit(str: string, sep: string, maxNumSplits: number): string[] { const result: string[] = []; let start = 0, pos; for (let i = 0; i < maxNumSplits; i++) { pos = str.indexOf(sep, start); if (pos === -1) { break; } result.push(str.slice(start, pos)); start = pos + sep.length; } result.push(str.slice(start)); return result; } // Compare arrays of scalars for equality. export function arraysEqual(a: any[], b: any[]): boolean { if (a === b) { return true; } if (!a || !b) { return false; } if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } // Gives a set representing the set difference a - b. export function setDifference(a: Set, b: Set): Set { const c = new Set(); for (const ai of a) { if (!b.has(ai)) { c.add(ai); } } return c; } // Like array.indexOf, but works with array-like objects like HTMLCollection. export function indexOf(arrayLike: ArrayLike, item: T): number { return Array.prototype.indexOf.call(arrayLike, item); } /** * Removes a value from the given array. Only the first instance is removed. * Returns true on success, false if the value was not found. */ export function arrayRemove(array: T[], value: T): boolean { const index = array.indexOf(value); if (index === -1) { return false; } array.splice(index, 1); return true; } /** * Inserts value into the array before nextValue, or at the end if nextValue is not found. */ export function arrayInsertBefore(array: T[], value: T, nextValue: T): void { const index = array.indexOf(nextValue); if (index === -1) { array.push(value); } else { array.splice(index, 0, value); } } /** * Extends the first array with the second. Like native push, but adds all values in anotherArray. */ export function arrayExtend(array: T[], anotherArray: T[]): void { for (let i = 0, len = anotherArray.length; i < len; i++) { array.push(anotherArray[i]); } } /** * Copies count items from fromArray to toArray, copying in a forward direction (which matters * when the arrays are the same and source and destination indices overlap). * * See test/common/arraySplice.js for alternative implementations with timings, from which this * one is chosen as consistently among the faster ones. */ export function arrayCopyForward(toArray: T[], toStart: number, fromArray: ArrayLike, fromStart: number, count: number): void { const end = toStart + count; for (const xend = end - 7; toStart < xend; fromStart += 8, toStart += 8) { toArray[toStart] = fromArray[fromStart]; toArray[toStart + 1] = fromArray[fromStart + 1]; toArray[toStart + 2] = fromArray[fromStart + 2]; toArray[toStart + 3] = fromArray[fromStart + 3]; toArray[toStart + 4] = fromArray[fromStart + 4]; toArray[toStart + 5] = fromArray[fromStart + 5]; toArray[toStart + 6] = fromArray[fromStart + 6]; toArray[toStart + 7] = fromArray[fromStart + 7]; } for (; toStart < end; ++fromStart, ++toStart) { toArray[toStart] = fromArray[fromStart]; } } /** * Copies count items from fromArray to toArray, copying in a backward direction (which matters * when the arrays are the same and source and destination indices overlap). * * See test/common/arraySplice.js for alternative implementations with timings, from which this * one is chosen as consistently among the faster ones. */ export function arrayCopyBackward(toArray: T[], toStart: number, fromArray: ArrayLike, fromStart: number, count: number): void { let i = toStart + count - 1, j = fromStart + count - 1; for (const xStart = toStart + 7; i >= xStart; i -= 8, j -= 8) { toArray[i] = fromArray[j]; toArray[i - 1] = fromArray[j - 1]; toArray[i - 2] = fromArray[j - 2]; toArray[i - 3] = fromArray[j - 3]; toArray[i - 4] = fromArray[j - 4]; toArray[i - 5] = fromArray[j - 5]; toArray[i - 6] = fromArray[j - 6]; toArray[i - 7] = fromArray[j - 7]; } for ( ; i >= toStart; --i, --j) { toArray[i] = fromArray[j]; } } /** * Appends a slice of fromArray to the end of toArray. * * See test/common/arraySplice.js for alternative implementations with timings, from which this * one is chosen as consistently among the faster ones. */ export function arrayAppend(toArray: T[], fromArray: ArrayLike, fromStart: number, count: number): void { if (count === 1) { toArray.push(fromArray[fromStart]); } else { const len = toArray.length; toArray.length = len + count; arrayCopyForward(toArray, len, fromArray, fromStart, count); } } /** * Splices array arrToInsert into target starting at the given start index. * This implementation tries to be smart by avoiding allocations, appending to the array * contiguously, then filling in the gap. * * See test/common/arraySplice.js for alternative implementations with timings, from which this * one is chosen as consistently among the faster ones. */ export function arraySplice(target: T[], start: number, arrToInsert: ArrayLike): T[] { const origLen = target.length; const tailLen = origLen - start; const insLen = arrToInsert.length; target.length = origLen + insLen; if (insLen > tailLen) { arrayCopyForward(target, origLen, arrToInsert, tailLen, insLen - tailLen); arrayCopyForward(target, start + insLen, target, start, tailLen); arrayCopyForward(target, start, arrToInsert, 0, tailLen); } else { arrayCopyForward(target, origLen, target, origLen - insLen, insLen); arrayCopyBackward(target, start + insLen, target, start, tailLen - insLen); arrayCopyForward(target, start, arrToInsert, 0, insLen); } return target; } /** * Returns a new array of length count, filled with the given value. */ export function arrayRepeat(count: number, value: T): T[] { return times(count, constant(value)); } // Type for a compare func that returns a positive, negative, or zero value, as used for sorting. export type CompareFunc = (a: T, b: T) => number; /** * Returns the index at which the given element can be inserted to keep the array sorted. * This is equivalent to underscore's sortedIndex and python's bisect_left. * @param {Array} array - sorted array of elements based on the given compareFunc * @param {object} elem - object to be inserted in the given array * @param {function} compareFunc - compares 2 elements. Returns a pos value if the 1st element is * larger, 0 if they're equal, a neg value if the 2nd is larger. */ export function sortedIndex(array: ArrayLike, elem: T, compareFunc: CompareFunc): number { let lo = 0, mid; let hi = array.length; if (array.length === 0) { return 0; } while (lo < hi) { mid = Math.floor((lo + hi) / 2); if (compareFunc(array[mid], elem) < 0) { // mid < elem lo = mid + 1; } else { hi = mid; } } return lo; } /** * Returns true if an array contains duplicate values. * Values are considered equal if their toString() representations are equal. */ export function hasDuplicates(array: any[]): boolean { const prevVals = Object.create(null); for (const value of array) { if (value in prevVals) { return true; } prevVals[value] = true; } return false; } /** * Counts the number of items in array which satisfy the callback. */ export function countIf(array: ReadonlyArray, callback: (item: T) => boolean): number { let count = 0; array.forEach(item => { if (callback(item)) { count++; } }); return count; } /** * For two parallel arrays, calls mapFunc(a[i], b[i]) for each pair of corresponding elements, and * returns an array of the results. */ export function map2(array1: ArrayLike, array2: ArrayLike, mapFunc: (a: T, b: U) => V): V[] { const len = array1.length; const result: V[] = new Array(len); for (let i = 0; i < len; i++) { result[i] = mapFunc(array1[i], array2[i]); } return result; } /** * Takes a 2d array returns a new matrix with r rows and c columns * @param [Array] dataMatrix: a 2d array * @param [Number] r: final row length * @param [Number] c: final column length */ export function growMatrix(dataMatrix: T[][], r: number, c: number): T[][] { const colArr = dataMatrix.map(colVals => Array.from({length: c}, (_v, k) => colVals[k % colVals.length]) ); return Array.from({length: r}, (_v, k) => colArr[k % colArr.length]); } /** * Returns a function that compares two elements based on multiple sort keys and the * given compare functions. * Elements are compared using the sort key functions with index 0 having the greatest priority. * Subsequent sort key functions are used as tie breakers. * @param {function Array} sortKeyFuncs - a list of sort key functions. * @param {function Array} compareKeyFuncs - a list of comparison functions parallel to sortKeyFuncs * Each compare function must satisfy the comparison invariant: * If compare(a, b) > 0 then a > b, * If compare(a, b) < 0 then a < b, * If compare(a, b) == 0 then a == b, * @param {Array of 1/-1's} optAscending - Comparison on sortKeyFuncs[i] is inverted if optAscending[i] == -1 */ export function multiCompareFunc(sortKeyFuncs: ReadonlyArray<(a: T) => U>, compareFuncs: ArrayLike>, optAscending?: number[]): CompareFunc { if (sortKeyFuncs.length !== compareFuncs.length) { throw new Error('Number of sort key funcs must be the same as the number of compare funcs'); } const ascending = optAscending || sortKeyFuncs.map(() => 1); return function(a: T, b: T): number { let compareOutcome, keyA, keyB; for (let i = 0; i < compareFuncs.length; i++) { keyA = sortKeyFuncs[i](a); keyB = sortKeyFuncs[i](b); compareOutcome = compareFuncs[i](keyA, keyB); if (compareOutcome !== 0) { return ascending[i] * compareOutcome; } } return 0; }; } export function nativeCompare(a: T, b: T): number { return (a < b ? -1 : (a > b ? 1 : 0)); } /** * A copy of python`s `setdefault` function. * Sets key in mapInst to value, if key is not already set. * @param {Map} mapInst: Instance of Map. * @param {Object} key: Key into the map. * @param {Object} value: Value to insert, possibly. */ export function setDefault(mapInst: Map, key: K, val: V): V { if (!mapInst.has(key)) { mapInst.set(key, val); } return mapInst.get(key)!; } /** * Similar to Python's `setdefault`: returns the key `key` from `mapInst`, or if it's not there, sets * it to the result buildValue(). */ export function getSetMapValue(mapInst: Map, key: K, buildValue: () => V): V { if (!mapInst.has(key)) { mapInst.set(key, buildValue()); } return mapInst.get(key)!; } /** * If key is in mapInst, remove it and return its value, else return `undefined`. * @param {Map} mapInst: Instance of Map. * @param {Object} key: Key into the map to remove. */ export function popFromMap(mapInst: Map, key: K): V|undefined { const value = mapInst.get(key); mapInst.delete(key); return value; } /** * For each encountered value in `values`, increment the corresponding counter in `valueCounts`. */ export function addCountsToMap(valueCounts: Map, values: Iterable, mapFunc: (v: any) => any = identity) { for (const v of values) { const mappedValue = mapFunc(v); valueCounts.set(mappedValue, (valueCounts.get(mappedValue) || 0) + 1); } } /** * Returns whether one Set is a subset of another. */ export function isSubset(smaller: Set, larger: Set): boolean { for (const value of smaller) { if (!larger.has(value)) { return false; } } return true; } /** * Merges the contents of two or more objects together into the first object, recursing into * nested objects and arrays (like jquery.extend(true, ...)). * @param {Object} target - The object to modify. Use {} to create a new merged object. * @param {Object} ... - Additional objects from which to copy properties into target. * @returns {Object} The first argument, target, modified. */ export function deepExtend(target: any, _varArgObjects: any): any { for (let i = 1; i < arguments.length; i++) { const object = arguments[i]; // Extend the base object for (const name in object) { if (!object.hasOwnProperty(name)) { continue; } let src = object[name]; if (src === target || src === undefined) { // Prevent one kind of infinite loop, as JQuery's extend does, and skip undefined values. continue; } if (src) { // Recurse if we're merging plain objects or arrays const tgt = target[name]; if (Array.isArray(src)) { src = deepExtend(tgt && Array.isArray(tgt) ? tgt : [], src); } else if (typeof src === 'object') { src = deepExtend(tgt && typeof tgt === 'object' ? tgt : {}, src); } } target[name] = src; } } // Return the modified object return target; } /** * Returns a human-readable string containing a number of bytes, KB, or MB. * @param {Number} bytes. Number of bytes. * @returns {String} A description such as "4.1KB". */ export function byteString(bytes: number): string { if (bytes < 1024) { return bytes + 'B'; } else if (bytes < 1024 * 1024) { return (bytes / 1024).toFixed(1) + 'KB'; } else { return (bytes / 1024 / 1024).toFixed(1) + 'MB'; } } /** * Creates a new object mapping each key in keysArray to the value returned by callback. * @param {Array} keysArray - Array of strings to use as the properties of the returned object. * @param {Function} callback - Function that produces the value for each key. Called in the same * way as array.map() calls its callbacks. * @param {Object} optThisArg - Value to use as `this` when executing callback. * @returns {Object} - object mapping keys from `keysArray` to values returned by `callback`. */ export function mapToObject(keysArray: string[], callback: (key: string) => T, optThisArg: any): {[key: string]: T} { const values: T[] = keysArray.map(callback, optThisArg); const map: {[key: string]: T} = {}; for (let i = 0; i < keysArray.length; i++) { map[keysArray[i]] = values[i]; } return map; } /** * A List of python identifiers; the result of running keywords.kwlist in Python 2.7.6, * plus additional illegal identifiers None, False, True * Using [] instead of new Array causes a "comprehension error" for some reason */ const _kwlist = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'exec', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'not', 'or', 'pass', 'print', 'raise', 'return', 'try', 'while', 'with', 'yield', 'None', 'False', 'True']; /** * Given an arbitrary string, makes substitutions to make it a valid SQL/Python identifier. * Corresponds to sandbox/grist/gencode.sanitize_ident */ export function sanitizeIdent(ident: string, prefix?: string) { prefix = prefix || 'c'; // Remove non-alphanumeric non-_ chars ident = ident.replace(/[^a-zA-Z0-9_]+/g, '_'); // Remove leading and trailing _ ident = ident.replace(/^_+|_+$/g, ''); // Place prefix at front if the beginning isn't a number ident = ident.replace(/^(?=[0-9])/g, prefix); // Append prefix until it is not python keyword while (_kwlist.includes(ident)) { ident = prefix + ident; } return ident; } /** * Clone a function, returning a function object that represents a brand new function with the * same code. If the same function is used with different argument types, it would prevent JS V8 * engine optimizations (or cause it to deoptimize it). If different clones are called with * different argument types, they can be optimized independently. * * As with all micro-optimizations, only do this when the optimization matters. */ export function cloneFunc(fn: Function): Function { // tslint:disable-line:ban-types /* jshint evil:true */ // suppress eval warning. return eval('(' + fn.toString() + ')'); // tslint:disable-line:no-eval } /** * Generates a random id using a sequence of uppercase alphanumeric characters * preceeded by an optional prefix. */ export function genRandomId(len: number, optPrefix?: string): string { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; let ret = optPrefix || ''; for (let i = 0; i < len; i++) { ret += chars[Math.floor(Math.random() * chars.length)]; } return ret; } /** * Scans through two sorted arrays, calling a function on each item or pair of items * for every present key in order. * @param {Array} arrA - First array to scan. NOTE: Should be sorted by the key value. * @param {Array} arrB - Second array to scan. NOTE: Should be sorted by the key value. * @param {Function} callback - Called with an item from arrA as the first argument and an * item from arrB as the second. Called for every unique key in order, either with one of the * arguments null if the key is present only in one array, or both non-null if the key is * present in both arrays. NOTE: Key values should not be null. * @param {Function} optKeyFunc - Optional function to map each array value to a sort key. * Defaults to the identity function. */ export function sortedScan(arrA: ArrayLike, arrB: ArrayLike, callback: (a: T|null, B: U|null) => void, optKeyFunc?: (item: T|U) => any) { const keyFunc = optKeyFunc || identity; let i = 0, j = 0; while (i < arrA.length || j < arrB.length) { const a = arrA[i], b = arrB[j]; const keyA = i < arrA.length ? keyFunc(a) : null; const keyB = j < arrB.length ? keyFunc(b) : null; if (keyA !== null && (keyB === null || keyA < keyB)) { callback(a, null); i++; } else if (keyA === null || keyA > keyB) { callback(null, b); j++; } else { callback(a, b); i++; j++; } } } /** * Returns the time in ms to wait until attempting another connection. * @param {Number} attemptNumber - Reconnect attempt number starting at 0. * @param {Array} intervals - Array of reconnect intervals in ms. * @returns {Number} */ export function getReconnectTimeout(attemptNumber: number, intervals: ArrayLike): number { if (attemptNumber >= intervals.length) { // Add an additional wait time if already at max attempts. const timeout = intervals[intervals.length - 1]; return timeout + Math.random() * timeout; } else { return intervals[attemptNumber]; } } /** * Returns whether the given email is a valid formatted email string. * @param {String} email - Email to test. * @returns {Boolean} */ export function isEmail(email: string): boolean { return EMAIL_RE.test(email.toLowerCase()); } /* * Takes an observable and returns a promise for when the observable's value matches the given * predicate. It then unsubscribes from the observable, and returns its value. * If a predicate is not given, resolves to the observable values as soon as it's truthy. */ export function waitObs(observable: KoObservable, predicate: (value: T) => boolean = Boolean): Promise { return new Promise((resolve, _reject) => { const value = observable.peek(); if (predicate(value)) { return resolve(value); } const sub = observable.subscribe((val: T) => { if (predicate(val)) { sub.dispose(); resolve(val); } }); }); } /** * Same as waitObs but for grainjs observables. */ export async function waitGrainObs(observable: Observable): Promise>; export async function waitGrainObs(observable: Observable, predicate?: (value: T) => boolean): Promise; export async function waitGrainObs(observable: Observable, predicate: (value: T) => boolean = Boolean): Promise { let sub: Listener|undefined; const res: T = await new Promise((resolve, _reject) => { const value = observable.get(); if (predicate(value)) { return resolve(value); } sub = observable.addListener((val: T) => { if (predicate(val)) { resolve(val); } }); }); if (sub) { sub.dispose(); } return res; } /** * Class to maintain a chain of promise-returning callbacks. All scheduled callbacks will be * called in order as long as the previous one is successful. If a callback fails is rejected, * already-scheduled callbacks will be skipped, but newly-scheduled ones will be run. */ export class PromiseChain { private _last: Promise = Promise.resolve(); // Adds a callback to the chain. If the callback runs, the return value is the return value of // the callback. If it's skipped due to a failure earlier in the chain, the return value is the // rejection with the message "Skipped due to an earlier error". public add(nextCB: () => Promise): Promise { const next = this._last.catch(() => { throw new Error("Skipped due to an earlier error"); }).then(nextCB); // If any callback fails, all queued ones will be skipped. Here we reset the chain, so that // callbacks added later do get run. next.catch(() => { this._last = Promise.resolve(); }); this._last = next; return next; } } /** * Indicates if a hex color value, e.g. '#000000', is darker than the given value. * Darkness is measured from 0..255, where 0 is the darkest and 255 is the lightest. * * Taken from: https://stackoverflow.com/questions/12043187/how-to-check-if-hex-color-is-too-black */ export function isColorDark(hexColor: string, isDarkBelow: number = 220): boolean { const c = hexColor.substring(1); // strip # const rgb = parseInt(c, 16); // convert rrggbb to decimal // Extract RGB components const r = (rgb >> 16) & 0xff; // tslint:disable-line:no-bitwise const g = (rgb >> 8) & 0xff; // tslint:disable-line:no-bitwise const b = (rgb >> 0) & 0xff; // tslint:disable-line:no-bitwise const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 return luma < isDarkBelow; } /** * Returns a promise that resolves to true if promise takes longer than timeoutMsec to resolve. If not * or if promise throws returns false. */ export async function isLongerThan(promise: Promise, timeoutMsec: number): Promise { let isPending = true; const done = () => {isPending = false; }; await Promise.race([ promise.then(done, done), delay(timeoutMsec) ]); return isPending; }