@manta-eu/lib 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/ad-platform-links.js +1 -0
  2. package/dist/ad-platform-links.js.map +1 -0
  3. package/dist/array.js +1 -0
  4. package/dist/array.js.map +1 -0
  5. package/dist/asset-upload-grouping.js +1 -0
  6. package/dist/asset-upload-grouping.js.map +1 -0
  7. package/dist/backoff.js +1 -0
  8. package/dist/backoff.js.map +1 -0
  9. package/dist/color.js +1 -0
  10. package/dist/color.js.map +1 -0
  11. package/dist/connection-string.js +1 -0
  12. package/dist/connection-string.js.map +1 -0
  13. package/dist/countries.js +1 -0
  14. package/dist/countries.js.map +1 -0
  15. package/dist/custom-app.d.ts +51 -0
  16. package/dist/custom-app.d.ts.map +1 -0
  17. package/dist/custom-app.js +145 -0
  18. package/dist/custom-app.js.map +1 -0
  19. package/dist/date.js +1 -0
  20. package/dist/date.js.map +1 -0
  21. package/dist/enum.js +1 -0
  22. package/dist/enum.js.map +1 -0
  23. package/dist/file.js +1 -0
  24. package/dist/file.js.map +1 -0
  25. package/dist/humanize.js +1 -0
  26. package/dist/humanize.js.map +1 -0
  27. package/dist/identifier-words.js +1 -0
  28. package/dist/identifier-words.js.map +1 -0
  29. package/dist/number.js +1 -0
  30. package/dist/number.js.map +1 -0
  31. package/dist/object-url.js +1 -0
  32. package/dist/object-url.js.map +1 -0
  33. package/dist/present.js +1 -0
  34. package/dist/present.js.map +1 -0
  35. package/dist/set.js +1 -0
  36. package/dist/set.js.map +1 -0
  37. package/dist/sleep.js +1 -0
  38. package/dist/sleep.js.map +1 -0
  39. package/dist/string.js +1 -0
  40. package/dist/string.js.map +1 -0
  41. package/dist/types.js +1 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/url.js +1 -0
  44. package/dist/url.js.map +1 -0
  45. package/package.json +7 -2
  46. package/src/ad-platform-links.ts +20 -0
  47. package/src/array.ts +9 -0
  48. package/src/asset-upload-grouping.ts +70 -0
  49. package/src/backoff.ts +14 -0
  50. package/src/color.ts +92 -0
  51. package/src/connection-string.ts +95 -0
  52. package/src/countries.ts +18 -0
  53. package/src/custom-app.ts +180 -0
  54. package/src/date.ts +51 -0
  55. package/src/enum.ts +18 -0
  56. package/src/file.ts +19 -0
  57. package/src/humanize.ts +94 -0
  58. package/src/identifier-words.ts +16 -0
  59. package/src/number.ts +10 -0
  60. package/src/object-url.ts +10 -0
  61. package/src/present.ts +30 -0
  62. package/src/set.ts +38 -0
  63. package/src/sleep.ts +3 -0
  64. package/src/string.ts +18 -0
  65. package/src/types.ts +1 -0
  66. package/src/url.ts +47 -0
package/src/color.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { DateArg } from "date-fns";
2
+ import { differenceInHours } from "date-fns";
3
+
4
+ export type RGBColor = {
5
+ r: number;
6
+ g: number;
7
+ b: number;
8
+ };
9
+
10
+ export type HexColor = `#${string}`;
11
+
12
+ export const hexToRgb = (hex: HexColor): RGBColor => {
13
+ let sanitizedHex = hex.replace("#", "");
14
+
15
+ if (sanitizedHex.length === 3) {
16
+ sanitizedHex = sanitizedHex
17
+ .split("")
18
+ .map((h) => `${h}${h}`)
19
+ .join("");
20
+ }
21
+
22
+ const bigint = parseInt(sanitizedHex, 16);
23
+
24
+ if (isNaN(bigint)) throw new Error(`Invalid hex value: ${hex}`);
25
+
26
+ return {
27
+ r: (bigint >> 16) & 255,
28
+ g: (bigint >> 8) & 255,
29
+ b: bigint & 255,
30
+ };
31
+ };
32
+
33
+ export function rgbToHex({ r, g, b }: RGBColor): HexColor {
34
+ const componentToHex = (c: number) => {
35
+ const hex = c.toString(16);
36
+ return hex.length === 1 ? `0${hex}` : hex;
37
+ };
38
+
39
+ return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
40
+ }
41
+
42
+ export function capBrightness(hexColor: HexColor, maxBrightness: number = 150): string {
43
+ let { r, g, b } = hexToRgb(hexColor);
44
+
45
+ r = Math.min(r, maxBrightness);
46
+ g = Math.min(g, maxBrightness);
47
+ b = Math.min(b, maxBrightness);
48
+
49
+ return rgbToHex({ r, g, b });
50
+ }
51
+
52
+ export const generateHexColorBasedOnName = (name: string) => {
53
+ const hash = name.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
54
+
55
+ const color = `hsl(${hash % 360}, 50%, 50%)`;
56
+ const contrast = hash % 360 > 180 ? "black" : "white";
57
+
58
+ return { color, contrast };
59
+ };
60
+
61
+ export const getAverageColor = (ctx: CanvasRenderingContext2D, width: number, height: number): RGBColor => {
62
+ const imageData = ctx.getImageData(0, 0, width, height);
63
+ const data = imageData.data;
64
+ const length = data.length;
65
+
66
+ let r = 0,
67
+ g = 0,
68
+ b = 0,
69
+ count = 0;
70
+
71
+ for (let i = 0; i < length; i += 4) {
72
+ r += data[i]!;
73
+ g += data[i + 1]!;
74
+ b += data[i + 2]!;
75
+ count++;
76
+ }
77
+
78
+ return {
79
+ r: Math.floor(r / count),
80
+ g: Math.floor(g / count),
81
+ b: Math.floor(b / count),
82
+ };
83
+ };
84
+
85
+ export function getAgeColor(date: DateArg<Date>): string {
86
+ const diffInHours = differenceInHours(Date.now(), date);
87
+ const clampedHours = Math.min(Math.max(diffInHours, 0), 36);
88
+
89
+ const hue = Math.round(120 - clampedHours * 3.3333);
90
+
91
+ return `hsl(${hue}, 80%, 40%)`;
92
+ }
@@ -0,0 +1,95 @@
1
+ import { isPresent } from "./present";
2
+
3
+ export type ConnectionString = {
4
+ scheme: string;
5
+ host?: string;
6
+ port?: string;
7
+ username?: string;
8
+ password?: string;
9
+ path: string[];
10
+ params: Record<string, string>;
11
+ };
12
+
13
+ export type ParseOptions = {
14
+ // A URL can hold a raw ";" in its user name or password, so only a JDBC format that uses ";KEY=VALUE" sets this.
15
+ semicolonProperties?: boolean;
16
+ };
17
+
18
+ export function parseConnectionString(input: string, options: ParseOptions = {}): ConnectionString | undefined {
19
+ const { semicolonProperties = false } = options;
20
+ const schemeAndRest = splitScheme(stripJdbcPrefix(input.trim()));
21
+ if (!schemeAndRest) return undefined;
22
+
23
+ const { scheme, rest } = schemeAndRest;
24
+ const [head, properties] = semicolonProperties ? splitHead(rest) : [rest, ""];
25
+ if (head.includes("\\")) return undefined;
26
+
27
+ try {
28
+ const { path, query, ...authority } = headParts(scheme, head, semicolonProperties);
29
+ return {
30
+ scheme,
31
+ ...withoutBlankValues(authority),
32
+ path,
33
+ params: Object.fromEntries([...query, ...propertyEntries(properties)]),
34
+ };
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ export function unescapeBracedValue(value: string): string {
41
+ const braced = /^\{(.*)\}$/s.exec(value);
42
+ return braced?.[1] === undefined ? value : braced[1].replaceAll("}}", "}");
43
+ }
44
+
45
+ function stripJdbcPrefix(input: string): string {
46
+ return input.replace(/^jdbc:/i, "");
47
+ }
48
+
49
+ const schemePattern = /^([a-z][a-z0-9+.-]*):\/\//i;
50
+
51
+ function splitScheme(text: string): { scheme: string; rest: string } | undefined {
52
+ const match = schemePattern.exec(text);
53
+ if (!match?.[1]) return undefined;
54
+ return { scheme: match[1].toLowerCase(), rest: text.slice(match[0].length) };
55
+ }
56
+
57
+ function splitHead(text: string): [string, string] {
58
+ const [head = "", ...properties] = text.split(";");
59
+ return [head, properties.join(";")];
60
+ }
61
+
62
+ // The BigQuery JDBC head is an endpoint URL, so a head with its own scheme carries no host.
63
+ function headParts(scheme: string, head: string, semicolonProperties: boolean) {
64
+ if (!isPresent(head) || (semicolonProperties && head.includes("://"))) return { path: [], query: [] };
65
+ return urlParts(scheme, head);
66
+ }
67
+
68
+ function urlParts(scheme: string, head: string) {
69
+ const url = new URL(`${scheme}://${head}`);
70
+
71
+ return {
72
+ host: url.hostname.replace(/^\[(.*)\]$/, "$1"),
73
+ port: url.port,
74
+ username: decodeURIComponent(url.username),
75
+ password: decodeURIComponent(url.password),
76
+ path: url.pathname.split("/").filter(Boolean).map(decodeURIComponent),
77
+ query: [...url.searchParams],
78
+ };
79
+ }
80
+
81
+ function withoutBlankValues(entries: Record<string, string>): Record<string, string> {
82
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => isPresent(value)));
83
+ }
84
+
85
+ const propertyName = "([^;=]+)";
86
+ // The SQL Server JDBC driver escapes a value in braces, and "}}" in the braces stands for one "}".
87
+ const bracedValue = String.raw`\{(?:[^}]|\}\})*\}`;
88
+ const plainValue = "[^;]*";
89
+ const propertyPattern = new RegExp(String.raw`${propertyName}=\s*(${bracedValue}|${plainValue})`, "g");
90
+
91
+ function propertyEntries(text: string): [string, string][] {
92
+ return [...text.matchAll(propertyPattern)]
93
+ .map(([, name = "", value = ""]): [string, string] => [name.trim(), value.trim()])
94
+ .filter(([name]) => isPresent(name));
95
+ }
@@ -0,0 +1,18 @@
1
+ const regionDisplayNames = new Intl.DisplayNames(["en"], { type: "region" });
2
+ const languageDisplayNames = new Intl.DisplayNames(["en"], { type: "language" });
3
+
4
+ export function getCountryName(countryCode: string) {
5
+ try {
6
+ return regionDisplayNames.of(countryCode) ?? countryCode;
7
+ } catch {
8
+ return countryCode;
9
+ }
10
+ }
11
+
12
+ export function getLanguageName(languageCode: string) {
13
+ try {
14
+ return languageDisplayNames.of(languageCode.toLowerCase()) ?? languageCode;
15
+ } catch {
16
+ return languageCode;
17
+ }
18
+ }
@@ -0,0 +1,180 @@
1
+ import { isPresent } from "./present";
2
+
3
+ export const customAppClientIdPrefix = "ext-";
4
+ // As the plugin's own pattern: anything else names no app, and each name would get its own rate limiter.
5
+ export const customAppClientIdPattern = /^ext-[a-z0-9-]+$/;
6
+ export const maxCustomAppClientIdLength = 64;
7
+
8
+ export function customAppClientIdProblem(clientId: unknown): string | undefined {
9
+ if (
10
+ typeof clientId !== "string" ||
11
+ clientId.length > maxCustomAppClientIdLength ||
12
+ !customAppClientIdPattern.test(clientId)
13
+ ) {
14
+ return `${JSON.stringify(clientId)} is not a custom app's client ID, ext-...`;
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
20
+ const maxRedirectUrlLength = 2048;
21
+
22
+ // Keycloak compares a redirect URL with what the app sends as an exact string, so it is stored as typed.
23
+ export function redirectUrlProblem(raw: string): string | undefined {
24
+ if (raw.length > maxRedirectUrlLength) return `Keep it under ${maxRedirectUrlLength} characters`;
25
+ if (raw.includes("*")) return "Wildcards are not allowed";
26
+ if (/[\s,]/.test(raw)) return "Leave out commas and spaces, one URL per field";
27
+
28
+ let url: URL;
29
+ try {
30
+ url = new URL(raw);
31
+ } catch {
32
+ return "Enter a full URL, like https://app.example.com/callback";
33
+ }
34
+
35
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopbackHosts.has(url.hostname))) {
36
+ return "Use https, or http for localhost";
37
+ }
38
+ if (isPresent(url.username) || isPresent(url.password)) return "Leave out the username and password";
39
+ if (isPresent(url.hash)) return "Leave out the #fragment";
40
+ return undefined;
41
+ }
42
+
43
+ export const customAppUsersIdpAlias = "manta-custom-app-users";
44
+ export const customAppUsersPath = "/agents/custom-app-users";
45
+ export const customAppUserTokenPath = `${customAppUsersPath}/token`;
46
+ export const customAppUserIdPattern = /^[A-Za-z0-9_.@|-]{1,128}$/;
47
+ export const customAppUserFieldKeyPattern = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
48
+ export const maxCustomAppUserFields = 50;
49
+ export const maxCustomAppUserFieldLength = 1024;
50
+ // The fields marked for the agent ride in every access token, so they are held well under a request header's size.
51
+ export const maxCustomAppUserAgentFieldsLength = 4096;
52
+ export const maxCustomAppUserNameLength = 200;
53
+ export const maxCustomAppUserEmailLength = 320;
54
+ export const maxCustomAppUserTeams = 50;
55
+ export const maxCustomAppUserDatastores = 50;
56
+ const maxTeamPathLength = 255;
57
+
58
+ export type CustomAppUserFieldValue = string | number | boolean;
59
+ export type CustomAppUserField = { value: CustomAppUserFieldValue; agent?: boolean };
60
+
61
+ export type CustomAppUserProfile = {
62
+ name?: string;
63
+ email?: string;
64
+ fields?: Record<string, CustomAppUserField>;
65
+ };
66
+
67
+ export type CustomAppUserTokenRequest = CustomAppUserProfile & {
68
+ client_id: string;
69
+ client_secret: string;
70
+ user_id?: string;
71
+ anonymous_id?: string;
72
+ datastores?: string[];
73
+ teams?: string[];
74
+ };
75
+
76
+ export function customAppUserTeamsProblem(teams: unknown): string | undefined {
77
+ if (teams === undefined) return undefined;
78
+ if (!Array.isArray(teams) || teams.length > maxCustomAppUserTeams)
79
+ return `teams lists at most ${maxCustomAppUserTeams} teams`;
80
+ const bad = teams.find(
81
+ (team) => typeof team !== "string" || !team.startsWith("/") || team.length < 2 || team.length > maxTeamPathLength,
82
+ );
83
+ return bad === undefined ? undefined : `A team is its path, like /Sales/EU; ${JSON.stringify(bad)} is not one`;
84
+ }
85
+
86
+ export function customAppUserDatastoresProblem(datastores: unknown): string | undefined {
87
+ if (datastores === undefined) return undefined;
88
+ if (!Array.isArray(datastores) || !isPresent(datastores) || datastores.length > maxCustomAppUserDatastores) {
89
+ return `datastores lists 1 to ${maxCustomAppUserDatastores} datastores, or is left out`;
90
+ }
91
+ const bad = datastores.find((datastore) => typeof datastore !== "string" || !isPresent(datastore));
92
+ return bad === undefined ? undefined : `A datastore is its id; ${JSON.stringify(bad)} is not one`;
93
+ }
94
+
95
+ export type CustomAppUserTokenResponse = { access_token: string; token_type: "Bearer"; expires_in: number };
96
+
97
+ export function customAppUserSubject(clientId: string, userId: string): string {
98
+ return `app:${clientId}:${userId}`;
99
+ }
100
+
101
+ export function customAppUserIdOfSubject(clientId: string, subject: string | undefined): string | undefined {
102
+ const prefix = customAppUserSubject(clientId, "");
103
+ return subject?.startsWith(prefix) === true && subject.length > prefix.length
104
+ ? subject.slice(prefix.length)
105
+ : undefined;
106
+ }
107
+
108
+ // Every anonymous visitor of one app shares this account; the visitor id rides in the token instead.
109
+ export function anonymousSubject(clientId: string): string {
110
+ return `anon:${clientId}`;
111
+ }
112
+
113
+ // Only accounts the Worker made carry app.<client id>.<hash>; an upgrade renames the account to its email.
114
+ const generatedCustomAppUsername = /^app\.(ext-[a-z0-9-]+)\.([0-9a-f]{32}|anonymous)$/;
115
+
116
+ export function customAppClientIdOfUsername(username: string | undefined): string | undefined {
117
+ return generatedCustomAppUsername.exec(username ?? "")?.[1];
118
+ }
119
+
120
+ export function customAppUsernamePrefix(clientId: string): string {
121
+ return `app.${clientId}.`;
122
+ }
123
+
124
+ export function anonymousPrincipalId(clientId: string, anonymousId: string): string {
125
+ return `anon:${clientId}:${anonymousId}`;
126
+ }
127
+
128
+ export function customAppUserIdProblem(userId: unknown): string | undefined {
129
+ if (typeof userId !== "string" || !customAppUserIdPattern.test(userId)) {
130
+ return "A custom app user id is 1 to 128 letters, digits or - _ . @ |";
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ function fieldProblem(key: string, field: unknown): string | undefined {
136
+ if (!customAppUserFieldKeyPattern.test(key))
137
+ return `Field name "${key}" must start with a letter and use letters, digits, - _ .`;
138
+ if (typeof field !== "object" || field === null || !("value" in field)) return `Field "${key}" needs a value`;
139
+ const { value, agent } = field as { value: unknown; agent?: unknown };
140
+ if (!["string", "number", "boolean"].includes(typeof value))
141
+ return `Field "${key}" must be a string, number or boolean`;
142
+ if (typeof value === "string" && value.length > maxCustomAppUserFieldLength) {
143
+ return `Field "${key}" is longer than ${maxCustomAppUserFieldLength} characters`;
144
+ }
145
+ if (agent !== undefined && typeof agent !== "boolean") return `Field "${key}" marks agent with true or false`;
146
+ return undefined;
147
+ }
148
+
149
+ export function customAppUserProfileProblem(profile: CustomAppUserProfile): string | undefined {
150
+ const { name, email, fields } = profile;
151
+ if (name !== undefined && (typeof name !== "string" || name.length > maxCustomAppUserNameLength)) {
152
+ return `A name is text of at most ${maxCustomAppUserNameLength} characters`;
153
+ }
154
+ if (
155
+ email !== undefined &&
156
+ (typeof email !== "string" || email.length > maxCustomAppUserEmailLength || !email.includes("@"))
157
+ ) {
158
+ return "An email needs an @ and at most 320 characters";
159
+ }
160
+ if (fields === undefined) return undefined;
161
+ if (typeof fields !== "object" || fields === null || Array.isArray(fields))
162
+ return "Fields are an object of named fields";
163
+ const entries = Object.entries(fields);
164
+ if (entries.length > maxCustomAppUserFields) return `At most ${maxCustomAppUserFields} fields`;
165
+ for (const [key, field] of entries) {
166
+ const problem = fieldProblem(key, field);
167
+ if (problem !== undefined) return problem;
168
+ }
169
+ const agentFieldsLength = entries.reduce(
170
+ (length, [key, field]) =>
171
+ (field as { agent?: boolean }).agent === true
172
+ ? length + key.length + String((field as { value: unknown }).value).length
173
+ : length,
174
+ 0,
175
+ );
176
+ if (agentFieldsLength > maxCustomAppUserAgentFieldsLength) {
177
+ return `The fields marked for the agent come to at most ${maxCustomAppUserAgentFieldsLength} characters, names included`;
178
+ }
179
+ return undefined;
180
+ }
package/src/date.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { DateArg, FormatOptions } from "date-fns";
2
+ import { format, formatISO, set } from "date-fns";
3
+ import { take } from "ramda";
4
+ import { parseInteger } from "./number";
5
+
6
+ export function setTime<DateType extends Date, ResultDate extends Date = DateType>(
7
+ date: DateArg<DateType>,
8
+ time: string,
9
+ ): ResultDate;
10
+
11
+ export function setTime<DateType extends Date, ResultDate extends Date = DateType>(
12
+ date: DateArg<DateType>,
13
+ hours: number,
14
+ minutes: number,
15
+ ): ResultDate;
16
+
17
+ export function setTime<DateType extends Date, ResultDate extends Date = DateType>(
18
+ date: DateArg<DateType>,
19
+ timeOrHour: string | number,
20
+ optMinutes?: number,
21
+ ): ResultDate {
22
+ let hours: number | undefined;
23
+ let minutes: number | undefined;
24
+
25
+ if (typeof timeOrHour === "string") {
26
+ [hours, minutes] = take(2, timeOrHour.split(":", 3)).map(parseInteger);
27
+ } else {
28
+ hours = timeOrHour;
29
+ minutes = optMinutes;
30
+ }
31
+
32
+ return set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
33
+ }
34
+
35
+ export function formatDate(date: DateArg<Date> & {}): string {
36
+ return format(date, "yyyy-MM-dd");
37
+ }
38
+
39
+ export function formatISODate(date: DateArg<Date>): string {
40
+ return formatISO(date, { representation: "date" });
41
+ }
42
+
43
+ export type FormatTimeOptions = Intl.DateTimeFormatOptions;
44
+
45
+ export function formatTime(date: Date, options: FormatTimeOptions = {}): string {
46
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", ...options });
47
+ }
48
+
49
+ export function formatDateTime(date: DateArg<Date>, formatStr = "yyyy-MM-dd HH:mm", options?: FormatOptions): string {
50
+ return format(date, formatStr, options);
51
+ }
package/src/enum.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { identity } from "ramda";
2
+
3
+ export type Option<T extends string> = {
4
+ id: T;
5
+ label: string;
6
+ };
7
+
8
+ export function enumToOptions<T extends string>(
9
+ enums: T[],
10
+ labelFunction: (value: T) => string = identity,
11
+ ): Option<T>[] {
12
+ return enums
13
+ .map((value) => ({
14
+ id: value,
15
+ label: labelFunction(value),
16
+ }))
17
+ .sort((a, b) => a.label.localeCompare(b.label));
18
+ }
package/src/file.ts ADDED
@@ -0,0 +1,19 @@
1
+ export function getExtension(filename: string) {
2
+ const lastDotIndex = filename.lastIndexOf(".");
3
+
4
+ if (lastDotIndex <= 0 || lastDotIndex === filename.length - 1) {
5
+ return undefined;
6
+ }
7
+
8
+ return filename.slice(lastDotIndex + 1).toLowerCase();
9
+ }
10
+
11
+ export function getBasename(filename: string) {
12
+ const lastDotIndex = filename.lastIndexOf(".");
13
+
14
+ return lastDotIndex === -1 ? filename : filename.slice(0, lastDotIndex);
15
+ }
16
+
17
+ export function fileIdentityKey(file: File) {
18
+ return `${file.name}-${file.size}-${file.lastModified}`;
19
+ }
@@ -0,0 +1,94 @@
1
+ import type { DateArg, Duration, DurationUnit } from "date-fns";
2
+ import { formatDuration, intervalToDuration } from "date-fns";
3
+ import { filesize } from "filesize";
4
+ import pluralize from "pluralize";
5
+ import { isPresent } from "./present";
6
+
7
+ export function humanBytes(input: number): string {
8
+ return filesize(input, {
9
+ base: 2,
10
+ separator: ".",
11
+ standard: "jedec",
12
+ }) as string;
13
+ }
14
+
15
+ export function humanCount(count: number, word: string): string {
16
+ return pluralize(word, count, true);
17
+ }
18
+
19
+ export function humanNumber(num: bigint | number): string {
20
+ const n = Number(num);
21
+ if (n >= 1000000) {
22
+ return `${(n / 1000000).toFixed(1)}M`;
23
+ }
24
+ if (n >= 1000) {
25
+ return `${(n / 1000).toFixed(1)}K`;
26
+ }
27
+ return n.toString();
28
+ }
29
+
30
+ export function humanizeEnum(value: string | undefined): string | undefined {
31
+ if (!isPresent(value)) return undefined;
32
+
33
+ return value
34
+ .toLowerCase()
35
+ .split(/[_-]/)
36
+ .filter(isPresent)
37
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
38
+ .join(" ");
39
+ }
40
+
41
+ export function humanDimensions({ width, height }: { width: number; height: number }): string {
42
+ return `${width}x${height}`;
43
+ }
44
+
45
+ export function humanPlural(word: string, count: number): string {
46
+ return pluralize(word, count, false);
47
+ }
48
+
49
+ export type HumanListOptions = Intl.ListFormatOptions & {
50
+ truncateAt?: number;
51
+ };
52
+
53
+ export function humanList(list: string[], options?: HumanListOptions): string {
54
+ const { truncateAt, ...formatOptions } = options || {};
55
+ const listFormat = new Intl.ListFormat("en", formatOptions);
56
+
57
+ if (!truncateAt || list.length <= truncateAt) {
58
+ return listFormat.format(list);
59
+ }
60
+
61
+ const remaining = list.length - truncateAt;
62
+ const truncated = [...list.slice(0, truncateAt), `+${remaining} more`];
63
+
64
+ return listFormat.format(truncated);
65
+ }
66
+
67
+ export type HumanDurationArgs = {
68
+ start: DateArg<Date>;
69
+ end: DateArg<Date>;
70
+ largest?: number;
71
+ };
72
+
73
+ const periods = ["years", "months", "weeks", "days", "hours", "minutes", "seconds"] as const satisfies DurationUnit[];
74
+
75
+ function getDurationFormat(duration: Duration, largest: number): DurationUnit[] {
76
+ const formats = periods.reduce((acc, period) => {
77
+ return acc.length < largest && duration[period] ? [...acc, period] : acc;
78
+ }, [] as DurationUnit[]);
79
+
80
+ return formats.length === 0 ? ["seconds"] : formats;
81
+ }
82
+
83
+ export function humanDuration(args: HumanDurationArgs): string {
84
+ const { start, end, largest } = args;
85
+
86
+ const duration = intervalToDuration({
87
+ start,
88
+ end,
89
+ });
90
+
91
+ return formatDuration(duration, {
92
+ format: largest ? getDurationFormat(duration, largest) : undefined,
93
+ });
94
+ }
@@ -0,0 +1,16 @@
1
+ // Splits a programmer identifier into words: `actionPlan_v2` becomes `action Plan v2`.
2
+ export const identifierWordRules: ReadonlyArray<readonly [pattern: string, replacement: string]> = [
3
+ ["_", " "],
4
+ ["([a-z])([A-Z])", "$1 $2"],
5
+ ["([A-Z])([A-Z][a-z])", "$1 $2"],
6
+ ["([a-z])([0-9])", "$1 $2"],
7
+ ["([0-9])([A-Z][a-z])", "$1 $2"],
8
+ ["([0-9]+)x", " $1x"],
9
+ [" *", " "],
10
+ ];
11
+
12
+ export function splitIdentifierWords(raw: string): string {
13
+ return identifierWordRules
14
+ .reduce((text, [pattern, replacement]) => text.replace(new RegExp(pattern, "g"), replacement), raw)
15
+ .trim();
16
+ }
package/src/number.ts ADDED
@@ -0,0 +1,10 @@
1
+ export function parseInteger(input?: string | null): number | undefined {
2
+ if (input && input.trim().length > 0) {
3
+ const number = parseInt(input, 10);
4
+ if (!isNaN(number)) {
5
+ return number;
6
+ }
7
+ }
8
+
9
+ return;
10
+ }
@@ -0,0 +1,10 @@
1
+ export const createObjectURL = (file: File | Blob | undefined): string => {
2
+ if (!file) return "";
3
+ return URL.createObjectURL(file);
4
+ };
5
+
6
+ export const revokeObjectURL = (url: string): void => {
7
+ if (url && url.startsWith("blob:")) {
8
+ URL.revokeObjectURL(url);
9
+ }
10
+ };
package/src/present.ts ADDED
@@ -0,0 +1,30 @@
1
+ // After https://apidock.com/rails/Object/blank%3F
2
+
3
+ export function isPresent(value: string | null | undefined): value is string;
4
+ export function isPresent<T>(value: T[] | null | undefined): value is [T, ...T[]];
5
+ export function isPresent(value: Record<string, unknown> | null | undefined): value is Record<string, unknown>;
6
+ export function isPresent<T>(value: T | null | undefined): value is T;
7
+ export function isPresent<T>(value: T | null | undefined): value is NonNullable<T> {
8
+ if (value === null || value === undefined || value === false) {
9
+ return false;
10
+ }
11
+
12
+ if (typeof value === "string") {
13
+ return value.trim().length > 0;
14
+ }
15
+
16
+ if (Array.isArray(value)) {
17
+ return value.length > 0;
18
+ }
19
+
20
+ if (typeof value === "object") {
21
+ if (Object.keys(value).length > 0) return true;
22
+ if (value instanceof Map || value instanceof Set) return value.size > 0;
23
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value.byteLength > 0;
24
+
25
+ const prototype = Object.getPrototypeOf(value);
26
+ return prototype !== Object.prototype && prototype !== null;
27
+ }
28
+
29
+ return true;
30
+ }
package/src/set.ts ADDED
@@ -0,0 +1,38 @@
1
+ // A Set that removes its oldest member to make room when it is full. Only its add method changes.
2
+ // A copy such as `new Set(bounded)` keeps the members but not the limit.
3
+ export function boundedSet<T>(limit: number): Set<T> {
4
+ if (!Number.isInteger(limit) || limit < 1) {
5
+ throw new RangeError(`A bounded set needs a whole limit of 1 or more, but it got ${limit}.`);
6
+ }
7
+
8
+ const set = new Set<T>();
9
+ const nativeAdd = Set.prototype.add.bind(set);
10
+
11
+ set.add = (value) => {
12
+ if (!set.has(value) && set.size >= limit) removeOldest(set);
13
+ return nativeAdd(value);
14
+ };
15
+
16
+ return set;
17
+ }
18
+
19
+ function removeOldest<T>(set: Set<T>): void {
20
+ const oldest = set.values().next();
21
+ if (!oldest.done) set.delete(oldest.value);
22
+ }
23
+
24
+ export function addAll<T>(set: Set<T>, items: Array<T>): void;
25
+ export function addAll<T>(set: Set<T>, other: Set<T>): void;
26
+ export function addAll<T>(set: Set<T>, items: unknown): void {
27
+ if (Array.isArray(items)) {
28
+ items.forEach((item) => set.add(item));
29
+ return;
30
+ }
31
+
32
+ if (items instanceof Set) {
33
+ items.forEach((item) => set.add(item));
34
+ return;
35
+ }
36
+
37
+ throw new Error("Unsupported type");
38
+ }
package/src/sleep.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function sleep(ms: number): Promise<void> {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }