From f63ecb66978a01258b69734adf5365ef41aa45fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 6 Oct 2026 22:07:19 -0400 Subject: [PATCH] Translate finished messages by template and the world clock, Dropbox and scoped titles at display --- shell/Commons/I18n.qml | 4 + shell/Commons/I18nZh.js | 79 ++++++++++++++++++- shell/Commons/Tr.js | 5 ++ shell/plugins/agents/Panel.qml | 26 +++--- .../components/NotificationCard.qml | 4 +- shell/plugins/osd/Osd.qml | 4 +- shell/plugins/panels/dropbox/Panel.qml | 13 ++- shell/plugins/panels/elsewhen/CityRow.qml | 14 +++- shell/plugins/panels/elsewhen/Globe.qml | 2 +- shell/plugins/panels/elsewhen/Panel.qml | 2 +- test/shell.d/agents-panel-test.sh | 2 +- test/shell.d/notifications-test.sh | 2 +- test/shell.d/shell-i18n-test.sh | 10 ++- 13 files changed, 139 insertions(+), 28 deletions(-) diff --git a/shell/Commons/I18n.qml b/shell/Commons/I18n.qml index 857b21d4..731bad2d 100644 --- a/shell/Commons/I18n.qml +++ b/shell/Commons/I18n.qml @@ -13,4 +13,8 @@ QtObject { function trMatch(text, templates) { return Tr.trMatch(text, templates) } + + function trMessage(text) { + return Tr.trMessage(text) + } } diff --git a/shell/Commons/I18nZh.js b/shell/Commons/I18nZh.js index 384a3975..400d0872 100644 --- a/shell/Commons/I18nZh.js +++ b/shell/Commons/I18nZh.js @@ -97,6 +97,15 @@ var table = { "Limits paused": "额度已暂停", "Sign-in expired": "登录已过期", "Waiting for auth": "等待认证", + "Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.": "Anthropic 用量接口正在限制查询频率(%1 秒后重试)。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.": "Anthropic 用量接口返回状态 %1。仍显示本地 Claude Code 统计。", + "Claude Code's access token has lapsed — showing the last known limits. Start Claude Code to refresh it; signing in again is not needed.": "Claude Code 的访问令牌已过期,显示上次的额度。启动 Claude Code 即可刷新,不需要重新登录。", + "Claude Code's access token has lapsed. Start Claude Code to refresh it; signing in again is not needed.": "Claude Code 的访问令牌已过期。启动 Claude Code 即可刷新,不需要重新登录。", + "Claude Code's saved sign-in expired — showing the last known limits. Start Claude Code, or run `claude auth login`, to refresh it.": "Claude Code 保存的登录已过期,显示上次的额度。启动 Claude Code 或运行 `claude auth login` 刷新。", + "Claude Code's saved sign-in expired. Start Claude Code, or run `claude auth login`, to refresh it.": "Claude Code 保存的登录已过期。启动 Claude Code 或运行 `claude auth login` 刷新。", + "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.": "无法连接 Anthropic 用量接口,稍后重试。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown.": "Anthropic 用量接口没有返回额度。仍显示本地 Claude Code 统计。", + "Anthropic's usage endpoint is rate limiting checks right now. Local Claude Code stats are still shown.": "Anthropic 用量接口正在限制查询频率。仍显示本地 Claude Code 统计。", // Network panel "Wiring bits": "接线中", "Handling packets": "处理数据包", @@ -317,6 +326,21 @@ var table = { "Start the authentication flow": "开始认证", "Install Dropbox from the service menu": "从服务菜单安装 Dropbox", "Untitled": "未命名", + "%1 of %2": "%1 / %2", + "Just now": "刚刚", + "Unknown time": "时间未知", + "%1mo ago": "%1个月前", + "%1m ago": "%1分钟前", + "%1h ago": "%1小时前", + "%1d ago": "%1天前", + "%1y ago": "%1年前", + "Starting Dropbox login…": "正在开始 Dropbox 登录…", + "Opened Dropbox login": "已打开 Dropbox 登录", + "Failed to read Dropbox status": "无法读取 Dropbox 状态", + "Could not read Dropbox status": "无法读取 Dropbox 状态", + "Dropbox login failed": "Dropbox 登录失败", + "Dropbox command failed": "Dropbox 命令失败", + "Failed to parse Dropbox status": "无法解析 Dropbox 状态", // Weather panel "Search city": "搜索城市", "FEELS": "体感", @@ -451,6 +475,23 @@ var table = { "Voxtype Dictation Ready": "Voxtype 语音输入就绪", "Web app removed": "网页应用已移除", "Windows VM": "Windows 虚拟机", + "Battery is down to %1%": "电量降到 %1%", + "Could not send to %1": "无法发送给 %1", + "Couldn't add the %1 account": "无法添加 %1 账户", + "Mirroring enabled (%1)": "已开启镜像(%1)", + "New %1 sessions now use %2": "新的 %1 会话现在使用 %2", + "No plugin to %1": "没有插件可以%1", + "No saved width found for %1 on workspace %2": "工作区 %2 没有为 %1 保存的宽度", + "Saved width for %1 on workspace %2": "已为工作区 %2 的 %1 保存宽度", + "Screen recording directory does not exist: %1": "录屏目录不存在:%1", + "Sent to %1": "已发送给 %1", + "Timezone is now set to %1": "时区已设为 %1", + "Transcoded to %1 %2": "已转码为 %1 %2", + "Workspace layout set to %1": "工作区布局已设为 %1", + "%1 is now the default browser": "%1 现在是默认浏览器", + "%1 is now the default editor": "%1 现在是默认编辑器", + "%1 is now the default terminal": "%1 现在是默认终端", + "%1 installed": "%1 已安装", // Clock "Right-click to toggle format": "右键切换格式", "Back to today": "回到今天", @@ -483,7 +524,43 @@ var table = { "Jump to a city": "跳到一个城市", "Add a city": "添加城市", "Loading zones…": "正在加载时区…", + "Tomorrow": "明天", + "Yesterday": "昨天", + " in %1": "(%1)", + "Loading cities…": "正在加载城市…", // Media "Pause": "暂停", - "Play": "播放" + "Play": "播放", + "Next": "下一首", + "Previous": "上一首" } + +// Parameterized messages that arrive finished; trMessage matches them, so each needs a table entry above. +var templates = [ + "Battery is down to %1%", + "Could not send to %1", + "Couldn't add the %1 account", + "Mirroring enabled (%1)", + "New %1 sessions now use %2", + "No plugin to %1", + "No saved width found for %1 on workspace %2", + "Saved width for %1 on workspace %2", + "Screen recording directory does not exist: %1", + "Sent to %1", + "Timezone is now set to %1", + "Transcoded to %1 %2", + "Workspace layout set to %1", + "%1 is now the default browser", + "%1 is now the default editor", + "%1 is now the default terminal", + "%1 installed", + "Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.", + "Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.", + "Installing the %1 CLI…", + "Sign in to %1 again in the browser window that opens.", + "Sign in to %1 in the browser window that opens.", + "Signed in to %1 again.", + "Signed in to %1.", + "The %1 sign-in didn't finish.", + "Added %1." +] diff --git a/shell/Commons/Tr.js b/shell/Commons/Tr.js index 1cf6fc1a..96c72f5b 100644 --- a/shell/Commons/Tr.js +++ b/shell/Commons/Tr.js @@ -22,3 +22,8 @@ function trMatch(text, templates) { } return tr(text) } + +// Finished messages from other programs (notifications, collectors) that carry a name or number. +function trMessage(text) { + return trMatch(text, Zh.templates) +} diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 0f98f143..1b51d224 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -455,11 +455,11 @@ Panel { return text } - // omarchy-agent-account-add reports progress in English around the agent's name. - readonly property var addMessageTemplates: [ - "Installing the %1 CLI…", "Sign in to %1 again in the browser window that opens.", "Sign in to %1 in the browser window that opens.", - "Signed in to %1 again.", "Signed in to %1.", "The %1 sign-in didn't finish.", "Added %1." - ] + // A model-scoped title ("Opus Monthly") keeps the model and translates the window. + function windowLabel(title) { + var match = String(title || "").match(/^(.*\S)\s+(Monthly|Weekly|Session|Limit)$/) + return match ? match[1] + " " + I18n.tr(match[2]) : I18n.tr(String(title || "")) + } function planLabel(p) { var tier = String(p && p.tierLabel || "") @@ -1173,7 +1173,7 @@ Panel { Text { width: parent.width textFormat: Text.PlainText - text: I18n.trMatch(root.addStatus, root.addMessageTemplates) + text: I18n.trMessage(root.addStatus) color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -1243,7 +1243,7 @@ Panel { visible: root.addStage === "done" || root.addStage === "error" width: parent.width textFormat: Text.PlainText - text: I18n.trMatch(root.addResult, root.addMessageTemplates) + text: I18n.trMessage(root.addResult) color: root.addStage === "error" ? root.urgent : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -1350,7 +1350,7 @@ Panel { width: parent.width textFormat: Text.PlainText // Shown for the status, so a record with no help to offer says the status rather than nothing. - text: section.provider ? I18n.tr(String(section.provider.authHelpText || section.provider.usageStatusText || "")) : "" + text: section.provider ? I18n.trMessage(String(section.provider.authHelpText || section.provider.usageStatusText || "")) : "" color: root.urgent font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1879,7 +1879,7 @@ Panel { PanelToolTip { visible: unavailableHover.hovered && unavailable.visible - text: unavailable.record ? I18n.tr(String(unavailable.record.authHelpText || "Usage has not been updated yet.")) : "" + text: unavailable.record ? I18n.trMessage(String(unavailable.record.authHelpText || "Usage has not been updated yet.")) : "" } } @@ -1904,7 +1904,7 @@ Panel { text: { var lines = [] if (compact.window) { - lines.push(I18n.tr("%1: %2% used", I18n.tr(compact.window.title), Math.round(compact.window.percent * 100)) + lines.push(I18n.tr("%1: %2% used", root.windowLabel(compact.window.title), Math.round(compact.window.percent * 100)) + (compact.resetMs > 0 ? " · " + I18n.tr("resets in %1", root.formatDuration(compact.resetMs)) : "")) } if (compact.stale) @@ -1912,8 +1912,8 @@ Panel { ? I18n.tr("Last updated %1 ago", root.formatDuration(root.nowMs - compact.fetchedAt)) : compact.fetchedAt > 0 ? I18n.tr("Last updated less than a minute ago") : I18n.tr("Last updated time unavailable")) for (var i = 0; i < compact.scoped.length; i++) - lines.push(I18n.tr("%1: %2% of its %3 allowance", I18n.tr(compact.scoped[i].title), Math.round(compact.scoped[i].percent * 100), - I18n.tr(String(compact.window ? compact.window.title : "")).toLowerCase())) + lines.push(I18n.tr("%1: %2% of its %3 allowance", root.windowLabel(compact.scoped[i].title), Math.round(compact.scoped[i].percent * 100), + root.windowLabel(compact.window ? compact.window.title : "").toLowerCase())) return lines.join("\n") } } @@ -1923,7 +1923,7 @@ Panel { textFormat: Text.PlainText width: parent.width * 0.3 anchors.verticalCenter: parent.verticalCenter - text: compact.window ? I18n.tr(compact.window.title) : "" + text: compact.window ? root.windowLabel(compact.window.title) : "" color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index 4c0fcd90..97a6eb34 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -44,7 +44,7 @@ BorderSurface { readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph readonly property string sanitizedBody: sanitizeBody(body) - readonly property string styledBody: NotificationLogic.styledBody(I18n.tr(body), app, appIcon) + readonly property string styledBody: NotificationLogic.styledBody(I18n.trMessage(body), app, appIcon) readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) @@ -168,7 +168,7 @@ BorderSurface { textFormat: Text.PlainText Layout.fillWidth: true visible: root.summary.length > 0 - text: I18n.tr(root.summary) + text: I18n.trMessage(root.summary) font.family: "Liberation Sans" color: Color.notifications.text font.pixelSize: Style.font.title diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 6b36f00f..abb43f14 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -90,7 +90,7 @@ Item { font.family: Style.font.family font.bold: true font.pixelSize: Style.font.title - text: I18n.tr(root.message) + text: I18n.trMessage(root.message) } TextMetrics { @@ -190,7 +190,7 @@ Item { // a hole in the padding; the slack lands in the gap after the bar. horizontalAlignment: root.hasProgress ? Text.AlignRight : Text.AlignLeft anchors.verticalCenter: parent.verticalCenter - text: I18n.tr(root.message) + text: I18n.trMessage(root.message) font: messageMetrics.font color: Color.popups.text elide: Text.ElideRight diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index d585d3a2..b16df749 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -37,6 +37,13 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property color iconColor: dropbox.authenticated && dropbox.active ? foreground : dim + // Model.fileMeta reads "5m ago · Folder"; the age is translated, the folder name kept. + function fileMetaText(file) { + var parts = Model.fileMeta(file).split(" · ") + parts[0] = I18n.trMatch(parts[0], ["Just now", "Unknown time", "%1mo ago", "%1m ago", "%1h ago", "%1d ago", "%1y ago"]) + return parts.join(" · ") + } + readonly property string toggleHint: dropbox.active ? I18n.tr("Pause syncing") : I18n.tr("Resume syncing") readonly property color barIconColor: dropbox.authenticated && dropbox.active ? barForeground : Qt.darker(barForeground, 1.55) // Only claim the header cursor when the switch is actually on screen — @@ -284,7 +291,7 @@ Panel { textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width - text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError + text: I18n.trMessage(dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError) color: dropbox.lastError !== "" && dropbox.actionStatus === "" ? root.urgent : root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall @@ -304,7 +311,7 @@ Panel { Column { width: parent.width spacing: Style.spacing.labelGap - InfoPair { label: I18n.tr("Stored"); value: Model.usageText(dropbox.usedBytes, dropbox.quotaBytes, dropbox.quotaKnown) } + InfoPair { label: I18n.tr("Stored"); value: I18n.trMatch(Model.usageText(dropbox.usedBytes, dropbox.quotaBytes, dropbox.quotaKnown), ["%1 of %2"]) } } } @@ -508,7 +515,7 @@ Panel { Text { textFormat: Text.PlainText Layout.fillWidth: true - text: Model.fileMeta(fileRow.file) + text: root.fileMetaText(fileRow.file) color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption diff --git a/shell/plugins/panels/elsewhen/CityRow.qml b/shell/plugins/panels/elsewhen/CityRow.qml index 6d05e2d4..ad2834c2 100644 --- a/shell/plugins/panels/elsewhen/CityRow.qml +++ b/shell/plugins/panels/elsewhen/CityRow.qml @@ -11,6 +11,16 @@ import "Greetings.js" as Greet Rectangle { id: row + // Model.formatDate reads "Tue Oct 6"; a Chinese session reads 10月6日 周二. + function dateText(date) { + var match = String(date).match(/^(\w{3}) (\w{3}) (\d+)$/) + if (!I18n.chinese || !match) return date + var month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(match[2]) + 1 + var weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(match[1]) + if (month < 1 || weekday < 0) return date + return month + "月" + match[3] + "日 " + ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][weekday] + } + required property var modelData required property int index // The owning Panel: the row reads its clock, facts and drag state. @@ -222,7 +232,7 @@ Rectangle { Behavior on opacity { NumberAnimation { duration: Style.duration(110) } } Caption { - text: row.ready ? row.rowData.date : "\u2026" + text: row.ready ? row.dateText(row.rowData.date) : "\u2026" color: row.ready && row.rowData.dayLabel !== "" ? row.dim : row.fainter } @@ -233,7 +243,7 @@ Rectangle { } Caption { - text: row.ready ? row.rowData.dayLabel : "" + text: row.ready ? I18n.tr(row.rowData.dayLabel) : "" visible: text !== "" } } diff --git a/shell/plugins/panels/elsewhen/Globe.qml b/shell/plugins/panels/elsewhen/Globe.qml index 4769cdc0..cb514960 100644 --- a/shell/plugins/panels/elsewhen/Globe.qml +++ b/shell/plugins/panels/elsewhen/Globe.qml @@ -585,7 +585,7 @@ Item { inlineResults: false loading: root.jumpOptions.length === 0 buttonText: I18n.tr("Jump to a city") - loadingText: "Loading cities\u2026" + loadingText: I18n.tr("Loading cities…") offsetLabel: function(zoneId) { return Model.utcOffsetLabel(root.offsets[zoneId]) } foreground: root.foreground dim: root.dim diff --git a/shell/plugins/panels/elsewhen/Panel.qml b/shell/plugins/panels/elsewhen/Panel.qml index eeda5551..88284008 100644 --- a/shell/plugins/panels/elsewhen/Panel.qml +++ b/shell/plugins/panels/elsewhen/Panel.qml @@ -335,7 +335,7 @@ Panel { // No full stop: a period hanging off the arc reads as a speck. readonly property string hereLine: I18n.tr("It's %1 here", localTime) - + (homeCity !== "" ? " in " + homeCity : "") + + (homeCity !== "" ? I18n.tr(" in %1", homeCity) : "") + (scrubLabel !== "" ? " " + scrubLabel : "") // A capital's line box has empty space above it; the row's top pad drops by it. diff --git a/test/shell.d/agents-panel-test.sh b/test/shell.d/agents-panel-test.sh index 5449aaff..c5981b42 100755 --- a/test/shell.d/agents-panel-test.sh +++ b/test/shell.d/agents-panel-test.sh @@ -31,7 +31,7 @@ assert(!/opacity: stale \?/.test(panelSource) && /I18n\.tr\("Last updated %1 ago assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes') assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in') assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop') -assert(/text: section\.provider \? I18n\.tr\(String\(section\.provider\.authHelpText \|\| section\.provider\.usageStatusText \|\| ""\)\) : ""/.test(panelSource), 'the trouble line falls back to the usage status when a record has no auth help') +assert(/text: section\.provider \? I18n\.trMessage\(String\(section\.provider\.authHelpText \|\| section\.provider\.usageStatusText \|\| ""\)\) : ""/.test(panelSource), 'the trouble line falls back to the usage status when a record has no auth help') const mainSource = fs.readFileSync(root + '/shell/plugins/agents/Main.qml', 'utf8') assert(/var from = Math\.min\(0\.8, \(threshold - 15\) \/ 100\)/.test(mainSource), 'faster checks start 15 points below the switch threshold') assert(/rows\.push\(\[\{ kind: "provider", index: p \}\]\)/.test(panelSource), "every agent's header is a keyboard stop") diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index a3484514..bf9890d5 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -137,7 +137,7 @@ assertEqual( // belongs in the logic module, where the strip runs after it. const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8') assert( - /readonly property string styledBody: NotificationLogic\.styledBody\(I18n\.tr\(body\), app, appIcon\)/.test(cardQml), + /readonly property string styledBody: NotificationLogic\.styledBody\(I18n\.trMessage\(body\), app, appIcon\)/.test(cardQml), 'the notification card renders the body that was stripped after the newline rewrite' ) assert( diff --git a/test/shell.d/shell-i18n-test.sh b/test/shell.d/shell-i18n-test.sh index 700600a6..a0e6a227 100644 --- a/test/shell.d/shell-i18n-test.sh +++ b/test/shell.d/shell-i18n-test.sh @@ -10,7 +10,7 @@ const vm = require('vm') const source = fs.readFileSync(path.join(root, 'shell/Commons/I18nZh.js'), 'utf8').replace('.pragma library', '') const context = {} -vm.runInNewContext(source + '\nthis.table = table', context) +vm.runInNewContext(source + '\nthis.table = table; this.templates = templates', context) const table = context.table const files = [] @@ -27,6 +27,12 @@ const missing = [] let calls = 0 for (const file of files) { const text = fs.readFileSync(file, 'utf8') + for (const list of text.matchAll(/I18n\.trMatch\([^\[]*\[([^\]]*)\]/g)) { + for (const item of list[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)) { + const key = JSON.parse('"' + item[1] + '"') + if (!(key in table)) missing.push(`${path.relative(root, file)}: ${key}`) + } + } for (const match of text.matchAll(/I18n\.tr\(\s*"((?:[^"\\]|\\.)*)"/g)) { calls++ const key = JSON.parse('"' + match[1] + '"') @@ -34,6 +40,8 @@ for (const file of files) { } } assert(calls > 300, 'the shell routes its text through I18n.tr', `found ${calls} calls`) +const missingTemplates = Array.from(context.templates).filter(t => !(t in table)) +assert(missingTemplates.length === 0, 'every message template has a Chinese entry', missingTemplates.join('\n')) assert(missing.length === 0, 'every I18n.tr text has a Chinese entry', missing.join('\n')) const placeholders = s => (s.match(/%\d/g) || []).sort().join(',')