diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2a5b0ca..78bc2e65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,15 @@ jobs: - name: Engine purity guard run: npm run check-purity + # Every message the code asks for is in the English source bundle (a new + # l10n.t() that nobody re-extracted fails here), and every translation + # covers it with the same placeholders and no added markup. + - name: Translations are complete + run: | + node scripts/i18n/bundle-nls.mjs --write + git diff --exit-code -- apps/extension/l10n/bundle.l10n.json + npm run i18n + - name: Package extension (smoke-test the build) run: | npm run package diff --git a/apps/extension/l10n/bundle.l10n.json b/apps/extension/l10n/bundle.l10n.json index 1134accc..72b5ab41 100644 --- a/apps/extension/l10n/bundle.l10n.json +++ b/apps/extension/l10n/bundle.l10n.json @@ -2446,7 +2446,11 @@ "Your changes to {0} were put back, but they conflict with what came in — ": "Your changes to {0} were put back, but they conflict with what came in — ", "Your comment is back in its box.": "Your comment is back in its box.", "Your commit a1b2c3d “{0}” from feature/session-hardening": "Your commit a1b2c3d “{0}” from feature/session-hardening", + "Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", + "Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", "Your commit {0} “{1}” from {2}": "Your commit {0} “{1}” from {2}", + "Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", + "Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", "Your commits stay. The PR's newer commits aren't brought in.": "Your commits stay. The PR's newer commits aren't brought in.", "Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.": "Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.", "Your history will appear here after the first commit.": "Your history will appear here after the first commit.", @@ -2466,7 +2470,6 @@ "Your uncommitted changes to {0} are in the way of {1} — ": "Your uncommitted changes to {0} are in the way of {1} — ", "Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.": "Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.", "Your verdict": "Your verdict", - "Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", "Yours": "Yours", "Yours {0}, theirs {1}: the result keeps {2}.": "Yours {0}, theirs {1}: the result keeps {2}.", "[$(arrow-up) Push](command:gitstudio.sync.push)": "[$(arrow-up) Push](command:gitstudio.sync.push)", @@ -2895,8 +2898,6 @@ "use the commit as-is": "use the commit as-is", "uses {0}": "uses {0}", "v1.2.0 a1b2c3d origin/main~3": "v1.2.0 a1b2c3d origin/main~3", - "version": "version", - "versions": "versions", "wants to merge": "wants to merge", "week": "week", "weeks": "weeks", diff --git a/apps/extension/l10n/bundle.l10n.zh-cn.json b/apps/extension/l10n/bundle.l10n.zh-cn.json index 50f80c89..7df3d52d 100644 --- a/apps/extension/l10n/bundle.l10n.zh-cn.json +++ b/apps/extension/l10n/bundle.l10n.zh-cn.json @@ -2446,7 +2446,11 @@ "Your changes to {0} were put back, but they conflict with what came in — ": "你对 {0} 的更改已放回,但它们与进来的内容冲突 — ", "Your comment is back in its box.": "你的评论已回到输入框中。", "Your commit a1b2c3d “{0}” from feature/session-hardening": "你在 feature/session-hardening 上的提交 a1b2c3d“{0}”", + "Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的提交替换了远程仍持有的版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。", + "Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的提交替换了远程仍持有的多个版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。", "Your commit {0} “{1}” from {2}": "你的提交 {0} “{1}”,来自 {2}", + "Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的多个提交替换了远程仍持有的版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。", + "Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的多个提交替换了远程仍持有的多个版本 — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。", "Your commits stay. The PR's newer commits aren't brought in.": "你的提交会保留。该 PR 较新的提交不会被引入。", "Your commits stay. The pull request's newer commits aren't brought in: pull to merge them.": "你的提交会保留。该拉取请求较新的提交不会被引入:请拉取以合并它们。", "Your history will appear here after the first commit.": "第一次提交后,你的历史将显示在这里。", @@ -2466,7 +2470,6 @@ "Your uncommitted changes to {0} are in the way of {1} — ": "你对 {0} 的未提交更改妨碍了{1} — ", "Your uncommitted changes touch files the {0} changed. Commit or stash them, then undo.": "你的未提交更改涉及{0}所改动的文件。请先提交或贮藏它们,然后撤销。", "Your verdict": "你的结论", - "Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.": "你的{0}替换了远程仍持有的{1} — 修改已推送的提交会导致这种情况。拉取会带回旧的那个。", "Yours": "你的", "Yours {0}, theirs {1}: the result keeps {2}.": "你的 {0},他们的 {1}:结果保留 {2}。", "[$(arrow-up) Push](command:gitstudio.sync.push)": "[$(arrow-up) 推送](command:gitstudio.sync.push)", @@ -2895,8 +2898,6 @@ "use the commit as-is": "按原样使用该提交", "uses {0}": "使用 {0}", "v1.2.0 a1b2c3d origin/main~3": "v1.2.0 a1b2c3d origin/main~3", - "version": "版本", - "versions": "版本", "wants to merge": "想合并", "week": "周", "weeks": "周", diff --git a/apps/extension/src/statusBar/syncStatus.ts b/apps/extension/src/statusBar/syncStatus.ts index ab41d014..6f81ce95 100644 --- a/apps/extension/src/statusBar/syncStatus.ts +++ b/apps/extension/src/statusBar/syncStatus.ts @@ -521,11 +521,15 @@ export class SyncStatusItem implements vscode.Disposable { }): Promise { const choice = await promptPick({ title: l10n.t("This branch was rewritten"), - hint: l10n.t( - "Your {0} replaced the {1} the remote still has — amending a pushed commit does this. Pulling would bring the old one back.", - ab.ahead === 1 ? l10n.t("commit") : l10n.t("commits"), - ab.behind === 1 ? l10n.t("version") : l10n.t("versions"), - ), + // Whole sentences, one per count: a translation reorders the words. + hint: + ab.ahead === 1 + ? ab.behind === 1 + ? l10n.t("Your commit replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.") + : l10n.t("Your commit replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back.") + : ab.behind === 1 + ? l10n.t("Your commits replaced the version the remote still has — amending a pushed commit does this. Pulling would bring the old one back.") + : l10n.t("Your commits replaced versions the remote still has — amending a pushed commit does this. Pulling would bring the old one back."), choices: [ { id: "force", diff --git a/package-lock.json b/package-lock.json index 2b8d4760..84786a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4815,6 +4815,7 @@ "@gitstudio/ai": "*", "@gitstudio/engine": "*", "@gitstudio/host-bridge": "*", + "@gitstudio/l10n": "*", "@vscode/l10n": "^0.0.18" } }, diff --git a/packages/git-service/package.json b/packages/git-service/package.json index 6befe17a..77f9550f 100644 --- a/packages/git-service/package.json +++ b/packages/git-service/package.json @@ -15,6 +15,7 @@ "@gitstudio/ai": "*", "@gitstudio/engine": "*", "@gitstudio/host-bridge": "*", + "@gitstudio/l10n": "*", "@vscode/l10n": "^0.0.18" } } diff --git a/packages/git-service/src/SnapshotProvider.ts b/packages/git-service/src/SnapshotProvider.ts index 561a902f..0d0903eb 100644 --- a/packages/git-service/src/SnapshotProvider.ts +++ b/packages/git-service/src/SnapshotProvider.ts @@ -14,6 +14,7 @@ import { import { placeHolds, restoreStash, stashStack, type StashSlot } from "./stashRestore"; import { stashTitle } from "./StashProvider"; import * as l10n from "@vscode/l10n"; +import { englishOf } from "@gitstudio/l10n/index"; /** * A record of what an operation is about to change, and — once it has run and @@ -319,7 +320,7 @@ export class SnapshotProvider { let stashSha: string | null = null; let uncopied: NoCopy | undefined; if (!refsOnly && (await this.isDirty(opts))) { - const created = await this.process.run(["stash", "create", label], opts); + const created = await this.process.run(["stash", "create", englishOf(label)], opts); if (created.code === 0) { // `stash create` prints nothing (empty) when there's nothing to stash. stashSha = created.stdout.trim() || null; @@ -740,7 +741,7 @@ export class SnapshotProvider { async execute(snap: Snapshot, steps: readonly RestoreStep[], opts?: GitRunOptions): Promise { // English on purpose: git writes this into the reflog, where `git reflog` // shows it to whoever reads the repository, whatever their editor says. - const message = `GitStudio undo: ${snap.label}`; + const message = `GitStudio undo: ${englishOf(snap.label)}`; for (const st of steps) { switch (st.do) { case "abort-rebase": { @@ -836,7 +837,8 @@ export class SnapshotProvider { } // English on purpose: this is a commit message, stored in the repository // itself and read by everyone who looks at the history. - const msg = `Revert "${snap.label}"\n\nThis puts back the files as they were before "${snap.label}" (${shortSha(p.from)}), which had already been pushed as ${shortSha(p.to)}.\n`; + const label = englishOf(snap.label); + const msg = `Revert "${label}"\n\nThis puts back the files as they were before "${label}" (${shortSha(p.from)}), which had already been pushed as ${shortSha(p.to)}.\n`; const made = await this.process.run(["commit-tree", `${p.from}^{tree}`, "-p", p.to, "-F", "-"], { ...opts, input: msg }); if (made.code !== 0) return made; // The new commit's parent is `p.to`: moving HEAD onto it from anywhere diff --git a/packages/git-service/src/dropCommit.ts b/packages/git-service/src/dropCommit.ts index 3b7f34ff..1c61013b 100644 --- a/packages/git-service/src/dropCommit.ts +++ b/packages/git-service/src/dropCommit.ts @@ -18,6 +18,7 @@ import { type RefMove, } from "./refRestore"; import * as l10n from "@vscode/l10n"; +import { englishOf } from "@gitstudio/l10n/index"; // Drop Commit (issue #32), for both products. // @@ -464,7 +465,7 @@ async function undoOnBranch( /** The reflog entry an undo of `what` writes on each branch it puts back. */ function undoReflog(what: string): string { // English on purpose: a reflog entry, stored in the repository itself. - return what === "drop" ? "GitStudio undo: drop commit" : `GitStudio undo: ${what}`; + return what === "drop" ? "GitStudio undo: drop commit" : `GitStudio undo: ${englishOf(what)}`; } /** The carried branches back, each by compare-and-swap. */ diff --git a/packages/l10n/package.json b/packages/l10n/package.json index afaa59a7..63048d29 100644 --- a/packages/l10n/package.json +++ b/packages/l10n/package.json @@ -8,7 +8,8 @@ "./*": "./src/*.ts" }, "scripts": { - "check-types": "tsc --noEmit -p tsconfig.json" + "check-types": "tsc --noEmit -p tsconfig.json", + "test": "tsx --import ../../scripts/test/hermetic-git.mjs --test \"test/**/*.test.ts\"" }, "dependencies": { "@vscode/l10n": "^0.0.18" diff --git a/packages/l10n/src/index.ts b/packages/l10n/src/index.ts index 81667d1c..6c481e26 100644 --- a/packages/l10n/src/index.ts +++ b/packages/l10n/src/index.ts @@ -47,6 +47,7 @@ export function configureL10n(uri: { fsPath: string } | undefined): void { bundleText = undefined; locale = undefined; scriptCache = undefined; + reverse = undefined; if (!uri) { return; } @@ -158,3 +159,69 @@ export function currentBundle(): l10nJsonFormat | undefined { return undefined; } } + +/** Each translation as a pattern that gives its English message back. */ +type Reverse = { exact: Map; patterns: { re: RegExp; names: string[]; message: string }[] }; +let reverse: Reverse | undefined; + +/** + * The English a translated message was made from: "合并 feat" → "Merge feat". + * + * For what git stores in the repository — a reflog entry, a stash's message, a + * commit message — which everyone who reads the history sees, whatever + * language GitStudio was showing the person who ran the operation. The label + * those are built from is the one the UI showed, so it is turned back here + * rather than threading a second, English label through every caller. + * + * Arguments are matched as wildcards and turned back on their own when they are + * whole messages ("提交" → "commit"); a branch name stays as written. Text no + * translation produces (English, or no bundle at all) is handed back unchanged. + */ +export function englishOf(text: string): string { + if (!reverse) { + const bundle = currentBundle(); + if (!bundle) return text; + reverse = buildReverse(bundle); + } + const exact = reverse.exact.get(text); + if (exact !== undefined) return exact; + for (const { re, names, message } of reverse.patterns) { + const m = re.exec(text); + if (!m) continue; + return message.replace(/\{([^}]+)\}/g, (whole, key: string) => { + const at = names.indexOf(key); + if (at < 0) return whole; + const value = m[at + 1] ?? ""; + return reverse?.exact.get(value) ?? value; + }); + } + return text; +} + +function buildReverse(bundle: l10nJsonFormat): Reverse { + const exact = new Map(); + const patterns: Reverse["patterns"] = []; + for (const [message, entry] of Object.entries(bundle)) { + const translated = typeof entry === "string" ? entry : entry.message; + if (!translated || translated === message) continue; + const parts = translated.split(/\{([^}]+)\}/); + if (parts.length === 1) { + if (!exact.has(translated)) exact.set(translated, message); + continue; + } + const names: string[] = []; + let source = "^"; + parts.forEach((part, i) => { + if (i % 2) { + names.push(part); + source += "([\\s\\S]+?)"; + } else { + source += part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + }); + patterns.push({ re: new RegExp(`${source}$`), names, message }); + } + // The longest literal text first: "{0} 已推送" must not win over "{0} 已推送到 {1}". + patterns.sort((a, b) => b.re.source.length - a.re.source.length); + return { exact, patterns }; +} diff --git a/packages/l10n/test/englishOf.test.ts b/packages/l10n/test/englishOf.test.ts new file mode 100644 index 00000000..e7485653 --- /dev/null +++ b/packages/l10n/test/englishOf.test.ts @@ -0,0 +1,42 @@ +// englishOf turns what the UI showed back into the English git should store. +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { configureL10n, englishOf } from "../src/index"; + +function withBundle(bundle: Record, t: { after(fn: () => void): void }): void { + const dir = mkdtempSync(join(tmpdir(), "gs-l10n-")); + const file = join(dir, "bundle.l10n.zh-cn.json"); + writeFileSync(file, JSON.stringify(bundle)); + configureL10n({ fsPath: file }); + t.after(() => { + configureL10n(undefined); + rmSync(dir, { recursive: true, force: true }); + }); +} + +test("English, or no bundle at all, comes back as it went in", () => { + configureL10n(undefined); + assert.equal(englishOf("Merge feat into main"), "Merge feat into main"); +}); + +test("a translated label turns back into its English message, the names in it kept", (t) => { + withBundle({ "Merge {0} into {1}": "将 {0} 合并到 {1}", "Drop commit": "丢弃提交", "{0} pushed": "{0} 已推送", "{0} pushed to {1}": "{0} 已推送到 {1}" }, t); + assert.equal(englishOf("将 feat/登录 合并到 main"), "Merge feat/登录 into main"); + assert.equal(englishOf("丢弃提交"), "Drop commit"); + assert.equal(englishOf("main 已推送到 origin"), "main pushed to origin", "the longer pattern wins"); + assert.equal(englishOf("main 已推送"), "main pushed"); +}); + +test("an argument that is itself a whole message turns back too", (t) => { + withBundle({ "Reorder {0}": "重新排序{0}", "commits": "提交" }, t); + assert.equal(englishOf("重新排序提交"), "Reorder commits"); +}); + +test("text no translation produced is handed back unchanged", (t) => { + withBundle({ "Merge {0} into {1}": "将 {0} 合并到 {1}" }, t); + assert.equal(englishOf("something a person typed"), "something a person typed"); +}); diff --git a/packages/merge-vscode/src/dashboardController.ts b/packages/merge-vscode/src/dashboardController.ts index c96f7805..2325fa50 100644 --- a/packages/merge-vscode/src/dashboardController.ts +++ b/packages/merge-vscode/src/dashboardController.ts @@ -270,7 +270,7 @@ const NO_OP: ConflictsState["op"] = { yours: { role: "yours", stage: 2, name: "", paneTitle: "", description: "" }, theirs: { role: "theirs", stage: 3, name: "", paneTitle: "", description: "" }, // English sentinel: abortLabel() replaces it with a specific, localized word. - verbs: { abort: l10n.t("Cancel") }, + verbs: { abort: "Cancel" }, canContinue: false, canSkip: false, episode: "none", diff --git a/scripts/i18n/bundle-nls.mjs b/scripts/i18n/bundle-nls.mjs index 342df156..21b1c86a 100644 --- a/scripts/i18n/bundle-nls.mjs +++ b/scripts/i18n/bundle-nls.mjs @@ -129,7 +129,18 @@ for (const file of files) { report(`${file}: placeholders changed for ${JSON.stringify(key)}: expected ${placeholders(source[key]).map((p) => `{${p}}`).join(" ")} got ${placeholders(translated[key]).map((p) => `{${p}}`).join(" ")}`); } if (broken.length > 5) report(`${file}: ${broken.length - 5} more message(s) with changed placeholders`); - if (!missing.length && !extra.length && !blank.length && !broken.length) { + + // Translations reach pages through innerHTML and quoted attributes, which + // trust the English source not to carry markup. A translation may not add a + // character that markup or an attribute would read: < > & " beyond what its + // English source already has. + const count = (text, ch) => String(text).split(ch).length - 1; + const markup = keys.filter((key) => ["<", ">", "&", '"'].some((ch) => count(translated[key], ch) > count(source[key], ch))); + for (const key of markup.slice(0, 5)) { + report(`${file}: adds markup characters (< > & ") the English does not have, in ${JSON.stringify(key)}: ${JSON.stringify(translated[key])}`); + } + if (markup.length > 5) report(`${file}: ${markup.length - 5} more message(s) adding markup characters`); + if (!missing.length && !extra.length && !blank.length && !broken.length && !markup.length) { console.log(`bundle-nls: ${file} covers all ${keys.length} messages`); } }