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:
2026-10-08 16:57:09 -04:00
co-authored by Claude Opus 5.5
parent 29dd6dd351
commit 921c0a47bd
9 changed files with 45 additions and 17 deletions
@@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -203,7 +204,7 @@ fun TagsScreen(
body = body =
open.tag.count open.tag.count
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?.let { stringResource(R.string.tags_delete_body_counted, it) } ?.let { pluralStringResource(R.plurals.tags_delete_body_counted, it.toInt(), it.toInt()) }
?: stringResource(R.string.tags_delete_body), ?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text), footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm), confirm = stringResource(R.string.tags_delete_confirm),
@@ -378,8 +379,7 @@ private fun countLabel(count: Long?): String =
when { when {
count == null -> "" count == null -> ""
count <= 0L -> stringResource(R.string.tags_count_none) count <= 0L -> stringResource(R.string.tags_count_none)
count == 1L -> stringResource(R.string.tags_count_one) else -> pluralStringResource(R.plurals.tags_count, count.toInt(), count.toInt())
else -> stringResource(R.string.tags_count, count.toInt())
} }
/** /**
+8 -3
View File
@@ -137,8 +137,10 @@
<string name="tags_back">Back</string> <string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string> <string name="tags_new_hint">New tag</string>
<string name="tags_create">Create</string> <string name="tags_create">Create</string>
<string name="tags_count">%1$d notes</string> <plurals name="tags_count">
<string name="tags_count_one">1 note</string> <item quantity="one">%d note</item>
<item quantity="other">%d notes</item>
</plurals>
<string name="tags_count_none">No notes yet</string> <string name="tags_count_none">No notes yet</string>
<string name="tags_empty_title">No tags yet</string> <string name="tags_empty_title">No tags yet</string>
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string> <string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
@@ -166,7 +168,10 @@
<string name="tags_delete">Delete</string> <string name="tags_delete">Delete</string>
<string name="tags_delete_title">Delete %1$s?</string> <string name="tags_delete_title">Delete %1$s?</string>
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string> <string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string> <plurals name="tags_delete_body_counted">
<item quantity="one">It is on %d note. It will be removed from it, on every device you sync with. The note itself is kept.</item>
<item quantity="other">It is on %d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</item>
</plurals>
<string name="tags_delete_confirm">Delete</string> <string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the <!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit — row cannot un-write the word, so it comes back on that note's next edit —
+2 -1
View File
@@ -7,6 +7,7 @@ import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue"; import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { useRowAction, useLoad } from "../composables/useAction"; 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 // 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 // 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"> <div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p> <p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p>
<p class="text-xs text-neutral-400"> <p class="text-xs text-neutral-400">
{{ g.members.length }} {{ g.members.length === 1 ? "person" : "people" }} {{ counted(g.members.length, "person", "people") }}
</p> </p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
+2 -2
View File
@@ -5,6 +5,7 @@ import { useNotesStore } from "../stores/notes";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction"; import { useAction } from "../composables/useAction";
import { counted } from "../counted";
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip, // 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 // or a Google Keep Takeout archive) and import its notes. Additive — never
@@ -27,9 +28,8 @@ async function onFile(e: Event) {
await act(async () => { await act(async () => {
const res = await notes.importNotes(file); const res = await notes.importNotes(file);
await labels.load(); // surface any labels the import created await labels.load(); // surface any labels the import created
const noun = res.imported === 1 ? "note" : "notes";
const tail = res.skipped ? ` (${res.skipped} skipped)` : ""; 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."); }, "Import failed.");
} }
</script> </script>
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "../notes/colors"; } from "../notes/colors";
import BaseModal from "./BaseModal.vue"; import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { counted } from "../counted";
const emit = defineEmits<{ (e: "close"): void }>(); const emit = defineEmits<{ (e: "close"): void }>();
const labels = useLabelsStore(); const labels = useLabelsStore();
@@ -86,7 +87,7 @@ async function doMerge(sourceId: string, targetId: string) {
/> />
<span <span
class="shrink-0 tabular-nums text-xs text-neutral-400" 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 >{{ lb.count ?? 0 }}</span
> >
<button <button
+2 -2
View File
@@ -6,6 +6,7 @@ import { errorMessage } from "../api/errors";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import BaseModal from "./BaseModal.vue"; import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.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, // 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 // 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 { function detailOf(share: NoteShare): string {
if (!share.group) return share.member?.email ?? ""; if (!share.group) return share.member?.email ?? "";
const n = share.group.member_count; return `Group · ${counted(share.group.member_count, "person", "people")}`;
return `Group · ${n} ${n === 1 ? "person" : "people"}`;
} }
async function load() { async function load() {
+14
View File
@@ -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");
});
});
+5
View File
@@ -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}`;
}
+7 -5
View File
@@ -25,6 +25,7 @@ import {
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime"; import { formatDateTime } from "../notes/datetime";
import { toastOnFailure } from "../composables/useAction"; import { toastOnFailure } from "../composables/useAction";
import { counted } from "../counted";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting // 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 // 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[] = []; const parts: string[] = [];
if (sent > 0) parts.push(`sent ${sent}`); if (sent > 0) parts.push(`sent ${sent}`);
const up = outcome.push.uploaded; 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 (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."; 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 // 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. // an error — but saying nothing would leave a missing image unexplained.
if (outcome.pull.blobs_failed > 0) { 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 // Rejections are the server refusing a specific change — surfaced, never
// swallowed, because only the person can resolve them. The background cycle does // 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 // A file refused for good is listed once, here, and not retried; the editor says so
// on the file itself from then on. // on the file itself from then on.
const problems: string[] = []; const problems: string[] = [];
if (outcome.push.rejected > 0) problems.push(`${outcome.push.rejected} change(s) the server wouldn't accept`); 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(`${outcome.push.upload_failed} file(s) didn't upload`); 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("; ")}` : ""; syncError.value = problems.length ? `${problems.join(" and ")}: ${outcome.push.errors.join("; ")}` : "";
} }