Spaces:
Running
Running
/** | |
* 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(/\//g, '/') | |
.replace(/\n/g, '<br />'); | |
} | |
/** | |
* 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<T>(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<T extends Comparable>(array: T[]): T[]; | |
export function sortBy<T>(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 | |
* <wbr /> 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, '<wbr />'); | |
} | |
/** | |
* 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<T>(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<T>(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<T>(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<void> { | |
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<T> extends Map<T, number> { | |
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, | |
}; | |