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 | 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 = (...args: Args) => Generator;
export const buildDict = (
fn: GeneratorFunc<[K, V], Args>, ...args: Args
): Record => {
const dict: Record = {} as Record;
for (const [key, value] of fn(...args)) {
dict[key] = value;
}
return dict;
};
export const buildList = (fn: GeneratorFunc, ...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 {
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, 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 & { emailHash?: string },
size: number = 240,
): Promise => {
const emailHash = user.emailHash ?? await sha256(user.email);
return `https://gravatar.com/avatar/${emailHash}?d=${encodeURIComponent(fallbackAvatar(user, size))}&s=${size}`;
};
export interface DictEntry {
key: K;
value: V;
}
export const dictToList = (dict: Record): DictEntry[] => {
const list = [];
for (const key in dict) {
if (Object.hasOwn(dict, key)) {
list.push({ key, value: dict[key] });
}
}
return list;
};
export const listToDict = (list: DictEntry[]): Record => {
if (Object.keys(list).length === 0) {
return {} as Record;
}
const dict: Record = {} as Record;
for (const el of list) {
dict[el.key] = el.value;
}
return dict;
};
export const fromUnionEntries = (
entries: Iterable,
): Record => {
return Object.fromEntries(entries) as Record;
};
export function curry(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(list: [K, V][], reverse: false): Record;
export function zip(list: [V, K][], reverse: true): Record;
export function zip(list: [K, V][] | [V, K][], reverse: boolean): Record {
const zipped = {} as Record;
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 = (array: T[]): T[] => new Array(...array).sort(() => random() - 0.5) as T[];
export const randomItem = (array: T[]): T => shuffle(array)[0];
interface WeightedItem {
chance?: number;
}
export const randomItemWeighted = (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,
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;
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 = , 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 => 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 += ``;
currentPhonemes = null;
}
} else {
if (currentPhonemes === null) {
ssml += character;
} else {
currentPhonemes += character;
}
}
}
}
if (currentPhonemes !== null) {
ssml += `/${currentPhonemes}`;
}
return `${ssml}`;
};
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 extends Array {
override map(callbackFn: (value: T, index: number, array: T[]) => U): ImmutableArray {
return super.map(callbackFn) as ImmutableArray;
}
override filter(predicate: (value: T, index: number, array: T[]) => unknown): ImmutableArray {
return super.filter(predicate) as ImmutableArray;
}
sorted(compareFn?: (a: T, b: T) => number): ImmutableArray {
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]> {
const keys: Record = {};
const grouped: ImmutableArray<[string | number, ImmutableArray]> = 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 = (list: V[], fn: (element: V) => string): Record => {
const grouped: Record = {};
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;
export const findAdmins = async (db: Database, locale: LocaleCode, area: PermissionAreas): Promise => {
const admins = await db.all(`
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 = , K extends keyof T>(
obj: T,
keysToKeep: K[],
): Pick => {
return keysToKeep.reduce((filteredObj, key) => {
if (key in obj) {
filteredObj[key] = obj[key];
}
return filteredObj;
}, {} as Pick);
};
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();
};
};