refactor: every count in the web app is said by one plural(), with the viewer's thousands separator; a source guard names any hand-written copy (M505 #5642)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 16:18:04 -04:00
co-authored by Claude Opus 5.5
parent af2d4f9859
commit 9e24b2b4fc
34 changed files with 144 additions and 75 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { plural } from '#lib/utils/plural.js';
import type { AdminQueue, AdminSummary } from '#lib/api/types.js'; import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists // 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 { export function queueLabel(def: QueueDef, n: number): string {
return `${n} ${n === 1 ? def.one : def.many}`; return plural(n, def.one, def.many);
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { plural } from '#lib/utils/plural.js';
export type ApiError = { export type ApiError = {
code: string; code: string;
message: string; message: string;
@@ -18,7 +19,7 @@ export function rateLimitMessage(err: unknown): string | null {
const secs = apiErr.retryAfter; const secs = apiErr.retryAfter;
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.'; if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
const mins = Math.ceil(secs / 60); 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 = { export type User = {
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte'; import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
import { import {
@@ -55,7 +56,7 @@
busy = true; busy = true;
try { try {
const n = await revokeOtherSessions(); const n = await revokeOtherSessions();
pushToast(n === 1 ? 'Signed out 1 other device.' : `Signed out ${n} other devices.`); pushToast(`Signed out ${plural(n, 'other device', 'other devices')}.`);
confirmingLogoutOthers = false; confirmingLogoutOthers = false;
await load(); await load();
} catch { } catch {
@@ -101,7 +102,7 @@
if (mins < 1) return 'just now'; if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`; if (mins < 60) return `${mins} min ago`;
const hours = Math.round(mins / 60); const hours = Math.round(mins / 60);
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`; if (hours < 24) return `${plural(hours, 'hour', 'hours')} ago`;
const days = Math.round(hours / 24); const days = Math.round(hours / 24);
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`; if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
@@ -192,7 +193,7 @@
{#if confirmingLogoutOthers} {#if confirmingLogoutOthers}
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-text-secondary"> <span class="text-sm text-text-secondary">
Sign out {others === 1 ? '1 other device' : `${others} other devices`}? You'll stay Sign out {plural(others, 'other device', 'other devices')}? You'll stay
signed in here. signed in here.
</span> </span>
<button <button
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
getNetworkSettings, getNetworkSettings,
@@ -84,9 +85,8 @@
</SettingRow> </SettingRow>
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested} {#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
<p class="px-4 py-3 text-sm text-text-primary"> <p class="px-4 py-3 text-sm text-text-primary">
This request arrived with {suggested} This request arrived with {plural(suggested, 'forwarded address', 'forwarded addresses')}, so
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, so {suggested} {plural(suggested, 'proxy is', 'proxies are')} likely.
{suggested === 1 ? 'proxy is' : 'proxies are'} likely.
</p> </p>
{/if} {/if}
<!-- Verification, not decoration: "the address Minstrel sees for you" <!-- Verification, not decoration: "the address Minstrel sees for you"
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte'; import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js'; import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
@@ -227,7 +228,7 @@
</div> </div>
<div class="w-full min-w-0"> <div class="w-full min-w-0">
<div class="truncate text-xs text-text-muted"> <div class="truncate text-xs text-text-muted">
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'} {plural(playlist.track_count, 'track', 'tracks')}
{#if !isOwn} {#if !isOwn}
· by {playlist.owner_username} · by {playlist.owner_username}
{/if} {/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte'; import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '#lib/player/store.svelte.js'; import { player, clearQueue } from '#lib/player/store.svelte.js';
@@ -83,7 +84,7 @@
<div> <div>
<h2 class="text-lg font-semibold">Queue</h2> <h2 class="text-lg font-semibold">Queue</h2>
<p class="text-xs text-text-secondary"> <p class="text-xs text-text-secondary">
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'} {plural(player.queue.length, 'track', 'tracks')}
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if} {#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p> </p>
</div> </div>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
getReacquisitionSettings, getReacquisitionSettings,
@@ -96,8 +97,7 @@
{#if saved && saved.unnameable_albums > 0} {#if saved && saved.unnameable_albums > 0}
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. --> <!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
<p class="px-4 py-3 text-sm text-text-primary"> <p class="px-4 py-3 text-sm text-text-primary">
{saved.unnameable_albums} {plural(saved.unnameable_albums, 'album has', 'albums have')} missing files but no MusicBrainz ID,
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no MusicBrainz ID,
so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix. so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix.
</p> </p>
{/if} {/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { import {
createReleaseProfilesQuery, createReleaseProfilesQuery,
@@ -188,7 +189,7 @@
{/if} {/if}
</div> </div>
<div class="text-xs text-text-secondary"> <div class="text-xs text-text-secondary">
{describe(p)} · {p.follow_count === 1 ? '1 artist' : `${p.follow_count} artists`} {describe(p)} · {plural(p.follow_count, 'artist', 'artists')}
</div> </div>
</div> </div>
<button <button
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { import {
createCandidatesQuery, createCandidatesQuery,
@@ -45,7 +46,7 @@
function meta(c: Candidate): string { function meta(c: Candidate): string {
// An indexer that states no size sends 0. // An indexer that states no size sends 0.
const parts = [c.format || 'format not stated', c.size_bytes > 0 ? formatBytes(c.size_bytes) : '']; const parts = [c.format || 'format not stated', c.size_bytes > 0 ? formatBytes(c.size_bytes) : ''];
parts.push(c.seeders === 1 ? '1 seeder' : `${c.seeders} seeders`); parts.push(plural(c.seeders, 'seeder', 'seeders'));
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer); if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
return parts.filter(Boolean).join(' · '); return parts.filter(Boolean).join(' · ');
} }
@@ -121,7 +122,7 @@
</p> </p>
{:else} {:else}
<p class="text-xs text-text-muted"> <p class="text-xs text-text-muted">
{items.length === 1 ? '1 release' : `${items.length} releases`} found {relativeTime(items[0].searched_at)}, best first. {plural(items.length, 'release', 'releases')} found {relativeTime(items[0].searched_at)}, best first.
</p> </p>
<ul class="divide-y divide-border rounded-md border border-border"> <ul class="divide-y divide-border rounded-md border border-border">
{#each items as c (c.key)} {#each items as c (c.key)}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { Download as DownloadIcon } from 'lucide-svelte'; import { Download as DownloadIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { import {
@@ -138,7 +139,7 @@
function filed(d: Download): string | null { function filed(d: Download): string | null {
if (d.import_state !== 'imported' && d.import_state !== 'held') return null; if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
let said = d.imported_files === 1 ? '1 track filed' : `${d.imported_files} tracks filed`; let said = plural(d.imported_files, 'track filed', 'tracks filed');
if (d.skipped) said += `, ${d.skipped} already held`; if (d.skipped) said += `, ${d.skipped} already held`;
if (d.destination) said += ` into ${d.destination}`; if (d.destination) said += ` into ${d.destination}`;
return said; return said;
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { Disc3 } from 'lucide-svelte'; import { Disc3 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { import {
@@ -85,7 +86,7 @@
{/if} {/if}
</div> </div>
<div class="text-xs text-text-secondary"> <div class="text-xs text-text-secondary">
{f.open_wants === 1 ? '1 album wanted' : `${f.open_wants} albums wanted`} · {plural(f.open_wants, 'album wanted', 'albums wanted')} ·
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'} {f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { CircleCheck } from 'lucide-svelte'; import { CircleCheck } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js'; import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
@@ -57,7 +58,6 @@
return `${u.title} is back to be matched.`; return `${u.title} is back to be matched.`;
}); });
const tracks = (n: number) => (n === 1 ? '1 track' : `${n} tracks`);
</script> </script>
{#if unmatched.isPending} {#if unmatched.isPending}
@@ -89,7 +89,7 @@
<a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline" <a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline"
>{u.title}</a >{u.title}</a
> >
<div class="truncate text-xs text-text-secondary">{u.artist} · {tracks(u.tracks)}</div> <div class="truncate text-xs text-text-secondary">{u.artist} · {plural(u.tracks, 'track', 'tracks')}</div>
<p class="text-xs text-text-muted">{u.detail}</p> <p class="text-xs text-text-muted">{u.detail}</p>
</div> </div>
<div class="flex shrink-0 gap-2"> <div class="flex shrink-0 gap-2">
@@ -131,7 +131,7 @@
<div class="min-w-0 flex-1 space-y-0.5"> <div class="min-w-0 flex-1 space-y-0.5">
<a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a> <a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a>
<div class="truncate text-xs text-text-secondary"> <div class="truncate text-xs text-text-secondary">
{u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)} {u.artist} · {plural(u.tracks, 'track', 'tracks')} · left off {relativeTime(u.decided_at)}
</div> </div>
</div> </div>
<button <button
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte'; import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js'; import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
@@ -98,8 +99,7 @@
function arrived(w: Want): string | null { function arrived(w: Want): string | null {
const g = w.grab; const g = w.grab;
if (!g || g.import_state !== 'imported' || !g.imported_at) return null; 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 ${plural(g.imported_files, 'track', 'tracks')} ${relativeTime(g.imported_at)} from ${g.name}`;
return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
} }
// A want the schedule still searches for: open, or held but wanted for a // A want the schedule still searches for: open, or held but wanted for a
@@ -119,7 +119,6 @@
return 'Its files went missing'; return 'Its files went missing';
} }
const albums = (n: number) => (n === 1 ? '1 album' : `${n} albums`);
function kind(w: Want): string { function kind(w: Want): string {
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean); const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
@@ -141,7 +140,7 @@
<a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a> <a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a>
<h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2> <h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2>
<p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts"> <p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts">
{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.missing} with files missing`
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''} : ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
</p> </p>
@@ -200,7 +199,7 @@
{a.name} {a.name}
{/if} {/if}
</span> </span>
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span> <span class="shrink-0 text-xs text-text-muted">{plural(a.wants.length, 'album', 'albums')}</span>
</h2>{/if} </h2>{/if}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface"> <ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each a.wants as w (w.id)} {#each a.wants as w (w.id)}
+53
View File
@@ -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(/<!--[\s\S]*?-->/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([]);
});
});
+7
View File
@@ -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}`;
}
+3 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js'; import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
import { relativeTime } from '#lib/utils/relativeTime.js'; import { relativeTime } from '#lib/utils/relativeTime.js';
@@ -47,7 +48,7 @@
if (dl.needs_you > 0) { if (dl.needs_you > 0) {
out.push({ out.push({
key: 'downloads', key: 'downloads',
text: dl.needs_you === 1 ? '1 download needs you' : `${dl.needs_you} downloads need you`, text: plural(dl.needs_you, 'download needs you', 'downloads need you'),
since: dl.needs_you_since, since: dl.needs_you_since,
href: '/collection/activity' href: '/collection/activity'
}); });
@@ -66,7 +67,6 @@
return out; return out;
}); });
const plural = (n: number, one: string, many: string) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
// Worded as the wanted list words it; before the client reports, it is // Worded as the wanted list words it; before the client reports, it is
// simply downloading, as the Downloads list says. // simply downloading, as the Downloads list says.
@@ -92,7 +92,7 @@
{#if problems === 0} {#if problems === 0}
Nothing needs you. Nothing needs you.
{:else} {:else}
{problems === 1 ? '1 thing needs you.' : `${problems} things need you.`} {plural(problems, 'thing needs you.', 'things need you.')}
{/if} {/if}
</p> </p>
{#if problems > 0} {#if problems > 0}
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte'; import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
@@ -196,10 +197,10 @@
{#if r.imported_album_count > 0 || r.imported_track_count > 0} {#if r.imported_album_count > 0 || r.imported_track_count > 0}
<div class="text-sm text-accent-fg" data-testid="ingest-progress"> <div class="text-sm text-accent-fg" data-testid="ingest-progress">
{#if r.kind === 'artist'} {#if r.kind === 'artist'}
{r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} {plural(r.imported_album_count, 'album', 'albums')}
· {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested · {plural(r.imported_track_count, 'track', 'tracks')} ingested
{:else if r.kind === 'album'} {:else if r.kind === 'album'}
{r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested {plural(r.imported_track_count, 'track', 'tracks')} ingested
{:else} {:else}
Track ingested Track ingested
{/if} {/if}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { qk } from '#lib/api/queries.js'; import { qk } from '#lib/api/queries.js';
@@ -227,8 +228,8 @@
{#if usersQuery.data} {#if usersQuery.data}
{@const admins = users.filter((u) => u.is_admin).length} {@const admins = users.filter((u) => u.is_admin).length}
<p class="text-sm text-text-secondary" data-testid="users-summary"> <p class="text-sm text-text-secondary" data-testid="users-summary">
{users.length === 1 ? '1 account' : `${users.length} accounts`}, {admins === 1 ? '1 admin' : `${admins} admins`}{invitesQuery.data {plural(users.length, 'account', 'accounts')}, {plural(admins, 'admin', 'admins')}{invitesQuery.data
? ` · ${invites.length === 1 ? '1 invite open' : `${invites.length} invites open`}` ? ` · ${plural(invites.length, 'invite open', 'invites open')}`
: ''}. : ''}.
</p> </p>
{/if} {/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import PageColumn from '#lib/components/PageColumn.svelte'; import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { createAlbumQuery } from '#lib/api/queries.js'; import { createAlbumQuery } from '#lib/api/queries.js';
@@ -140,7 +141,7 @@
</ul> </ul>
{/if} {/if}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} {plural(album.track_count, 'track', 'tracks')}
· {formatDuration(album.duration_sec)} · {formatDuration(album.duration_sec)}
</p> </p>
{#snippet actions()} {#snippet actions()}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import PlayActions from '#lib/components/PlayActions.svelte'; import PlayActions from '#lib/components/PlayActions.svelte';
import { shuffled } from '#lib/utils/shuffle.js'; import { shuffled } from '#lib/utils/shuffle.js';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -86,7 +87,7 @@
<LikeButton entityType="artist" entityId={detail.id} size="lg" /> <LikeButton entityType="artist" entityId={detail.id} size="lg" />
</div> </div>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} {plural(detail.album_count, 'album', 'albums')}
</p> </p>
<!-- Genre quick-jumps (#367). No year link here: an artist spans many, <!-- Genre quick-jumps (#367). No year link here: an artist spans many,
so a single year would be a lie about the discography. --> so a single year would be a lie about the discography. -->
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { createWantArtistsQuery } from '#lib/api/wanted.js'; import { createWantArtistsQuery } from '#lib/api/wanted.js';
import { createAdminSummaryQuery } from '#lib/api/admin.js'; import { createAdminSummaryQuery } from '#lib/api/admin.js';
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js'; import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
@@ -18,13 +19,13 @@
const verdict = $derived.by(() => { const verdict = $derived.by(() => {
if (!counts) return ''; if (!counts) return '';
if (counts.attention) { if (counts.attention) {
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`; return `${plural(counts.attention, 'download needs', 'downloads need')} you.`;
} }
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`]; const parts = [plural(counts.wanted, 'album wanted', 'albums wanted')];
if (counts.activity) parts.push(`${counts.activity} downloading`); if (counts.activity) parts.push(`${counts.activity} downloading`);
// Library albums no MusicBrainz album matched wait on a person too (#5579). // Library albums no MusicBrainz album matched wait on a person too (#5579).
if (counts.unmatched) { if (counts.unmatched) {
const n = counts.unmatched === 1 ? '1 album in your library needs' : `${counts.unmatched} albums in your library need`; const n = plural(counts.unmatched, 'album in your library needs', 'albums in your library need');
return parts.join(', ') + `. ${n} matching.`; return parts.join(', ') + `. ${n} matching.`;
} }
return parts.join(', ') + '. Nothing needs you.'; return parts.join(', ') + '. Nothing needs you.';
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { Copy, Info, TriangleAlert } from 'lucide-svelte'; import { Copy, Info, TriangleAlert } from 'lucide-svelte';
@@ -103,10 +104,6 @@
return keepChoice[group.id] ?? group.survivor_track_id; return keepChoice[group.id] ?? group.survivor_track_id;
} }
function fileCountLabel(n: number): string {
return n === 1 ? '1 file' : `${n} files`;
}
async function onMerge(group: AdminDuplicateGroup) { async function onMerge(group: AdminDuplicateGroup) {
// First click arms; the second, on the button that now names how many files // First click arms; the second, on the button that now names how many files
// go, does it. A merge deletes files, and nothing brings them back. // go, does it. A merge deletes files, and nothing brings them back.
@@ -118,7 +115,7 @@
merging = group.id; merging = group.id;
try { try {
const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) }); const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) });
pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`); pushToast(`Merged. Removed ${plural(result.removed_paths.length, 'file', 'files')}.`);
query.refetch(); query.refetch();
} catch (e: unknown) { } catch (e: unknown) {
pushToast(errMessage(e), 'error'); pushToast(errMessage(e), 'error');
@@ -136,10 +133,6 @@
return `Same recording · ${match}% match`; return `Same recording · ${match}% match`;
} }
function countLabel(n: number, one: string, many: string): string {
return n === 1 ? `1 ${one}` : `${n} ${many}`;
}
// Identical audio under different titles means at least one file's tags are // Identical audio under different titles means at least one file's tags are
// wrong, and the recording the other title names may not be in the library at // wrong, and the recording the other title names may not be in the library at
// all. #3885: "WWW" was a second copy of the instrumental, and the vocal was // all. #3885: "WWW" was a second copy of the instrumental, and the vocal was
@@ -164,7 +157,7 @@
// What the copy carries is the fact that decides which to keep. // What the copy carries is the fact that decides which to keep.
function historyLabel(m: AdminDuplicateMember): string { function historyLabel(m: AdminDuplicateMember): string {
if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays'; if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays';
return `${countLabel(m.like_count, 'like', 'likes')} · ${countLabel(m.play_count, 'play', 'plays')}`; return `${plural(m.like_count, 'like', 'likes')} · ${plural(m.play_count, 'play', 'plays')}`;
} }
function position(m: AdminDuplicateMember): string { function position(m: AdminDuplicateMember): string {
@@ -253,7 +246,7 @@
{#if item.details.survivor_reason} {#if item.details.survivor_reason}
<span class="text-text-secondary">({item.details.survivor_reason})</span> <span class="text-text-secondary">({item.details.survivor_reason})</span>
{/if} {/if}
and removed {fileCountLabel(removed.length)}. and removed {plural(removed.length, 'file', 'files')}.
</p> </p>
{#each removed as r (r.track_id)} {#each removed as r (r.track_id)}
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p> <p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
@@ -346,7 +339,7 @@
{#if merging === group.id} {#if merging === group.id}
Merging… Merging…
{:else if confirmingMerge === group.id} {:else if confirmingMerge === group.id}
Remove {fileCountLabel(group.members.length - 1)} and merge Remove {plural(group.members.length - 1, 'file', 'files')} and merge
{:else} {:else}
Merge… Merge…
{/if} {/if}
@@ -382,7 +375,7 @@
<!-- What the second click will do, said plainly before it is done. --> <!-- What the second click will do, said plainly before it is done. -->
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm"> <div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
<p class="text-text-primary"> <p class="text-text-primary">
The copy marked Keep stays. The other {fileCountLabel(group.members.length - 1)} will be The copy marked Keep stays. The other {plural(group.members.length - 1, 'file', 'files')} will be
deleted from disk, and their likes, plays and playlist entries move to the copy kept. deleted from disk, and their likes, plays and playlist entries move to the copy kept.
</p> </p>
<button <button
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte'; import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
@@ -40,10 +41,6 @@
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0)); const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total); const hasMore = $derived(offset + shown < total);
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
// What happens to these is set under Acquisition (#5607); the line above // What happens to these is set under Acquisition (#5607); the line above
// the list says what it is now, since this is where the question comes up. // the list says what it is now, since this is where the question comes up.
let policy = $state<ReacquisitionSettings | null>(null); let policy = $state<ReacquisitionSettings | null>(null);
@@ -158,7 +155,7 @@
{group.directory} {group.directory}
</h3> </h3>
<span class="shrink-0 text-xs text-text-secondary"> <span class="shrink-0 text-xs text-text-secondary">
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)} {plural(group.tracks.length, 'track', 'tracks')} · gone {relativeTime(group.missing_since)}
</span> </span>
</div> </div>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte'; import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
@@ -144,7 +145,6 @@
}; };
} }
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
</script> </script>
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head> <svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { FileCheck, Music2 } from 'lucide-svelte'; import { FileCheck, Music2 } from 'lucide-svelte';
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js'; import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
@@ -64,10 +65,6 @@
} }
} }
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
function basename(path: string): string { function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1); return path.slice(path.lastIndexOf('/') + 1);
} }
@@ -134,7 +131,7 @@
{group.directory} {group.directory}
</h3> </h3>
<span class="shrink-0 text-xs text-text-secondary"> <span class="shrink-0 text-xs text-text-secondary">
{trackCountLabel(group.tracks.length)} {plural(group.tracks.length, 'track', 'tracks')}
</span> </span>
</div> </div>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { createAlbumsAlphaInfiniteQuery } from '#lib/api/albums.js'; import { createAlbumsAlphaInfiniteQuery } from '#lib/api/albums.js';
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
@@ -35,7 +36,7 @@
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1> <h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)} {#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'} {plural(total, 'album', 'albums')}
</p> </p>
{/if} {/if}
</div> </div>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { createArtistsQuery } from '#lib/api/queries.js'; import { createArtistsQuery } from '#lib/api/queries.js';
import ArtistCard from '#lib/components/ArtistCard.svelte'; import ArtistCard from '#lib/components/ArtistCard.svelte';
@@ -33,7 +34,7 @@
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1> <h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)} {#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'artist' : 'artists'} {plural(total, 'artist', 'artists')}
</p> </p>
{/if} {/if}
</div> </div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { ChevronLeft } from 'lucide-svelte'; import { ChevronLeft } from 'lucide-svelte';
@@ -131,7 +132,7 @@
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1> <h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0} {#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'} {plural(total, 'album', 'albums')}
</p> </p>
{/if} {/if}
</div> </div>
@@ -183,7 +184,7 @@
{#if filter.trim()} {#if filter.trim()}
{visibleGenres.length} of {genres.length} genres {visibleGenres.length} of {genres.length} genres
{:else} {:else}
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags {plural(genres.length, 'genre', 'genres')}, straight from your file tags
{/if} {/if}
</p> </p>
{/if} {/if}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { ChevronLeft } from 'lucide-svelte'; import { ChevronLeft } from 'lucide-svelte';
@@ -116,7 +117,7 @@
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1> <h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0} {#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'} {plural(total, 'album', 'albums')}
</p> </p>
{/if} {/if}
</div> </div>
@@ -161,7 +162,7 @@
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1> <h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
{#if !index.isPending && !index.isError} {#if !index.isPending && !index.isError}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{years.length} {years.length === 1 ? 'year' : 'years'} with dated releases {plural(years.length, 'year', 'years')} with dated releases
</p> </p>
{/if} {/if}
</header> </header>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import PageColumn from '#lib/components/PageColumn.svelte'; import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -272,7 +273,7 @@
<p class="text-sm text-text-muted">{pl.description}</p> <p class="text-sm text-text-muted">{pl.description}</p>
{/if} {/if}
<p class="text-xs text-text-muted"> <p class="text-xs text-text-muted">
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} {plural(pl.track_count, 'track', 'tracks')}
{#if pl.is_public}· public{:else}· private{/if} {#if pl.is_public}· public{:else}· private{/if}
{#if !isOwner}· by {pl.owner_username}{/if} {#if !isOwner}· by {pl.owner_username}{/if}
</p> </p>
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { Disc3, Album, Music2, X } from 'lucide-svelte'; import { Disc3, Album, Music2, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
@@ -109,10 +110,10 @@
{#if r.imported_album_count > 0 || r.imported_track_count > 0} {#if r.imported_album_count > 0 || r.imported_track_count > 0}
<div class="text-sm text-accent-fg" data-testid="ingest-progress"> <div class="text-sm text-accent-fg" data-testid="ingest-progress">
{#if r.kind === 'artist'} {#if r.kind === 'artist'}
{r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} {plural(r.imported_album_count, 'album', 'albums')}
· {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested · {plural(r.imported_track_count, 'track', 'tracks')} ingested
{:else if r.kind === 'album'} {:else if r.kind === 'album'}
{r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested {plural(r.imported_track_count, 'track', 'tracks')} ingested
{:else} {:else}
Track ingested Track ingested
{/if} {/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { createSearchAlbumsInfiniteQuery } from '#lib/api/queries.js'; import { createSearchAlbumsInfiniteQuery } from '#lib/api/queries.js';
@@ -26,7 +27,7 @@
</a> </a>
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1> <h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
{#if q && !query?.isPending && !(query?.isError && albums.length === 0)} {#if q && !query?.isPending && !(query?.isError && albums.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p> <p class="text-sm text-text-secondary">{plural(total, 'album', 'albums')}</p>
{/if} {/if}
</header> </header>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { createSearchArtistsInfiniteQuery } from '#lib/api/queries.js'; import { createSearchArtistsInfiniteQuery } from '#lib/api/queries.js';
@@ -26,7 +27,7 @@
</a> </a>
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1> <h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
{#if q && !query?.isPending && !(query?.isError && artists.length === 0)} {#if q && !query?.isPending && !(query?.isError && artists.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p> <p class="text-sm text-text-secondary">{plural(total, 'artist', 'artists')}</p>
{/if} {/if}
</header> </header>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '#lib/utils/plural.js';
import PageColumn from '#lib/components/PageColumn.svelte'; import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -34,7 +35,7 @@
</a> </a>
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1> <h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
{#if q && !query?.isPending && !(query?.isError && tracks.length === 0)} {#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p> <p class="text-sm text-text-secondary">{plural(total, 'track', 'tracks')}</p>
{/if} {/if}
</header> </header>