summary refs log tree commit diff
path: root/src/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'src/extensions')
-rw-r--r--src/extensions/DateBuilder.test.ts144
-rw-r--r--src/extensions/DateBuilder.ts89
-rw-r--r--src/extensions/ElapsedTime.test.ts80
-rw-r--r--src/extensions/ElapsedTime.ts84
-rw-r--r--src/extensions/Random.ts92
-rw-r--r--src/extensions/Stopwatch.test.ts54
-rw-r--r--src/extensions/Stopwatch.ts70
-rw-r--r--src/extensions/String.ts68
-rw-r--r--src/extensions/Timespan.test.ts122
-rw-r--r--src/extensions/Timespan.ts130
-rw-r--r--src/extensions/Url.ts41
-rw-r--r--src/extensions/index.ts7
12 files changed, 981 insertions, 0 deletions
diff --git a/src/extensions/DateBuilder.test.ts b/src/extensions/DateBuilder.test.ts
new file mode 100644

index 00000000..74de4dcc --- /dev/null +++ b/src/extensions/DateBuilder.test.ts
@@ -0,0 +1,144 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { DateBuilder } from "./DateBuilder"; + +describe("DateBuilder", () => { + test("should be able to be initialised", () => { + const db = new DateBuilder(); + assert.equal(db instanceof DateBuilder, true); + }); + + test("should be able to build current date", () => { + const now = new Date(); + const db = new DateBuilder(); + const built = db.build(); + assert.equal(built.getFullYear(), now.getFullYear()); + assert.equal(built.getMonth(), now.getMonth()); + assert.equal(built.getDate(), now.getDate()); + assert.equal(built.getHours(), now.getHours()); + assert.equal(built.getMinutes(), now.getMinutes()); + assert.equal(built.getSeconds(), now.getSeconds()); + }); + + test("should be able to build timestamp", () => { + const now = new Date(); + const db = new DateBuilder(); + const built = db.buildTimestamp(); + assert.equal(built, now.getTime()); + }); + + test("should be able to add days", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024 + db.addDays(30); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 0); // January + assert.equal(built.getDate(), 31); // January has 31 days + }); + + test("should be able to add months", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 31, 2024 + db.addMonths(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 1); // February + }); + + test("should be able to add years", () => { + const db = new DateBuilder(new Date(2020, 1, 1)); + db.addYears(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2021); + assert.equal(built.getMonth(), 1); // February + assert.equal(built.getDate(), 1); + }); + + test("should be able to set date", () => { + const db = new DateBuilder(); + db.withDate(2022, 12, 25); // Dec 25, 2022 + const built = db.build(); + assert.equal(built.getFullYear(), 2022); + assert.equal(built.getMonth(), 11); // December + assert.equal(built.getDate(), 25); + }); + + test("should be able to set time", () => { + const db = new DateBuilder(); + db.withTime(15, 30, 45, 123); // 15:30:45.123 + const built = db.build(); + assert.equal(built.getHours(), 15); + assert.equal(built.getMinutes(), 30); + assert.equal(built.getSeconds(), 45); + assert.equal(built.getMilliseconds(), 123); + }); + + test("should be able to set start of day", () => { + const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456 + db.atStartOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 5); // June + assert.equal(built.getDate(), 15); + assert.equal(built.getHours(), 0); + assert.equal(built.getMinutes(), 0); + assert.equal(built.getSeconds(), 0); + assert.equal(built.getMilliseconds(), 0); + }); + + test("should be able to set end of day", () => { + const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456 + db.atEndOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 5); // June + assert.equal(built.getDate(), 15); + assert.equal(built.getHours(), 23); + assert.equal(built.getMinutes(), 59); + assert.equal(built.getSeconds(), 59); + assert.equal(built.getMilliseconds(), 999); + }); + + test("should be able to chain methods", () => { + const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024 + db.addDays(1).addMonths(1).addYears(1).withTime(12, 0, 0).atEndOfDay(); + const built = db.build(); + assert.equal(built.getFullYear(), 2025); + assert.equal(built.getMonth(), 1); // March + assert.equal(built.getDate(), 2); + assert.equal(built.getHours(), 23); + assert.equal(built.getMinutes(), 59); + assert.equal(built.getSeconds(), 59); + assert.equal(built.getMilliseconds(), 999); + }); + + test("should not mutate original date", () => { + const original = new Date(2024, 0, 1); // Jan 1, 2024 + const db = new DateBuilder(original); + db.addDays(10); + const built = db.build(); + assert.equal(original.getFullYear(), 2024); + assert.equal(original.getMonth(), 0); // January + assert.equal(original.getDate(), 1); // Original date should remain unchanged + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 0); // January + assert.equal(built.getDate(), 11); // New date should be Jan 11, 2024 + }); + + test("should handle leap years correctly", () => { + const db = new DateBuilder(new Date(2020, 1, 29)); // Feb 29, 2020 (leap year) + db.addYears(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2021); + assert.equal(built.getMonth(), 2); // March + assert.equal(built.getDate(), 1); // March 1, 2021 (not a leap year) + }); + + test("should handle month overflow correctly", () => { + const db = new DateBuilder(new Date(2024, 0, 31)); // Jan 31, 2024 + db.addDays(1); + const built = db.build(); + assert.equal(built.getFullYear(), 2024); + assert.equal(built.getMonth(), 1); // February + assert.equal(built.getDate(), 1); // Feb 29, 2024 (leap year) + }); +}); diff --git a/src/extensions/DateBuilder.ts b/src/extensions/DateBuilder.ts new file mode 100644
index 00000000..58588c8b --- /dev/null +++ b/src/extensions/DateBuilder.ts
@@ -0,0 +1,89 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +export class DateBuilder { + private date: Date; + // constructors + constructor(date: Date = new Date()) { + this.date = new Date(date.getTime()); // Create a copy to avoid mutating the original date + } + + // methods + addYears(years: number) { + this.date.setFullYear(this.date.getFullYear() + years); + return this; + } + + addMonths(months: number) { + this.date.setMonth(this.date.getMonth() + months); + return this; + } + + addDays(days: number) { + this.date.setDate(this.date.getDate() + days); + return this; + } + + addHours(hours: number) { + this.date.setHours(this.date.getHours() + hours); + return this; + } + + addMinutes(minutes: number) { + this.date.setMinutes(this.date.getMinutes() + minutes); + return this; + } + + addSeconds(seconds: number) { + this.date.setSeconds(this.date.getSeconds() + seconds); + return this; + } + + addMillis(millis: number) { + this.date.setTime(this.date.getTime() + millis); + return this; + } + + withDate(year: number, month: number, day: number | undefined) { + this.date.setFullYear(year, month - 1, day); // month is 0-based + return this; + } + + withTime(hour: number, minute = 0, second = 0, millisecond = 0) { + this.date.setHours(hour, minute, second, millisecond); + return this; + } + + atStartOfDay() { + this.date.setHours(0, 0, 0, 0); + return this; + } + + atEndOfDay() { + this.date.setHours(23, 59, 59, 999); + return this; + } + + build() { + return new Date(this.date.getTime()); // Return a copy to avoid external mutation + } + + buildTimestamp() { + return this.date.getTime(); + } +} diff --git a/src/extensions/ElapsedTime.test.ts b/src/extensions/ElapsedTime.test.ts new file mode 100644
index 00000000..b8cbbdce --- /dev/null +++ b/src/extensions/ElapsedTime.test.ts
@@ -0,0 +1,80 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { ElapsedTime } from "./ElapsedTime"; + +describe("ElapsedTime", () => { + test("should be able to be initialised", () => { + const db = new ElapsedTime(0n); + assert.equal(db != null, true); + }); + + test("should return correct total nanoseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.totalNanoseconds, 1234567890n); + }); + + test("should return correct total microseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.totalMicroseconds, 1234567); + }); + + test("should return correct total milliseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.totalMilliseconds, 1234); + }); + + test("should return correct total seconds", () => { + const db = new ElapsedTime(5000000000n); + assert.equal(db.totalSeconds, 5); + }); + + test("should return correct total minutes", () => { + const db = new ElapsedTime(300000000000n); // 5 minutes + assert.equal(db.totalMinutes, 5); + }); + + test("should return correct total hours", () => { + const db = new ElapsedTime(7200000000000n); // 2 hours + assert.equal(db.totalHours, 2); + }); + + test("should return correct total days", () => { + const db = new ElapsedTime(172800000000000n); // 2 days + assert.equal(db.totalDays, 2); + }); + + test("should return correct nanoseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.nanoseconds, 890); + }); + + test("should return correct microseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.microseconds, 567); + }); + + test("should return correct milliseconds", () => { + const db = new ElapsedTime(1234567890n); + assert.equal(db.milliseconds, 234); + }); + + test("should return correct seconds", () => { + const db = new ElapsedTime(5000000000n); + assert.equal(db.seconds, 5); + }); + + test("should return correct minutes", () => { + const db = new ElapsedTime(300000000000n); // 5 minutes + assert.equal(db.minutes, 5); + }); + + test("should return correct hours", () => { + const db = new ElapsedTime(7200000000000n); // 2 hours + assert.equal(db.hours, 2); + }); + + test("should return correct days", () => { + const db = new ElapsedTime(172800000000000n); // 2 days + assert.equal(db.days, 2); + }); +}); diff --git a/src/extensions/ElapsedTime.ts b/src/extensions/ElapsedTime.ts new file mode 100644
index 00000000..cce91280 --- /dev/null +++ b/src/extensions/ElapsedTime.ts
@@ -0,0 +1,84 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +// Inspired by the dotnet Stopwatch class +// Provides a simple interface to get elapsed time in high resolution + +export class ElapsedTime { + private readonly timeNanos: bigint; + + constructor(timeNanos: bigint) { + this.timeNanos = timeNanos; + } + + get totalNanoseconds(): bigint { + return this.timeNanos; + } + get totalMicroseconds(): number { + return Number(this.timeNanos / 1_000n); + } + get totalMilliseconds(): number { + return Number(this.timeNanos / 1_000_000n); + } + get totalSeconds(): number { + return Number(this.timeNanos / 1_000_000_000n); + } + get totalMinutes(): number { + return this.totalSeconds / 60; + } + get totalHours(): number { + return this.totalMinutes / 60; + } + get totalDays(): number { + return this.totalHours / 24; + } + get nanoseconds(): number { + return Number(this.timeNanos % 1_000n); + } + get microseconds(): number { + return Number(this.timeNanos / 1_000n) % 1000; + } + get milliseconds(): number { + return Number(this.timeNanos / 1_000_000n) % 1000; + } + get seconds(): number { + return Number(this.timeNanos / 1_000_000_000n) % 60; + } + get minutes(): number { + return this.totalMinutes % 60; + } + get hours(): number { + return this.totalHours % 24; + } + get days(): number { + return this.totalDays; + } + + toString(): string { + // Format: "DD.HH:MM:SS.mmmuuuNNN", with days being optional + const daysPart = Math.floor(this.days) > 0 ? `${Math.floor(this.days)}.` : ""; + const hoursPart = Math.floor(this.hours).toString().padStart(2, "0"); + const minutesPart = Math.floor(this.minutes).toString().padStart(2, "0"); + const secondsPart = Math.floor(this.seconds).toString().padStart(2, "0"); + const millisecondsPart = Math.floor(this.milliseconds).toString().padStart(3, "0"); + const microsecondsPart = Math.floor(this.microseconds).toString().padStart(3, "0"); + const nanosecondsPart = Math.floor(this.nanoseconds).toString().padStart(3, "0"); + + return `${daysPart}${hoursPart}:${minutesPart}:${secondsPart}.${millisecondsPart}${microsecondsPart}${nanosecondsPart}`; + } +} diff --git a/src/extensions/Random.ts b/src/extensions/Random.ts new file mode 100644
index 00000000..212e1c9a --- /dev/null +++ b/src/extensions/Random.ts
@@ -0,0 +1,92 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +// Inspired by dotnet: https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-9.0#methods +export class Random { + public static nextInt(min?: number, max?: number): number { + if (min === undefined && max === undefined) { + // Next() + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + } else if (max === undefined) { + // Next(Int32) + if (min! <= 0) throw new RangeError("min must be greater than 0"); + return Math.floor(Math.random() * min!); + } else { + // Next(Int32, Int32) + if (min! >= max!) throw new RangeError("min must be less than max"); + return Math.floor(Math.random() * (max! - min!)) + min!; + } + } + + public static nextDouble(min?: number, max?: number): number { + if (min === undefined && max === undefined) { + // NextDouble() + return Math.random(); + } else if (max === undefined) { + // NextDouble(Double) + if (min! <= 0) throw new RangeError("min must be greater than 0"); + return Math.random() * min!; + } else { + // NextDouble(Double, Double) + if (min! >= max!) throw new RangeError("min must be less than max"); + return Math.random() * (max! - min!) + min!; + } + } + + public static nextBytes(count: number): Uint8Array { + if (count <= 0) throw new RangeError("count must be greater than 0"); + const arr = new Uint8Array(count); + for (let i = 0; i < count; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + } + + public static nextBytesArray(count: number) { + if (count <= 0) throw new RangeError("count must be greater than 0"); + const arr = []; + for (let i = 0; i < count; i++) { + arr.push(Math.floor(Math.random() * 256)); + } + return arr; + } + + public static getItems<T>(items: T[], count: number): T[] { + if (count <= 0) throw new RangeError("count must be greater than 0"); + if (count >= items.length) return this.shuffle(items); + const usedIndices = new Set<number>(); + const result: T[] = []; + while (result.length < count && usedIndices.size < items.length) { + const index = Math.floor(Math.random() * items.length); + if (!usedIndices.has(index)) { + usedIndices.add(index); + result.push(items[index]); + } + } + return result; + } + + public static shuffle<T>(items: T[]): T[] { + const array = [...items]; + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + } +} diff --git a/src/extensions/Stopwatch.test.ts b/src/extensions/Stopwatch.test.ts new file mode 100644
index 00000000..35618314 --- /dev/null +++ b/src/extensions/Stopwatch.test.ts
@@ -0,0 +1,54 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { Stopwatch, timePromise } from "./Stopwatch"; + +describe("Stopwatch", () => { + test("should be able to be initialised", () => { + const sw = new Stopwatch(); + assert.equal(sw != null, true); + }); + + test("should measure elapsed time", async () => { + const sw = Stopwatch.startNew(); + await new Promise((resolve) => void setTimeout(resolve, 101)); + sw.stop(); + const elapsed = sw.elapsed(); + assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`); + }); + + test("should reset correctly", async () => { + const sw = Stopwatch.startNew(); + await new Promise((resolve) => void setTimeout(resolve, 101)); + sw.stop(); + let elapsed = sw.elapsed(); + assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`); + + sw.reset(); + await new Promise((resolve) => void setTimeout(resolve, 50)); + sw.stop(); + elapsed = sw.elapsed(); + assert(elapsed.totalMilliseconds >= 49 && elapsed.totalMilliseconds < 100, `Elapsed time after reset was ${elapsed.totalMilliseconds} ms`); + }); + + test("getElapsedAndReset should work correctly", async () => { + const sw = Stopwatch.startNew(); + await new Promise((resolve) => void setTimeout(resolve, 101)); + sw.stop(); + let elapsed = sw.getElapsedAndReset(); + assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`); + + await new Promise((resolve) => void setTimeout(resolve, 50)); + sw.stop(); + elapsed = sw.elapsed(); + assert(elapsed.totalMilliseconds >= 50 && elapsed.totalMilliseconds < 100, `Elapsed time after getElapsedAndReset was ${elapsed.totalMilliseconds} ms`); + }); + + test("timePromise should measure promise execution time", async () => { + const { result, elapsed } = await timePromise(async () => { + await new Promise((resolve) => void setTimeout(resolve, 101)); + return 42; + }); + assert.equal(result, 42); + assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`); + }); +}); diff --git a/src/extensions/Stopwatch.ts b/src/extensions/Stopwatch.ts new file mode 100644
index 00000000..1f83c847 --- /dev/null +++ b/src/extensions/Stopwatch.ts
@@ -0,0 +1,70 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +// Inspired by the dotnet Stopwatch class +// Provides a simple interface to get elapsed time in high resolution +import { ElapsedTime } from "./ElapsedTime"; + +export class Stopwatch { + private startTime: bigint; + private endTime: bigint | null = null; + + static startNew(): Stopwatch { + const stopwatch = new Stopwatch(); + stopwatch.start(); + return stopwatch; + } + + start(): void { + this.startTime = process.hrtime.bigint(); + this.endTime = null; + } + + reset(): void { + this.startTime = process.hrtime.bigint(); + this.endTime = null; + } + + stop(): void { + this.endTime = process.hrtime.bigint(); + } + + elapsed(): ElapsedTime { + return new ElapsedTime((this.endTime ?? process.hrtime.bigint()) - this.startTime); + } + + getElapsedAndReset(): ElapsedTime { + const elapsed = this.elapsed(); + this.reset(); + return elapsed; + } +} + +export async function timePromise<T>(fn: () => Promise<T>): Promise<{ result: T; elapsed: ElapsedTime }> { + const stopwatch = Stopwatch.startNew(); + const result = await fn(); + const elapsed = stopwatch.elapsed(); + return { result, elapsed }; +} + +export function timeFunction<T>(fn: () => T): { result: T; elapsed: ElapsedTime } { + const stopwatch = Stopwatch.startNew(); + const result = fn(); + const elapsed = stopwatch.elapsed(); + return { result, elapsed }; +} diff --git a/src/extensions/String.ts b/src/extensions/String.ts new file mode 100644
index 00000000..5c623e30 --- /dev/null +++ b/src/extensions/String.ts
@@ -0,0 +1,68 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { Request } from "express"; +import { SPECIAL_CHAR } from "@spacebar/util/util/Regex"; +import { Random } from "@spacebar/extensions/Random"; +import { ntob } from "@spacebar/api"; +import { FieldErrors } from "@spacebar/util"; + +export function trimSpecial(str?: string): string { + if (!str) return ""; + return str.replace(SPECIAL_CHAR, "").trim(); +} + +/** + * Capitalizes the first letter of a string. + * @param str The string to capitalize. + * @returns The capitalized string. + */ +export function capitalize(str: string): string { + if (!str) return ""; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function centerString(str: string, len: number): string { + const pad = len - str.length; + const padLeft = Math.floor(pad / 2) + str.length; + return str.padStart(padLeft).padEnd(len); +} + +export function stringGlobToRegexp(str: string, flags?: string): RegExp { + // Convert simple wildcard patterns to regex + const escaped = str.replace(".", "\\.").replace("?", ".").replace("*", ".*"); + return new RegExp(escaped, flags); +} + +export function stringCheckLength(str: string, min: number, max: number, key: string, req: Request) { + if (str.length < min || str.length > max) { + throw FieldErrors({ + [key]: { + code: "BASE_TYPE_BAD_LENGTH", + // TODO: remove dependency on request (add generic field?) + message: req.t("common:field.BASE_TYPE_BAD_LENGTH", { + length: `${min} - ${max}`, + }), + }, + }); + } +} + +export function generateCode() { + return ntob(Date.now() + Random.nextInt(0, 10000)); +} diff --git a/src/extensions/Timespan.test.ts b/src/extensions/Timespan.test.ts new file mode 100644
index 00000000..02195506 --- /dev/null +++ b/src/extensions/Timespan.test.ts
@@ -0,0 +1,122 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { TimeSpan } from "./Timespan"; + +describe("TimeSpan", () => { + test("should be able to be initialised", () => { + const db = new TimeSpan(); + assert.equal(db != null, true); + }); + + test("should be able to be initialised with start and end", () => { + const now = Date.now(); + const later = now + 5000; + const ts = new TimeSpan(now, later); + assert.equal(ts.start, now); + assert.equal(ts.end, later); + }); + + test("should be able to be initialised with start and end (fromDates static method)", () => { + const now = Date.now(); + const later = now + 5000; + const ts = TimeSpan.fromDates(now, later); + assert.equal(ts.start, now); + assert.equal(ts.end, later); + }); + + test("should throw error if start is greater than end", () => { + assert.throws(() => { + new TimeSpan(2000, 1000); + }, /Start time must be less than or equal to end time./); + }); + + test("should be able to return zero", () => { + const ts = new TimeSpan(); + assert.equal(ts.totalMillis, 0); + }); + + test("should be able to return timespan from milliseconds", () => { + const ts = TimeSpan.fromMillis(1000); + assert.equal(ts.totalMillis, 1000); + assert.equal(ts.totalSeconds, 1); + }); + + test("should be able to return timespan from seconds", () => { + const ts = TimeSpan.fromSeconds(60); + assert.equal(ts.totalMillis, 60000); + assert.equal(ts.totalSeconds, 60); + assert.equal(ts.totalMinutes, 1); + assert.equal(ts.minutes, 1); + assert.equal(ts.hours, 0); + assert.equal(ts.days, 0); + }); + + test("should be pure", () => { + const count = 10; + const timestamps = []; + for (let i = 0; i < count; i++) { + timestamps.push(TimeSpan.fromMillis(8972347984)); + for (const ts2 of timestamps) { + assert.equal(ts2.totalMillis, 8972347984); + assert.equal(ts2.totalSeconds, 8972347); + assert.equal(ts2.totalMinutes, 149539); + assert.equal(ts2.totalHours, 2492); + assert.equal(ts2.totalDays, 103); + assert.equal(ts2.totalWeeks, 14); + assert.equal(ts2.totalMonths, 3); + assert.equal(ts2.totalYears, 0); + + assert.equal(ts2.millis, 984); + assert.equal(ts2.seconds, 7); + assert.equal(ts2.minutes, 19); + assert.equal(ts2.hours, 20); + assert.equal(ts2.days, 12); + assert.equal(ts2.weekDays, 5); + assert.equal(ts2.weeks, 1); + assert.equal(ts2.months, 3); + assert.equal(ts2.years, 0); + } + } + }); + + test("should be able to stringify", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toString(), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(true), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(true, false), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds"); + assert.equal(ts.toString(false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds"); + assert.equal(ts.toString(false, false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds"); + }); + + test("should be able to shortStringify", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toShortString(), "3mo1w5d20h19m7s984ms"); + assert.equal(ts.toShortString(true), "3mo1w5d20h19m7s984ms"); + assert.equal(ts.toShortString(true, false), "3mo1w5d20h19m7s"); + assert.equal(ts.toShortString(false), "3mo12d20h19m7s984ms"); + assert.equal(ts.toShortString(false, false), "3mo12d20h19m7s"); + }); + + test("should be able to shortStringify with spaces", () => { + const ts = TimeSpan.fromMillis(8972347984); + assert.equal(ts.toShortString(undefined, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(true, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(true, false, true), "3mo 1w 5d 20h 19m 7s"); + assert.equal(ts.toShortString(false, undefined, true), "3mo 12d 20h 19m 7s 984ms"); + assert.equal(ts.toShortString(false, false, true), "3mo 12d 20h 19m 7s"); + }); + + test("should be able to return start date", () => { + const now = Date.now(); + const later = now + 5000; + const ts = new TimeSpan(now, later); + assert.equal(ts.startDate.getTime(), now); + }); + + test("should be able to return end date", () => { + const now = Date.now(); + const later = now + 5000; + const ts = new TimeSpan(now, later); + assert.equal(ts.endDate.getTime(), later); + }); +}); diff --git a/src/extensions/Timespan.ts b/src/extensions/Timespan.ts new file mode 100644
index 00000000..ad0c1d10 --- /dev/null +++ b/src/extensions/Timespan.ts
@@ -0,0 +1,130 @@ +/** + * Represents a timespan with a start and end time. + */ +export class TimeSpan { + public readonly start: number; + public readonly end: number; + // constructors + constructor(start = Date.now(), end = Date.now()) { + if (start > end) { + throw new Error("Start time must be less than or equal to end time."); + } + this.start = start; + this.end = end; + } + + static fromDates(startDate: number, endDate: number) { + return new TimeSpan(startDate, endDate); + } + + static fromMillis(millis: number) { + return new TimeSpan(0, millis); + } + + static fromSeconds(seconds: number) { + return TimeSpan.fromMillis(seconds * 1000); + } + + // methods + get totalMillis() { + return this.end - this.start; + } + + get millis() { + return Math.floor(this.totalMillis % 1000); + } + + get totalSeconds() { + return Math.floor(this.totalMillis / 1000); + } + + get seconds() { + return Math.floor((this.totalMillis / 1000) % 60); + } + + get totalMinutes() { + return Math.floor(this.totalMillis / 1000 / 60); + } + + get minutes() { + return Math.floor((this.totalMillis / 1000 / 60) % 60); + } + + get totalHours() { + return Math.floor(this.totalMillis / 1000 / 60 / 60); + } + + get hours() { + return Math.floor((this.totalMillis / 1000 / 60 / 60) % 24); + } + + get totalDays() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24); + } + + get days() { + return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24) % 30.44); // Average days in a month + } + + get weekDays() { + return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24) % 7); + } + + get totalWeeks() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7); + } + + get weeks() { + return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24 / 7) % 4.345); // Average weeks in a month + } + + get totalMonths() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44); // Average days in a month + } + + get months() { + return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24 / 30.44) % 12); // Average days in a month + } + + get totalYears() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25); // Average days in a year + } + + get years() { + return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25); // Average days in a year + } + + toString(includeWeeks = true, includeMillis = true) { + const parts = []; + if (this.totalYears >= 1) parts.push(`${this.totalYears} years`); + if (this.totalMonths >= 1) parts.push(`${this.months} months`); + if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks} weeks`); + if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days} days`); + if (this.totalHours >= 1) parts.push(`${this.hours} hours`); + if (this.totalMinutes >= 1) parts.push(`${this.minutes} minutes`); + if (this.totalSeconds >= 1) parts.push(`${this.seconds} seconds`); + if (includeMillis) parts.push(`${this.millis} milliseconds`); + return parts.join(", "); + } + + toShortString(includeWeeks = true, includeMillis = true, withSpaces = false) { + const parts = []; + if (this.totalYears >= 1) parts.push(`${this.totalYears}y`); + if (this.totalMonths >= 1) parts.push(`${this.months}mo`); + if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks}w`); + if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days}d`); + if (this.totalHours >= 1) parts.push(`${this.hours}h`); + if (this.totalMinutes >= 1) parts.push(`${this.minutes}m`); + if (this.totalSeconds >= 1) parts.push(`${this.seconds}s`); + if (includeMillis) parts.push(`${this.millis}ms`); + return parts.join(withSpaces ? " " : ""); + } + + get startDate() { + return new Date(this.start); + } + + get endDate() { + return new Date(this.end); + } +} diff --git a/src/extensions/Url.ts b/src/extensions/Url.ts new file mode 100644
index 00000000..7fa681bf --- /dev/null +++ b/src/extensions/Url.ts
@@ -0,0 +1,41 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +export function normalizeUrl(input: string): string { + try { + const u = new URL(input); + // Remove fragment + u.hash = ""; + // Normalize pathname - remove trailing slash except for root "/" + if (u.pathname !== "/" && u.pathname.endsWith("/")) { + u.pathname = u.pathname.slice(0, -1); + } + // Normalize query params: sort by key + if (u.search) { + const params = Array.from(u.searchParams.entries()); + params.sort(([a], [b]) => a.localeCompare(b)); + u.search = params.length ? "?" + params.map(([k, v]) => `${k}=${v}`).join("&") : ""; + } else { + // Ensure no empty search string + u.search = ""; + } + return u.toString(); + } catch (e) { + return input; + } +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts
index 53bd443c..15563724 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts
@@ -17,7 +17,14 @@ */ export * from "./Array"; +export * from "./DateBuilder"; +export * from "./ElapsedTime"; export * from "./Math"; +export * from "./Random"; +export * from "./Stopwatch"; +export * from "./String"; +export * from "./Timespan"; +export * from "./Url"; // TODO: move to a separate file export async function sleep(ms: number) {