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
+192
View File
@@ -864,6 +864,183 @@ export const catalog: Catalog = {
"Saved {when}": "已保存 {when}",
"Unsaved": "未保存",
"Agenda from {date}": "从 {date} 起的日程",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(无主题)",
"(skipped: {actions})": "(已跳过:{actions}",
"Activate": "启用",
"Add a contact or import a vCard file.": "添加联系人,或导入 vCard 文件。",
"Added to your calendar": "已添加到您的日历",
"All events in this calendar will be deleted.": "此日历中的所有日程都将被删除。",
"All occurrences": "所有重复日程",
"An occurrence cannot be moved to another calendar on its own": "单个重复日程无法单独移动到其他日历",
"Anything signed in with this password stops working immediately.": "使用此密码登录的一切将立即停止工作。",
"App password revoked": "应用专用密码已吊销",
"Applies to every date in the series.": "对该系列的所有日期生效。",
"Applies to this date only.": "仅对此日期生效。",
"Apply to series": "应用于整个系列",
"Applying filter to existing messages…": "正在对现有邮件应用过滤器…",
"Attachments are still uploading": "附件仍在上传中",
"Calendar saved": "日历已保存",
"Categorised as {name}": "已归入分类「{name}」",
"Category cleared": "已清除分类",
"Change this event?": "要修改此日程吗?",
"Choose a calendar": "请选择日历",
"Choose an address book": "请选择通讯录",
"Colour updated": "颜色已更新",
"Contact created": "联系人已创建",
"Contact deleted": "联系人已删除",
"Contact saved": "联系人已保存",
"Copied": "已复制",
"Copy of {title}": "{title} 的副本",
"Could not cancel: {error}": "无法取消:{error}",
"Could not copy": "无法复制",
"Could not copy the address": "无法复制该地址",
"Could not empty folder: {error}": "无法清空文件夹:{error}",
"Could not load source: {error}": "无法加载原文:{error}",
"Could not mark as read: {error}": "无法标为已读:{error}",
"Could not save draft: {error}": "无法保存草稿:{error}",
"Could not save filter: {error}": "无法保存过滤器:{error}",
"Could not save filters: {error}": "无法保存过滤器:{error}",
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
"Could not sign in.": "无法登录。",
"Could not store image: {error}": "无法保存图片:{error}",
"Could not update labels: {error}": "无法更新标签:{error}",
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
"Could not update: {error}": "无法更新:{error}",
"Create": "创建",
"Create an address book first": "请先创建通讯录",
"Custom colour removed": "已清除自定义颜色",
"Deactivate": "停用",
"Delete failed: {error}": "删除失败:{error}",
"Delete forever": "永久删除",
"Delete script “{name}”?": "删除脚本「{name}」?",
"Delete this event?": "删除此日程?",
"Delete this identity?": "删除此发件身份?",
"Delete {name}?": "删除 {name}",
"Deleted": "已删除",
"Deletes the whole series. This cannot be undone.": "删除整个系列。此操作无法撤销。",
"Did you forget the attachment?": "您是不是忘了添加附件?",
"Discard": "放弃",
"Discard this draft?": "放弃此草稿?",
"Edit calendar": "编辑日历",
"Edit event": "编辑日程",
"Edit identity": "编辑发件身份",
"Edit template": "编辑模板",
"End must be after start": "结束时间必须晚于开始时间",
"Event duplicated": "日程已创建副本",
"Everything inside it goes too.": "其中的所有内容也会一并删除。",
"Filter created": "过滤器已创建",
"Filter created — it will run on new mail": "过滤器已创建——它将对新邮件生效",
"Filter saved": "过滤器已保存",
"Filter saved — it will run on new mail": "过滤器已保存——它将对新邮件生效",
"Filter saved, but applying it failed: {error}": "过滤器已保存,但应用失败:{error}",
"Filters saved": "过滤器已保存",
"Folder changed, but its filter rules could not be updated: {error}": "文件夹已修改,但其过滤规则无法更新:{error}",
"Folder created": "文件夹已创建",
"Folder deleted": "文件夹已删除",
"Folder name": "文件夹名称",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "文件夹名称(用 / 创建子文件夹,例如 工作/发票)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "文件夹名称(用 / 创建子文件夹,例如 工作/发票)",
"Folder “{name}” created": "文件夹「{name}」已创建",
"Forwarded": "已转发",
"Hand to the server, held until {when} (Ctrl+Enter)": "交给服务器保留至 {when} (Ctrl+Enter)",
"Hide": "隐藏",
"Hide quoted text": "隐藏引用文本",
"Hide this from the compose picker": "写邮件时不提供",
"Identity saved": "发件身份已保存",
"Images in signatures need the Files feature, which this account doesn't have.": "签名中的图片需要「文件」功能,而此账户没有该功能。",
"Invalid address: {address}": "地址无效:{address}",
"Invalid username or password.": "用户名或密码不正确。",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "它属于一项已应用于此日程及其后所有重复日程的修改,而服务器只能整体编辑这项修改。改为应用于整个系列吗?",
"Label name": "标签名称",
"Larger than {size} MB limit": "超过 {size} MB 的上限",
"Message sent": "邮件已发送",
"Move failed: {error}": "移动失败:{error}",
"Move “{name}”": "移动「{name}」",
"Moved": "已移动",
"Network error. Please check your connection.": "网络错误。请检查您的网络连接。",
"New all-day event on {date}": "在 {date} 新建全天日程",
"New event at {time}": "在 {time} 新建日程",
"New identity": "新建发件身份",
"No Archive folder found. Create one named “Archive” first.": "未找到归档文件夹。请先创建一个名为「Archive」的文件夹。",
"No contacts yet": "还没有联系人",
"No longer shared": "已停止共享",
"No matches": "没有匹配项",
"No new mail in your inbox.": "收件箱中没有新邮件。",
"No results": "没有结果",
"Nothing here": "这里什么都没有",
"Only Deleted Items and Junk Mail can be emptied.": "只有已删除邮件和垃圾邮件可以清空。",
"Please add at least one recipient": "请至少添加一位收件人",
"Please use an image under 512 KB for signatures.": "签名请使用小于 512 KB 的图片。",
"Preview": "预览",
"Read receipt sent": "已读回执已发送",
"Remove attachments that failed to upload first": "请先移除上传失败的附件",
"Removed from calendar": "已从日历中移除",
"Removes this date and leaves the rest of the series.": "删除此日期,保留系列中的其余日程。",
"Rename address book": "重命名通讯录",
"Rename category": "重命名分类",
"Replied": "已回复",
"Report spam (!)": "举报垃圾邮件 (!)",
"Response sent": "回复已发送",
"Revoke “{name}”?": "吊销「{name}」?",
"Script has errors": "脚本存在错误",
"Script is valid": "脚本有效",
"Script name is required": "必须填写脚本名称",
"Script saved": "脚本已保存",
"Send (Ctrl+Enter)": "发送 (Ctrl+Enter)",
"Send anyway": "仍然发送",
"Send cancelled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
"Send failed: {error}": "发送失败:{error}",
"Send invites": "发送邀请",
"Send scheduled for {when}": "已定时于 {when} 发送",
"Send without a subject?": "不填主题就发送吗?",
"Share “{name}”": "共享「{name}」",
"Sharing updated": "共享设置已更新",
"Show": "显示",
"Show quoted text": "显示引用文本",
"Show this in the compose picker": "写邮件时提供",
"Sign out other sessions?": "退出其他会话?",
"Sign out others": "退出其他",
"Stop sharing “{name}”?": "停止共享「{name}」?",
"Switch to {theme}": "切换到{theme}",
"Template": "模板",
"Template name": "模板名称",
"The contacts in it go too.": "其中的联系人也会一并删除。",
"The default identity is always offered when composing": "默认发件身份在写邮件时始终提供",
"The new passwords don't match": "两次输入的新密码不一致",
"The server scheduled this for {when}, not the time requested.": "服务器把发送时间定在了 {when},而不是您请求的时间。",
"This date cannot be changed on its own": "此日期无法单独修改",
"This occurrence": "仅此重复日程",
"Too many attempts. Please wait a few minutes and try again.": "尝试次数过多。请等待几分钟后再试。",
"Try another search.": "换个搜索词试试。",
"Try different keywords or filters.": "换些关键词或过滤条件试试。",
"Two-factor authentication is off": "两步验证已关闭",
"Undo window: {seconds}s": "撤销时限:{seconds} 秒",
"You're all caught up": "您已看完全部邮件",
"Your browser refused the request: {error}": "您的浏览器拒绝了该请求:{error}",
"Your browser will ask whether to open mail links in ihasmail": "浏览器会询问是否用 ihasmail 打开邮件链接",
"Your message mentions an attachment, but nothing is attached.": "您的邮件提到了附件,但没有添加任何附件。",
"event": "日程",
"Hide password": "隐藏密码",
"Show password": "显示密码",
"Settings reset to defaults": "设置已恢复为默认值",
"Removed. Mail links will open in whatever your browser falls back to.": "已移除。邮件链接将改由浏览器的后备程序打开。",
"Edit rule": "编辑规则",
"Address copied": "地址已复制",
"Search or create label": "搜索或创建标签",
"New label name": "新标签名称",
"That folder no longer exists. Showing your inbox instead.": "该文件夹已不存在,改为显示收件箱。",
"Labels updated": "标签已更新",
"Unsubscribe message prepared — just hit Send": "退订邮件已准备好——点击「发送」即可",
"Maximize": "最大化",
"Full screen": "全屏",
"Resize panes": "调整窗格大小",
"QR code": "二维码",
"Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存",
"Emptying folder…": "正在清空文件夹…",
"Nothing unread here": "这里没有未读邮件",
},
plurals: {
/*
@@ -874,5 +1051,20 @@ export const catalog: Catalog = {
"{n} messages": { other: "{n} 封邮件" },
"{n} conversations": { other: "{n} 个会话" },
"{n} selected": { other: "已选择 {n} 项" },
"{n} messages will be permanently deleted.": { other: "将永久删除 {n} 封邮件。" },
"Signed out {n} other sessions": { other: "已退出 {n} 个其他会话" },
"applied to {matched} of {n} messages": { other: "已对 {n} 封邮件中的 {matched} 封生效" },
"Move {n} messages to…": { other: "将 {n} 封邮件移动到…" },
"This permanently deletes the folder and its {n} messages.": { other: "这将永久删除该文件夹及其中的 {n} 封邮件。" },
"Added {n} contacts": { other: "已添加 {n} 位联系人" },
"{n} people will lose access. The contacts in it are not affected.": { other: "{n} 人将失去访问权限。其中的联系人不受影响。" },
"Imported {n} contacts": { other: "已导入 {n} 位联系人" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { other: "已为此日期保存。{names} 适用于整个系列,未作更改。" },
"{n} people will lose access. Events in it are not affected.": { other: "{n} 人将失去访问权限。其中的日程不受影响。" },
"{n} messages could not be deleted": { other: "有 {n} 封邮件无法删除" },
"({n} deleted first)": { other: "(已先删除 {n} 封)" },
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
"in {n} folders": { other: "在 {n} 个文件夹中" },
"Deleted {n} messages": { other: "已删除 {n} 封邮件" },
},
};