/** * Utils library * * Miscellaneous utility functions that don't really have a better place. * * It'll always be a judgment call whether or not a function goes into a * "catch-all" library like this, so here are some guidelines: * * - It must not have any dependencies * * - It must conceivably have a use in a wide variety of projects, not just * Pokémon (if it's Pokémon-specific, Dex is probably a good place for it) * * - A lot of Chat functions are kind of iffy, but I'm going to say for now * that if it's English-specific, it should be left out of here. */ export type Comparable = number | string | boolean | Comparable[] | { reverse: Comparable }; /** * Safely converts the passed variable into a string. Unlike `${str}`, * String(str), or str.toString(), Utils.getString is guaranteed not to * crash. * * Specifically, the fear with untrusted JSON is an object like: * * let a = {"toString": "this is not a function"}; * console.log(`a is ${a}`); * * This will crash (because a.toString() is not a function). Instead, * getString simply returns '' if the passed variable isn't a * string or a number. */ export function getString(str: any): string { return (typeof str === 'string' || typeof str === 'number') ? `${str}` : ''; } export function escapeRegex(str: string) { return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); } /** * Escapes HTML in a string. */ export function escapeHTML(str: string | number) { if (str === null || str === undefined) return ''; return `${str}` .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/') .replace(/\n/g, '
'); } /** * Strips HTML from a string. */ export function stripHTML(htmlContent: string) { if (!htmlContent) return ''; return htmlContent.replace(/<[^>]*>/g, ''); } /** * Maps numbers to their ordinal string. */ export function formatOrder(place: number) { // anything between 10 and 20 should always end with -th let remainder = place % 100; if (remainder >= 10 && remainder <= 20) return `${place}th`; // follow standard rules with -st, -nd, -rd, and -th remainder = place % 10; if (remainder === 1) return `${place}st`; if (remainder === 2) return `${place}nd`; if (remainder === 3) return `${place}rd`; return `${place}th`; } /** * Visualizes eval output in a slightly more readable form */ export function visualize(value: any, depth = 0): string { if (value === undefined) return `undefined`; if (value === null) return `null`; if (typeof value === 'number' || typeof value === 'boolean') { return `${value}`; } if (typeof value === 'string') { return `"${value}"`; // NOT ESCAPED } if (typeof value === 'symbol') { return value.toString(); } if (Array.isArray(value)) { if (depth > 10) return `[array]`; return `[` + value.map(elem => visualize(elem, depth + 1)).join(`, `) + `]`; } if (value instanceof RegExp || value instanceof Date || value instanceof Function) { if (depth && value instanceof Function) return `Function`; return `${value}`; } let constructor = ''; if (typeof value.constructor?.name === 'string') { constructor = value.constructor.name; if (constructor === 'Object') constructor = ''; } else { constructor = 'null'; } // If it has a toString, try to grab the base class from there // (This is for Map/Set subclasses like user.auth) const baseClass = (value?.toString && /\[object (.*)\]/.exec(value.toString())?.[1]) || constructor; switch (baseClass) { case 'Map': if (depth > 2) return `Map`; const mapped = [...value.entries()].map( val => `${visualize(val[0], depth + 1)} => ${visualize(val[1], depth + 1)}` ); return `${constructor} (${value.size}) { ${mapped.join(', ')} }`; case 'Set': if (depth > 2) return `Set`; return `${constructor} (${value.size}) { ${[...value].map(v => visualize(v), depth + 1).join(', ')} }`; } if (value.toString) { try { const stringValue = value.toString(); if ( typeof stringValue === 'string' && stringValue !== '[object Object]' && stringValue !== `[object ${constructor}]` ) { return `${constructor}(${stringValue})`; } } catch {} } let buf = ''; for (const key in value) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; if (depth > 2 || (depth && constructor)) { buf = '...'; break; } if (buf) buf += `, `; let displayedKey = key; if (!/^[A-Za-z0-9_$]+$/.test(key)) displayedKey = JSON.stringify(key); buf += `${displayedKey}: ` + visualize(value[key], depth + 1); } if (constructor && !buf && constructor !== 'null') return constructor; return `${constructor}{${buf}}`; } /** * Compares two variables; intended to be used as a smarter comparator. * The two variables must be the same type (TypeScript will not check this). * * - Numbers are sorted low-to-high, use `-val` to reverse * - Strings are sorted A to Z case-semi-insensitively, use `{reverse: val}` to reverse * - Booleans are sorted true-first (REVERSE of casting to numbers), use `!val` to reverse * - Arrays are sorted lexically in the order of their elements * * In other words: `[num, str]` will be sorted A to Z, `[num, {reverse: str}]` will be sorted Z to A. */ export function compare(a: Comparable, b: Comparable): number { if (typeof a === 'number') { return a - (b as number); } if (typeof a === 'string') { return a.localeCompare(b as string); } if (typeof a === 'boolean') { return (a ? 1 : 2) - (b ? 1 : 2); } if (Array.isArray(a)) { for (let i = 0; i < a.length; i++) { const comparison = compare(a[i], (b as Comparable[])[i]); if (comparison) return comparison; } return 0; } if ('reverse' in a) { return compare((b as { reverse: string }).reverse, a.reverse); } throw new Error(`Passed value ${a} is not comparable`); } /** * Sorts an array according to the callback's output on its elements. * * The callback's output is compared according to `PSUtils.compare` * (numbers low to high, strings A-Z, booleans true-first, arrays in order). */ export function sortBy(array: T[], callback: (a: T) => Comparable): T[]; /** * Sorts an array according to `PSUtils.compare` * (numbers low to high, strings A-Z, booleans true-first, arrays in order). * * Note that array.sort() only works on strings, not numbers, so you'll need * this to sort numbers. */ export function sortBy(array: T[]): T[]; export function sortBy(array: T[], callback?: (a: T) => Comparable) { if (!callback) return (array as any[]).sort(compare); return array.sort((a, b) => compare(callback(a), callback(b))); } export function splitFirst(str: string, delimiter: string | RegExp): [string, string]; export function splitFirst(str: string, delimiter: string | RegExp, limit: 2): [string, string, string]; export function splitFirst(str: string, delimiter: string | RegExp, limit: 3): [string, string, string, string]; export function splitFirst(str: string, delimiter: string | RegExp, limit: number): string[]; /** * Like string.split(delimiter), but only recognizes the first `limit` * delimiters (default 1). * * `"1 2 3 4".split(" ", 2) => ["1", "2"]` * * `Utils.splitFirst("1 2 3 4", " ", 1) => ["1", "2 3 4"]` * * Returns an array of length exactly limit + 1. * */ export function splitFirst(str: string, delimiter: string | RegExp, limit = 1) { const splitStr: string[] = []; while (splitStr.length < limit) { let delimiterIndex, delimiterLength; if (typeof delimiter === 'string') { delimiterIndex = str.indexOf(delimiter); delimiterLength = delimiter.length; } else { delimiter.lastIndex = 0; const match = delimiter.exec(str); delimiterIndex = match ? match.index : -1; delimiterLength = match ? match[0].length : 0; } if (delimiterIndex >= 0) { splitStr.push(str.slice(0, delimiterIndex)); str = str.slice(delimiterIndex + delimiterLength); } else { splitStr.push(str); str = ''; } } splitStr.push(str); return splitStr; } /** * Template string tag function for escaping HTML */ export function html(strings: TemplateStringsArray, ...args: any) { let buf = strings[0]; let i = 0; while (i < args.length) { buf += escapeHTML(args[i]); buf += strings[++i]; } return buf; } /** * This combines escapeHTML and forceWrap. The combination allows us to use * instead of U+200B, which will make sure the word-wrapping hints * can't be copy/pasted (which would mess up code). */ export function escapeHTMLForceWrap(text: string): string { return escapeHTML(forceWrap(text)).replace(/\u200B/g, ''); } /** * HTML doesn't support `word-wrap: break-word` in tables, but sometimes it * would be really nice if it did. This emulates `word-wrap: break-word` by * manually inserting U+200B to tell long words to wrap. */ export function forceWrap(text: string): string { return text.replace(/[^\s]{30,}/g, word => { let lastBreak = 0; let brokenWord = ''; for (let i = 1; i < word.length; i++) { if (i - lastBreak >= 10 || /[^a-zA-Z0-9([{][a-zA-Z0-9]/.test(word.slice(i - 1, i + 1))) { brokenWord += word.slice(lastBreak, i) + '\u200B'; lastBreak = i; } } brokenWord += word.slice(lastBreak); return brokenWord; }); } export function shuffle(arr: T[]): T[] { // In-place shuffle by Fisher-Yates algorithm for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } export function randomElement(arr: T[]): T { const i = Math.floor(Math.random() * arr.length); return arr[i]; } /** Forces num to be an integer (between min and max). */ export function clampIntRange(num: any, min?: number, max?: number): number { if (typeof num !== 'number') num = 0; num = Math.floor(num); if (min !== undefined && num < min) num = min; if (max !== undefined && num > max) num = max; return num; } export function clearRequireCache(options: { exclude?: string[] } = {}) { const excludes = options?.exclude || []; excludes.push('/node_modules/'); for (const path in require.cache) { if (excludes.some(p => path.includes(p))) continue; const mod = require.cache[path]; // have to ref to appease ts if (!mod) continue; uncacheModuleTree(mod, excludes); delete require.cache[path]; } } export function uncacheModuleTree(mod: NodeJS.Module, excludes: string[]) { if (!mod.children?.length || excludes.some(p => mod.filename.includes(p))) return; for (const [i, child] of mod.children.entries()) { if (excludes.some(p => child.filename.includes(p))) continue; mod.children?.splice(i, 1); uncacheModuleTree(child, excludes); } delete (mod as any).children; } export function deepClone(obj: any): any { if (obj === null || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(prop => deepClone(prop)); const clone = Object.create(Object.getPrototypeOf(obj)); for (const key of Object.keys(obj)) { clone[key] = deepClone(obj[key]); } return clone; } export function deepFreeze(obj: T): T { if (obj === null || typeof obj !== 'object') return obj; // support objects with reference loops if (Object.isFrozen(obj)) return obj; Object.freeze(obj); if (Array.isArray(obj)) { for (const elem of obj) deepFreeze(elem); } else { for (const elem of Object.values(obj)) deepFreeze(elem); } return obj; } export function levenshtein(s: string, t: string, l: number): number { // Original levenshtein distance function by James Westgate, turned out to be the fastest const d: number[][] = []; // Step 1 const n = s.length; const m = t.length; if (n === 0) return m; if (m === 0) return n; if (l && Math.abs(m - n) > l) return Math.abs(m - n); // Create an array of arrays in javascript (a descending loop is quicker) for (let i = n; i >= 0; i--) d[i] = []; // Step 2 for (let i = n; i >= 0; i--) d[i][0] = i; for (let j = m; j >= 0; j--) d[0][j] = j; // Step 3 for (let i = 1; i <= n; i++) { const si = s.charAt(i - 1); // Step 4 for (let j = 1; j <= m; j++) { // Check the jagged ld total so far if (i === j && d[i][j] > 4) return n; const tj = t.charAt(j - 1); const cost = (si === tj) ? 0 : 1; // Step 5 // Calculate the minimum let mi = d[i - 1][j] + 1; const b = d[i][j - 1] + 1; const c = d[i - 1][j - 1] + cost; if (b < mi) mi = b; if (c < mi) mi = c; d[i][j] = mi; // Step 6 } } // Step 7 return d[n][m]; } export function waitUntil(time: number): Promise { return new Promise(resolve => { setTimeout(() => resolve(), time - Date.now()); }); } /** Like parseInt, but returns NaN if the int isn't already in normalized form */ export function parseExactInt(str: string): number { if (!/^-?(0|[1-9][0-9]*)$/.test(str)) return NaN; return parseInt(str); } /** formats an array into a series of question marks and adds the elements to an arguments array */ export function formatSQLArray(arr: unknown[], args?: unknown[]) { args?.push(...arr); return [...'?'.repeat(arr.length)].join(', '); } export function bufFromHex(hex: string) { const buf = new Uint8Array(Math.ceil(hex.length / 2)); bufWriteHex(buf, hex); return buf; } export function bufWriteHex(buf: Uint8Array, hex: string, offset = 0) { const size = Math.ceil(hex.length / 2); for (let i = 0; i < size; i++) { buf[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2).padEnd(2, '0'), 16); } } export function bufReadHex(buf: Uint8Array, start = 0, end?: number) { return [...buf.slice(start, end)].map(val => val.toString(16).padStart(2, '0')).join(''); } export class Multiset extends Map { get(key: T) { return super.get(key) ?? 0; } add(key: T) { this.set(key, this.get(key) + 1); return this; } remove(key: T) { const newValue = this.get(key) - 1; if (newValue <= 0) return this.delete(key); this.set(key, newValue); return true; } } // backwards compatibility export const Utils = { parseExactInt, waitUntil, html, escapeHTML, compare, sortBy, levenshtein, shuffle, deepClone, deepFreeze, clampIntRange, clearRequireCache, randomElement, forceWrap, splitFirst, stripHTML, visualize, getString, escapeRegex, formatSQLArray, bufFromHex, bufReadHex, bufWriteHex, Multiset, };