mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-28 23:42:58 -04:00
625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
import type { NextFunction, Request, Response } from 'express';
|
|
import { Base64 } from 'js-base64';
|
|
|
|
import type { User } from './user.ts';
|
|
|
|
import type locales from '~~/locale/locales.ts';
|
|
import type { Database } from '~~/server/db.ts';
|
|
|
|
export type Primitive = string | number | boolean | null | undefined;
|
|
export type Nullable<Base> = Base | null | undefined;
|
|
|
|
export interface DeepObject {
|
|
[key: string]: Primitive | DeepObject | DeepObject[];
|
|
}
|
|
|
|
const runningSnapshotTests = (() => {
|
|
// Server or Node-like environment
|
|
if (typeof process !== 'undefined' && process.env?.RUN_SNAPSHOT_TESTS) {
|
|
return true;
|
|
}
|
|
|
|
// Nuxt client environment
|
|
try {
|
|
// In a try-catch block to prevent errors if not running in the correct
|
|
// context (e.g. Cannot use 'import.meta' outside a module).
|
|
if (import.meta.env?.RUN_SNAPSHOT_TESTS) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// ignore errors
|
|
}
|
|
|
|
return false;
|
|
})();
|
|
|
|
/**
|
|
* Generates a random number greater than or equal to 0 and less than 1. If the
|
|
* app is being run in a snapshot test environment, will always return 0.5. This
|
|
* is to ensure that the snapshots are consistent by enforcing a deterministic
|
|
* result.
|
|
*/
|
|
export const random = runningSnapshotTests ? () => 0.5 : Math.random;
|
|
|
|
/**
|
|
* Gets the current date and time. If the app is being run in a snapshot test
|
|
* environment, will return a fixed date of 2025-01-01T00:00:00.000Z. This is to
|
|
* ensure that the snapshots are consistent by enforcing a deterministic result.
|
|
*/
|
|
export const newDate = runningSnapshotTests
|
|
? () => new Date('2025-01-01T00:00:00.000Z')
|
|
: () => new Date();
|
|
|
|
/**
|
|
* Gets the current timestamp in seconds. If the app is being run in a snapshot
|
|
* test environment, will return a fixed timestamp equivalent to
|
|
* 2025-01-01T00:00:00.000Z. This is to ensure that the snapshots are consistent
|
|
* by enforcing a deterministic result.
|
|
*/
|
|
export const now = runningSnapshotTests
|
|
? () => 1735689600 // 2025-01-01T00:00:00.000Z
|
|
: () => Math.floor(Date.now() / 1000);
|
|
|
|
type GeneratorFunc<R, Args extends unknown[]> = (...args: Args) => Generator<R>;
|
|
|
|
export const buildDict = <K extends string | number | symbol, V, Args extends unknown[]>(
|
|
fn: GeneratorFunc<[K, V], Args>, ...args: Args
|
|
): Record<K, V> => {
|
|
const dict: Record<K, V> = {} as Record<K, V>;
|
|
for (const [key, value] of fn(...args)) {
|
|
dict[key] = value;
|
|
}
|
|
return dict;
|
|
};
|
|
|
|
export const buildList = <V, Args extends unknown[]>(fn: GeneratorFunc<V, Args>, ...args: Args): V[] => {
|
|
const list = [];
|
|
for (const value of fn(...args)) {
|
|
list.push(value);
|
|
}
|
|
return list;
|
|
};
|
|
|
|
export const deepGet = (obj: object, path: string): unknown => {
|
|
let value: any = obj;
|
|
for (const part of path.split('.')) {
|
|
value = value[part];
|
|
if (value === undefined || value === null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
export function* deepListKeys(obj: object): Generator<string> {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (value instanceof Object && !Array.isArray(value)) {
|
|
for (const subkey of deepListKeys(value)) {
|
|
yield `${key}.${subkey}`;
|
|
}
|
|
} else {
|
|
yield key;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const clearUrl = (url: string): string => decodeURIComponent(
|
|
url
|
|
.trim() // Remove leading and trailing whitespace
|
|
.replace(/http(s)?:\/\/(www\.)?|mailto:/g, '') // Remove protocol and www
|
|
.split(/(\?|#)/)[0] // Remove query and fragment parts
|
|
.replace(/\/$/, ''), // Remove trailing slash
|
|
);
|
|
|
|
export const buildImageUrl = (cloudfrontUrl: string, imageId: string, size: string): string => {
|
|
return `${cloudfrontUrl}/images/${imageId}-${size}.png`;
|
|
};
|
|
|
|
export const makeId = (
|
|
length: number,
|
|
characters: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
|
): string => Array(length)
|
|
.fill('')
|
|
.map(() => characters.charAt(Math.floor(random() * characters.length)))
|
|
.join('');
|
|
|
|
export const fallbackAvatar = (user: Pick<User, 'username'>, size: number = 240): string => {
|
|
return `https://avi.avris.it/shape-${size}/${Base64.encode(user.username).replace(/\+/g, '-')
|
|
.replace(/\//g, '_')}.png`;
|
|
};
|
|
|
|
export const sha256 = async (message: string) => Array.from(new Uint8Array(
|
|
await crypto.subtle.digest(
|
|
'sha-256',
|
|
new TextEncoder().encode(message),
|
|
),
|
|
))
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
|
|
export const gravatar = async (
|
|
user: Pick<User, 'username' | 'email'> & { emailHash?: string },
|
|
size: number = 240,
|
|
): Promise<string> => {
|
|
const emailHash = user.emailHash ?? await sha256(user.email);
|
|
return `https://gravatar.com/avatar/${emailHash}?d=${encodeURIComponent(fallbackAvatar(user, size))}&s=${size}`;
|
|
};
|
|
|
|
export interface DictEntry<K extends string | number | symbol, V> {
|
|
key: K;
|
|
value: V;
|
|
}
|
|
export const dictToList = <K extends string | number | symbol, V>(dict: Record<K, V>): DictEntry<K, V>[] => {
|
|
const list = [];
|
|
for (const key in dict) {
|
|
if (Object.hasOwn(dict, key)) {
|
|
list.push({ key, value: dict[key] });
|
|
}
|
|
}
|
|
return list;
|
|
};
|
|
|
|
export const listToDict = <K extends string | number | symbol, V>(list: DictEntry<K, V>[]): Record<K, V> => {
|
|
if (Object.keys(list).length === 0) {
|
|
return {} as Record<K, V>;
|
|
}
|
|
const dict: Record<K, V> = {} as Record<K, V>;
|
|
for (const el of list) {
|
|
dict[el.key] = el.value;
|
|
}
|
|
return dict;
|
|
};
|
|
|
|
export const fromUnionEntries = <K extends PropertyKey, V = unknown>(
|
|
entries: Iterable<readonly [K, V]>,
|
|
): Record<K, V> => {
|
|
return Object.fromEntries(entries) as Record<K, V>;
|
|
};
|
|
|
|
export function curry<T, A, B, R>(func: (this: T, a: A, b: B) => R): (a: A) => (this: T, b: B) => R {
|
|
return function curried(a: A) {
|
|
return function (b: B) {
|
|
return func.apply(this, [a, b]);
|
|
};
|
|
};
|
|
}
|
|
|
|
export const capitalise = (word: string): string => word.substring(0, 1).toUpperCase() + word.substring(1);
|
|
|
|
export const camelCase = (words: string[]): string => {
|
|
const text = words.map(capitalise).join('');
|
|
return text.substring(0, 1).toLowerCase() + text.substring(1);
|
|
};
|
|
|
|
export const removeSuffix = (word: string, suffix: string) => {
|
|
if (!word.endsWith(suffix)) {
|
|
return word;
|
|
}
|
|
return word.substring(0, word.length - suffix.length);
|
|
};
|
|
|
|
export const isEmoji = (string: string): boolean => {
|
|
return /^((?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|[\u2700-\u27bf]|[\u25aa-\u25ab]|[\u25fb-\u25fe]|[\u2600-\u26FF]|[\u23e9-\u23f3]|[\u23f8-\u23fa]|[\u2190-\u21ff]|\ud83c([\udd70-\udd71]|[\udd7e-\udd7f]|[\udd91-\udd9a]|[\udde6-\uddff]|[\ude01-\ude02]|[\ude32-\ude3a]|[\ude50-\ude51]|\udd8e|\ude1a|\ude2f|\udccf|\udc04)|\u3299|\u3297|\u303d|\u3030|\u24c2|\u203c|\u2049|\u25b6|\u25c0|\u00a9|\u00ae|\u2122|\u2139|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|\ud83c|\u2934|\u2935)$/.test(string);
|
|
};
|
|
|
|
export function zip<K extends string, V>(list: [K, V][], reverse: false): Record<K, V>;
|
|
export function zip<K extends string, V>(list: [V, K][], reverse: true): Record<K, V>;
|
|
export function zip<K extends string, V>(list: [K, V][] | [V, K][], reverse: boolean): Record<K, V> {
|
|
const zipped = {} as Record<K, V>;
|
|
for (const [key, value] of list) {
|
|
if (reverse) {
|
|
// @ts-expect-error: General Type Error; No actual issue
|
|
zipped[value] = key;
|
|
} else {
|
|
// @ts-expect-error: General Type Error; No actual issue
|
|
zipped[key] = value;
|
|
}
|
|
}
|
|
return zipped;
|
|
}
|
|
|
|
// https://stackoverflow.com/a/6274381/3297012
|
|
export const shuffle = <T>(array: T[]): T[] => new Array(...array).sort(() => random() - 0.5) as T[];
|
|
|
|
export const randomItem = <T>(array: T[]): T => shuffle(array)[0];
|
|
|
|
interface WeightedItem {
|
|
chance?: number;
|
|
}
|
|
|
|
export const randomItemWeighted = <T extends WeightedItem>(array: T[]): T => {
|
|
const totalChance = array.reduce((sum, obj) => sum + (obj.chance ?? 1), 0);
|
|
let randomChance = random() * totalChance;
|
|
|
|
for (const el of array) {
|
|
randomChance -= el.chance ?? 1;
|
|
if (randomChance <= 0) {
|
|
return el;
|
|
}
|
|
}
|
|
|
|
return array[array.length - 1];
|
|
};
|
|
|
|
export const randomNumber = (min: number, max: number): number => Math.floor(random() * (max - min + 1)) + min;
|
|
|
|
export type LocaleCode = typeof locales[number]['code'];
|
|
|
|
export enum PermissionAreas {
|
|
Superuser = '*',
|
|
Basic = 'basic',
|
|
Panel = 'panel',
|
|
Users = 'users',
|
|
Sources = 'sources',
|
|
Nouns = 'nouns',
|
|
Terms = 'terms',
|
|
Inclusive = 'inclusive',
|
|
Census = 'census',
|
|
Names = 'names',
|
|
Translations = 'translations',
|
|
Code = 'code',
|
|
Org = 'org',
|
|
Impersonate = 'impersonate',
|
|
Community = 'community',
|
|
Blog = 'blog',
|
|
External = 'external',
|
|
Null = '',
|
|
}
|
|
|
|
const RESTRICTED_AREAS = ['code', 'org', 'impersonate', 'community'];
|
|
|
|
export const isPermissionArea = (value: string): value is PermissionAreas => {
|
|
return Object.values(PermissionAreas).includes(value as PermissionAreas);
|
|
};
|
|
|
|
export const isGrantedForUser = (
|
|
user: Pick<User, 'roles'>,
|
|
requestedLocale: LocaleCode | null,
|
|
requestedArea: PermissionAreas = PermissionAreas.Null,
|
|
): boolean => {
|
|
if (requestedArea === PermissionAreas.Superuser) {
|
|
// only super-admin
|
|
return user.roles.split('|').includes('*');
|
|
}
|
|
|
|
if (!user.roles) {
|
|
return false;
|
|
}
|
|
|
|
for (const permission of user.roles.split('|')) {
|
|
if (permission === PermissionAreas.Superuser && !RESTRICTED_AREAS.includes(requestedArea)) {
|
|
return true;
|
|
}
|
|
|
|
const [givenLocale, givenArea] = permission.split('-');
|
|
|
|
if (!(
|
|
givenLocale === PermissionAreas.Superuser ||
|
|
givenLocale === requestedLocale ||
|
|
requestedLocale === null
|
|
)) {
|
|
continue;
|
|
}
|
|
|
|
if (givenArea === PermissionAreas.External && requestedArea !== PermissionAreas.External) {
|
|
continue;
|
|
}
|
|
|
|
if (givenArea === PermissionAreas.Superuser && !RESTRICTED_AREAS.includes(requestedArea)) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
givenArea === requestedArea ||
|
|
requestedArea === PermissionAreas.Null ||
|
|
requestedArea === PermissionAreas.Panel
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
type ErrorAsyncFunction = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
|
|
export const handleErrorAsync = (func: ErrorAsyncFunction) => {
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
func(req, res, next).catch((error: unknown) => next(error));
|
|
};
|
|
};
|
|
|
|
export const clearLinkedText = (text: string, quotes: boolean = true): string => {
|
|
text = text
|
|
.replace(/{[^}]+=([^}=]+)}/g, '$1')
|
|
.replace(/{([^}]+)}/g, '$1');
|
|
|
|
if (quotes) {
|
|
text = text.replace(/[„”"']/g, '');
|
|
}
|
|
|
|
text = text.replace(/\s+/g, ' ');
|
|
|
|
return text;
|
|
};
|
|
|
|
export const sortClearedLinkedText = <T extends Record<K, string>, K extends string>(
|
|
items: T[],
|
|
key: K,
|
|
): T[] => items.sort(
|
|
(a, b) => clearLinkedText(a[key].toLowerCase())
|
|
.localeCompare(clearLinkedText(b[key].toLowerCase())),
|
|
);
|
|
|
|
export const clearKey = (key: string | null): string | null => key
|
|
? key.replace(/'/g, '_').toLowerCase()
|
|
: null;
|
|
|
|
export const sleep = (milliseconds: number): Promise<void> => new Promise(
|
|
(resolve) => setTimeout(resolve, milliseconds),
|
|
);
|
|
|
|
export const splitSlashes = (path: string): string[] => {
|
|
const chunks = [];
|
|
let escape = false;
|
|
let currentChunk = '';
|
|
for (const character of path) {
|
|
if (escape) {
|
|
currentChunk += `\`${character}`;
|
|
escape = false;
|
|
continue;
|
|
}
|
|
switch (character) {
|
|
case '`':
|
|
escape = true;
|
|
break;
|
|
case '/':
|
|
chunks.push(currentChunk);
|
|
currentChunk = '';
|
|
break;
|
|
default:
|
|
currentChunk += character;
|
|
break;
|
|
}
|
|
}
|
|
chunks.push(currentChunk);
|
|
return chunks;
|
|
};
|
|
|
|
const escapeChars = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
};
|
|
|
|
export const escapeHtml = (text: string): string => {
|
|
return Object.entries(escapeChars).reduce(
|
|
(cur, [character, replacement]) => cur.replaceAll(character, replacement),
|
|
text,
|
|
);
|
|
};
|
|
|
|
export const escapeControlSymbols = (text: string | null): string | null => {
|
|
// a backtick is used because browsers replace backslashes
|
|
// with forward slashes if they are not url-encoded
|
|
return text ? text.replaceAll(/[`&/|,:]/g, '`$&') : null;
|
|
};
|
|
|
|
export const unescapeControlSymbols = (
|
|
text: string | null,
|
|
): string | null => text ? text.replaceAll(/`(.)/g, '$1') : null;
|
|
|
|
export const escapePronunciationString = (text: string): string => {
|
|
return text.replaceAll('\\', '\\\\')
|
|
.replaceAll('/', '\\/');
|
|
};
|
|
|
|
export const unescapePronunciationString = (pronunciationString: string): string => {
|
|
return pronunciationString.replaceAll('\\/', '/')
|
|
.replaceAll('\\\\', '\\');
|
|
};
|
|
|
|
export const convertPronunciationStringToSsml = (pronunciationString: string): string => {
|
|
const escapedString = escapeHtml(pronunciationString);
|
|
let ssml = '';
|
|
let escape = false;
|
|
let currentPhonemes = null;
|
|
for (const character of escapedString) {
|
|
if (escape) {
|
|
if (currentPhonemes === null) {
|
|
ssml += character;
|
|
} else {
|
|
currentPhonemes += character;
|
|
}
|
|
escape = false;
|
|
} else {
|
|
if (character === '\\') {
|
|
escape = true;
|
|
} else if (character === '/') {
|
|
if (currentPhonemes === null) {
|
|
currentPhonemes = '';
|
|
} else {
|
|
ssml += `<phoneme alphabet="ipa" ph="${currentPhonemes}"></phoneme>`;
|
|
currentPhonemes = null;
|
|
}
|
|
} else {
|
|
if (currentPhonemes === null) {
|
|
ssml += character;
|
|
} else {
|
|
currentPhonemes += character;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (currentPhonemes !== null) {
|
|
ssml += `/${currentPhonemes}`;
|
|
}
|
|
return `<speak>${ssml}</speak>`;
|
|
};
|
|
|
|
export const convertPronunciationStringToNarakeetFormat = (pronunciationString: string): string => {
|
|
const escapedString = escapeHtml(pronunciationString);
|
|
let output = '';
|
|
let escape = false;
|
|
let currentPhonemes = null;
|
|
for (const character of escapedString) {
|
|
if (escape) {
|
|
if (currentPhonemes === null) {
|
|
output += character;
|
|
} else {
|
|
currentPhonemes += character;
|
|
}
|
|
escape = false;
|
|
} else {
|
|
if (character === '\\') {
|
|
escape = true;
|
|
} else if (character === '/') {
|
|
if (currentPhonemes === null) {
|
|
currentPhonemes = '';
|
|
} else {
|
|
output += `[${currentPhonemes}]{ipa}`;
|
|
currentPhonemes = null;
|
|
}
|
|
} else {
|
|
if (currentPhonemes === null) {
|
|
output += character;
|
|
} else {
|
|
currentPhonemes += character;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (currentPhonemes !== null) {
|
|
output += `/${currentPhonemes}`;
|
|
}
|
|
return output;
|
|
};
|
|
|
|
export class ImmutableArray<T> extends Array<T> {
|
|
override map<U>(callbackFn: (value: T, index: number, array: T[]) => U): ImmutableArray<U> {
|
|
return super.map(callbackFn) as ImmutableArray<U>;
|
|
}
|
|
|
|
override filter(predicate: (value: T, index: number, array: T[]) => unknown): ImmutableArray<T> {
|
|
return super.filter(predicate) as ImmutableArray<T>;
|
|
}
|
|
|
|
sorted(compareFn?: (a: T, b: T) => number): ImmutableArray<T> {
|
|
return new ImmutableArray(...[...this].sort(compareFn));
|
|
}
|
|
|
|
randomElement(): T {
|
|
return this[Math.floor(random() * this.length)];
|
|
}
|
|
|
|
groupBy(m: (element: T) => string | number): ImmutableArray<[string | number, ImmutableArray<T>]> {
|
|
const keys: Record<string | number, number> = {};
|
|
const grouped: ImmutableArray<[string | number, ImmutableArray<T>]> = new ImmutableArray();
|
|
for (const el of this) {
|
|
const key = m(el);
|
|
if (!Object.hasOwn(keys, key)) {
|
|
keys[key] = grouped.length;
|
|
grouped.push([key, new ImmutableArray()]);
|
|
}
|
|
grouped[keys[key]][1].push(el);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
indexOrFallback(index: number, fallback: T): T {
|
|
return this.length > index ? this[index] : fallback;
|
|
}
|
|
}
|
|
|
|
export const groupBy = <V>(list: V[], fn: (element: V) => string): Record<string, V[]> => {
|
|
const grouped: Record<string, V[]> = {};
|
|
for (const el of list) {
|
|
const key = fn(el);
|
|
if (!Object.hasOwn(grouped, key)) {
|
|
grouped[key] = [];
|
|
}
|
|
grouped[key].push(el);
|
|
}
|
|
|
|
return grouped;
|
|
};
|
|
|
|
export const obfuscateEmail = (email: string): string | null => {
|
|
const [username, hostname] = email.toLowerCase().split('@');
|
|
const tld = hostname.split('.').slice(-1).pop();
|
|
|
|
return tld === 'oauth'
|
|
? null
|
|
: `${username.substring(0, username.length <= 5 ? 1 : 3)}*****@*****.${tld}`;
|
|
};
|
|
|
|
// https://newbedev.com/dynamic-deep-setting-for-a-javascript-object
|
|
export const deepSet = (obj: object, path: string, value: unknown): void => {
|
|
const a = path.split('.');
|
|
let o: any = obj;
|
|
while (a.length - 1) {
|
|
const n = a.shift()!;
|
|
if (!(n in o)) {
|
|
o[n] = {};
|
|
}
|
|
o = o[n];
|
|
}
|
|
o[a[0]] = value;
|
|
};
|
|
|
|
type AdminUser = Pick<User, 'username' | 'email' | 'roles' | 'adminNotifications'>;
|
|
|
|
export const findAdmins = async (db: Database, locale: LocaleCode, area: PermissionAreas): Promise<AdminUser[]> => {
|
|
const admins = await db.all<AdminUser>(`
|
|
SELECT username, email, roles, adminNotifications
|
|
FROM users
|
|
WHERE (roles != '' AND roles != '*-external')
|
|
`);
|
|
return admins.filter((admin) => isGrantedForUser(admin, locale, area as PermissionAreas));
|
|
};
|
|
|
|
export const isValidLink = (url: string | URL): boolean => {
|
|
return URL.canParse(url) ? /^(http(s)?)|mailto:/.test(new URL(url).protocol) : false;
|
|
};
|
|
|
|
export const addSlash = (link: string): string => {
|
|
return link + (['*', '\''].includes(link.substring(link.length - 1)) ? '/' : '');
|
|
};
|
|
|
|
export const filterObjectKeys = <T extends Record<string, any>, K extends keyof T>(
|
|
obj: T,
|
|
keysToKeep: K[],
|
|
): Pick<T, K> => {
|
|
return keysToKeep.reduce((filteredObj, key) => {
|
|
if (key in obj) {
|
|
filteredObj[key] = obj[key];
|
|
}
|
|
return filteredObj;
|
|
}, {} as Pick<T, K>);
|
|
};
|
|
|
|
export const formatSize = (number: number): string => {
|
|
const pow = Math.log10(number);
|
|
if (pow > 9) {
|
|
return `${(number / 10 ** 9).toFixed(1)}\u00a0GB`;
|
|
}
|
|
if (pow > 6) {
|
|
return `${(number / 10 ** 6).toFixed(1)}\u00a0MB`;
|
|
}
|
|
if (pow > 3) {
|
|
return `${(number / 10 ** 3).toFixed(1)}\u00a0kB`;
|
|
}
|
|
return `${number}\u00a0B`;
|
|
};
|
|
|
|
export const executeUnlessPrerendering = (fn: () => void): (() => void) => {
|
|
return () => {
|
|
if ((document as any).prerendering) {
|
|
return void document.addEventListener('prerenderingchange', fn, { once: true });
|
|
}
|
|
fn();
|
|
};
|
|
};
|