From c02e6136a1900829bc8f23587ddd82b1bbfc9cbe Mon Sep 17 00:00:00 2001 From: Martin Delophy <845046459@qq.com> Date: Thu, 23 Jul 2026 11:03:26 +0800 Subject: [PATCH] complete editor internationalization (#43) Co-authored-by: haixin.yang --- scripts/generate-i18n-completion.mjs | 98 + scripts/generate-ui-message-copy.mjs | 115 + src/App.jsx | 2 +- src/components/Timeline.jsx | 2 +- src/components/Topbar.jsx | 8 +- src/components/panels.jsx | 2 +- src/components/ui.jsx | 4 +- src/hooks/useToast.js | 8 +- src/i18n.js | 49 +- src/i18nCompleteness.test.js | 42 + src/i18nCompletion.js | 3925 ++++++++++++++++++++++++++ src/i18nMessageRuntime.js | 37 + src/i18nMessageRuntime.test.js | 19 + src/i18nMessages.js | 2951 +++++++++++++++++++ src/i18nMessagesCompleteness.test.js | 53 + src/i18nProjectChrome.test.js | 26 + src/styles.css | 2 +- 17 files changed, 7329 insertions(+), 14 deletions(-) create mode 100644 scripts/generate-i18n-completion.mjs create mode 100644 scripts/generate-ui-message-copy.mjs create mode 100644 src/i18nCompleteness.test.js create mode 100644 src/i18nCompletion.js create mode 100644 src/i18nMessageRuntime.js create mode 100644 src/i18nMessageRuntime.test.js create mode 100644 src/i18nMessages.js create mode 100644 src/i18nMessagesCompleteness.test.js create mode 100644 src/i18nProjectChrome.test.js diff --git a/scripts/generate-i18n-completion.mjs b/scripts/generate-i18n-completion.mjs new file mode 100644 index 0000000..2ad5e94 --- /dev/null +++ b/scripts/generate-i18n-completion.mjs @@ -0,0 +1,98 @@ +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const RAW_KEY_ENGLISH = { + cancel: "Cancel", + canvasRatio: "Canvas ratio", + clipDuration: "Clip duration", + clipStart: "Clip start", + layer: "Layer", + lock: "Lock", + visualBasic: "Basic", +}; + +const TARGET_CODES = { zh: "zh-CN", en: "en", ja: "ja", ko: "ko", es: "es", fr: "fr", de: "de", pt: "pt", th: "th", vi: "vi", ru: "ru" }; +const keys = new Set(); + +globalThis.__GENERATING_I18N__ = true; +const { APP_LANGUAGES, UI_COPY, createTranslator } = await import("../src/i18n.js"); +for (const key of Object.keys(UI_COPY.en)) keys.add(key); + +async function collectKeys(directory) { + for (const name of await readdir(directory)) { + const path = join(directory, name); + const info = await stat(path); + if (info.isDirectory()) await collectKeys(path); + else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && name !== "i18nCompletion.js") { + const source = await readFile(path, "utf8"); + for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) keys.add(match[1]); + } + } +} + +function chunksFor(entries, maximumLength = 3500) { + const chunks = []; + let current = []; + let length = 0; + for (const entry of entries) { + const line = `@@${entry.index}@@ ${entry.text.replace(/\s+/g, " ").trim()}`; + if (current.length && length + line.length + 1 > maximumLength) { + chunks.push(current); + current = []; + length = 0; + } + current.push({ ...entry, line }); + length += line.length + 1; + } + if (current.length) chunks.push(current); + return chunks; +} + +async function translateChunk(chunk, target) { + const url = new URL("https://translate.googleapis.com/translate_a/single"); + url.searchParams.set("client", "gtx"); + url.searchParams.set("sl", "en"); + url.searchParams.set("tl", target); + url.searchParams.set("dt", "t"); + url.searchParams.set("q", chunk.map(({ line }) => line).join("\n")); + const response = await fetch(url); + if (!response.ok) throw new Error(`Translation failed: ${response.status}`); + const payload = await response.json(); + const translated = payload[0].map((part) => part[0]).join(""); + const values = new Map(); + for (const match of translated.matchAll(/@@(\d+)@@\s*([\s\S]*?)(?=\s*@@\d+@@|$)/g)) { + values.set(Number(match[1]), match[2].trim()); + } + for (const entry of chunk) { + if (!values.has(entry.index)) throw new Error(`Missing translated entry K${entry.index} for ${target}`); + } + return values; +} + +await collectKeys(new URL("../src", import.meta.url).pathname); +const sortedKeys = [...keys].sort(); +const english = createTranslator("en"); +const output = {}; + +for (const { id } of APP_LANGUAGES) { + const current = createTranslator(id); + const entries = sortedKeys.flatMap((key, index) => { + const englishText = english(key) === key ? RAW_KEY_ENGLISH[key] : english(key); + if (!englishText) throw new Error(`No English source text for ${key}`); + const needsCompletion = current(key) === key || (id !== "en" && current(key) === english(key)); + return needsCompletion ? [{ key, index, text: englishText }] : []; + }); + output[id] = {}; + if (id === "en") { + for (const entry of entries) output[id][entry.key] = entry.text; + continue; + } + for (const chunk of chunksFor(entries)) { + const translated = await translateChunk(chunk, TARGET_CODES[id]); + for (const entry of chunk) output[id][entry.key] = translated.get(entry.index); + } + process.stdout.write(`${id}: ${entries.length} completed\n`); +} + +const source = `// Generated by scripts/generate-i18n-completion.mjs.\n// Regenerate after adding user-visible t(\"…\") keys.\nexport const I18N_COMPLETION_COPY = ${JSON.stringify(output, null, 2)};\n`; +await writeFile(new URL("../src/i18nCompletion.js", import.meta.url), source); diff --git a/scripts/generate-ui-message-copy.mjs b/scripts/generate-ui-message-copy.mjs new file mode 100644 index 0000000..d0d9537 --- /dev/null +++ b/scripts/generate-ui-message-copy.mjs @@ -0,0 +1,115 @@ +import { parse } from "@babel/parser"; +import traverseModule from "@babel/traverse"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const traverse = traverseModule.default; +const LANGUAGES = { zh: "zh-CN", en: "en", ja: "ja", ko: "ko", es: "es", fr: "fr", de: "de", pt: "pt", th: "th", vi: "vi", ru: "ru" }; +const messages = new Set(); +const USER_MESSAGE_CALL = /^(?:notify|commit|clear|replace|setStatus|setStatusText|reject|onProgress|confirm|alert|Error)/; + +function callName(node) { + const callee = node.callee; + if (callee?.type === "Identifier") return callee.name; + if (callee?.type === "MemberExpression") return callee.property?.name ?? callee.property?.value ?? ""; + return ""; +} + +function belongsToUserMessage(path) { + return Boolean(path.findParent((parent) => (parent.isCallExpression() || parent.isNewExpression()) && USER_MESSAGE_CALL.test(callName(parent.node)))); +} + +async function collectMessages(directory) { + for (const name of await readdir(directory)) { + const path = join(directory, name); + const info = await stat(path); + if (info.isDirectory()) await collectMessages(path); + else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && !/i18n|ttsText|asr\.js|workers/.test(path)) { + const source = await readFile(path, "utf8"); + const ast = parse(source, { sourceType: "module", plugins: ["jsx"] }); + traverse(ast, { + StringLiteral(path) { + if (/[\u3400-\u9fff]/u.test(path.node.value) && belongsToUserMessage(path)) messages.add(path.node.value.replace(/\s+/g, " ").trim()); + }, + TemplateLiteral(path) { + const value = path.node.quasis.map((part, index) => `${part.value.cooked}${index < path.node.expressions.length ? `{${index}}` : ""}`).join("").replace(/\s+/g, " ").trim(); + if (/[\u3400-\u9fff]/u.test(value) && belongsToUserMessage(path)) messages.add(value); + }, + }); + } + } +} + +function chunksFor(entries, maximumLength = 900) { + const chunks = []; + let current = []; + let length = 0; + for (const entry of entries) { + const line = `@@${entry.index}@@ ${entry.source}`; + if (current.length && length + line.length + 1 > maximumLength) { + chunks.push(current); + current = []; + length = 0; + } + current.push({ ...entry, line }); + length += line.length + 1; + } + if (current.length) chunks.push(current); + return chunks; +} + +async function translateChunk(chunk, target) { + const url = new URL("https://translate.googleapis.com/translate_a/single"); + url.searchParams.set("client", "gtx"); + url.searchParams.set("sl", "zh-CN"); + url.searchParams.set("tl", target); + url.searchParams.set("dt", "t"); + url.searchParams.set("q", chunk.map(({ line }) => line).join("\n")); + const response = await fetch(url); + if (!response.ok) throw new Error(`Translation failed: ${response.status}`); + const payload = await response.json(); + const translated = payload[0].map((part) => part[0]).join(""); + const values = new Map(); + for (const match of translated.matchAll(/@@(\d+)@@\s*([\s\S]*?)(?=\s*@@\d+@@|$)/g)) values.set(Number(match[1]), match[2].trim()); + for (const entry of chunk) if (!values.has(entry.index)) throw new Error(`Missing translated message ${entry.index} for ${target}`); + return values; +} + +async function translateText(source, target) { + const url = new URL("https://translate.googleapis.com/translate_a/single"); + url.searchParams.set("client", "gtx"); + url.searchParams.set("sl", "zh-CN"); + url.searchParams.set("tl", target); + url.searchParams.set("dt", "t"); + url.searchParams.set("q", source); + const response = await fetch(url); + if (!response.ok) throw new Error(`Translation failed: ${response.status}`); + const payload = await response.json(); + return payload[0].map((part) => part[0]).join("").trim(); +} + +await collectMessages(new URL("../src", import.meta.url).pathname); +const entries = [...messages].sort().map((source, index) => ({ index, source })); +const output = {}; + +for (const [language, target] of Object.entries(LANGUAGES)) { + output[language] = {}; + if (language === "zh") { + for (const entry of entries) output[language][entry.source] = entry.source; + } else { + for (const chunk of chunksFor(entries)) { + const translated = await translateChunk(chunk, target); + for (const entry of chunk) output[language][entry.source] = translated.get(entry.index); + } + const templates = entries.filter(({ source }) => /\{\d+\}/.test(source)); + for (let index = 0; index < templates.length; index += 6) { + const batch = templates.slice(index, index + 6); + const translated = await Promise.all(batch.map(({ source }) => translateText(source, target))); + batch.forEach((entry, itemIndex) => { output[language][entry.source] = translated[itemIndex]; }); + } + } + process.stdout.write(`${language}: ${entries.length} messages\n`); +} + +const source = `// Generated by scripts/generate-ui-message-copy.mjs.\nexport const UI_MESSAGE_COPY = ${JSON.stringify(output, null, 2)};\n`; +await writeFile(new URL("../src/i18nMessages.js", import.meta.url), source); diff --git a/src/App.jsx b/src/App.jsx index b8f7656..2529afd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -146,7 +146,7 @@ export function App() { voiceFilter, voiceTab, } = useEditorUiState(); const [userAssets, setUserAssets] = useState([]); - const { notify, toast } = useToast(); + const { notify, toast } = useToast(2600, uiLanguage || "zh"); const [previewVideoMediaTime, setPreviewVideoMediaTime] = useState(0); const [visionRecords, setVisionRecords] = useState({}); const [visionJob, setVisionJob] = useState({ diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 8e76b14..4e1a94b 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -1895,7 +1895,7 @@ export function Timeline({ key={`junction-${segment.id}`} type="button" aria-label={`${t("transition")}: ${trOption(TRANSITIONS.find((item) => item.id === transition.id)?.name || "无转场")}`} - title="设置转场" + title={t("transitionSettings")} style={{ left: `${((range?.end || 0) / Math.max(0.01, timelineDuration)) * 100}%` }} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { diff --git a/src/components/Topbar.jsx b/src/components/Topbar.jsx index f5b6cc6..7888471 100644 --- a/src/components/Topbar.jsx +++ b/src/components/Topbar.jsx @@ -67,7 +67,7 @@ export function Topbar({ {t("fileMenu")} {showFileMenu ? ( - setShowFileMenu(false)}> + setShowFileMenu(false)}>
{t("projectMenuHeading")} @@ -127,7 +127,7 @@ export function Topbar({ {ratio.label} {showRatioMenu ? ( - setShowRatioMenu(false)}> + setShowRatioMenu(false)}>
{RATIO_OPTIONS.map((option) => ( {showExportMenu ? ( - setShowExportMenu(false)}> + setShowExportMenu(false)}>
{t("videoExport")}{t("videoExportHint")}
{exportSettings.codec === "h264" ? "MP4" : "WebM"}
@@ -189,7 +189,7 @@ export function Topbar({ {showSettings ? ( - setShowSettings(false)}> + setShowSettings(false)}>
{t("exportSettings")}