UNPKG

@ibgib/helper-gib

Version:

common helper/utils/etc used in ibgib libs. Node v19+ needed for heavily-used isomorphic webcrypto hashing consumed in both node and browsers.

726 lines (671 loc) 24.2 kB
import { DEFAULT_DATA_PATH_DELIMITER, HELPER_LOG_A_LOT, ONLY_HAS_NON_ALPHANUMERICS } from "../constants.mjs"; const logalot = HELPER_LOG_A_LOT || false; let crypto: any = globalThis.crypto; let { subtle } = crypto; export type HashAlgorithm = 'SHA-256' | 'SHA-512'; export const HashAlgorithm: { [key: string]: HashAlgorithm } = { 'sha_256': 'SHA-256' as HashAlgorithm, 'sha_512': 'SHA-512' as HashAlgorithm, } export function clone(obj: any) { return JSON.parse(JSON.stringify(obj)); } export function getTimestamp() { return (new Date()).toUTCString(); } /** * Simple hash function. * * NOTE: * This is not used for ibGib.gib values (ATOW) * but rather as a helper function for generating random UUIDs. * * @param s string to hash * @param algorithm to use, currently only 'SHA-256' */ export async function hash({ s, algorithm = 'SHA-256', }: { s: string, algorithm?: HashAlgorithm, }): Promise<string> { if (!s) { return ''; } try { const validAlgorithms = Object.values(HashAlgorithm); if (!validAlgorithms.includes(algorithm)) { throw new Error(`Only ${validAlgorithms} implemented (E: 73cb52cd4d7f70c3415fdf695ba6ba23)`); } const msgUint8 = new TextEncoder().encode(s); const buffer = await subtle.digest(algorithm, msgUint8); const asArray = Array.from(new Uint8Array(buffer)); return asArray.map(b => b.toString(16).padStart(2, '0')).join(''); } catch (error) { console.error(extractErrorMsg(error.message)); throw error; // return ''; // why had I decided to return an empty string on error? } } /** * Simple func to generate UUID (sha-256 hash basically). * * @param seedSize size of seed for UUID generation */ export async function getUUID(seedSize = 64): Promise<string> { let uuid: string = ''; if (seedSize < 32) { throw new Error(`Seed size must be at least 32`); } if (!globalThis.crypto) { throw new Error(`Cannot create UUID, as unknown crypto library version. If using node.js, v19+ is required. (E: c02cee3fd8a94f678d3f4ebe9dc49797)`); } const values = crypto.getRandomValues(new Uint8Array(16)); uuid = await hash({ s: values.join('') }); if (!uuid) { throw new Error(`Did not create UUID...hmm...`); } return uuid; } /** * Syntactic sugar for JSON.stringify(obj, null, 2); * * @param obj to pretty stringify */ export function pretty(obj: any): string { return JSON.stringify(obj, null, 2); } /** * Just delays given number of ms. * * @param ms milliseconds to delay */ export async function delay(ms: number): Promise<void> { return new Promise<void>(resolve => { setTimeout(() => { resolve(); }, ms); }); } /** * extracts the error message from an error object/string/falsy arg. * * ## notes * * * some libs throw errors, some throw just strings. * * who knows what else it could be. * * ## todo * * * extract inner errors/causes if we ever use this function extensively. * * @param error the error object in the catch area of the try..catch block. * @returns error.message if it's a string, error itself if it's a string, or canned error messages if it's falsy or none of the above. */ export function extractErrorMsg(error: any): string { if (!error && error !== 0) { return '[error is falsy]'; } else if (typeof error === 'string') { return error; } else if (typeof error.message === 'string') { return error.message; } else if (typeof error === 'number') { return JSON.stringify(error); } else { return `[error is not a string and error.message is not a string. typeof error: ${typeof error}]`; } } export function groupBy<TItem>({ items, keyFn, }: { items: TItem[], keyFn: (x: TItem) => string, }): { [key: string]: TItem[] } { const lc = `[${groupBy.name}]`; try { const result: { [key: string]: TItem[] } = {}; for (let i = 0; i < items.length; i++) { const item = items[i]; const key = keyFn(item); result[key] = [...(result[key] ?? []), item]; } return result; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } } /** * Just trying to centralize and standardize regular expressions here... */ export function getRegExp({ min, max, chars, noSpaces, }: { min?: number, max?: number, chars?: string, noSpaces?: boolean, }): RegExp { min = min ?? 1; max = max ?? 999999999999; chars = chars ?? ''; return noSpaces ? new RegExp(`^[\\w${chars}]{${min},${max}}$`) : new RegExp(`^[\\w\\s${chars}]{${min},${max}}$`); } /** * syntactic sugar for `(new Date()).getTime().toString()` * @returns ticks string */ export function getTimestampInTicks(timestamp?: string): string { let date: Date; if (timestamp) { date = new Date(timestamp); if (date.toString() === "Invalid Date") { throw new Error(`invalid date created by timestamp (${timestamp}) (E: cbd6aeefe00708184e276ea3c2532b22)`); } } else { date = new Date(); } return date.getTime().toString(); } /** * ## requires * at least either `startDate` or one of the intervals to be truthy. * * ## thanks * * https://stackoverflow.com/questions/8609261/how-to-determine-one-year-from-now-in-javascript * * ## tested manually eek ``` console.log(new Date().toUTCString()); // Mon, 14 Feb 2022 14:19:32 GMT console.log(getExpirationUTCString({years: 1})); // Tue, 14 Feb 2023 14:19:32 GMT console.log(getExpirationUTCString({months: 13})); // Tue, 14 Mar 2023 13:19:32 GMT console.log(getExpirationUTCString({days: 365})); // Tue, 14 Feb 2023 14:19:32 GMT console.log(getExpirationUTCString({days: 45})); // Thu, 31 Mar 2022 13:19:32 GMT console.log(getExpirationUTCString({years: 1, days: 45, hours: 25, seconds: 70})); // Sat, 01 Apr 2023 14:20:42 GMT console.log(getExpirationUTCString({days: 10, hours: 10, seconds: 10})); // Fri, 25 Feb 2022 00:19:42 GMT console.log(getExpirationUTCString({years: 1, days: 45, hours: 25, seconds: 70})); // Sat, 01 Apr 2023 14:20:42 GMT console.log(getExpirationUTCString({years: 1, days: 45, hours: 25, seconds: 35})); // Sat, 01 Apr 2023 14:20:07 GMT ``` */ export function getExpirationUTCString({ startDate, years, months, days, hours, seconds, }: { startDate?: Date, years?: number, months?: number, days?: number, hours?: number, seconds?: number, }): string { const lc = `[${getExpirationUTCString.name}]`; try { return addTimeToDate({ startDate, years, months, days, hours, seconds, }).toUTCString(); } catch (error) { console.log(`${lc} ${error.message}`); throw error; } } export function addTimeToDate({ startDate, years, months, days, hours, seconds, }: { startDate?: Date, years?: number, months?: number, days?: number, hours?: number, seconds?: number, }): Date { const lc = `[${addTimeToDate.name}]`; try { if (!startDate && !years && !months && !days && !hours && !seconds) { // throw here because otherwise we would return an expiration // timestamp string with now as the expiration, which doesn't make // sense. throw new Error(`either startDate or a time interval required. (E: 30248f8b306f443ab036fa8c313c50d8)`); } // don't want to mutate the incoming date startDate = startDate ? new Date(startDate) : new Date(); // default to now /** incoming years/months/days/hours/seconds to add to start date */ let intervalToAdd: number; /** start date + interval in ticks, before assigning to Date obj */ let newDateTicks: number; if (years) { intervalToAdd = startDate.getFullYear() + years; newDateTicks = startDate.setFullYear(intervalToAdd); // call recursively for other interval args (if any) return addTimeToDate({ startDate: new Date(newDateTicks), months, days, hours, seconds, // all but years (just set) }) } else if (months) { intervalToAdd = startDate.getMonth() + months; newDateTicks = startDate.setMonth(intervalToAdd); // call recursively for other interval args (if any) return addTimeToDate({ startDate: new Date(newDateTicks), years, days, hours, seconds, // all but months (just set) }) } else if (days) { intervalToAdd = startDate.getDate() + days; newDateTicks = startDate.setDate(intervalToAdd); // call recursively for other interval args (if any) return addTimeToDate({ startDate: new Date(newDateTicks), years, months, hours, seconds, // all but days (just set) }) } else if (hours) { intervalToAdd = startDate.getHours() + hours; newDateTicks = startDate.setHours(intervalToAdd); // call recursively for other interval args (if any) return addTimeToDate({ startDate: new Date(newDateTicks), years, months, days, seconds, // all but hours (just set) }) } else if (seconds) { intervalToAdd = startDate.getSeconds() + seconds; newDateTicks = startDate.setSeconds(intervalToAdd); // call recursively for other interval args (if any) return addTimeToDate({ startDate: new Date(newDateTicks), years, months, days, hours, // all but seconds (just set) }) } else { // we've called our function recursively and all intervals args // falsy now, so startDate is the output date. return startDate; } } catch (error) { console.log(`${lc} ${error.message}`); throw error; } } export function isExpired({ expirationTimestampUTC, }: { expirationTimestampUTC: string, }): boolean { const lc = `[${isExpired.name}]`; try { if (!expirationTimestampUTC) { throw new Error(`expirationTimestampUTC required (E: 5eeb1e29f93d64f70c71a8112080a222)`); } let expirationDate = new Date(expirationTimestampUTC); if (expirationDate.toUTCString() === "Invalid Date") { throw new Error(`invalid expirationTimestampUTC: ${expirationTimestampUTC} (E: 66a1a165bcf1f9336fe78856ab777822)`); } const now = new Date(); const expired = expirationDate < now; return expired; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } } /** * Creates a new array that is a unique set of incoming `arr`. * @param arr array to make unique * @returns new array with unique items */ export function unique<T>(arr: T[]): T[] { return Array.from(new Set<T>(arr)); } // export function getExt(path: string): { filename: string, ext: string } { // const pathPieces = path.split('/'); // const fullFilename = pathPieces[pathPieces.length-1]; // if (fullFilename.includes('.') && !fullFilename.endsWith('.')) { // const lastDotIndex = fullFilename.lastIndexOf('.'); // return { // filename: fullFilename.slice(0, lastDotIndex), // ext: fullFilename.slice(lastDotIndex+1), // }; // } else { // return {filename: fullFilename, ext: ""} // } // } export function patchObject({ obj, value, path, pathDelimiter, logalot, }: { obj: Object, value: any, path: string, pathDelimiter?: string, logalot?: number | boolean, }): void { const lc = `[${patchObject.name}]`; try { if (logalot) { console.log(`${lc} starting...`); } if (!obj) { throw new Error(`obj required (E: 6a9dd32a361476e80b1bf7b91ec50522)`); } if (typeof obj !== 'object') { throw new Error(`obj must be type 'object' (E: 66fdc289b32c06492bd95f5d266e6a22)`); } if (!path) { throw new Error(`path required (at the very least should be the key in the root obj.) (E: fc779e7794ead8a0b44e5f2e776b0e22)`); } /** atow defaults to a forward slash, but could be a dot or who knows */ pathDelimiter = pathDelimiter || DEFAULT_DATA_PATH_DELIMITER; /** * the target starts off at the object level itself, but we will * traverse the given path, updating the targetObj as we go. */ let targetObj: { [key: string | number]: any } = obj; const pathPieces = path.split(pathDelimiter).filter(x => !!x); /** the last one is the key into the final targetObj with value */ const key = pathPieces.pop()!; // ensure each intermediate path exists and is an object pathPieces.forEach(piece => { let currentValue = targetObj[piece]; if (currentValue) { if (typeof currentValue !== 'object') { throw new Error(`invalid path into object. Each step along the path must be typeof === 'object', but typeof targetObj["${piece}"] === ${typeof currentValue}. (value: ${currentValue}) (E: 38cf29c5f624a40b4b56502c2ec39d22)`); } } else { // if not exist, create it targetObj[piece] = {}; } // update targetObj ref targetObj = targetObj[piece]; }); // reached target depth, so finally set the value targetObj[key] = value; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } export async function getIdPool({ n, }: { n: number, }): Promise<string[]> { const lc = `[${getIdPool.name}]`; try { if (logalot) { console.log(`${lc} starting...`); } let result: string[] = []; for (let i = 0; i < n; i++) { const id = await getUUID(); result.push(id.substring(0, 16)); } return result; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } export function getSaferSubstring({ text, length, keepLiterals = ['-'], replaceMap, }: { text: string; length?: number, /** * list of strings that you want to keep in the resultant string verbatim (without alteration). * * ## driving use case * * I want user comments that start with a question mark (?) to signify a * request to a robbot, e.g. "?start someAddr^gib" or whatever. So I want to * keep the question mark. I thought of an encoding mapping, like ? => * "__qstmark__" but it's easier just to keep it, as this function was * originally intended to just nerf text in general because there was no * reason not to. well now there is a reason. * * I'm adding in a couple other characters in common use for whenever I get around * to making those mean something in the app (#, @) */ keepLiterals?: string[], /** * */ replaceMap?: { [s: string]: string }, }): string { const lc = `[${getSaferSubstring.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 27437e312e5aa621adfebb84e059c822)`); } if (!text) { throw new Error(`text required (E: 87e0493613c8b30dfade83e1d2862a22)`); } let saferText: string = text; // before stripping "unsafe" characters, replace all instances of // keepLiterals with a temporary token if applicable let tokenToKeepMap: { [token: string]: string } = {}; keepLiterals = keepLiterals ?? []; for (let i = 0; i < keepLiterals.length; i++) { const keep = keepLiterals[i]; let tmpToken: string; do { tmpToken = pickRandom_Letters({ count: 10 }); } while (tmpToken.includes(keep) || keep.includes(tmpToken) || text.includes(tmpToken)); // replace instances of keep literals with our token if (saferText.includes(keep)) { tokenToKeepMap[tmpToken] = keep; while (saferText.includes(keep)) { saferText = saferText.replace(keep, tmpToken); } } } if (replaceMap && Object.keys(replaceMap).length > 0) { for (let i = 0; i < Object.keys(replaceMap).length; i++) { const toReplace = Object.keys(replaceMap)[i]; const replaceWith = replaceMap[toReplace]; while (saferText.includes(toReplace)) { saferText = saferText.replace(toReplace, replaceWith); } } } // now remove every non-alphanumeric saferText = saferText.replace(/\W/g, ''); // before checking length, put back in our keep literals (if any) const tokens = Object.keys(tokenToKeepMap); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; while (saferText.includes(token)) { saferText = saferText.replace(token, tokenToKeepMap[token]); } } // trim the text to length if specified if (length && length > 0) { // let resText: string; if (saferText.length > length) { saferText = saferText.substring(0, length); } } // replace if text only has characters/nonalphanumerics ("unsafe"). if (saferText.length === 0) { saferText = ONLY_HAS_NON_ALPHANUMERICS; } return saferText; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * picks a random item from an array */ export function pickRandom<T extends any>({ x }: { x: T[] }): T | undefined { if ((x ?? []).length === 0) { return undefined; /* <<<< returns early */ } let randomIndex = Math.floor(Math.random() * x.length); return x[randomIndex]; } /** * NOT strong crypto! * * returns `count` number of letters concatenated into a string. */ export function pickRandom_Letters({ count }: { count: number }): string { const lc = `${pickRandom_Letters.name}]`; try { if (!Number.isInteger(count)) { throw new Error(`count required to be a number. (E: c0a21d884ebd9afc4b2e8025207e0522)`); } let result: string = ""; for (let i = 0; i < count; i++) { result += pickRandom({ x: 'a b c d e f g h i j k l m n o p q r s t u v w x y z'.split(' ') }); } if (result.length !== count) { throw new Error(`${lc} (UNEXPECTED) result.length !== count ? (E: 9bec4ec8f78610d8055e565415392a22)`); } return result; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * creates a text selection of the entire element's text. * * ty https://stackoverflow.com/questions/985272/selecting-text-in-an-element-akin-to-highlighting-with-your-mouse * * @param el element whose text we're selecting */ export function selectElementText(el: HTMLElement): void { const lc = `[${selectElementText.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 0971989c737e5b846894357f671ab322)`); } if (((document as any).body).createTextRange) { const range = ((document as any).body).createTextRange(); range.moveToElementText(el); range.select(); } else if (window.getSelection) { const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); const range = document.createRange(); range.selectNodeContents(el); selection.addRange(range); } else { throw new Error(`(UNEXPECTED) window.getSelection() returned false? (E: 722b2d3084ed43fe8da22d889ddb52b8)`); } } else { throw new Error(`(UNEXPECTED) cannot select element text? (E: 163a1dd811b4f4bc22dd6823db859322)`); } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * replaces an individual character at a position. * * ## driving use case * * part of functionality to replace entire words with underscores (_'s) for blanking out * stimulations in wordy robbot. * * @returns string with replaced characters */ export function replaceCharAt({ s, pos, newChar, }: { s: string, pos: number, newChar: string, }): string { const chars = s.split(''); chars[pos] = newChar; return chars.join(''); } /** * Apparently it's a pain to determine if a keyboard event is hitting the * "enter" key across platforms. * * @link https://bugs.chromium.org/p/chromium/issues/detail?id=79407 * @link https://stackoverflow.com/questions/3883543/javascript-different-keycodes-on-different-browsers * * @returns true if the event is the user pressing the "Enter" key, else false */ export function isKeyboardEvent_Enter(event: KeyboardEvent): boolean { const isEnter = event.key === 'Enter' || event.code === 'Enter'; // event.keyCode === 10 || event.keyCode === 13 || // event.charCode === 10 || event.charCode === 13; return isEnter; } /** * https://github.com/ionic-team/capacitor/issues/1564 * * Still doesn't work...hmm */ export function getFileReaderHack(): FileReader { const lc = `[${getFileReaderHack.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: d03faf53dd5f1cd1014f2f0e01058b22)`); } const fileReader = new FileReader(); const zoneOriginalInstance = (fileReader as any)["__zone_symbol__originalInstance"]; return zoneOriginalInstance || fileReader; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * checks for mouse/trackball presence and infers keyboard when one is detected. * * ## aside * * It's amazing this isn't in an API... * * @returns true if by magical inference there is probably* a keyboard */ export function weHaveAPhysicalKeyboardProbably(): boolean { const lc = `${weHaveAPhysicalKeyboardProbably.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 70a952db8e1f23263ba98607def6f422)`); } const hasHover = window?.matchMedia?.('(hover:hover)').matches; const hasPointerFine = window?.matchMedia?.('(pointer:fine)').matches; return hasHover && hasPointerFine; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Check for either a physical keyboard or a relatively large window * * ## notes * * Such a long silly name because it's silly we don't have a better way of * detecting this with an official API. * * @returns true if we think that we're running on mobile */ export function weAreRunningOnMobileProbably(): boolean { const lc = `${weAreRunningOnMobileProbably.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 5fd8deba6cb8cd40633c69371df95f22)`); } const keyboard = weHaveAPhysicalKeyboardProbably(); const isMightyLargeForMobile = window.innerWidth > 810; return keyboard || isMightyLargeForMobile; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } }