import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { roundTo2 } from "@monkeytype/util/numbers"; import uaparser from "ua-parser-js"; import { MonkeyRequest } from "../api/types"; import { ObjectId } from "mongodb"; //todo split this file into smaller util files (grouped by functionality) export function identity(value: unknown): string { return Object.prototype.toString .call(value) .replace(/^\[object\s+([a-z]+)\]$/i, "$1") .toLowerCase(); } export function base64UrlEncode(data: string): string { return Buffer.from(data).toString("base64url"); } export function base64UrlDecode(data: string): string { return Buffer.from(data, "base64url").toString(); } type AgentLog = { ip: string; agent: string; device?: string; }; export function buildAgentLog(req: MonkeyRequest): AgentLog { const agent = uaparser(req.raw.headers["user-agent"]); const agentLog: AgentLog = { ip: (req.raw.headers["cf-connecting-ip"] as string) || (req.raw.headers["x-forwarded-for"] as string) || (req.raw.ip as string) || "255.255.255.255", agent: `${agent.os.name} ${agent.os.version} ${agent.browser.name} ${agent.browser.version}`, }; const { device: { vendor, model, type }, } = agent; agentLog.device = `${vendor ?? "unknown vendor"} ${ model ?? "unknown model" } ${type ?? "unknown type"}`; return agentLog; } export function padNumbers( numbers: number[], maxLength: number, fillString: string, ): string[] { return numbers.map((number) => number.toString().padStart(maxLength, fillString), ); } export function matchesAPattern(text: string, pattern: string): boolean { const regex = new RegExp(`^${pattern}$`); return regex.test(text); } export function kogascore(wpm: number, acc: number, timestamp: number): number { // its safe to round after multiplying by 100 (99.99 * 100 rounded will be 9999 not 100) // rounding is necessary to protect against floating point errors const normalizedWpm = Math.round(wpm * 100); const normalizedAcc = Math.round(acc * 100); const padAmount = 100000; const firstPart = (padAmount + normalizedWpm) * padAmount; const secondPart = (firstPart + normalizedAcc) * padAmount; const currentDayTimeMilliseconds = timestamp - (timestamp % MILLISECONDS_IN_DAY); const todayMilliseconds = timestamp - currentDayTimeMilliseconds; return ( secondPart + Math.floor((MILLISECONDS_IN_DAY - todayMilliseconds) / 1000) ); } export function flattenObjectDeep( obj: Record, prefix = "", ): Record { const result: Record = {}; const keys = Object.keys(obj); keys.forEach((key) => { const value = obj[key]; const newPrefix = prefix.length > 0 ? `${prefix}.${key}` : key; if (isPlainObject(value)) { const flattened = flattenObjectDeep(value as Record); const flattenedKeys = Object.keys(flattened); if (flattenedKeys.length === 0) { result[newPrefix] = value; } flattenedKeys.forEach((flattenedKey) => { result[`${newPrefix}.${flattenedKey}`] = flattened[flattenedKey]; }); } else { result[newPrefix] = value; } }); return result; } export function sanitizeString(str: string | undefined): string | undefined { if (str === undefined || str === "") { return str; } return str .replace(/[\u0300-\u036F]/g, "") .trim() .replace(/\n{3,}/g, "\n\n") .replace(/\s{3,}/g, " "); } const suffixes = ["th", "st", "nd", "rd"]; export function getOrdinalNumberString(number: number): string { const lastTwo = number % 100; const suffix = suffixes[(lastTwo - 20) % 10] ?? suffixes[lastTwo] ?? suffixes[0]; return `${number}${suffix}`; } type TimeUnit = | "second" | "minute" | "hour" | "day" | "week" | "month" | "year"; export const MINUTE_IN_SECONDS = 1 * 60; export const HOUR_IN_SECONDS = 1 * 60 * MINUTE_IN_SECONDS; export const DAY_IN_SECONDS = 1 * 24 * HOUR_IN_SECONDS; export const WEEK_IN_SECONDS = 1 * 7 * DAY_IN_SECONDS; export const MONTH_IN_SECONDS = 1 * 30.4167 * DAY_IN_SECONDS; export const YEAR_IN_SECONDS = 1 * 12 * MONTH_IN_SECONDS; export function formatSeconds( seconds: number, ): `${number} ${TimeUnit}${"s" | ""}` { let unit: TimeUnit; let secondsInUnit: number; if (seconds < MINUTE_IN_SECONDS) { unit = "second"; secondsInUnit = 1; } else if (seconds < HOUR_IN_SECONDS) { unit = "minute"; secondsInUnit = MINUTE_IN_SECONDS; } else if (seconds < DAY_IN_SECONDS) { unit = "hour"; secondsInUnit = HOUR_IN_SECONDS; } else if (seconds < WEEK_IN_SECONDS) { unit = "day"; secondsInUnit = DAY_IN_SECONDS; } else if (seconds < YEAR_IN_SECONDS) { if (seconds < WEEK_IN_SECONDS * 4) { unit = "week"; secondsInUnit = WEEK_IN_SECONDS; } else { unit = "month"; secondsInUnit = MONTH_IN_SECONDS; } } else { unit = "year"; secondsInUnit = YEAR_IN_SECONDS; } const normalized = roundTo2(seconds / secondsInUnit); return `${normalized} ${unit}${normalized > 1 ? "s" : ""}`; } export function isDevEnvironment(): boolean { return process.env["MODE"] === "dev"; } export function getFrontendUrl(): string { return isDevEnvironment() ? "http://localhost:3000" : (process.env["FRONTEND_URL"] ?? "https://monkeytype.com"); } /** * convert database object into api object * @param data database object with `_id: ObjectId` * @returns api object with `id: string` */ export function replaceObjectId( data: T, ): T & { _id: string }; export function replaceObjectId( data: T | null, ): (T & { _id: string }) | null; export function replaceObjectId( data: T | null, ): (T & { _id: string }) | null { if (data === null) { return null; } const result = { ...data, _id: data._id.toString(), } as T & { _id: string }; return result; } /** * convert database objects into api objects * @param data database objects with `_id: ObjectId` * @returns api objects with `id: string` */ export function replaceObjectIds( data: T[], ): (T & { _id: string })[] { if (data === undefined) return data; return data.map((it) => replaceObjectId(it)); } export type WithObjectId = Omit & { _id: ObjectId; }; export function omit( obj: T, keys: K[], ): Omit { const result = { ...obj }; for (const key of keys) { // oxlint-disable-next-line no-dynamic-delete delete result[key]; } return result; } export function isPlainObject(value: unknown): boolean { return ( value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype ); }