дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(i18n): Translate UI text built from template literals
Template literals such as `Remove data source '${name}'?` reached props and
calls that look text up exactly (dialog messages, labels, aria-labels, menu
items, action titles), so they always showed in English. Some notification
titles also spliced lowercase English words into a sentence.
- `tx` also works as a tag: tx`Remove data source '${name}'?` looks up
"Remove data source '{name}'?" by its literal parts, fills in the values
(translated when the catalog has them) and remembers the result, so the
primitives that pass strings through `tx` translate it again after a locale
change or once the catalog has loaded (actions registered at startup).
- 269 template literals at exact-lookup sites now use the tag; text that
spliced words or plural suffixes is written as one sentence per variant
(safe delete, Docker delete/prune, rebase, conflicts, VCS "No … in
progress", database copy errors, inspection descriptions, ...).
- zh: 280 new entries; stale and duplicate-shape entries removed.
- textCatalog guard: untagged template literals at exact-lookup sites fail
(with an allowlist for code-like text), tagged text is checked against the
catalog by shape, case-changing values and `c ? "a" : "b"` suffixes inside
translated templates fail, and a catalog may hold one entry per template shape.
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
2616e3fe6d
коміт
b03983847c
142 files changed
+1032
-363
No files matched your search
@@ -127,6 +127,26 @@ describe("t / locales", () => {
|
||||
expect(txTemplate("Something else entirely")).toBe("Something else entirely");
|
||||
});
|
||||
|
||||
it("translates text built by the tx tag, again after the locale changes", async () => {
|
||||
await setLocale("en");
|
||||
const name = "orders";
|
||||
const built = tx`Run '${name}'`;
|
||||
expect(built).toBe("Run 'orders'");
|
||||
expect(tx`Unknown ${name} text`).toBe("Unknown orders text");
|
||||
await setLocale("zh");
|
||||
// Built before the catalog loaded: primitives passing it through `tx` translate it.
|
||||
expect(tx(built)).toBe("运行 'orders'");
|
||||
expect(txTemplate(built)).toBe("运行 'orders'");
|
||||
const zh = tx`Run '${name}'`;
|
||||
expect(zh).toBe("运行 'orders'");
|
||||
// Substituted text is translated when the catalog has it.
|
||||
expect(tx`Run '${"Retry"}'`).toBe(`运行 '${tx("Retry")}'`);
|
||||
// Values are inserted as they are, braces included.
|
||||
expect(tx`Drop ${"stash@{0}"}? This cannot be undone.`).toBe("是否丢弃 stash@{0}?此操作无法撤消。");
|
||||
await setLocale("en");
|
||||
expect(tx(zh)).toBe("Run 'orders'");
|
||||
});
|
||||
|
||||
it("lists keys by prefix", () => {
|
||||
const keys = keysWithPrefix("menu.");
|
||||
expect(keys).toContain("menu.file.save");
|
||||
|
||||
@@ -158,26 +158,97 @@ export function useT(): Translate {
|
||||
return useCallback((key, params) => translateWith({ locale, messages }, key, params), [locale, messages]);
|
||||
}
|
||||
|
||||
/** Translates English UI text through the source-text catalog; unknown text is returned as is. */
|
||||
/**
|
||||
* Translates English UI text through the source-text catalog; unknown text is returned as is.
|
||||
*
|
||||
* As a tag it translates text built at runtime: `` tx`Remove data source '${name}'?` ``
|
||||
* looks up `"Remove data source '{name}'?"` (whatever the placeholder names) and fills
|
||||
* in the values, each translated as text when the catalog has it. The result is
|
||||
* remembered, so the UI primitives, which pass their strings through `tx` as they
|
||||
* render, translate it again after a locale change or once the catalog has loaded.
|
||||
*/
|
||||
export function tx(strings: TemplateStringsArray, ...values: unknown[]): string;
|
||||
export function tx(text: string): string;
|
||||
export function tx(text: string | undefined): string | undefined;
|
||||
export function tx(text: string | undefined): string | undefined {
|
||||
return text && (useI18n.getState().texts[text] ?? text);
|
||||
export function tx(text: string | TemplateStringsArray | undefined, ...values: unknown[]): string | undefined {
|
||||
return translateSource(useI18n.getState().texts, text, values);
|
||||
}
|
||||
|
||||
/** `tx` bound to the current locale; components re-render when it changes. */
|
||||
export function useTx(): typeof tx {
|
||||
const texts = useI18n((s) => s.texts);
|
||||
return useMemo(() => {
|
||||
function bound(strings: TemplateStringsArray, ...values: unknown[]): string;
|
||||
function bound(text: string): string;
|
||||
function bound(text: string | undefined): string | undefined;
|
||||
function bound(text: string | undefined) {
|
||||
return text && (texts[text] ?? text);
|
||||
function bound(text: string | TemplateStringsArray | undefined, ...values: unknown[]) {
|
||||
return translateSource(texts, text, values);
|
||||
}
|
||||
return bound;
|
||||
}, [texts]);
|
||||
}
|
||||
|
||||
function translateSource(texts: Catalog, text: string | TemplateStringsArray | undefined, values: unknown[]) {
|
||||
if (text === undefined || typeof text === "string") return text && (texts[text] ?? rebuild(texts, text) ?? text);
|
||||
return buildText(texts, { strings: text, values });
|
||||
}
|
||||
|
||||
/** Text built by a `tx` tag: the template's literal parts and the interpolated values. */
|
||||
type Built = { strings: readonly string[]; values: readonly unknown[] };
|
||||
|
||||
const MAX_BUILT = 10_000;
|
||||
/** Text that `tx` tags returned (in English and translated), most recently built last. */
|
||||
const built = new Map<string, Built>();
|
||||
|
||||
function remember(text: string, b: Built) {
|
||||
built.delete(text);
|
||||
built.set(text, b);
|
||||
if (built.size > MAX_BUILT) built.delete(built.keys().next().value!);
|
||||
}
|
||||
|
||||
type Shape = { names: string[]; translation: string };
|
||||
|
||||
const shapeCache = new WeakMap<Catalog, Map<string, Shape>>();
|
||||
|
||||
/** Source-text templates by their literal parts: `"Run '{name}'"` under `"Run '{}'"`. */
|
||||
function shapesOf(texts: Catalog): Map<string, Shape> {
|
||||
let shapes = shapeCache.get(texts);
|
||||
if (shapes) return shapes;
|
||||
shapes = new Map();
|
||||
for (const [source, translation] of Object.entries(texts)) {
|
||||
const names = [...source.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!);
|
||||
if (names.length) shapes.set(source.replace(/\{\w+\}/g, "{}"), { names, translation });
|
||||
}
|
||||
shapeCache.set(texts, shapes);
|
||||
return shapes;
|
||||
}
|
||||
|
||||
const interleave = (strings: readonly string[], parts: readonly string[]) =>
|
||||
strings.reduce((out, s, i) => out + parts[i - 1] + s);
|
||||
|
||||
function formatBuilt(texts: Catalog, { strings, values }: Built): string {
|
||||
const parts = values.map((v) => (typeof v === "string" ? (texts[v] ?? rebuild(texts, v) ?? v) : String(v)));
|
||||
const shape = shapesOf(texts).get(strings.join("{}"));
|
||||
if (!shape) return interleave(strings, parts);
|
||||
const params: MessageParams = {};
|
||||
shape.names.forEach((name, i) => (params[name] = parts[i]));
|
||||
return formatMessage(shape.translation, params);
|
||||
}
|
||||
|
||||
function buildText(texts: Catalog, b: Built): string {
|
||||
const english = interleave(b.strings, b.values.map(String));
|
||||
remember(english, b);
|
||||
const text = formatBuilt(texts, b);
|
||||
if (text !== english) remember(text, b);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Text that a `tx` tag built, in the locale of `texts`; `undefined` for any other text. */
|
||||
function rebuild(texts: Catalog, text: string): string | undefined {
|
||||
const b = built.get(text);
|
||||
return b && formatBuilt(texts, b);
|
||||
}
|
||||
|
||||
type Template = { pattern: RegExp; names: string[]; translation: string; literal: number };
|
||||
|
||||
const templateCache = new WeakMap<Catalog, Template[]>();
|
||||
@@ -208,7 +279,7 @@ function templatesOf(texts: Catalog): Template[] {
|
||||
}
|
||||
|
||||
function translateText(texts: Catalog, text: string): string {
|
||||
const exact = texts[text];
|
||||
const exact = texts[text] ?? rebuild(texts, text);
|
||||
if (exact !== undefined) return exact;
|
||||
for (const { pattern, names, translation } of templatesOf(texts)) {
|
||||
const m = pattern.exec(text);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type InspectionRef,
|
||||
type ProfileOverrides,
|
||||
} from "./inspections";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
/** Menu rows (structurally `@/ui` `MenuItem`s). */
|
||||
export type ControlItem =
|
||||
@@ -106,7 +107,7 @@ export function problemControls(inspection: InspectionRef): ControlItem[] {
|
||||
return [
|
||||
{
|
||||
id: "inspection.disable",
|
||||
label: `Disable Inspection '${inspection.name}'`,
|
||||
label: tx`Disable Inspection '${inspection.name}'`,
|
||||
verbatim: true,
|
||||
run: () => report(disableInspection(inspection.id)),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { executeAction, getAction } from "./actions";
|
||||
import { call, commands, nextRequestId, type InstallEvent, type Sdk } from "./ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export const DOWNLOAD_JDK_ACTION = "java.downloadJdk";
|
||||
export const TEMURIN = "temurin";
|
||||
@@ -38,7 +39,7 @@ export function downloadJdkActions(message: string | null | undefined): Array<{
|
||||
const need = requiredJdk(message);
|
||||
if (!need || !getAction(DOWNLOAD_JDK_ACTION)) return [];
|
||||
const args: DownloadJdkArgs = { version: need.version, languageServer: need.languageServer };
|
||||
return [{ label: `Download JDK ${need.version}`, run: () => void executeAction(DOWNLOAD_JDK_ACTION, args) }];
|
||||
return [{ label: tx`Download JDK ${need.version}`, run: () => void executeAction(DOWNLOAD_JDK_ACTION, args) }];
|
||||
}
|
||||
|
||||
export function isDownloadJdkArgs(arg: unknown): arg is DownloadJdkArgs {
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
"Environment": "环境",
|
||||
"Environment variables": "环境变量",
|
||||
"Error Highlighting": "错误高亮",
|
||||
"Ethan Schoonover's Solarized palette, dark variant.": "Ethan Schoonover 的 Solarized 调色板,深色变体。",
|
||||
"Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.": "Solarized Light 和 Solarized Dark:Ethan Schoonover 的 Solarized 调色板。",
|
||||
"Evaluate": "计算",
|
||||
"Evaluate Expression…": "计算表达式…",
|
||||
"Evaluate expression": "计算表达式",
|
||||
@@ -2343,7 +2343,6 @@
|
||||
"select specific rows from a table": "从表中选择特定行",
|
||||
"select the number of specific rows in a table": "选择表中特定行的数量",
|
||||
"surrounded text,": "被包围的文本,",
|
||||
"the JavaScript debugger (vscode-js-debug)": "JavaScript 调试器 (vscode-js-debug)",
|
||||
"the language server's own TypeScript": "语言服务器自带的 TypeScript",
|
||||
"update values in a table": "更新表中的值",
|
||||
"val declaration": "val 声明",
|
||||
@@ -2693,7 +2692,6 @@
|
||||
"Client Certificate File": "客户端证书文件",
|
||||
"Client key file:": "客户端密钥文件:",
|
||||
"Client Key File": "客户端密钥文件",
|
||||
"schemas (databases)": "架构(数据库)",
|
||||
"Load Names from Server": "从服务器加载名称",
|
||||
"Driver properties": "驱动程序属性",
|
||||
"Property name": "属性名称",
|
||||
@@ -3181,7 +3179,6 @@
|
||||
"Unused Images": "未使用的镜像",
|
||||
"Unused Volumes": "未使用的卷",
|
||||
"Unused Networks": "未使用的网络",
|
||||
"Unused Containers": "未使用的容器",
|
||||
"Everything Unused (System Prune)": "所有未使用的对象(系统清理)",
|
||||
"Remove all stopped containers?": "移除所有已停止的容器?",
|
||||
"Remove all dangling (untagged) images?": "移除所有悬空(无标签)镜像?",
|
||||
@@ -3190,7 +3187,6 @@
|
||||
"Remove all networks not used by a container?": "移除所有未被容器使用的网络?",
|
||||
"Remove stopped containers, unused networks, dangling images and the build cache?": "移除已停止的容器、未使用的网络、悬空镜像和构建缓存?",
|
||||
"Prune {target}": "清理{target}",
|
||||
"Prune {target}…": "清理{target}…",
|
||||
"Pruning {target}…": "正在清理 {target}…",
|
||||
"Pulling {name}…": "正在拉取 {name}…",
|
||||
"containers": "容器",
|
||||
@@ -3454,7 +3450,6 @@
|
||||
"default": "默认",
|
||||
"<unchanged>": "<未更改>",
|
||||
"Single database mode (show only the configured database)": "单数据库模式(仅显示配置的数据库)",
|
||||
"databases": "数据库",
|
||||
"schemas": "架构",
|
||||
"Object filter:": "对象筛选:",
|
||||
"e.g. order*, customers (tables, views, routines, sequences)": "例如 order*、customers(表、视图、例程、序列)",
|
||||
@@ -3469,7 +3464,6 @@
|
||||
"<data source>": "<数据源>",
|
||||
"Cannot explain the statement": "无法解释该语句",
|
||||
"Cannot get the definition of {name}": "无法获取 {name} 的定义",
|
||||
"Cannot copy the {what}": "无法复制 {what}",
|
||||
"Script finished: {length} statement": "脚本已完成:{length} 条语句",
|
||||
"Script finished: {length} statements": "脚本已完成:{length} 条语句",
|
||||
"Import from Clipboard": "从剪贴板导入",
|
||||
@@ -3623,7 +3617,6 @@
|
||||
"Compare Versions": "比较版本",
|
||||
"Select two commits to compare": "选择两个提交进行比较",
|
||||
"Label '{value}' added to Local History": "已将标签 '{value}' 添加到本地历史记录",
|
||||
"No {noun} in progress": "没有正在进行的 {noun}",
|
||||
"{doneTitle} successful": "{doneTitle}成功",
|
||||
"{file} updated in {commit}": "已在 {commit} 中更新 {file}",
|
||||
"Shelved {length} file": "已搁置 {length} 个文件",
|
||||
@@ -3639,7 +3632,6 @@
|
||||
"Open the HTML or SVG file in the default browser through the built-in server": "通过内置服务器在默认浏览器中打开 HTML 或 SVG 文件",
|
||||
"Show the HTML file rendered beside the editor": "在编辑器旁显示 HTML 文件的渲染结果",
|
||||
"Project folder not found: {name}": "找不到项目文件夹:{name}",
|
||||
"Cannot open {name}": "无法打开 {name}",
|
||||
"Cannot reopen {name}": "无法重新打开 {name}",
|
||||
"Edit inspection profile setting": "编辑检查配置文件设置",
|
||||
"Disable inspection": "禁用检查",
|
||||
@@ -3650,5 +3642,285 @@
|
||||
"Process literals": "处理字面量",
|
||||
"Process comments": "处理注释",
|
||||
"Distraction Free Mode": "无干扰模式",
|
||||
"Exit Distraction Free Mode": "退出无干扰模式"
|
||||
"Exit Distraction Free Mode": "退出无干扰模式",
|
||||
"Copied reference: {reference}": "已复制引用:{reference}",
|
||||
"{count} lines": "{count} 行",
|
||||
"Preview of {path}": "{path} 的预览",
|
||||
"Editor for {path}": "{path} 的编辑器",
|
||||
"Unwrap '{label}'": "解包 '{label}'",
|
||||
"'{name}' is read-only. Make it writable?": "'{name}' 为只读。是否设为可写?",
|
||||
"Make '{name}' read-only?": "是否将 '{name}' 设为只读?",
|
||||
"Some unsaved edits to {path} could not be applied and were replaced by the file's current content.": "{path} 的部分未保存编辑无法应用,已被文件的当前内容替换。",
|
||||
"No editor for “{kind}”": "没有适用于“{kind}”的编辑器",
|
||||
"Show usages of {name}": "显示 {name} 的用法",
|
||||
"Cannot resolve file '{value}'": "无法解析文件 '{value}'",
|
||||
"Reformat File: {name}": "重新格式化文件:{name}",
|
||||
"Inspection '{name}' options": "检查 '{name}' 的选项",
|
||||
"Choose Declaration of '{label}' ({count} found)": "选择 '{label}' 的声明(找到 {count} 个)",
|
||||
"Press {keys} to show dialog with more options": "按 {keys} 显示包含更多选项的对话框",
|
||||
"Rename '{oldName}' to '{newName}'": "将 '{oldName}' 重命名为 '{newName}'",
|
||||
"Rename '{name}'": "重命名 '{name}'",
|
||||
"Change '{word}' to": "将 '{word}' 更改为",
|
||||
"Rename '{word}' to": "将 '{word}' 重命名为",
|
||||
"Save '{word}' to project-level dictionary": "将 '{word}' 保存到项目级词典",
|
||||
"Save '{word}' to application-level dictionary": "将 '{word}' 保存到应用程序级词典",
|
||||
"Typo: In word '{word}'": "拼写错误:单词 '{word}'",
|
||||
"Can't rename '{name}' without the language server: only local variables can be renamed based on syntax": "没有语言服务器时无法重命名 '{name}':基于语法只能重命名局部变量",
|
||||
"Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)",
|
||||
"Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)",
|
||||
"Replace all {count} occurrences": "替换全部 {count} 处",
|
||||
"↵ Apply · Esc Keep '{name}'": "↵ 应用 · Esc 保留 '{name}'",
|
||||
" (in {files} files)": "(位于 {files} 个文件中)",
|
||||
"{subject} have {n} usage that is not safe to delete{where}.": "{subject}有 {n} 个用法无法安全删除{where}。",
|
||||
"{subject} have {n} usages that are not safe to delete{where}.": "{subject}有 {n} 个用法无法安全删除{where}。",
|
||||
"{subject} has {n} usage that is not safe to delete{where}.": "{subject}有 {n} 个用法无法安全删除{where}。",
|
||||
"{subject} has {n} usages that are not safe to delete{where}.": "{subject}有 {n} 个用法无法安全删除{where}。",
|
||||
"{n} usage found": "找到 {n} 个用法",
|
||||
"{n} usages found": "找到 {n} 个用法",
|
||||
"Safe Delete {kind} '{name}'": "安全删除{kind} '{name}'",
|
||||
"Delete {kind} '{name}'?": "是否删除{kind} '{name}'?",
|
||||
"File '{name}'": "文件 '{name}'",
|
||||
"The {count} files": "这 {count} 个文件",
|
||||
"Delete “{title}”? Its branch {branch} is kept.": "是否删除“{title}”?其分支 {branch} 将保留。",
|
||||
"Delete “{title}”? Files it changed are kept.": "是否删除“{title}”?它更改的文件将保留。",
|
||||
"{name} is over 5 MB.": "{name} 超过 5 MB。",
|
||||
"{used} / {size} tokens{cost}": "{used} / {size} 个令牌{cost}",
|
||||
"Replace in {path}:{line}": "在 {path}:{line} 中替换",
|
||||
"Clean up {count} files?": "是否清理 {count} 个文件?",
|
||||
"Leave empty to use the folder name ({folder}).": "留空则使用文件夹名称({folder})。",
|
||||
"Debug '{name}'": "调试 '{name}'",
|
||||
"Open {file}": "打开 {file}",
|
||||
"{direction} Hierarchy": "{direction}层次结构",
|
||||
"Language servers: {text}": "语言服务器:{text}",
|
||||
"Install {name}": "安装 {name}",
|
||||
"Restart {name}": "重启 {name}",
|
||||
"{count} error": "{count} 个错误",
|
||||
"{count} errors": "{count} 个错误",
|
||||
"{count} warning": "{count} 个警告",
|
||||
"{count} warnings": "{count} 个警告",
|
||||
"{errors} and {warnings}": "{errors}和{warnings}",
|
||||
"{found} found in {n} file": "在 {n} 个文件中发现{found}",
|
||||
"{found} found in {n} files": "在 {n} 个文件中发现{found}",
|
||||
"{message} Install it with: {command}": "{message} 安装命令:{command}",
|
||||
"Import Data into {schema}.{table}": "将数据导入 {schema}.{table}",
|
||||
"Import {count} Rows": "导入 {count} 行",
|
||||
"{source} Query History": "{source} 查询历史记录",
|
||||
"Local filter of {column}": "{column} 的本地筛选",
|
||||
"Values of {column}": "{column} 的值",
|
||||
"Copy as {format}": "复制为 {format}",
|
||||
"Connect to {name}": "连接到 {name}",
|
||||
"SSH: {name}": "SSH:{name}",
|
||||
"Modify Table {table}": "修改表 {table}",
|
||||
"{label} (seconds)": "{label}(秒)",
|
||||
"Succeeded: {version}": "成功:{version}",
|
||||
"All databases": "全部数据库",
|
||||
"Add databases pattern": "添加数据库模式",
|
||||
"All schemas (databases)": "全部架构(数据库)",
|
||||
"All schemas": "全部架构",
|
||||
"Add schemas (databases) pattern": "添加架构(数据库)模式",
|
||||
"Add schemas pattern": "添加架构模式",
|
||||
"Data Source: {name}": "数据源:{name}",
|
||||
"{table} data": "{table} 数据",
|
||||
"{count} rows": "{count} 行",
|
||||
"Remove data source '{name}'? Its query console file stays on disk.": "是否移除数据源 '{name}'?其查询控制台文件将保留在磁盘上。",
|
||||
"Result {n}": "结果 {n}",
|
||||
"{name} has uncommitted changes. Commit or roll them back before switching to auto-commit?": "{name} 有未提交的更改。是否在切换到自动提交前提交或回滚这些更改?",
|
||||
"Cannot copy the name": "无法复制名称",
|
||||
"Cannot copy the reference": "无法复制引用",
|
||||
"Cannot copy the DDL": "无法复制 DDL",
|
||||
"Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y": "使用 -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y 启动远程 JVM",
|
||||
"{class} reloaded": "已重新加载 {class}",
|
||||
"The JavaScript debugger (vscode-js-debug) is not installed": "JavaScript 调试器 (vscode-js-debug) 未安装",
|
||||
"Delve is not installed": "Delve 未安装",
|
||||
"CodeLLDB is not installed": "CodeLLDB 未安装",
|
||||
"{name} (child)": "{name}(子进程)",
|
||||
"Enable breakpoint at {path}:{line}": "启用位于 {path}:{line} 的断点",
|
||||
"Files {count}": "文件 {count}",
|
||||
"Commits {count}": "提交 {count}",
|
||||
"Conversation {count}": "对话 {count}",
|
||||
"Push '{head}' first": "请先推送 '{head}'",
|
||||
"Review #{number}": "审查 #{number}",
|
||||
"Headers ({count})": "标头 ({count})",
|
||||
"Headers": "标头",
|
||||
"Delete the macro '{name}'?": "是否删除宏 '{name}'?",
|
||||
"{count} processes": "{count} 个进程",
|
||||
"Memory: {usage}": "内存:{usage}",
|
||||
" Memory {before} → {after}.": " 内存 {before} → {after}。",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "已丢弃 {count} 个语法树,已压缩索引。{memory}",
|
||||
"{count} syntax trees dropped, index compacted.{memory}": "已丢弃 {count} 个语法树,已压缩索引。{memory}",
|
||||
"Disable {name}": "禁用 {name}",
|
||||
"Enable {name}": "启用 {name}",
|
||||
"Run {name}": "运行 {name}",
|
||||
"Apply theme {name}": "应用主题 {name}",
|
||||
"Confirm uninstall {name}": "确认卸载 {name}",
|
||||
"From plugin {name}": "来自插件 {name}",
|
||||
"{count} items": "{count} 项",
|
||||
"Refactoring Preview: {title}": "重构预览:{title}",
|
||||
"Include {path}": "包含 {path}",
|
||||
"Project View: {option}": "项目视图:{option}",
|
||||
"Project View: Show {view}": "项目视图:显示{view}",
|
||||
"Reveal in {fileManager}": "在{fileManager}中显示",
|
||||
"Unmark as {role}": "取消标记为{role}",
|
||||
"Updated references in {count} file": "已更新 {count} 个文件中的引用",
|
||||
"Updated references in {count} files": "已更新 {count} 个文件中的引用",
|
||||
"Changed since the refactoring, not reverted: {files}": "重构后已更改,未还原:{files}",
|
||||
"Remove '{name}'": "移除 '{name}'",
|
||||
"{name} output": "{name} 输出",
|
||||
"Finished in {duration}, exit code {code}": "已完成,用时 {duration},退出代码 {code}",
|
||||
"{name} terminal": "{name} 终端",
|
||||
"Stop ({count} running)": "停止({count} 个正在运行)",
|
||||
"Rerun '{name}'": "重新运行 '{name}'",
|
||||
"Stop '{name}'": "停止 '{name}'",
|
||||
"Comparison Failure: {name}": "比较失败:{name}",
|
||||
"Process '{name}' Is Running": "进程 '{name}' 正在运行",
|
||||
"'{name}' is not allowed to run in parallel.\nWould you like to stop {count} running instances?": "'{name}' 不允许并行运行。\n是否停止 {count} 个正在运行的实例?",
|
||||
"'{name}' is not allowed to run in parallel.\nWould you like to stop the running one?": "'{name}' 不允许并行运行。\n是否停止正在运行的实例?",
|
||||
"Do you want to terminate the process '{name}'?": "是否终止进程 '{name}'?",
|
||||
"Running '{name}' without the debugger.": "正在不使用调试器运行 '{name}'。",
|
||||
"{className} is not part of the project, and no library sources are available for it.": "{className} 不属于项目,也没有可用的库源代码。",
|
||||
"{count} running": "{count} 个正在运行",
|
||||
"Stack trace {title}": "堆栈跟踪 {title}",
|
||||
"Error running '{name}': {message}\n": "运行 '{name}' 时出错:{message}\n",
|
||||
"Cannot write to stdin: {message}\n": "无法写入 stdin:{message}\n",
|
||||
"Run Gradle tasks": "运行 Gradle 任务",
|
||||
"Run Maven goals": "运行 Maven 目标",
|
||||
"Delete the list '{name}' with its {count} bookmark?": "是否删除列表 '{name}' 及其 {count} 个书签?",
|
||||
"Delete the list '{name}' with its {count} bookmarks?": "是否删除列表 '{name}' 及其 {count} 个书签?",
|
||||
"Delete {count} Bookmarks": "删除 {count} 个书签",
|
||||
"Remove the connection '{name}' from Services?": "是否从服务中移除连接 '{name}'?",
|
||||
"Remove the registry '{name}' and its saved password?": "是否移除注册表 '{name}' 及其保存的密码?",
|
||||
"Prune Unused Containers…": "清理未使用的容器…",
|
||||
"Prune Dangling Images…": "清理悬空镜像…",
|
||||
"Prune Unused Images…": "清理未使用的镜像…",
|
||||
"Prune Unused Volumes…": "清理未使用的卷…",
|
||||
"Prune Unused Networks…": "清理未使用的网络…",
|
||||
"Prune Everything Unused…": "清理所有未使用的内容…",
|
||||
"Delete Container": "删除容器",
|
||||
"Delete container '{name}'? It is running and will be stopped.": "是否删除容器 '{name}'?它正在运行,将被停止。",
|
||||
"Delete container '{name}'?": "是否删除容器 '{name}'?",
|
||||
"Delete Image": "删除镜像",
|
||||
"Delete image '{name}'?": "是否删除镜像 '{name}'?",
|
||||
"Delete Volume": "删除卷",
|
||||
"Delete volume '{name}'?": "是否删除卷 '{name}'?",
|
||||
"Delete Network": "删除网络",
|
||||
"Delete network '{name}'?": "是否删除网络 '{name}'?",
|
||||
"Stop and remove the containers and networks of '{name}'?": "是否停止并移除 '{name}' 的容器和网络?",
|
||||
"Build Image '{tag}'": "构建镜像 '{tag}'",
|
||||
"Enter a value between {min} and {max}": "请输入介于 {min} 和 {max} 之间的值",
|
||||
"based on {parent}": "基于 {parent}",
|
||||
"Delete keymap '{name}'?": "是否删除按键映射 '{name}'?",
|
||||
"Remove {shortcut}": "移除 {shortcut}",
|
||||
"Keyboard Shortcut: {action}": "键盘快捷键:{action}",
|
||||
"Remove content root {path}": "移除内容根 {path}",
|
||||
"Unmark {folder}": "取消标记 {folder}",
|
||||
"File name patterns of {name}": "{name} 的文件名模式",
|
||||
"Hashbang patterns of {name}": "{name} 的 Hashbang 模式",
|
||||
"{group} popup type": "{group}弹出类型",
|
||||
"Show {group} in tool window": "在工具窗口中显示{group}",
|
||||
"{label} hex": "{label}十六进制值",
|
||||
"{label} custom value": "{label}自定义值",
|
||||
"Delete inspection profile '{name}'?": "是否删除检查配置文件 '{name}'?",
|
||||
"{server} problems with code {code} ({source}).": "{server} 报告的代码为 {code} 的问题({source})。",
|
||||
"{server} problems with code {code}.": "{server} 报告的代码为 {code} 的问题。",
|
||||
"{description}\n\nExample: {sample}": "{description}\n\n示例:{sample}",
|
||||
"Problems the {server} language server reports in {languages} files. Severity \"As reported\" keeps the level the server assigns; a code below can override it.": "{server} 语言服务器在 {languages} 文件中报告的问题。严重性“按报告”保留服务器分配的级别;下方的代码可以覆盖它。",
|
||||
"Problems the {server} language server reports. Severity \"As reported\" keeps the level the server assigns; a code below can override it.": "{server} 语言服务器报告的问题。严重性“按报告”保留服务器分配的级别;下方的代码可以覆盖它。",
|
||||
"Auto-detected: {path}": "自动检测:{path}",
|
||||
"New {name}": "新建 {name}",
|
||||
"Name (.{extension})": "名称 (.{extension})",
|
||||
"'{abbreviation}' does not describe a single element.": "'{abbreviation}' 不是描述单个元素的缩写。",
|
||||
"New {name}…": "新建 {name}…",
|
||||
"{name} expression": "{name} 表达式",
|
||||
"{name} default value": "{name} 默认值",
|
||||
"Skip {name} if defined": "已定义时跳过 {name}",
|
||||
"Enable .{key}": "启用 .{key}",
|
||||
"{keyword} colour": "{keyword} 颜色",
|
||||
"{count} file": "{count} 个文件",
|
||||
"{count} files": "{count} 个文件",
|
||||
"Reinstall {name}": "重新安装 {name}",
|
||||
"Installed in {path}; it is used for projects targeting Java {version}.": "已安装到 {path};用于以 Java {version} 为目标的项目。",
|
||||
"Code insight for {languages} needs {command}. Install it via {method}?": "{languages} 的代码洞察需要 {command}。是否通过 {method} 安装?",
|
||||
"Version {version}": "版本 {version}",
|
||||
"Branch {name}": "分支 {name}",
|
||||
"Tag {name}": "标签 {name}",
|
||||
"Branch {branch} of {repository}": "{repository} 的分支 {branch}",
|
||||
"Branches of {root}": "{root} 的分支",
|
||||
"Include {name} in commit": "在提交中包含 {name}",
|
||||
"Compare {current} with {branch}": "比较 {current} 与 {branch}",
|
||||
"Rebase '{head}' onto Selected Commit": "将 '{head}' 变基到所选提交",
|
||||
"Revert {file} to the revision from {time} ({label})?": "是否将 {file} 还原到 {time} 的修订({label})?",
|
||||
"Revert {count} files under {folder} to label '{label}'?": "是否将 {folder} 下的 {count} 个文件还原到标签 '{label}'?",
|
||||
"Revert {file} to label '{label}'?": "是否将 {file} 还原到标签 '{label}'?",
|
||||
"Revert from Local History": "从本地历史记录还原",
|
||||
"{count} conflict is unresolved and keeps the base text. Save and mark the file as resolved anyway?": "{count} 个冲突未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"{count} conflicts are unresolved and keep the base text. Save and mark the file as resolved anyway?": "{count} 个冲突未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"{submodule} points to another commit. Check out the recorded commit in the submodule (git submodule update) to revert it.": "{submodule} 指向另一个提交。请在子模块中签出记录的提交 (git submodule update) 以还原它。",
|
||||
"New Branch from '{name}'…": "从 '{name}' 新建分支…",
|
||||
"Rebase Current onto '{name}'": "将当前分支变基到 '{name}'",
|
||||
"Merge '{name}' into Current": "将 '{name}' 合并到当前分支",
|
||||
"Checkout and Rebase onto '{current}'": "签出并变基到 '{current}'",
|
||||
"Compare with '{current}'": "与 '{current}' 比较",
|
||||
"Rebase '{current}' onto '{name}'": "将 '{current}' 变基到 '{name}'",
|
||||
"Merge '{name}' into '{current}'": "将 '{name}' 合并到 '{current}'",
|
||||
"Create New Branch from {from}": "从 {from} 创建新分支",
|
||||
"Rename Branch {name}": "重命名分支 {name}",
|
||||
"Delete branch '{name}' on the remote? The remote-tracking branch '{tracking}' is removed as well.": "是否删除远程上的分支 '{name}'?远程跟踪分支 '{tracking}' 也将被移除。",
|
||||
"New branch name (in {count} repositories):": "新分支名称(在 {count} 个仓库中):",
|
||||
"Delete branch '{name}' in {count} repositories? Unmerged commits are discarded.": "是否删除 {count} 个仓库中的分支 '{name}'?未合并的提交将被丢弃。",
|
||||
"Your local changes to the following files would be overwritten by checkout:\n{files}\n\nSmart Checkout can stash the changes, checkout and unstash them after that.": "签出将覆盖以下文件的本地更改:\n{files}\n\n智能签出可以储藏这些更改,签出后再取消储藏。",
|
||||
"Unstash {ref} as branch {branch}": "将 {ref} 取消储藏为分支 {branch}",
|
||||
"Pop {ref}": "弹出 {ref}",
|
||||
"Apply {ref}": "应用 {ref}",
|
||||
"Drop {ref} “{message}”? This cannot be undone.": "是否丢弃 {ref}“{message}”?此操作无法撤消。",
|
||||
"Drop {ref}? This cannot be undone.": "是否丢弃 {ref}?此操作无法撤消。",
|
||||
"Changelist '{name}' contains {count} change. It will be moved to '{target}'. Delete the changelist?": "变更列表 '{name}' 包含 {count} 个更改,它将被移至 '{target}'。是否删除该变更列表?",
|
||||
"Changelist '{name}' contains {count} changes. They will be moved to '{target}'. Delete the changelist?": "变更列表 '{name}' 包含 {count} 个更改,它们将被移至 '{target}'。是否删除该变更列表?",
|
||||
"{name} (active)": "{name}(活动)",
|
||||
"from {path}": "来自 {path}",
|
||||
"{dir} · from {path}": "{dir} · 来自 {path}",
|
||||
"Clipboard ↔ {file} (selection)": "剪贴板 ↔ {file}(所选内容)",
|
||||
"Clipboard ↔ {file}": "剪贴板 ↔ {file}",
|
||||
"Pull to {head}": "拉取到 {head}",
|
||||
"Merge into {head}": "合并到 {head}",
|
||||
"Merge branch '{branch}' into {head}": "将分支 '{branch}' 合并到 {head}",
|
||||
"Remove remote '{name}'? Its remote-tracking branches are deleted.": "是否移除远程 '{name}'?其远程跟踪分支将被删除。",
|
||||
"Remove the worktree at {path}? Its folder is deleted.": "是否移除位于 {path} 的工作树?其文件夹将被删除。",
|
||||
"{error}\n\nRemove it anyway, discarding its changes?": "{error}\n\n仍要移除并丢弃其更改吗?",
|
||||
"{count} file has conflicts: {files}": "{count} 个文件存在冲突:{files}",
|
||||
"{count} files have conflicts: {files}": "{count} 个文件存在冲突:{files}",
|
||||
"{status} (from {path})": "{status}(来自 {path})",
|
||||
"Create New Tag On {commit}": "在 {commit} 上创建新标签",
|
||||
"Changes between {from} and Local Working Tree": "{from} 与本地工作树之间的更改",
|
||||
"Changes between {from} and {to}": "{from} 与 {to} 之间的更改",
|
||||
"No rebase in progress": "没有正在进行的变基",
|
||||
"No merge in progress": "没有正在进行的合并",
|
||||
"No cherry-pick in progress": "没有正在进行的拣选",
|
||||
"No revert in progress": "没有正在进行的还原",
|
||||
"No patch application in progress": "没有正在进行的补丁应用",
|
||||
"Apply Patch: {source}": "应用补丁:{source}",
|
||||
"Drop {count} commits from the current branch? Their changes are removed.": "是否从当前分支丢弃 {count} 个提交?它们的更改将被移除。",
|
||||
"Drop commit {id} “{summary}” from the current branch? Its changes are removed.": "是否从当前分支丢弃提交 {id}“{summary}”?其更改将被移除。",
|
||||
"Action for {id}": "{id} 的操作",
|
||||
"Rebasing {n} commit onto {onto}": "正在将 {n} 个提交变基到 {onto}",
|
||||
"Rebasing {n} commits onto {onto}": "正在将 {n} 个提交变基到 {onto}",
|
||||
"Rebasing {n} commit from the root": "正在从根变基 {n} 个提交",
|
||||
"Rebasing {n} commits from the root": "正在从根变基 {n} 个提交",
|
||||
"Push Commits to {remote}": "推送提交到 {remote}",
|
||||
"You're going to force push to '{target}'. It may overwrite commits at the remote. Are you sure you want to proceed?": "您将强制推送到 '{target}'。这可能会覆盖远程上的提交。确定要继续吗?",
|
||||
"Delete “{name}” from the shelf? This cannot be undone.": "是否从搁置中删除“{name}”?此操作无法撤消。",
|
||||
"Handler for {keystroke}": "{keystroke} 的处理程序",
|
||||
"Open '{path}' in Browser": "在浏览器中打开 '{path}'",
|
||||
"Open ({shortcut})": "打开 ({shortcut})",
|
||||
"Remove '{name}' from the list of recent projects?": "是否从最近项目列表中移除 '{name}'?",
|
||||
"Disable Inspection '{name}'": "禁用检查 '{name}'",
|
||||
"Download JDK {version}": "下载 JDK {version}",
|
||||
"Cancel {title}": "取消{title}",
|
||||
"Background tasks: {title}": "后台任务:{title}",
|
||||
"{files} files · {symbols} symbols": "{files} 个文件 · {symbols} 个符号",
|
||||
"{title} view options": "{title}视图选项",
|
||||
"{title} views": "{title}视图",
|
||||
"{title} options": "{title}选项",
|
||||
"Delete the layout '{name}'?": "是否删除布局 '{name}'?",
|
||||
"In {left}, not in {right}": "在 {left} 中,不在 {right} 中",
|
||||
"Local branch '{local}' has commits that do not exist in '{remote}'. Rebase '{local}' onto '{remote}', or drop local commits?": "本地分支 '{local}' 有 '{remote}' 中不存在的提交。是将 '{local}' 变基到 '{remote}',还是丢弃本地提交?"
|
||||
}
|
||||
@@ -14,9 +14,14 @@
|
||||
* - a `TEXT_PROPS` property of an object literal (`{ label: "…" }`): actions, menu
|
||||
* items, select options, dialogs, notifications, settings pages, tool windows;
|
||||
* - the text arguments of `showHint` and `showError`.
|
||||
* Template literals (`Cannot open ${path}`) are checked only where the text is
|
||||
* matched against `{name}` templates (`txTemplate`, hints, notification titles);
|
||||
* elsewhere the lookup is exact and they cannot be translated as written.
|
||||
* Text built at runtime is written with the `tx` tag (`` tx`Cannot open ${path}` ``),
|
||||
* which looks up `"Cannot open {path}"`; untagged template literals are checked
|
||||
* where the text is matched against `{name}` templates (`txTemplate`, hints,
|
||||
* notification titles). Elsewhere the lookup is exact, so an untagged template
|
||||
* literal can never be translated: it fails here unless `TEMPLATES_AS_WRITTEN` lists
|
||||
* it. Tagged or matched text must not splice in English words to be read as part of
|
||||
* the sentence (`${kind.toLowerCase()}`, `${n === 1 ? "" : "s"}`): those need one
|
||||
* text per variant.
|
||||
*
|
||||
* Not seen: text kept in a variable or built by a helper before it reaches `tx`,
|
||||
* and text sent by the backend. Their entries count as used (not stale) as long as
|
||||
@@ -70,9 +75,7 @@ const UNCHECKED_FILES = new Set([
|
||||
]);
|
||||
|
||||
/** Entries for text the code assembles from parts, which no literal spells out. */
|
||||
const BUILT_AT_RUNTIME = new Set([
|
||||
"Unused Containers", // `Unused ${group}` in services/ops.ts
|
||||
]);
|
||||
const BUILT_AT_RUNTIME = new Set<string>([]);
|
||||
|
||||
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
|
||||
const UNTRANSLATED = new Set<string>([
|
||||
@@ -188,10 +191,25 @@ const UNTRANSLATED = new Set<string>([
|
||||
"text",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Template literals that reach an exact lookup on purpose: the text around the
|
||||
* values is code or a format that reads the same in every locale.
|
||||
*/
|
||||
const TEMPLATES_AS_WRITTEN = new Set<string>([
|
||||
"{name} (#[cfg(test)])", // Rust attribute after a module name
|
||||
"HEAD ({renamedFrom})", // Git revision and a path
|
||||
]);
|
||||
|
||||
/**
|
||||
* How a text is found in a catalog: as written (`exact`), by matching `{name}`
|
||||
* templates (`pattern`, see `txTemplate`), or by its literal parts (`shape`, the `tx` tag).
|
||||
*/
|
||||
type Match = "exact" | "pattern" | "shape";
|
||||
|
||||
interface Use {
|
||||
text: string;
|
||||
at: string;
|
||||
template: boolean;
|
||||
match: Match;
|
||||
}
|
||||
|
||||
function walk(dir: string, accept: (path: string) => boolean, out: string[] = []): string[] {
|
||||
@@ -237,23 +255,43 @@ const unwrap = (e: ts.Expression): ts.Expression => (ts.isParenthesizedExpressio
|
||||
function templateTexts(node: ts.TemplateExpression): string[] {
|
||||
let out = [node.head.text];
|
||||
for (const span of node.templateSpans) {
|
||||
const e = unwrap(span.expression);
|
||||
let parts: string[];
|
||||
if (
|
||||
ts.isConditionalExpression(e) &&
|
||||
ts.isStringLiteralLike(unwrap(e.whenTrue)) &&
|
||||
ts.isStringLiteralLike(unwrap(e.whenFalse))
|
||||
)
|
||||
parts = [(unwrap(e.whenTrue) as ts.StringLiteralLike).text, (unwrap(e.whenFalse) as ts.StringLiteralLike).text];
|
||||
else {
|
||||
const words = span.expression.getText().match(/[A-Za-z_]\w*/g) ?? ["value"];
|
||||
parts = [`{${words[words.length - 1]}}`];
|
||||
}
|
||||
const branches = stringBranches(span.expression);
|
||||
const parts = branches ?? [placeholder(span.expression)];
|
||||
out = out.flatMap((head) => parts.map((part) => head + part + span.literal.text));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Text of a `tx` tag as it looks it up: every `${expr}` is a placeholder. */
|
||||
function tagText(node: ts.TemplateLiteral): string {
|
||||
if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
return node.head.text + node.templateSpans.map((s) => placeholder(s.expression) + s.literal.text).join("");
|
||||
}
|
||||
|
||||
function placeholder(e: ts.Expression): string {
|
||||
const words = e.getText().match(/[A-Za-z_]\w*/g) ?? ["value"];
|
||||
return `{${words[words.length - 1]}}`;
|
||||
}
|
||||
|
||||
/** The branches of `c ? "a" : "b"`. */
|
||||
function stringBranches(expr: ts.Expression): string[] | undefined {
|
||||
const e = unwrap(expr);
|
||||
if (!ts.isConditionalExpression(e)) return undefined;
|
||||
const [a, b] = [unwrap(e.whenTrue), unwrap(e.whenFalse)];
|
||||
return ts.isStringLiteralLike(a) && ts.isStringLiteralLike(b) ? [a.text, b.text] : undefined;
|
||||
}
|
||||
|
||||
/** A value that changes the case of English text, to splice it into a sentence. */
|
||||
function changesCase(expr: ts.Expression): boolean {
|
||||
let found = false;
|
||||
const visit = (n: ts.Node) => {
|
||||
if (ts.isPropertyAccessExpression(n) && /^to(Lower|Upper)Case$/.test(n.name.text)) found = true;
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(expr);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `{anything}` → `{}`, so templates compare regardless of placeholder names. */
|
||||
const shape = (text: string) => text.replace(/\{[^{}]*\}/g, "{}");
|
||||
|
||||
@@ -339,10 +377,16 @@ interface Scan {
|
||||
translated: Use[];
|
||||
/** Every string literal and JSX text, in shape form. */
|
||||
literals: Set<string>;
|
||||
/** Template literals that reach an exact lookup, which can never match them. */
|
||||
untranslatable: Use[];
|
||||
/** Translated templates that splice English words into the sentence. */
|
||||
spliced: Use[];
|
||||
}
|
||||
|
||||
function scanTypeScript(): Scan {
|
||||
const translated: Use[] = [];
|
||||
const untranslatable: Use[] = [];
|
||||
const spliced: Use[] = [];
|
||||
const literals = new Set<string>();
|
||||
const files = walk(`${ROOT}src`, (p) => /\.tsx?$/.test(p) && !isTest(p) && !p.endsWith("/bindings.ts"));
|
||||
for (const file of files) {
|
||||
@@ -360,7 +404,17 @@ function scanTypeScript(): Scan {
|
||||
for (const m of source.matchAll(/(?:const|let)\s+(\w+)\s*=\s*useTx(Template)?\(\)/g))
|
||||
(m[2] ? template : exact).add(m[1]!);
|
||||
const checked = !UNCHECKED_FILES.has(path);
|
||||
const where = (node: ts.Node) => `${path}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (checked && ts.isTaggedTemplateExpression(node) && ts.isIdentifier(node.tag) && exact.has(node.tag.text)) {
|
||||
const use: Use = { text: tagText(node.template), at: where(node), match: "shape" };
|
||||
translated.push(use);
|
||||
if (
|
||||
ts.isTemplateExpression(node.template) &&
|
||||
node.template.templateSpans.some((s) => stringBranches(s.expression) || changesCase(s.expression))
|
||||
)
|
||||
spliced.push(use);
|
||||
}
|
||||
let texts: string[] | undefined;
|
||||
if (ts.isStringLiteral(node) && ts.isJsxAttribute(node.parent)) texts = [decodeEntities(node.text)];
|
||||
else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) texts = [node.text];
|
||||
@@ -372,17 +426,22 @@ function scanTypeScript(): Scan {
|
||||
literals.add(shape(text.trim()));
|
||||
}
|
||||
const sink = checked ? sinkOf(node, exact, template) : undefined;
|
||||
// A template literal only matches a catalog entry where templates are looked up.
|
||||
if (sink && (sink === "template" || !ts.isTemplateExpression(node))) {
|
||||
const at = `${path}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
|
||||
for (const text of texts) translated.push({ text, at, template: ts.isTemplateExpression(node) });
|
||||
if (sink) {
|
||||
const at = where(node);
|
||||
if (!ts.isTemplateExpression(node)) for (const text of texts) translated.push({ text, at, match: "exact" });
|
||||
else if (sink === "exact") for (const text of texts) untranslatable.push({ text, at, match: "exact" });
|
||||
else {
|
||||
for (const text of texts) translated.push({ text, at, match: "pattern" });
|
||||
if (node.templateSpans.some((s) => changesCase(s.expression)))
|
||||
spliced.push({ text: texts[0]!, at, match: "pattern" });
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
}
|
||||
return { translated, literals };
|
||||
return { translated, literals, untranslatable, spliced };
|
||||
}
|
||||
|
||||
/** String literals of the Rust crates and language definitions (backend messages). */
|
||||
@@ -423,21 +482,43 @@ function templatePatterns(catalog: Record<string, string>): RegExp[] {
|
||||
});
|
||||
}
|
||||
|
||||
const report = (uses: Use[]) =>
|
||||
[...new Map(uses.map((u) => [u.at, u.text]))].map(([at, text]) => `${at} ${JSON.stringify(text)}`);
|
||||
|
||||
it("passes no template literal UI text to an exact lookup; tag it as tx`…`", () => {
|
||||
expect(
|
||||
report(scan.untranslatable.filter(({ text }) => needsTranslation(text) && !TEMPLATES_AS_WRITTEN.has(text))),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("translates built text as whole sentences, one per variant", () => {
|
||||
expect(report(scan.spliced)).toEqual([]);
|
||||
});
|
||||
|
||||
describe.each(Object.keys(catalogs))("source-text catalog %s", (locale) => {
|
||||
const catalog = catalogs[locale]!;
|
||||
const patterns = templatePatterns(catalog);
|
||||
const shapes = new Set(Object.keys(catalog).map(shape));
|
||||
const has = (text: string) => Object.hasOwn(catalog, text);
|
||||
// Placeholders of the source stand for any text, as the runtime values would.
|
||||
const matchesTemplate = (text: string) => patterns.some((p) => p.test(text.replace(/\{[^{}]*\}/g, "\u0001")));
|
||||
const found = ({ text, match }: Use) =>
|
||||
has(text) || (match === "pattern" && matchesTemplate(text)) || (match === "shape" && shapes.has(shape(text)));
|
||||
|
||||
it("translates every literal UI text", () => {
|
||||
const missing = new Map<string, string>();
|
||||
for (const { text, at, template } of scan.translated)
|
||||
if (!has(text) && !(template && matchesTemplate(text)) && needsTranslation(text) && !missing.has(text))
|
||||
missing.set(text, at);
|
||||
for (const use of scan.translated)
|
||||
if (!found(use) && needsTranslation(use.text) && !missing.has(use.text)) missing.set(use.text, use.at);
|
||||
expect([...missing].map(([text, at]) => `${at} ${JSON.stringify(text)}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("has one entry per template, whatever its placeholder names", () => {
|
||||
const byShape = new Map<string, string[]>();
|
||||
for (const key of Object.keys(catalog))
|
||||
if (/\{\w+\}/.test(key)) byShape.set(shape(key), [...(byShape.get(shape(key)) ?? []), key]);
|
||||
expect([...byShape.values()].filter((keys) => keys.length > 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no entries whose text no longer appears in the source", () => {
|
||||
const stale = Object.keys(catalog).filter((key) => !scan.literals.has(shape(key)) && !BUILT_AT_RUNTIME.has(key));
|
||||
expect(stale).toEqual([]);
|
||||
|
||||
Reference in new issue
Заблокувати користувача