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:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
@@ -55,7 +56,7 @@
|
||||
busy = true;
|
||||
try {
|
||||
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;
|
||||
await load();
|
||||
} catch {
|
||||
@@ -101,7 +102,7 @@
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
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);
|
||||
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
@@ -192,7 +193,7 @@
|
||||
{#if confirmingLogoutOthers}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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.
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getNetworkSettings,
|
||||
@@ -84,9 +85,8 @@
|
||||
</SettingRow>
|
||||
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
|
||||
<p class="px-4 py-3 text-sm text-text-primary">
|
||||
This request arrived with {suggested}
|
||||
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, so {suggested}
|
||||
{suggested === 1 ? 'proxy is' : 'proxies are'} likely.
|
||||
This request arrived with {plural(suggested, 'forwarded address', 'forwarded addresses')}, so
|
||||
{plural(suggested, 'proxy is', 'proxies are')} likely.
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Verification, not decoration: "the address Minstrel sees for you"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
|
||||
@@ -227,7 +228,7 @@
|
||||
</div>
|
||||
<div class="w-full min-w-0">
|
||||
<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}
|
||||
· by {playlist.owner_username}
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { untrack } from 'svelte';
|
||||
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
||||
import { player, clearQueue } from '#lib/player/store.svelte.js';
|
||||
@@ -83,7 +84,7 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Queue</h2>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getReacquisitionSettings,
|
||||
@@ -96,8 +97,7 @@
|
||||
{#if saved && saved.unnameable_albums > 0}
|
||||
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
|
||||
<p class="px-4 py-3 text-sm text-text-primary">
|
||||
{saved.unnameable_albums}
|
||||
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no MusicBrainz ID,
|
||||
{plural(saved.unnameable_albums, '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.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createReleaseProfilesQuery,
|
||||
@@ -188,7 +189,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createCandidatesQuery,
|
||||
@@ -45,7 +46,7 @@
|
||||
function meta(c: Candidate): string {
|
||||
// An indexer that states no size sends 0.
|
||||
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);
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
@@ -121,7 +122,7 @@
|
||||
</p>
|
||||
{:else}
|
||||
<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>
|
||||
<ul class="divide-y divide-border rounded-md border border-border">
|
||||
{#each items as c (c.key)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { Download as DownloadIcon } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
@@ -138,7 +139,7 @@
|
||||
|
||||
function filed(d: Download): string | 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.destination) said += ` into ${d.destination}`;
|
||||
return said;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
@@ -85,7 +86,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { CircleCheck } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
|
||||
@@ -57,7 +58,6 @@
|
||||
return `${u.title} is back to be matched.`;
|
||||
});
|
||||
|
||||
const tracks = (n: number) => (n === 1 ? '1 track' : `${n} tracks`);
|
||||
</script>
|
||||
|
||||
{#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"
|
||||
>{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>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
@@ -131,7 +131,7 @@
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
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 @@
|
||||
<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>
|
||||
<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.downloading ? ` · ${head.downloading} downloading` : ''}
|
||||
</p>
|
||||
@@ -200,7 +199,7 @@
|
||||
{a.name}
|
||||
{/if}
|
||||
</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}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each a.wants as w (w.id)}
|
||||
|
||||
Reference in New Issue
Block a user