gristlabs_grist-core/app/common/gutil.ts
George Gevoian 1e42871cc9 (core) Add attachment and data size usage
Summary:
Adds attachment and data size to the usage section of
the raw data page. Also makes in-document usage banners
update as user actions are applied, causing them to be
hidden/shown or updated based on the current state of
the document.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3395
2022-05-04 13:46:55 -07:00

951 lines
33 KiB
TypeScript

import {delay} from 'app/common/delay';
import {BindableValue, DomElementMethod, ISubscribable, Listener, Observable, subscribeElem, UseCB} from 'grainjs';
import {Observable as KoObservable} from 'knockout';
import identity = require('lodash/identity');
// Some definitions have moved to be used by plugin API.
export {arrayRepeat} from 'app/plugin/gutil';
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 <string>.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());
}
// Capitalizes the first word in a string.
export function capitalizeFirstWord(str: string): string {
return str.replace(/\b[a-z]/i, 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<T>(x: T|undefined, y: T): T {
return (x !== void 0) ? x : y;
}
// for typescript 4
// type Undef<T> = T extends [infer A, ...infer B] ? undefined extends A ? NonNullable<A> | Undef<B> : A : unknown;
type Undef1<T> = T extends [infer A] ? A : unknown;
type Undef2<T> = T extends [infer A, infer B] ?
undefined extends A ? NonNullable<A> | Undef1<[B]> : A : Undef1<T>;
type Undef3<T> = T extends [infer A, infer B, infer C] ?
undefined extends A ? NonNullable<A> | Undef2<[B, C]> : A : Undef2<T>;
type Undef<T> = T extends [infer A, infer B, infer C, infer D] ?
undefined extends A ? NonNullable<A> | Undef3<[B, C, D]> : A : Undef3<T>;
/*
Undef<T> can detect correct type that will be returned as a first defined value:
const t1: number = undef(1, 1 as number | undefined);
const t1: number | undefined = undef(2 as number | undefined, 3 as number | undefined);
const t3: number = undef(3 as number | undefined, undefined, 4);
const t4: number = undef(1, '');
const t5: number = undef(1 as number | undefined, 4);
const t6: string = undef('1', 2);
const t7: string | number = undef(undefined, 2 as number | undefined, '3');
const t8: string = undef(undefined, undefined, '3');
const t9: string = undef(undefined, '2' as string | undefined, '3');
const ta: string | number | undefined = undef(undefined, '2' as string | undefined, 3 as number | undefined);
const tb: string | number = undef(undefined, '2' as string | undefined, 3 as number | undefined, 5);
*/
/**
* Returns the first defined value from the list or unknown.
* Use with typed result, so the typescript type checker can provide correct type.
*/
export function undef<T extends Array<any>>(...list: T): Undef<T> {
for(const value of list) {
if (value !== undefined) { return value; }
}
return undefined as any;
}
/**
* Parses json and returns the result, or returns defaultVal if parsing fails.
*/
export function safeJsonParse(json: string, defaultVal: any): any {
try {
return json !== '' && json !== undefined ? JSON.parse(json) : defaultVal;
} 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<T>(a: Set<T>, b: Set<T>): Set<T> {
const c = new Set<T>();
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<T>(arrayLike: ArrayLike<T>, 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<T>(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<T>(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<T>(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<T>(toArray: T[], toStart: number,
fromArray: ArrayLike<T>, 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<T>(toArray: T[], toStart: number,
fromArray: ArrayLike<T>, 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<T>(toArray: T[], fromArray: ArrayLike<T>, 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<T>(target: T[], start: number, arrToInsert: ArrayLike<T>): 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;
}
// Type for a compare func that returns a positive, negative, or zero value, as used for sorting.
export type CompareFunc<T> = (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<T>(array: ArrayLike<T>, elem: T, compareFunc: CompareFunc<T>): 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<T>(array: ReadonlyArray<T>, 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<T, U, V>(array1: ArrayLike<T>, array2: ArrayLike<U>, 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<T>(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<T, U>(sortKeyFuncs: ReadonlyArray<(a: T) => U>,
compareFuncs: ArrayLike<CompareFunc<U>>,
optAscending?: number[]): CompareFunc<T> {
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<T>(a: T, b: T): number {
return (a < b ? -1 : (a > b ? 1 : 0));
}
/**
* Creates a function that compares objects by a property value.
*/
export function propertyCompare<T>(property: keyof T) {
return function(a: T, b: T) {
return nativeCompare(a[property], b[property]);
};
}
// TODO: In the future, locale should be a value associated with the document or the user.
export const defaultLocale = 'en-US';
export const defaultCollator = new Intl.Collator(defaultLocale);
export const localeCompare = defaultCollator.compare;
/**
* 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<K, V>(mapInst: Map<K, V>, 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<K, V>(mapInst: Map<K, V>, 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<K, V>(mapInst: Map<K, V>, 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<T>(valueCounts: Map<T, number>, values: Iterable<T>,
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<any>, larger: Set<any>): 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<T>(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;
}
/**
* Remove the specified elements from the array, with the elements specified by
* their index. The array arr is modified in-place. The indexes must be provided
* in order, sorted lowest to highest, with no duplicates, or out-of-bound indices,
* etc (this method does no error checking; it is used in place of lodash-pullAt
* for performance reasons).
*/
export function pruneArray<T>(arr: T[], indexes: number[]) {
if (indexes.length === 0) { return; }
if (indexes.length === 1) {
arr.splice(indexes[0], 1);
return;
}
const len = arr.length;
let arrAt = 0;
let indexesAt = 0;
for (let i = 0; i < len; i++) {
if (i === indexes[indexesAt]) {
indexesAt++;
continue;
}
if (i !== arrAt) {
arr[arrAt] = arr[i];
}
arrAt++;
}
arr.length = arrAt;
}
/**
* 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<T, U>(arrA: ArrayLike<T>, arrB: ArrayLike<U>,
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>): 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<T>(observable: KoObservable<T>, predicate: (value: T) => boolean = Boolean): Promise<T> {
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<T>(observable: Observable<T>): Promise<NonNullable<T>>;
export async function waitGrainObs<T>(observable: Observable<T>, predicate?: (value: T) => boolean): Promise<T>;
export async function waitGrainObs<T>(observable: Observable<T>,
predicate: (value: T) => boolean = Boolean): Promise<T> {
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;
}
// `dom.style` does not work here because custom css property (ie: `--foo`) needs to be set using
// `style.setProperty` (credit: https://vanseodesign.com/css/custom-properties-and-javascript/).
// TODO: consider making PR to fix `dom.style` in grainjs.
export function inlineStyle(property: string, valueObs: BindableValue<string>): DomElementMethod {
return (elem) => subscribeElem(elem, valueObs, (val) => {
elem.style.setProperty(property, val);
});
}
/**
* 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<T> {
private _last: Promise<T|void> = 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<T>): Promise<T> {
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 true if val is a valid hex color value. For instance: #aabbaa is valid, #aabba is not. Do
* not accept neither short notation nor hex with transparency, ie: #aab, #aabb and #aabbaabb are
* invalid.
*/
export function isValidHex(val: string): boolean {
return /^#([0-9A-F]{6})$/i.test(val);
}
/**
* 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<any>, timeoutMsec: number): Promise<boolean> {
let isPending = true;
const done = () => { isPending = false; };
await Promise.race([
promise.then(done, done),
delay(timeoutMsec)
]);
return isPending;
}
/**
* Returns true if the parameter, when rendered as a string, matches
* 1, on, or true (case insensitively). Useful for processing query
* parameters that may have been manually set.
*/
export function isAffirmative(parameter: any): boolean {
return ['1', 'on', 'true', 'yes'].includes(String(parameter).toLowerCase());
}
/**
* Returns whether a value is neither null nor undefined, with a type guard for the return type.
*
* This is particularly useful for filtering, e.g. if `array` includes values of type
* T|null|undefined, then TypeScript can tell that `array.filter(isObject)` has the type T[].
*/
export function isObject<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
/**
* Returns the value of both grainjs and knockout observable without creating a dependency.
*/
export const unwrap: UseCB = (obs: ISubscribable) => {
if ('_getDepItem' in obs) {
return obs.get();
}
return (obs as ko.Observable).peek();
};
/**
* Get a set of up to `count` distinct values of `values`.
*/
export function getDistinctValues<T>(values: readonly T[], count: number = Infinity): Set<T> {
const distinct = new Set<T>();
// Add values to the set until it reaches the desired size, or until there are no more values.
for (let i = 0; i < values.length && distinct.size < count; i++) {
distinct.add(values[i]);
}
return distinct;
}
/**
* Asserts that variable `name` has a non-nullish `value`.
*/
export function assertIsDefined<T>(name: string, value: T): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error(`Expected '${name}' to be defined, but received ${value}`);
}
}
/**
* Calls function `fn`, passes any thrown errors to function `recover`, and finally calls `fn`
* once more if `recover` doesn't throw.
*/
export async function retryOnce<T>(fn: () => Promise<T>, recover: (e: unknown) => Promise<void>): Promise<T> {
try {
return await fn();
} catch (e) {
await recover(e);
return await fn();
}
}