Second extraction pass: the strings the codemod could not see

`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.

176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.

Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.

Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.

Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.

And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
This commit is contained in:
2026-08-31 14:14:30 -07:00
parent a4f7d386a6
commit a6863e98cc
52 changed files with 2101 additions and 191 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ export function CalendarSettings() {
<div className="card-head">
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
<h3>{c.name}</h3>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: t("Rename category"), defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label={t("Delete category")} onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
+8 -8
View File
@@ -73,9 +73,9 @@ function RulesEditor() {
try {
await sieve.saveRules(next);
setLocal(null);
toast.success("Filters saved");
toast.success(t("Filters saved"));
} catch (err) {
toast.error(`Could not save filters: ${(err as Error).message}`);
toast.error(t("Could not save filters: {error}", { error: (err as Error).message }));
} finally {
setSaving(false);
}
@@ -203,7 +203,7 @@ function ScriptsEditor() {
const save = async (activate: boolean) => {
if (!name.trim()) {
toast.error("Script name is required");
toast.error(t("Script name is required"));
return;
}
setBusy(true);
@@ -211,11 +211,11 @@ function ScriptsEditor() {
const err = await sieve.validate(content);
setValidation(err);
if (err) {
toast.error("Script has errors");
toast.error(t("Script has errors"));
return;
}
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
toast.success("Script saved");
toast.success(t("Script saved"));
setSel(null);
} catch (err) {
toast.error((err as Error).message);
@@ -236,7 +236,7 @@ function ScriptsEditor() {
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>{t("Cancel")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> {t("Validate")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success(t("Script is valid")); }}><Play size={14} /> {t("Validate")}</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
@@ -254,8 +254,8 @@ function ScriptsEditor() {
<div className="card-head">
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>{t("active")}</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>{t("Edit")}</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? t("Deactivate") : t("Activate")}</button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: t("Delete script “{name}”?", { name: s.name }), confirmLabel: t("Delete"), danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</div>
))}
+5 -5
View File
@@ -6,7 +6,7 @@ import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailboxName";
export function FoldersSettings() {
@@ -18,7 +18,7 @@ export function FoldersSettings() {
const q = quotas.find((x) => x.resourceType === "octets");
const create = async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name (use / for subfolders, e.g. Work/Invoices)") });
if (!name?.trim()) return;
try {
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
@@ -27,7 +27,7 @@ export function FoldersSettings() {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
}
toast.success("Folder created");
toast.success(t("Folder created"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -51,9 +51,9 @@ export function FoldersSettings() {
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localised one: this box writes
// back whatever it is prefilled with.
promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
<button className="icon-btn sm" title={m.isSubscribed ? t("Hide") : t("Show")} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: m.name }), message: plural(m.totalEmails, { one: "{n} message will be permanently deleted.", other: "{n} messages will be permanently deleted." }), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
+4 -4
View File
@@ -185,7 +185,7 @@ export function GeneralSettings() {
{t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}</button>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show(t("Settings reset to defaults")); }}>{t("Reset to defaults")}</button>
</div>
</div>
);
@@ -204,16 +204,16 @@ function MailHandlerSettings() {
try {
registerMailtoHandler();
setRequested(true);
toast.success("Your browser will ask whether to open mail links in ihasmail");
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
} catch (err) {
toast.error(`Your browser refused the request: ${(err as Error).message}`);
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
}
};
const remove = () => {
unregisterMailtoHandler();
setRequested(false);
toast.show("Removed. Mail links will open in whatever your browser falls back to.");
toast.show(t("Removed. Mail links will open in whatever your browser falls back to."));
};
if (support === "unsupported") {
@@ -48,13 +48,13 @@ export function IdentitiesSettings() {
<button
className="btn btn-sm btn-ghost"
disabled={isAlwaysVisible(i.id, [defaultId])}
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
title={isAlwaysVisible(i.id, [defaultId]) ? t("The default identity is always offered when composing") : hidden.includes(i.id) ? t("Show this in the compose picker") : t("Hide this from the compose picker")}
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
>
{hidden.includes(i.id) ? <><Eye size={14} /> {t("Show when composing")}</> : <><EyeOff size={14} /> {t("Hide when composing")}</>}
</button>
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: t("Delete this identity?"), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)}
</div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}</div>}
@@ -105,7 +105,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
};
if (!identity.id) patch.email = email.trim();
await useMail.getState().saveIdentity(identity.id ?? null, patch);
toast.success("Identity saved");
toast.success(t("Identity saved"));
onClose();
} catch (err) {
toast.error((err as Error).message);
@@ -114,7 +114,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
}
};
return (
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<Dialog open onClose={onClose} title={identity.id ? t("Edit identity") : t("New identity")} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? t("Saving…") : t("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>{t("Display name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{t("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
+1 -1
View File
@@ -11,7 +11,7 @@ export function LabelsSettings() {
const [editing, setEditing] = useState<string | null>(null);
const add = async () => {
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
if (!name?.trim()) return;
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
if (labels.some((l) => l.keyword === keyword)) return;
+3 -3
View File
@@ -30,7 +30,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
return (
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? translate("New rule") : translate("Edit rule"))} size="lg" footer={<>
{applyMailbox && (
<label className="check left" style={{ marginRight: "auto" }}>
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
@@ -116,7 +116,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const v = e.target.value;
if (v === "__new__") {
// Create a folder on the fly ("Parent/Child" creates nested folders).
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
const name = await promptDialog({ title: translate("New folder"), placeholder: translate("Folder name (use / for a subfolder, e.g. Work/Invoices)") });
if (!name?.trim()) return;
try {
const mail = useMail.getState();
@@ -128,7 +128,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
}
const path = useMail.getState().mailboxPath(parentId!);
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
toast.success(`Folder “${path}” created`);
toast.success(translate("Folder “{name}” created", { name: path }));
} catch (err) {
toast.error((err as Error).message);
}
+9 -9
View File
@@ -5,7 +5,7 @@ import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { t, tNode } from "@/lib/i18n";
import { plural, t, tNode } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -100,7 +100,7 @@ export function SecuritySettings() {
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: t("Sign out other sessions?"), confirmLabel: t("Sign out others") })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(plural(r.revoked, { one: "Signed out {n} other session", other: "Signed out {n} other sessions" })); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
</div>
</div>
@@ -119,7 +119,7 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (next !== confirm) {
toast.error("The new passwords don't match");
toast.error(t("The new passwords don't match"));
return;
}
setBusy(true);
@@ -188,7 +188,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
setDisabling(false);
await reload();
toast.success("Two-factor authentication is off");
toast.success(t("Two-factor authentication is off"));
} catch (err) {
toast.error((err as Error).message);
} finally {
@@ -256,16 +256,16 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
const revoke = async (row: AppPasswordRow) => {
const ok = await confirmDialog({
title: `Revoke "${row.description}"?`,
message: "Anything signed in with this password stops working immediately.",
confirmLabel: "Revoke",
title: t("Revoke “{name}”?", { name: row.description }),
message: t("Anything signed in with this password stops working immediately."),
confirmLabel: t("Revoke"),
danger: true,
});
if (!ok) return;
try {
await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) });
await reload();
toast.success("App password revoked");
toast.success(t("App password revoked"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -321,7 +321,7 @@ function CopyableSecret({ value }: { value: string }) {
type="button"
className="btn btn-sm btn-ghost"
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
>
<Copy size={14} />
</button>
+2 -2
View File
@@ -90,7 +90,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
toast.success("Sharing updated");
toast.success(t("Sharing updated"));
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
@@ -104,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
};
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
<Dialog open onClose={onClose} title={t("Share “{name}”", { name })} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
+1 -1
View File
@@ -26,7 +26,7 @@ export function TemplatesSettings() {
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> {translate("New template")}</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? translate("Edit template") : translate("New template")} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>