feat: add utils package

This commit is contained in:
Karol Sójko
2022-07-06 11:33:25 +02:00
parent d273770831
commit aef4ceb7f8
41 changed files with 1332 additions and 36 deletions

View File

@@ -0,0 +1,15 @@
export const Deferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: () => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return {
resolve,
reject,
promise,
}
}

View File

@@ -0,0 +1,61 @@
import * as DOMPurifyLib from 'dompurify'
import { JSDOM } from 'jsdom'
import { sortByKey, withoutLastElement } from './Utils'
const window = new JSDOM('').window
const DOMPurify = DOMPurifyLib(window as never)
describe('Utils', () => {
it('sanitizeHtmlString', () => {
const dirty = '<svg><animate onbegin=alert(1) attributeName=x dur=1s>'
const cleaned = DOMPurify.sanitize(dirty)
expect(cleaned).toEqual('<svg></svg>')
})
it('without last works', () => {
expect(withoutLastElement([])).toEqual([])
expect(withoutLastElement(['a'])).toEqual([])
expect(withoutLastElement(['a', 'b'])).toEqual(['a'])
expect(withoutLastElement(['a', 'b', 'c'])).toEqual(['a', 'b'])
})
const sortByKey_INPUT = [
{ id: 'aza', age: 0, missing: 7 },
{ id: 'aaa', age: 12, missing: 8 },
{ id: 'ace', age: 12, missing: 0 },
{ id: 'zzz', age: -9 },
]
it('sortByKey sort by key', () => {
const input = sortByKey_INPUT
expect(sortByKey(input, 'id')).toEqual([
{ id: 'aaa', age: 12, missing: 8 },
{ id: 'ace', age: 12, missing: 0 },
{ id: 'aza', age: 0, missing: 7 },
{ id: 'zzz', age: -9 },
])
expect(sortByKey(input, 'age')).toEqual([
{ id: 'zzz', age: -9 },
{ id: 'aza', age: 0, missing: 7 },
{ id: 'aaa', age: 12, missing: 8 },
{ id: 'ace', age: 12, missing: 0 },
])
expect(sortByKey(input, 'missing')).toEqual([
{ id: 'ace', age: 12, missing: 0 },
{ id: 'aza', age: 0, missing: 7 },
{ id: 'aaa', age: 12, missing: 8 },
{ id: 'zzz', age: -9 },
])
})
it('sortByKey does not mutate the input & creates a new array', () => {
const input = sortByKey_INPUT
const initial = [...input]
expect(sortByKey(input, 'id')).not.toBe(input)
expect(initial).toEqual(input)
})
})

View File

@@ -0,0 +1,681 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as DOMPurify 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 DOMPurify.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 {
const date = new Date()
const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`
customLog(
`%c${namespace}%c${timeString}`,
'color: black; 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
}

View File

@@ -0,0 +1,5 @@
export function Uuids(items: { uuid: string }[]): string[] {
return items.map((item) => {
return item.uuid
})
}

View File

@@ -0,0 +1,21 @@
/**
* An abstract class with no instance methods. Used globally to generate uuids by any
* consumer. Application must call SetGenerator before use.
*/
export class UuidGenerator {
private static syncUuidFunc: () => string
/**
* @param {function} syncImpl - A syncronous function that returns a UUID.
*/
static SetGenerator(syncImpl: () => string): void {
this.syncUuidFunc = syncImpl
}
/**
* Generates a UUID string asyncronously.
*/
public static GenerateUuid(): string {
return this.syncUuidFunc()
}
}

View File

@@ -0,0 +1,95 @@
import { Uuid } from '@standardnotes/common'
import { addIfUnique, removeFromArray } from '../Utils/Utils'
/**
* Maps a UUID to an array of UUIDS to establish either direct or inverse
* relationships between UUID strings (represantative of items or payloads).
*/
export class UuidMap {
/** uuid to uuids that we have a relationship with */
private directMap: Partial<Record<Uuid, Uuid[]>> = {}
/** uuid to uuids that have a relationship with us */
private inverseMap: Partial<Record<Uuid, Uuid[]>> = {}
public makeCopy(): UuidMap {
const copy = new UuidMap()
copy.directMap = Object.assign({}, this.directMap)
copy.inverseMap = Object.assign({}, this.inverseMap)
return copy
}
public getDirectRelationships(uuid: Uuid): Uuid[] {
return this.directMap[uuid] || []
}
public getInverseRelationships(uuid: Uuid): Uuid[] {
return this.inverseMap[uuid] || []
}
public establishRelationship(uuidA: Uuid, uuidB: Uuid): void {
this.establishDirectRelationship(uuidA, uuidB)
this.establishInverseRelationship(uuidA, uuidB)
}
public deestablishRelationship(uuidA: Uuid, uuidB: Uuid): void {
this.deestablishDirectRelationship(uuidA, uuidB)
this.deestablishInverseRelationship(uuidA, uuidB)
}
public setAllRelationships(uuid: Uuid, relationships: Uuid[]): void {
const previousDirect = this.directMap[uuid] || []
this.directMap[uuid] = relationships
/** Remove all previous values in case relationships have changed
* The updated references will be added afterwards.
*/
for (const previousRelationship of previousDirect) {
this.deestablishInverseRelationship(uuid, previousRelationship)
}
/** Now map current relationships */
for (const newRelationship of relationships) {
this.establishInverseRelationship(uuid, newRelationship)
}
}
public removeFromMap(uuid: Uuid): void {
/** Items that we reference */
const directReferences = this.directMap[uuid] || []
for (const directReference of directReferences) {
removeFromArray(this.inverseMap[directReference] || [], uuid)
}
delete this.directMap[uuid]
/** Items that are referencing us */
const inverseReferences = this.inverseMap[uuid] || []
for (const inverseReference of inverseReferences) {
removeFromArray(this.directMap[inverseReference] || [], uuid)
}
delete this.inverseMap[uuid]
}
private establishDirectRelationship(uuidA: Uuid, uuidB: Uuid): void {
const index = this.directMap[uuidA] || []
addIfUnique(index, uuidB)
this.directMap[uuidA] = index
}
private establishInverseRelationship(uuidA: Uuid, uuidB: Uuid): void {
const inverseIndex = this.inverseMap[uuidB] || []
addIfUnique(inverseIndex, uuidA)
this.inverseMap[uuidB] = inverseIndex
}
private deestablishDirectRelationship(uuidA: Uuid, uuidB: Uuid): void {
const index = this.directMap[uuidA] || []
removeFromArray(index, uuidB)
this.directMap[uuidA] = index
}
private deestablishInverseRelationship(uuidA: Uuid, uuidB: Uuid): void {
const inverseIndex = this.inverseMap[uuidB] || []
removeFromArray(inverseIndex, uuidA)
this.inverseMap[uuidB] = inverseIndex
}
}

View File

@@ -0,0 +1,5 @@
export * from './Utils/Utils'
export * from './Uuid/UuidGenerator'
export * from './Uuid/UuidMap'
export * from './Uuid/Utils'
export * from './Deferred/Deferred'

View File

@@ -0,0 +1 @@
export * from './Domain'