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
+10 -9
View File
@@ -9,6 +9,7 @@ import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
import { settings } from "./settings";
export interface ComposeAttachment {
@@ -334,15 +335,15 @@ export const useCompose = create<ComposeState>((set, get) => ({
/* ignore */
}
}
toast.show("Draft discarded");
toast.show(translate("Draft discarded"));
return;
}
if (d.dirty && (d.to.length || d.subject || hasContent(d))) {
try {
await saveDraftInternal(d, get, set, { silent: true, final: true });
toast.show("Draft saved");
toast.show(translate("Draft saved"));
} catch (err) {
toast.error(`Could not save draft: ${(err as Error).message}`);
toast.error(translate("Could not save draft: {error}", { error: (err as Error).message }));
}
}
},
@@ -355,7 +356,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
const accountId = useMail.getState().accountId;
if (!accountId) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, file: f }));
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : null, file: f }));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const a of atts) {
if (a.error || !a.file) continue;
@@ -396,7 +397,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
size: n.size ?? 0,
blobId: n.accountId === accountId ? n.blobId : null,
progress: n.accountId === accountId ? 100 : 0,
error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null,
error: (n.size ?? 0) > max ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : null,
}));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
@@ -426,7 +427,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
try {
return await saveDraftInternal(d, get, set, { silent: opts.silent ?? false });
} catch (err) {
if (!opts.silent) toast.error(`Could not save draft: ${(err as Error).message}`);
if (!opts.silent) toast.error(translate("Could not save draft: {error}", { error: (err as Error).message }));
return null;
}
},
@@ -449,9 +450,9 @@ export const useCompose = create<ComposeState>((set, get) => ({
});
try {
await sendInternal(d, get);
toast.success(scheduling ? `Send scheduled for ${formatScheduleTime(new Date(d.sendAt!))}` : "Message sent");
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
} catch (err) {
toast.error(`Send failed: ${(err as Error).message}`, {
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000,
});
@@ -783,7 +784,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
const created = (s.created?.s ?? {}) as { id?: Id; sendAt?: string; undoStatus?: string };
const settled = created.sendAt ? Date.parse(created.sendAt) : NaN;
if (!Number.isNaN(settled) && Math.abs(settled - d.sendAt!) > 60_000) {
toast.error(`The server scheduled this for ${formatScheduleTime(new Date(settled))}, not the time requested.`);
toast.error(translate("The server scheduled this for {when}, not the time requested.", { when: formatScheduleTime(new Date(settled)) }));
}
await useScheduled.getState().load();
}
+19 -15
View File
@@ -22,7 +22,7 @@ import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
import { mailboxDisplayName } from "@/lib/mailboxName";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
@@ -441,7 +441,7 @@ export const useMail = create<MailState>((set, get) => ({
try {
await setEmails(accountId, update);
} catch (err) {
toast.error(`Could not update: ${(err as Error).message}`);
toast.error(t("Could not update: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
}
},
@@ -503,7 +503,7 @@ export const useMail = create<MailState>((set, get) => ({
}
void get().loadMailboxes();
} catch (err) {
toast.error(`Move failed: ${(err as Error).message}`);
toast.error(t("Move failed: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
void get().refreshList();
}
@@ -530,7 +530,7 @@ export const useMail = create<MailState>((set, get) => ({
await setEmails(accountId, update);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not update labels: ${(err as Error).message}`);
toast.error(t("Could not update labels: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
}
},
@@ -557,11 +557,11 @@ export const useMail = create<MailState>((set, get) => ({
try {
const { notDestroyed } = await destroyEmails(accountId, ids);
const failed = Object.keys(notDestroyed);
if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`);
if (failed.length) toast.error(plural(failed.length, { one: "{n} message could not be deleted", other: "{n} messages could not be deleted" }));
else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`);
void get().loadMailboxes();
} catch (err) {
toast.error(`Delete failed: ${(err as Error).message}`);
toast.error(t("Delete failed: {error}", { error: (err as Error).message }));
void get().refreshList();
}
},
@@ -569,7 +569,7 @@ export const useMail = create<MailState>((set, get) => ({
async archive(ids) {
const archiveId = get().roleId("archive") ?? get().roleId("all");
if (!archiveId) {
toast.error("No Archive folder found. Create one named “Archive” first.");
toast.error(t("No Archive folder found. Create one named “Archive” first."));
return;
}
await get().move(ids, archiveId, { label: "Archive" });
@@ -602,7 +602,7 @@ export const useMail = create<MailState>((set, get) => ({
// there is no point routing spam through the bin on its way out, and it is
// what "delete all spam" means everywhere else. The dialogs say so.
if (mailboxId !== get().roleId("trash") && mailboxId !== get().roleId("junk")) {
toast.error("Only Deleted Items and Junk Mail can be emptied.");
toast.error(t("Only Deleted Items and Junk Mail can be emptied."));
return;
}
// A folder can hold far more messages than the server will destroy in one
@@ -617,7 +617,7 @@ export const useMail = create<MailState>((set, get) => ({
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: page });
if (!q.ids.length) break;
if (progress === null && (q.total ?? q.ids.length) > page) {
progress = toast.show("Emptying folder…", { duration: 0 });
progress = toast.show(t("Emptying folder…"), { duration: 0 });
}
const { destroyed, notDestroyed } = await destroyEmails(accountId, q.ids);
deleted += destroyed.length;
@@ -628,10 +628,11 @@ export const useMail = create<MailState>((set, get) => ({
throw new Error(err ? setErrorMessage(err) : "the server refused to delete these messages");
}
}
toast.show(`Deleted ${deleted} message${deleted === 1 ? "" : "s"}`);
toast.show(plural(deleted, { one: "Deleted {n} message", other: "Deleted {n} messages" }));
set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null });
} catch (err) {
toast.error(`Could not empty folder: ${(err as Error).message}${deleted ? ` (${deleted} deleted first)` : ""}`);
toast.error(t("Could not empty folder: {error}", { error: (err as Error).message })
+ (deleted ? " " + plural(deleted, { one: "({n} deleted first)", other: "({n} deleted first)" }) : ""));
} finally {
if (progress !== null) toast.dismiss(progress);
void get().loadMailboxes();
@@ -688,13 +689,16 @@ export const useMail = create<MailState>((set, get) => ({
marked += ids.length;
}
if (!marked) {
toast.show("Nothing unread here");
toast.show(t("Nothing unread here"));
return;
}
toast.success(`Marked ${marked} message${marked === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`);
toast.success(
plural(marked, { one: "Marked {n} message as read", other: "Marked {n} messages as read" })
+ (includeChildren && boxes.length > 1 ? " " + plural(boxes.length, { one: "in {n} folder", other: "in {n} folders" }) : ""),
);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not mark as read: ${(err as Error).message}`);
toast.error(t("Could not mark as read: {error}", { error: (err as Error).message }));
}
},
@@ -1141,6 +1145,6 @@ async function followFolders(before: FolderRef[]): Promise<void> {
toast.show(said.join(" · "), { duration: 8000 });
} catch (err) {
const { toast } = await import("@/ui/toast");
toast.error(`Folder changed, but its filter rules could not be updated: ${(err as Error).message}`);
toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message }));
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import type { EmailSubmission, GetResponse, Id, Mailbox, QueryResponse, SetRespo
import { toast } from "@/ui/toast";
import { useMail } from "./mail";
import { canScheduleSend, maxDelayMs, SUBMISSION_CAP, type SubmissionCapability } from "@/lib/schedule";
import { t } from "@/lib/i18n";
/**
* A held message lives in a folder of its own, the way Gmail's does, because
@@ -232,7 +233,7 @@ export const useScheduled = create<ScheduledState>((set, get) => ({
void mail.refreshList();
}
} catch (err) {
toast.error(`Could not update the Scheduled folder: ${(err as Error).message}`);
toast.error(t("Could not update the Scheduled folder: {error}", { error: (err as Error).message }));
}
},
}));