Files
standardnotes-app-web/packages/utils/src/Domain/Utils/Utils.ts

686 lines
18 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { sanitize } from 'dompurify'
import { find, isArray, mergeWith, remove, uniq, uniqWith } from 'lodash'
import { AnyRecord } from '@standardnotes/common'
const collator = typeof Intl !== 'undefined' ? new Intl.Collator('en', { numeric: true }) : undefined
export function getGlobalScope(): Window | unknown | null {
return typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : null
}
export function dictToArray<T>(dict: Record<any, T>): T[] {
return Object.values(dict)
}
/**
* Whether we are in a web browser
*/
export function isWebEnvironment(): boolean {
return getGlobalScope() !== null
}
interface IEDocument {
documentMode?: number
}
/**
* @returns true if WebCrypto is available
*/
export function isWebCryptoAvailable(): boolean {
return (
(isWebEnvironment() && !isReactNativeEnvironment() && !(document && (document as IEDocument).documentMode)) ||
(/Edge/.test(navigator.userAgent) && window.crypto && !!window.crypto.subtle)
)
}
/**
* Whether we are in React Native app
*/
export function isReactNativeEnvironment(): boolean {
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative'
}
/**
* Searches array of objects for first object where object[key] === value
* @returns Matching object or null if not found
*/
export function findInArray<T, K extends keyof T>(array: T[], key: K, value: T[K]): T | undefined {
return array.find((item: T) => item[key] === value)
}
/**
* Searches array of objects for first object where object[key] === value
* @returns Matching object or null if not found
*/
export function searchArray<T>(array: T[], predicate: Partial<T>): T | undefined {
return find(array, predicate) as T
}
export function sureSearchArray<T>(array: T[], predicate: Partial<T>): T {
return searchArray(array, predicate) as T
}
/**
* @returns Whether the value is a function or object
*/
export function isObject(value: unknown): value is object {
if (value === null) {
return false
}
return typeof value === 'function' || typeof value === 'object'
}
/**
* @returns Whether the value is a function
*/
export function isFunction(value: unknown): boolean {
if (value === null) {
return false
}
return typeof value === 'function'
}
/**
* @returns True if the object is null or undefined, otherwise false
*/
export function isNullOrUndefined(value: unknown): value is null | undefined {
return value === null || value === undefined
}
export function isNotUndefined<T>(val: T | undefined | null): val is T {
return val != undefined
}
/**
* @returns True if the string is empty or undefined
*/
export function isEmpty(string: string): boolean {
return !string || string.length === 0
}
/**
* @returns Whether the value is a string
*/
export function isString(value: unknown): value is string {
return typeof value === 'string' || value instanceof String
}
/**
* @returns The greater of the two dates
*/
export function greaterOfTwoDates(dateA: Date, dateB: Date): Date {
if (dateA > dateB) {
return dateA
} else {
return dateB
}
}
/**
* Returns a new array containing only unique values by combining the two input arrays.
* Elements are unique based on the values of `equalityKeys`.
* @param equalityKeys - Keys to determine element equality
* @returns Array containing unique values
*/
export function uniqCombineObjArrays<T>(arrayA: T[], arrayB: T[], equalityKeys: (keyof T)[]): T[] {
return uniqWith(arrayA.concat(arrayB), (a: T, b: T) => {
for (const key of equalityKeys) {
if (a[key] !== b[key]) {
return false
}
}
return true
})
}
/**
* Returns a new array containing only unique values
* @returns Array containing unique values
*/
export function uniqueArray<T>(array: T[]): T[] {
return uniq(array)
}
/**
* Returns a new array containing only unique values
* @returns Array containing unique values
*/
export function uniqueArrayByKey<T>(array: T[], key: keyof T): T[] {
return uniqWith(array, (a: T, b: T) => {
return a[key] === b[key]
})
}
/**
* Returns the last element in the array.
* @returns The last element in the array
*/
export function lastElement<T>(array: T[]): T | undefined {
return array[array.length - 1]
}
/**
* Adds all items from otherArray into inArray, in-place.
* Does not return a value.
*/
export function extendArray<T, K extends T>(inArray: T[], otherArray: K[]): void {
for (const value of otherArray) {
inArray.push(value)
}
}
/**
* Removes all items appearing in toSubtract from inArray, in-place
* @param toSubtract - The list of items to remove from inArray
*/
export function subtractFromArray<T>(inArray: T[], toSubtract: T[]): void {
for (const value of toSubtract) {
removeFromArray(inArray, value)
}
}
/**
* Removes the first matching element of an array by strict equality.
* If no matchin element is found, the array is left unchanged.
*/
export function removeFromArray<T>(array: T[], value: T): void {
const valueIndex = array.indexOf(value)
if (valueIndex === -1) {
return
}
array.splice(valueIndex, 1)
}
/**
* Adds the element to the array if the array does not already include the value.
* The array is searched via array.indexOf
* @returns true if value was added
*/
export function addIfUnique<T>(array: T[], value: T): boolean {
if (!array.includes(value)) {
array.push(value)
return true
}
return false
}
/**
* Removes an object from the array in-place by searching for an object where all the
* key/values in predicate match with the candidate element.
*/
export function filterFromArray<T>(
array: T[],
predicate: Partial<Record<keyof T, any>> | ((object: T) => boolean),
): void {
remove(array, predicate)
}
/**
* Returns a new array by removing all elements in subtract from array
*/
export function arrayByDifference<T>(array: T[], subtract: T[]): T[] {
return array.filter((x) => !subtract.includes(x)).concat(subtract.filter((x) => !array.includes(x)))
}
export function compareValues<T>(left: T, right: T) {
if ((left && !right) || (!left && right)) {
return false
}
if (left instanceof Date && right instanceof Date) {
return left.getTime() === right.getTime()
} else if (left instanceof String && right instanceof String) {
return left === right
} else {
return topLevelCompare(left, right)
}
}
/**
* Removes the value from the array at the given index, in-place.
*/
export function removeFromIndex(array: any[], index: number) {
array.splice(index, 1)
}
/**
* Adds the value from the array at the given index, in-place.
*/
export function addAtIndex<T>(array: T[], element: T, index: number) {
array.splice(index, 0, element)
}
/**
* Returns a new array by removeing the value from the array at the given index
*/
export function arrayByRemovingFromIndex<T>(array: T[], index: number) {
const copy = array.slice()
removeFromIndex(copy, index)
return copy
}
/**
* Returns an array where each element is the value of a top-level
* object key.
* Example: objectToValueArray({a: 1, b: 2}) returns [1, 2]
*/
export function objectToValueArray(object: AnyRecord) {
const values = []
for (const key of Object.keys(object)) {
values.push(object[key])
}
return values
}
/**
* Returns a key-sorted copy of the object.
* For example, sortedCopy({b: '1', a: '2'}) returns {a: '2', b: '1'}
*/
export function sortedCopy(object: any) {
const keys = Object.keys(object).sort()
const result: any = {}
for (const key of keys) {
result[key] = object[key]
}
return Copy(result)
}
export const sortByKey = <T>(input: T[], key: keyof T): T[] => {
const compare = (a: T, b: T): number => {
const valueA = a[key]
const valueB = b[key]
if (valueA < valueB) {
return -1
}
if (valueA > valueB) {
return 1
}
return 0
}
const newArray = [...input]
newArray.sort(compare)
return newArray
}
/** Returns a new object by omitting any keys which have an undefined or null value */
export function omitUndefinedCopy(object: any) {
const result: any = {}
for (const key of Object.keys(object)) {
if (!isNullOrUndefined(object[key])) {
result[key] = object[key]
}
}
return result
}
/**
* Returns a new array by sorting an array of elements based on a date property,
* as indicated by the input key value.
*/
export function dateSorted<T>(elements: T[], key: keyof T, ascending = true) {
return elements.sort((a, b) => {
const aTimestamp = (a[key] as unknown as Date).getTime()
const bTimestamp = (b[key] as unknown as Date).getTime()
const vector = ascending ? 1 : -1
if (aTimestamp < bTimestamp) {
return -1 * vector
} else if (aTimestamp > bTimestamp) {
return 1 * vector
} else {
return 0
}
})
}
/** Compares for equality by comparing top-level keys value equality (===) */
export function topLevelCompare<T>(left: T, right: T) {
if (!left && !right) {
return true
}
if (!left || !right) {
return false
}
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)
if (leftKeys.length !== rightKeys.length) {
return false
}
for (const key of leftKeys) {
if ((left as any)[key] !== (right as any)[key]) {
return false
}
}
return true
}
/**
* Returns a new object by attempting to JSON.parse any top-level object keys.
*/
export function jsonParseEmbeddedKeys(object: AnyRecord) {
const result: AnyRecord = {}
for (const key of Object.keys(object)) {
let value
try {
value = JSON.parse(object[key] as string)
} catch (error) {
value = object[key]
}
result[key] = value
}
return result
}
export const withoutLastElement = <T>(array: T[]): T[] => {
return array.slice(0, -1)
}
/**
* Deletes keys of the input object.
*/
export function omitInPlace<T>(object: T, keys: Array<keyof T>) {
if (!object) {
return
}
for (const key of keys) {
delete object[key]
}
}
/**
* Creates a new object by omitting `keys` from `object`
*/
export function omitByCopy<T>(object: T, keys: Array<keyof T>) {
if (isNullOrUndefined(object)) {
return undefined
}
const newObject = Object.assign({}, object)
/**
* Lodash's omit, which was previously used, seems to cause unexpected behavior
* when payload is an ES6 item class. So we instead manually omit each key.
*/
for (const key of keys) {
delete newObject[key]
}
return newObject
}
/**
* Similiar to Node's path.join, this function combines an array of paths into
* one resolved path.
*/
export function joinPaths(...args: string[]) {
return args
.map((part, i) => {
if (i === 0) {
return part.trim().replace(/[/]*$/g, '')
} else {
return part.trim().replace(/(^[/]*|[/]*$)/g, '')
}
})
.filter((x) => x.length)
.join('/')
}
/**
* Creates a copy of the input object by JSON stringifying the object then JSON parsing
* the string (if the input is an object). If input is date, a Date copy will be created,
* and if input is a primitive value, it will be returned as-is.
*/
export function Copy(object: any) {
if (object instanceof Date) {
return new Date(object)
} else if (isObject(object)) {
return JSON.parse(JSON.stringify(object))
} else {
return object
}
}
/**
* Merges the second object parameter into the first object, in-place.
* @returns The now modified first object parameter passed into the function.
*/
export function deepMerge(a: AnyRecord, b: AnyRecord) {
/**
* lodash.merge will not merge a full array with an empty one.
* deepMerge will replace arrays wholesale
*/
if (!a || !b) {
throw 'Attempting to deepMerge with null values'
}
const customizer = (aValue: any, bValue: any) => {
if (isArray(aValue)) {
return bValue
}
}
mergeWith(a, b, customizer)
return a
}
/**
* Returns a new object by selecting certain keys from input object.
*/
export function pickByCopy<T>(object: T, keys: Array<keyof T>) {
const result = {} as T
for (const key of keys) {
result[key] = object[key]
}
return Copy(result)
}
/**
* Recursively makes an object immutable via Object.freeze
*/
export function deepFreeze(object: any) {
const propNames = Object.getOwnPropertyNames(object)
for (const name of propNames) {
const value = object[name]
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
object[name] = deepFreeze(value)
} else {
object[name] = value
}
}
return Object.freeze(object)
}
export function isValidUrl(url: string): boolean {
try {
new URL(url)
return true
} catch (error) {
return false
}
}
/**
* Determines if an object has a getter defined for a given property
*/
export function hasGetter(object: any, property: string) {
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(object), property)
return descriptor && !isNullOrUndefined(descriptor.get)
}
/**
* Truncates a hex string into a desired number of bits
* @returns A hexadecimal string truncated to the number of desired bits
*/
export function truncateHexString(string: string, desiredBits: number) {
const BITS_PER_HEX_CHAR = 4
const desiredCharLength = desiredBits / BITS_PER_HEX_CHAR
return string.substring(0, desiredCharLength)
}
/**
* When awaited, this function allows code execution to pause for a set time.
* Should be used primarily for testing.
*/
export async function sleep(milliseconds: number, warn = true): Promise<void> {
if (warn) {
console.warn(`Sleeping for ${milliseconds}ms`)
}
return new Promise<void>((resolve) => {
setTimeout(function () {
resolve()
}, milliseconds)
})
}
export function assertUnreachable(uncheckedCase: never): never {
throw Error('Unchecked case ' + uncheckedCase)
}
/**
* Returns a boolean representing whether two dates are on the same day
*/
export function isSameDay(dateA: Date, dateB: Date) {
return (
dateA.getFullYear() === dateB.getFullYear() &&
dateA.getMonth() === dateB.getMonth() &&
dateA.getDate() === dateB.getDate()
)
}
/**
* Sorts an array of objects in natural order
* @param items - The array of objects to sort
* @param property - The objects' property to sort by
* @param direction - The sorting direction, either ascending (default) or descending
* @returns Array of objects sorted in natural order
*/
export function naturalSort<T extends AnyRecord>(
items: T[],
property: keyof T,
direction: 'asc' | 'desc' = 'asc',
): T[] {
switch (direction) {
case 'asc':
return [...items].sort(
collator
? (a, b) => collator.compare(a[property] as string, b[property] as string)
: (a, b) => (a[property] as string).localeCompare(b[property] as string, 'en', { numeric: true }),
)
case 'desc':
return [...items].sort(
collator
? (a, b) => collator.compare(b[property] as string, a[property] as string)
: (a, b) => (b[property] as string).localeCompare(a[property] as string, 'en', { numeric: true }),
)
}
}
export function arraysEqual<T>(left: T[], right: T[]): boolean {
if (left.length !== right.length) {
return false
}
return left.every((item) => right.includes(item)) && right.every((item) => left.includes(item))
}
const MicrosecondsInAMillisecond = 1_000
const MillisecondsInASecond = 1_000
enum TimestampDigits {
Seconds = 10,
Milliseconds = 13,
Microseconds = 16,
}
export function convertTimestampToMilliseconds(timestamp: number): number {
const digits = String(timestamp).length
switch (digits) {
case TimestampDigits.Seconds:
return timestamp * MillisecondsInASecond
case TimestampDigits.Milliseconds:
return timestamp
case TimestampDigits.Microseconds:
return Math.floor(timestamp / MicrosecondsInAMillisecond)
default:
throw `Unhandled timestamp precision: ${timestamp}`
}
}
export function sanitizeHtmlString(html: string): string {
return sanitize(html)
}
let sharedDateFormatter: unknown
export function dateToLocalizedString(date: Date): string {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat && typeof navigator !== 'undefined') {
if (!sharedDateFormatter) {
const locale = navigator.languages && navigator.languages.length ? navigator.languages[0] : navigator.language
sharedDateFormatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
})
}
return (sharedDateFormatter as Intl.DateTimeFormat).format(date)
} else {
// IE < 11, Safari <= 9.0.
// In English, this generates the string most similar to
// the toLocaleDateString() result above.
return date.toDateString() + ' ' + date.toLocaleTimeString()
}
}
export function nonSecureRandomIdentifier(): string {
return `${Math.random() * 100}`.replace('.', '')
}
export function splitString(string: string, parts: number): string[] {
const outputLength = string.length
const partLength = outputLength / parts
const partitions = []
for (let i = 0; i < parts; i++) {
const partition = string.slice(partLength * i, partLength * (i + 1))
partitions.push(partition)
}
return partitions
}
export function firstHalfOfString(string: string): string {
return string.substring(0, string.length / 2)
}
export function secondHalfOfString(string: string): string {
return string.substring(string.length / 2, string.length)
}
export function log(namespace: string, ...args: any[]): void {
logWithColor(namespace, 'black', ...args)
}
export function logWithColor(namespace: string, namespaceColor: string, ...args: any[]): void {
const date = new Date()
const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`
customLog(
`%c${namespace}%c${timeString}`,
`color: ${namespaceColor}; font-weight: bold; margin-right: 4px`,
'color: gray',
...args,
)
}
function customLog(..._args: any[]) {
// eslint-disable-next-line no-console, prefer-rest-params
Function.prototype.apply.call(console.log, console, arguments)
}
export function assert(value: unknown): asserts value {
if (value === undefined) {
throw new Error('Assertion failed; value must be defined')
}
}
export function useBoolean(value: boolean | undefined, defaultValue: boolean): boolean {
return value != undefined ? value : defaultValue
}