260 lines
6.8 KiB
TypeScript
260 lines
6.8 KiB
TypeScript
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<string, unknown>,
|
|
prefix = "",
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
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<string, unknown>);
|
|
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<T extends { _id: ObjectId }>(
|
|
data: T,
|
|
): T & { _id: string };
|
|
export function replaceObjectId<T extends { _id: ObjectId }>(
|
|
data: T | null,
|
|
): (T & { _id: string }) | null;
|
|
export function replaceObjectId<T extends { _id: ObjectId }>(
|
|
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<T extends { _id: ObjectId }>(
|
|
data: T[],
|
|
): (T & { _id: string })[] {
|
|
if (data === undefined) return data;
|
|
return data.map((it) => replaceObjectId(it));
|
|
}
|
|
export type WithObjectId<T extends { _id: string }> = Omit<T, "_id"> & {
|
|
_id: ObjectId;
|
|
};
|
|
|
|
export function omit<T extends object, K extends keyof T>(
|
|
obj: T,
|
|
keys: K[],
|
|
): Omit<T, K> {
|
|
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
|
|
);
|
|
}
|