mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2bd32e14 |
No files matched your search
@@ -1,34 +1,56 @@
|
||||
/**
|
||||
* zh-CN overlay for bundled Fig autocomplete descriptions.
|
||||
* The dictionary is keyed by the exact English description string.
|
||||
* zh-CN / zh-TW overlays for bundled Fig autocomplete descriptions.
|
||||
* Each dictionary is keyed by the exact English description string.
|
||||
* Spec objects stay in English; only suggestion copies shown in the popup change.
|
||||
*
|
||||
* The two dictionaries are separate dynamic imports so the renderer fetches
|
||||
* only the chunk for the active UI locale. zh-TW is generated from zh-CN with
|
||||
* OpenCC s2twp via scripts/generate-autocomplete-descriptions-zh-TW.mjs.
|
||||
*/
|
||||
|
||||
type DescriptionDictionary = Record<string, string>;
|
||||
|
||||
let dictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
let zhCnDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
let zhTwDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
|
||||
function dictionaryFromModule(
|
||||
mod: { default?: DescriptionDictionary } | DescriptionDictionary,
|
||||
): DescriptionDictionary {
|
||||
const loaded = mod as { default?: DescriptionDictionary };
|
||||
return loaded.default ?? (mod as DescriptionDictionary);
|
||||
}
|
||||
|
||||
function loadZhCnDescriptions(): Promise<DescriptionDictionary> {
|
||||
dictionaryPromise ??= import("./descriptions.zh-CN.json").then((mod) => {
|
||||
const loaded = mod as { default?: DescriptionDictionary };
|
||||
return loaded.default ?? (mod as unknown as DescriptionDictionary);
|
||||
});
|
||||
return dictionaryPromise;
|
||||
zhCnDictionaryPromise ??= import("./descriptions.zh-CN.json").then(dictionaryFromModule);
|
||||
return zhCnDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadZhTwDescriptions(): Promise<DescriptionDictionary> {
|
||||
zhTwDictionaryPromise ??= import("./descriptions.zh-TW.json").then(dictionaryFromModule);
|
||||
return zhTwDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadDescriptionsForLocale(locale: string): Promise<DescriptionDictionary> | undefined {
|
||||
if (locale === "zh-CN") return loadZhCnDescriptions();
|
||||
if (locale === "zh-TW") return loadZhTwDescriptions();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy spec-backed suggestions so a zh-CN description overlay cannot leak into
|
||||
* the cached spec or into a later query in another locale.
|
||||
* Copy spec-backed suggestions so a locale overlay cannot leak into the
|
||||
* cached spec or into a later query in another locale.
|
||||
*/
|
||||
export async function localizeSpecSuggestionDescriptions<T extends { description?: string }>(
|
||||
suggestions: readonly T[],
|
||||
locale: string | undefined,
|
||||
): Promise<readonly T[]> {
|
||||
if (locale !== "zh-CN" || suggestions.length === 0) return suggestions;
|
||||
if (!locale || suggestions.length === 0) return suggestions;
|
||||
const pending = loadDescriptionsForLocale(locale);
|
||||
if (!pending) return suggestions;
|
||||
|
||||
let dictionary: DescriptionDictionary;
|
||||
try {
|
||||
dictionary = await loadZhCnDescriptions();
|
||||
dictionary = await pending;
|
||||
} catch {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
@@ -274,8 +274,9 @@ export async function getCompletions(
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/**
|
||||
* UI locale. zh-CN overlays bundled spec descriptions; any other locale
|
||||
* keeps the original English text. Cached specs are not rewritten.
|
||||
* UI locale. zh-CN and zh-TW overlay bundled spec descriptions from their
|
||||
* own dictionaries; any other locale keeps the original English text.
|
||||
* Cached specs are not rewritten.
|
||||
*/
|
||||
locale?: string;
|
||||
} = {},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
||||
|
||||
const SINCE = "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)";
|
||||
const SINCE_ZH = "从指定时间起显示日志(例如 2013-01-02T13:23:37Z),也可用相对时间(例如 42m 表示 42 分钟)";
|
||||
const SINCE_TW = "從指定時間起顯示日誌(例如 2013-01-02T13:23:37Z),也可用相對時間(例如 42m 表示 42 分鐘)";
|
||||
const FILE_REMOVED = "\"%s: File removed before we read it\"";
|
||||
const FILE_REMOVED_TW = "\"%s: 讀取前檔案已被刪除\"";
|
||||
const DEFAULT_LABEL = "(Default)";
|
||||
const NETWORK = "Add a network";
|
||||
const PROCESS_TREE = "ASCII art process tree";
|
||||
const COMPRESSION_PROGRAM = "Do not use archive suffix to determine the compression program";
|
||||
const UNTRANSLATED = "This autocomplete description is intentionally untranslated.";
|
||||
|
||||
const dockerSpec: FigSpec = {
|
||||
@@ -15,6 +25,10 @@ const dockerSpec: FigSpec = {
|
||||
options: [
|
||||
{ name: "--since", description: SINCE },
|
||||
{ name: "--mystery", description: UNTRANSLATED },
|
||||
{ name: "--default-sample", description: DEFAULT_LABEL },
|
||||
{ name: "--network-sample", description: NETWORK },
|
||||
{ name: "--process-sample", description: PROCESS_TREE },
|
||||
{ name: "--file-sample", description: FILE_REMOVED },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -46,6 +60,13 @@ function sinceSuggestion(suggestions: Awaited<ReturnType<typeof getCompletions>>
|
||||
return suggestions.find((suggestion) => suggestion.displayText === "--since");
|
||||
}
|
||||
|
||||
function suggestionNamed(
|
||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
||||
displayText: string,
|
||||
) {
|
||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
||||
}
|
||||
|
||||
test("zh-CN translates docker logs --since and falls back for unknown descriptions", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
@@ -73,3 +94,85 @@ test("en locale leaves autocomplete descriptions unchanged", async () => {
|
||||
assert.equal(since?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-TW translates descriptions with Taiwan phrases and falls back when missing", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--default-sample")?.description, "(預設)");
|
||||
assert.equal(suggestionNamed(suggestions, "--network-sample")?.description, "新增網路");
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, "ASCII 藝術行程樹");
|
||||
assert.equal(suggestionNamed(suggestions, "--file-sample")?.description, FILE_REMOVED_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--mystery")?.description, UNTRANSLATED);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("locales other than zh-CN and zh-TW keep English descriptions", async () => {
|
||||
for (const locale of ["ru", "es", "zh-Hant", undefined]) {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale,
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE);
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, PROCESS_TREE);
|
||||
}
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-CN and zh-TW overlays do not leak across later lookups", async () => {
|
||||
const traditional = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const simplified = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const english = await getCompletions("docker logs --", {
|
||||
locale: "en",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(traditional)?.description, SINCE_TW);
|
||||
assert.equal(sinceSuggestion(simplified)?.description, SINCE_ZH);
|
||||
assert.equal(sinceSuggestion(english)?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("committed zh-TW dictionary keeps zh-CN keys and Taiwan terminology", () => {
|
||||
const dictDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "autocomplete");
|
||||
const simplified = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-CN.json"), "utf8"));
|
||||
const traditional = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-TW.json"), "utf8"));
|
||||
|
||||
assert.deepEqual(Object.keys(traditional), Object.keys(simplified));
|
||||
assert.equal(simplified[DEFAULT_LABEL], "(默认)");
|
||||
assert.equal(traditional[DEFAULT_LABEL], "(預設)");
|
||||
assert.equal(traditional[NETWORK], "新增網路");
|
||||
assert.equal(traditional[PROCESS_TREE], "ASCII 藝術行程樹");
|
||||
assert.equal(traditional[FILE_REMOVED], FILE_REMOVED_TW);
|
||||
assert.equal(traditional[SINCE], SINCE_TW);
|
||||
assert.equal(traditional[COMPRESSION_PROGRAM], "不根據歸檔字尾判斷壓縮程式");
|
||||
|
||||
let processCount = 0;
|
||||
let tripCount = 0;
|
||||
for (const key of Object.keys(simplified)) {
|
||||
const source = simplified[key];
|
||||
const converted = traditional[key];
|
||||
assert.equal(typeof converted, "string");
|
||||
assert.equal(converted.includes("进程"), false);
|
||||
assert.equal(converted.includes("默认"), false);
|
||||
assert.equal(converted.includes("网络"), false);
|
||||
processCount += source.split("进程").length - 1;
|
||||
tripCount += converted.split("行程").length - 1;
|
||||
}
|
||||
assert.ok(processCount > 0);
|
||||
assert.equal(tripCount, processCount);
|
||||
});
|
||||
Generated
+8
@@ -99,6 +99,7 @@
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
@@ -16865,6 +16866,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||
"dev": true,
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/opentype.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"check:codex-app-server-schema": "node scripts/generate-codex-app-server-schema.cjs --check",
|
||||
"check:codebuddy-sdk-contract": "tsc -p tsconfig.codebuddy-sdk-contract.json",
|
||||
"generate:plugin-contract": "node scripts/generate-plugin-contract.mjs",
|
||||
"generate:autocomplete-descriptions-zh-TW": "node scripts/generate-autocomplete-descriptions-zh-TW.mjs",
|
||||
"check:plugin-contract": "node scripts/generate-plugin-contract.mjs --check",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -159,6 +160,7 @@
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Regenerate the zh-TW autocomplete description overlay from the committed
|
||||
* zh-CN dictionary.
|
||||
*
|
||||
* Conversion is OpenCC s2twp: Simplified Chinese to Traditional Chinese
|
||||
* (Taiwan standard) with Taiwan phrases, for example 文件→檔案, 默认→預設,
|
||||
* 网络→網路. English dictionary keys are copied unchanged.
|
||||
*
|
||||
* OpenCC's TWPhrases entry for 進程 lists 程序 first, then 進程 and 行程.
|
||||
* opencc-js keeps that first candidate, so plain s2twp turns 进程 into 程序.
|
||||
* Command descriptions use the Taiwan IT term 行程 instead: 进程 is held out
|
||||
* of the converter and restored as 行程. 程序 in the source still follows
|
||||
* s2twp (程序→程式).
|
||||
*
|
||||
* opencc-js is a devDependency used only by this script. The renderer loads
|
||||
* the committed JSON and does not import opencc-js.
|
||||
*
|
||||
* Usage: node scripts/generate-autocomplete-descriptions-zh-TW.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import OpenCC from "opencc-js";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = path.join(
|
||||
rootDir,
|
||||
"components/terminal/autocomplete/descriptions.zh-CN.json",
|
||||
);
|
||||
const outputPath = path.join(
|
||||
rootDir,
|
||||
"components/terminal/autocomplete/descriptions.zh-TW.json",
|
||||
);
|
||||
|
||||
function printfTokens(text) {
|
||||
return text.match(/%[-+ #0]*\d*(?:\.\d+)?[a-zA-Z]|%%/g) ?? [];
|
||||
}
|
||||
|
||||
// Private-use mark so s2twp cannot rewrite 进程 before it becomes 行程.
|
||||
const PROCESS_MARK = "\u{E000}";
|
||||
|
||||
function toTraditionalTaiwan(text, convert) {
|
||||
if (text.includes(PROCESS_MARK)) {
|
||||
throw new Error("description already contains the 进程 placeholder");
|
||||
}
|
||||
const marked = text.replaceAll("进程", PROCESS_MARK);
|
||||
const converted = convert(marked);
|
||||
if (converted.includes("进程") || (marked.includes(PROCESS_MARK) && !converted.includes(PROCESS_MARK))) {
|
||||
throw new Error("OpenCC dropped the 进程 placeholder");
|
||||
}
|
||||
return converted.replaceAll(PROCESS_MARK, "行程");
|
||||
}
|
||||
|
||||
const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new Error("zh-CN description dictionary must be a JSON object");
|
||||
}
|
||||
|
||||
const convert = OpenCC.Converter({ from: "cn", to: "twp" });
|
||||
const converted = {};
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Expected a string value for ${JSON.stringify(key)}`);
|
||||
}
|
||||
const traditional = toTraditionalTaiwan(value, convert);
|
||||
const sourceTokens = printfTokens(value);
|
||||
const convertedTokens = printfTokens(traditional);
|
||||
if (sourceTokens.join(" ") !== convertedTokens.join(" ")) {
|
||||
throw new Error(
|
||||
`Printf placeholders changed for ${JSON.stringify(key)}: ${sourceTokens.join(" ")} -> ${convertedTokens.join(" ")}`,
|
||||
);
|
||||
}
|
||||
converted[key] = traditional;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(converted, null, 2)}\n`);
|
||||
console.log(
|
||||
`Wrote ${Object.keys(converted).length} zh-TW descriptions to ${path.relative(rootDir, outputPath)}`,
|
||||
);
|
||||
Reference in new issue
Block a user