diff --git a/web/src/lib/admin/queues.ts b/web/src/lib/admin/queues.ts
index f8d6149e..767831a7 100644
--- a/web/src/lib/admin/queues.ts
+++ b/web/src/lib/admin/queues.ts
@@ -1,3 +1,4 @@
+import { plural } from '#lib/utils/plural.js';
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists
@@ -30,5 +31,5 @@ export function countOf(summary: AdminSummary | undefined, key: QueueKey): numbe
}
export function queueLabel(def: QueueDef, n: number): string {
- return `${n} ${n === 1 ? def.one : def.many}`;
+ return plural(n, def.one, def.many);
}
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
index 2e623394..6b9beb48 100644
--- a/web/src/lib/api/client.ts
+++ b/web/src/lib/api/client.ts
@@ -1,3 +1,4 @@
+import { plural } from '#lib/utils/plural.js';
export type ApiError = {
code: string;
message: string;
@@ -18,7 +19,7 @@ export function rateLimitMessage(err: unknown): string | null {
const secs = apiErr.retryAfter;
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
const mins = Math.ceil(secs / 60);
- return `Too many attempts. Try again in ${mins} minute${mins === 1 ? '' : 's'}.`;
+ return `Too many attempts. Try again in ${plural(mins, 'minute', 'minutes')}.`;
}
export type User = {
diff --git a/web/src/lib/components/ActiveSessions.svelte b/web/src/lib/components/ActiveSessions.svelte
index 674c1d93..82ee5cd2 100644
--- a/web/src/lib/components/ActiveSessions.svelte
+++ b/web/src/lib/components/ActiveSessions.svelte
@@ -1,4 +1,5 @@
{#if unmatched.isPending}
@@ -89,7 +89,7 @@
{u.title}
-
{u.artist} · {tracks(u.tracks)}
+ {u.artist} · {plural(u.tracks, 'track', 'tracks')}
{u.detail}
@@ -131,7 +131,7 @@
{u.title}
- {u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)}
+ {u.artist} · {plural(u.tracks, 'track', 'tracks')} · left off {relativeTime(u.decided_at)}
+ import { plural } from '#lib/utils/plural.js';
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
@@ -98,8 +99,7 @@
function arrived(w: Want): string | null {
const g = w.grab;
if (!g || g.import_state !== 'imported' || !g.imported_at) return null;
- const n = g.imported_files === 1 ? '1 track' : `${g.imported_files} tracks`;
- return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
+ return `Imported ${plural(g.imported_files, 'track', 'tracks')} ${relativeTime(g.imported_at)} from ${g.name}`;
}
// A want the schedule still searches for: open, or held but wanted for a
@@ -119,7 +119,6 @@
return 'Its files went missing';
}
- const albums = (n: number) => (n === 1 ? '1 album' : `${n} albums`);
function kind(w: Want): string {
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
@@ -141,7 +140,7 @@
← Catalogue
{head.artist_name}
- {head.held} of {albums(head.albums)} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
+ {head.held} of {plural(head.albums, 'album', 'albums')} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
? ` · ${head.missing} with files missing`
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
@@ -200,7 +199,7 @@
{a.name}
{/if}
- {albums(a.wants.length)}
+ {plural(a.wants.length, 'album', 'albums')}
{/if}
{#each a.wants as w (w.id)}
diff --git a/web/src/lib/utils/plural.test.ts b/web/src/lib/utils/plural.test.ts
new file mode 100644
index 00000000..3941d778
--- /dev/null
+++ b/web/src/lib/utils/plural.test.ts
@@ -0,0 +1,53 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, test } from 'vitest';
+import { plural } from './plural.js';
+
+describe('plural', () => {
+ test('says a count with the noun that agrees with it', () => {
+ expect(plural(1, 'track', 'tracks')).toBe('1 track');
+ expect(plural(0, 'track', 'tracks')).toBe('0 tracks');
+ expect(plural(2, 'album has', 'albums have')).toBe('2 albums have');
+ expect(plural(26_171, 'track', 'tracks')).toBe('26,171 tracks');
+ });
+});
+
+// One way to say a count (M505). Before it there were ~55 hand-written
+// copies and two helpers that disagreed on the thousands separator. A
+// hand-written copy has a tell this guard looks for:
+// - a branch that spells the one: `n === 1 ? '1 track' : ...`;
+// - the number beside a choice of noun: `{n} {n === 1 ? ...}` in markup,
+// or `${n} ${n === 1 ? ...}` in a template string;
+// - the suffix trick: `minute${n === 1 ? '' : 's'}`.
+// A choice of words with no count in it ("it" / "they", "yesterday") is
+// not a count label and stays a ternary.
+const SRC = join(dirname(fileURLToPath(import.meta.url)), '../..');
+
+const HAND_ROLLED = [
+ /=== 1 \? ['"`]1 /,
+ /\{([\w.]+)\}\s*\{\1 === 1 \?/,
+ /\$\{([\w.]+)\} \$\{\1 === 1 \?/,
+ /=== 1 \? '' : 's'/
+];
+
+/** Comments describe the forbidden shape; strip them before asserting its absence. */
+const stripComments = (s: string) => s.replace(//g, '').replace(/^\s*\/\/.*$/gm, '');
+
+function sources(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.(svelte|ts)$/.test(name) && !/\.test\.ts$/.test(name) ? [path] : [];
+ });
+}
+
+describe('count labels', () => {
+ test('every count in the web app is said by plural()', () => {
+ const offenders = sources(SRC).flatMap((path) => {
+ const text = stripComments(readFileSync(path, 'utf8'));
+ return HAND_ROLLED.some((re) => re.test(text)) ? [relative(SRC, path)] : [];
+ });
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/web/src/lib/utils/plural.ts b/web/src/lib/utils/plural.ts
new file mode 100644
index 00000000..b6759ba5
--- /dev/null
+++ b/web/src/lib/utils/plural.ts
@@ -0,0 +1,7 @@
+// A count with its noun: "1 track", "26,171 tracks". The noun may carry the
+// words that agree with it ("album has" / "albums have"). The number is in the
+// viewer's locale, as the app's other counts are. The web app's one way to say
+// a count (M505); a source guard in plural.test.ts names any hand-written copy.
+export function plural(n: number, one: string, many: string): string {
+ return `${n.toLocaleString()} ${n === 1 ? one : many}`;
+}
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 599ce1d2..37c887e0 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -1,4 +1,5 @@
{pageTitle('Collection · Reports')}
diff --git a/web/src/routes/collection/suspect-sources/+page.svelte b/web/src/routes/collection/suspect-sources/+page.svelte
index 7e2192fc..9cc87539 100644
--- a/web/src/routes/collection/suspect-sources/+page.svelte
+++ b/web/src/routes/collection/suspect-sources/+page.svelte
@@ -1,4 +1,5 @@