Real plurals: the web's sync messages and Android's tag counts
Web: one counted(n, one, other) helper. The sync screen's three "(s)" messages (attachments that didn't download, changes refused, files that didn't upload) now say "1 file" / "2 files". The six hand-written ternaries (SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too. Android: tags_count was two strings chosen in code, and is now <plurals>. The delete confirmation read "It is on 1 notes … removed from all of them" and now has a one form of its own. Operator decision on #5371, 2026-10-08. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import BaseButton from "./BaseButton.vue";
|
||||
import BaseInput from "./BaseInput.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { useRowAction, useLoad } from "../composables/useAction";
|
||||
import { counted } from "../counted";
|
||||
|
||||
// Admin: groups of people a note can be shared with in one go (#5177). A group is
|
||||
// the instance's; anyone can share with it, and a note shared with it reaches whoever
|
||||
@@ -133,7 +134,7 @@ onMounted(() => {
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ g.members.length }} {{ g.members.length === 1 ? "person" : "people" }}
|
||||
{{ counted(g.members.length, "person", "people") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNotesStore } from "../stores/notes";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { useAction } from "../composables/useAction";
|
||||
import { counted } from "../counted";
|
||||
|
||||
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip,
|
||||
// or a Google Keep Takeout archive) and import its notes. Additive — never
|
||||
@@ -27,9 +28,8 @@ async function onFile(e: Event) {
|
||||
await act(async () => {
|
||||
const res = await notes.importNotes(file);
|
||||
await labels.load(); // surface any labels the import created
|
||||
const noun = res.imported === 1 ? "note" : "notes";
|
||||
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
|
||||
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
|
||||
ui.showToast(`Imported ${counted(res.imported, "note", "notes")}${tail}.`);
|
||||
}, "Import failed.");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../notes/colors";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { counted } from "../counted";
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
const labels = useLabelsStore();
|
||||
@@ -86,7 +87,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
/>
|
||||
<span
|
||||
class="shrink-0 tabular-nums text-xs text-neutral-400"
|
||||
:title="`${lb.count ?? 0} note${(lb.count ?? 0) === 1 ? '' : 's'}`"
|
||||
:title="counted(lb.count ?? 0, 'note', 'notes')"
|
||||
>{{ lb.count ?? 0 }}</span
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { errorMessage } from "../api/errors";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { counted } from "../counted";
|
||||
|
||||
// Share a note with other people on this instance (#5174). The owner picks a member,
|
||||
// or a group the admin made (#5177), and says whether they may only read it or also
|
||||
@@ -53,8 +54,7 @@ function nameOf(share: NoteShare): string {
|
||||
|
||||
function detailOf(share: NoteShare): string {
|
||||
if (!share.group) return share.member?.email ?? "";
|
||||
const n = share.group.member_count;
|
||||
return `Group · ${n} ${n === 1 ? "person" : "people"}`;
|
||||
return `Group · ${counted(share.group.member_count, "person", "people")}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { counted } from "./counted";
|
||||
|
||||
describe("counted", () => {
|
||||
it("uses the singular for exactly one", () => {
|
||||
expect(counted(1, "note", "notes")).toBe("1 note");
|
||||
});
|
||||
|
||||
it("uses the plural for everything else, zero included", () => {
|
||||
expect(counted(0, "note", "notes")).toBe("0 notes");
|
||||
expect(counted(3, "person", "people")).toBe("3 people");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// A number and its noun: "1 note", "3 notes". The web is English-only, and English
|
||||
// has two forms; Android's <plurals> carry the same two (quantity "one" and "other").
|
||||
export function counted(n: number, one: string, other: string): string {
|
||||
return `${n} ${n === 1 ? one : other}`;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
import { toastOnFailure } from "../composables/useAction";
|
||||
import { counted } from "../counted";
|
||||
|
||||
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
|
||||
// state, not an incomplete setup — the app is local-first and fully usable having
|
||||
@@ -313,14 +314,15 @@ function showOutcome(outcome: SyncOutcome) {
|
||||
const parts: string[] = [];
|
||||
if (sent > 0) parts.push(`sent ${sent}`);
|
||||
const up = outcome.push.uploaded;
|
||||
if (up > 0) parts.push(`uploaded ${up} file${up === 1 ? "" : "s"}`);
|
||||
if (up > 0) parts.push(`uploaded ${counted(up, "file", "files")}`);
|
||||
if (received > 0) parts.push(`received ${received}`);
|
||||
if (blobs > 0) parts.push(`${blobs} attachment${blobs === 1 ? "" : "s"}`);
|
||||
if (blobs > 0) parts.push(counted(blobs, "attachment", "attachments"));
|
||||
lastResult.value = parts.length ? `Synced — ${parts.join(", ")}.` : "Already up to date.";
|
||||
// Attachments that didn't arrive are retried next sync, so this is a note, not
|
||||
// an error — but saying nothing would leave a missing image unexplained.
|
||||
if (outcome.pull.blobs_failed > 0) {
|
||||
lastResult.value += ` ${outcome.pull.blobs_failed} attachment(s) didn't download — they'll retry on the next sync.`;
|
||||
const failed = counted(outcome.pull.blobs_failed, "attachment", "attachments");
|
||||
lastResult.value += ` ${failed} didn't download — they'll retry on the next sync.`;
|
||||
}
|
||||
// Rejections are the server refusing a specific change — surfaced, never
|
||||
// swallowed, because only the person can resolve them. The background cycle does
|
||||
@@ -328,8 +330,8 @@ function showOutcome(outcome: SyncOutcome) {
|
||||
// A file refused for good is listed once, here, and not retried; the editor says so
|
||||
// on the file itself from then on.
|
||||
const problems: string[] = [];
|
||||
if (outcome.push.rejected > 0) problems.push(`${outcome.push.rejected} change(s) the server wouldn't accept`);
|
||||
if (outcome.push.upload_failed > 0) problems.push(`${outcome.push.upload_failed} file(s) didn't upload`);
|
||||
if (outcome.push.rejected > 0) problems.push(`${counted(outcome.push.rejected, "change", "changes")} the server wouldn't accept`);
|
||||
if (outcome.push.upload_failed > 0) problems.push(`${counted(outcome.push.upload_failed, "file", "files")} didn't upload`);
|
||||
syncError.value = problems.length ? `${problems.join(" and ")}: ${outcome.push.errors.join("; ")}` : "";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user