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:
@@ -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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 { 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("; ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user