cctrans 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.hi.md +116 -0
- package/README.ja.md +116 -0
- package/README.ko.md +116 -0
- package/README.md +116 -0
- package/README.ru.md +117 -0
- package/README.zh-Hans.md +116 -0
- package/README.zh-Hant.md +116 -0
- package/ROADMAP.md +18 -0
- package/bin/tt.js +272 -0
- package/hook/message-display.js +85 -0
- package/hook/user-prompt-submit.js +63 -0
- package/package.json +43 -0
- package/src/backends/anthropic.js +59 -0
- package/src/backends/azure.js +35 -0
- package/src/backends/claude-code.js +52 -0
- package/src/backends/deepl.js +33 -0
- package/src/backends/google.js +25 -0
- package/src/backends/index.js +34 -0
- package/src/backends/openai.js +47 -0
- package/src/config.js +62 -0
- package/src/interleave.js +93 -0
- package/src/keys.js +48 -0
- package/src/langs.js +94 -0
- package/src/setup.js +99 -0
- package/src/transcript.js +166 -0
- package/src/translate.js +76 -0
package/src/langs.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Supported target languages (CJK + Russian + Hindi — non-Latin scripts only,
|
|
3
|
+
// so "already in target language" detection can be done by Unicode script
|
|
4
|
+
// ranges).
|
|
5
|
+
//
|
|
6
|
+
// Canonical codes use BCP-47 SCRIPT subtags for Chinese (zh-Hans / zh-Hant):
|
|
7
|
+
// Traditional Chinese is a script, not a region — zh-TW/zh-HK are kept as
|
|
8
|
+
// ALIASES for muscle memory and normalize to the script code.
|
|
9
|
+
//
|
|
10
|
+
// Each entry: display name (for LLM prompts), per-backend language codes, and
|
|
11
|
+
// a script regex used to skip lines that are already in the target language.
|
|
12
|
+
|
|
13
|
+
const LANGS = {
|
|
14
|
+
'zh-Hans': {
|
|
15
|
+
name: 'Simplified Chinese',
|
|
16
|
+
google: 'zh-CN', deepl: 'ZH-HANS', azure: 'zh-Hans',
|
|
17
|
+
script: /[一-鿿㐀-䶿]/g, // Han
|
|
18
|
+
},
|
|
19
|
+
'zh-Hant': {
|
|
20
|
+
name: 'Traditional Chinese',
|
|
21
|
+
google: 'zh-TW', deepl: 'ZH-HANT', azure: 'zh-Hant',
|
|
22
|
+
script: /[一-鿿㐀-䶿]/g, // Han
|
|
23
|
+
},
|
|
24
|
+
ja: {
|
|
25
|
+
name: 'Japanese',
|
|
26
|
+
google: 'ja', deepl: 'JA', azure: 'ja',
|
|
27
|
+
script: /[-ゟ゠-ヿ一-鿿]/g, // Kana + Han
|
|
28
|
+
},
|
|
29
|
+
ko: {
|
|
30
|
+
name: 'Korean',
|
|
31
|
+
google: 'ko', deepl: 'KO', azure: 'ko',
|
|
32
|
+
script: /[가-ᄀ-ᇿ-]/g, // Hangul
|
|
33
|
+
},
|
|
34
|
+
ru: {
|
|
35
|
+
name: 'Russian',
|
|
36
|
+
google: 'ru', deepl: 'RU', azure: 'ru',
|
|
37
|
+
script: /[Ѐ-ӿ]/g, // Cyrillic
|
|
38
|
+
},
|
|
39
|
+
hi: {
|
|
40
|
+
name: 'Hindi',
|
|
41
|
+
google: 'hi', deepl: 'HI', azure: 'hi',
|
|
42
|
+
script: /[ऀ-ॿ]/g, // Devanagari
|
|
43
|
+
},
|
|
44
|
+
en: {
|
|
45
|
+
name: 'English',
|
|
46
|
+
google: 'en', deepl: 'EN-US', azure: 'en',
|
|
47
|
+
script: /[A-Za-z]/g, // Latin — used by input translation (prompt -> English)
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Combined non-Latin script regex: "is this text written in one of the
|
|
52
|
+
// supported non-English languages?" Used by the input-translation hook.
|
|
53
|
+
const NON_LATIN = /[一-鿿㐀-䶿-ゟ゠-ヿ가-ᄀ-ᇿЀ-ӿऀ-ॿ]/g;
|
|
54
|
+
|
|
55
|
+
function nonLatinRatio(text) {
|
|
56
|
+
const hits = (text.match(NON_LATIN) || []).length;
|
|
57
|
+
const nonspace = text.replace(/\s/g, '').length;
|
|
58
|
+
return nonspace === 0 ? 0 : hits / nonspace;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Region-code (and bare-zh) aliases -> canonical script codes.
|
|
62
|
+
const ALIASES = {
|
|
63
|
+
zh: 'zh-Hans',
|
|
64
|
+
'zh-CN': 'zh-Hans',
|
|
65
|
+
'zh-SG': 'zh-Hans',
|
|
66
|
+
'zh-TW': 'zh-Hant',
|
|
67
|
+
'zh-HK': 'zh-Hant',
|
|
68
|
+
'zh-MO': 'zh-Hant',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function normalizeLang(code) {
|
|
72
|
+
return ALIASES[code] || code;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getLang(code) {
|
|
76
|
+
return LANGS[normalizeLang(code)] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listLangs() {
|
|
80
|
+
// 'en' is reserved for the input-translation direction (prompt -> English);
|
|
81
|
+
// it's resolvable via getLang but not advertised as an overlay target.
|
|
82
|
+
return Object.keys(LANGS).filter((k) => k !== 'en');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// True if the line is (mostly) already written in the target language's script.
|
|
86
|
+
function isProbablyTarget(line, code) {
|
|
87
|
+
const lang = getLang(code);
|
|
88
|
+
if (!lang) return false;
|
|
89
|
+
const hits = (line.match(lang.script) || []).length;
|
|
90
|
+
const nonspace = line.replace(/\s/g, '').length;
|
|
91
|
+
return nonspace > 0 && hits / nonspace >= 0.3;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { LANGS, getLang, listLangs, isProbablyTarget, normalizeLang, nonLatinRatio };
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Interactive setup wizard: language -> backend -> API-key entry -> live
|
|
3
|
+
// verification -> save. Re-runnable via `tt setup`; non-interactive with
|
|
4
|
+
// flags (--lang, --backend, --key, --yes). Keys go to keys.json only — the
|
|
5
|
+
// shell environment is never read.
|
|
6
|
+
|
|
7
|
+
const readline = require('node:readline/promises');
|
|
8
|
+
const { getState, setState } = require('./config');
|
|
9
|
+
const { listLangs, getLang, normalizeLang } = require('./langs');
|
|
10
|
+
const { listBackends, getBackend } = require('./backends');
|
|
11
|
+
const keys = require('./keys');
|
|
12
|
+
const { buildDisplayContent } = require('./interleave');
|
|
13
|
+
|
|
14
|
+
const C = {
|
|
15
|
+
dim: (s) => '\x1b[2m' + s + '\x1b[0m',
|
|
16
|
+
cyan: (s) => '\x1b[36m' + s + '\x1b[0m',
|
|
17
|
+
green: (s) => '\x1b[32m' + s + '\x1b[0m',
|
|
18
|
+
red: (s) => '\x1b[31m' + s + '\x1b[0m',
|
|
19
|
+
bold: (s) => '\x1b[1m' + s + '\x1b[0m',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function runSetup(opts) {
|
|
23
|
+
opts = opts || {};
|
|
24
|
+
const interactive = !opts.yes && process.stdin.isTTY;
|
|
25
|
+
const rl = interactive
|
|
26
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
27
|
+
: null;
|
|
28
|
+
const ask = async (q, def) => {
|
|
29
|
+
if (!rl) return def;
|
|
30
|
+
const a = (await rl.question(q + (def ? C.dim(' [' + def + '] ') : ' '))).trim();
|
|
31
|
+
return a || def;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
console.log(C.bold('cctranslate setup') + C.dim(' (re-run anytime: tt setup)'));
|
|
36
|
+
|
|
37
|
+
// 1. Target language
|
|
38
|
+
let lang = opts.lang;
|
|
39
|
+
if (!lang) {
|
|
40
|
+
const codes = listLangs();
|
|
41
|
+
console.log('\n' + C.bold('Target language') + ' — translations appear under each English line:');
|
|
42
|
+
codes.forEach((c, i) => console.log(' ' + (i + 1) + '. ' + c.padEnd(8) + C.dim(getLang(c).name)));
|
|
43
|
+
const cur = getState().target;
|
|
44
|
+
const a = await ask('Pick a number or code', cur);
|
|
45
|
+
lang = /^\d+$/.test(a) ? codes[parseInt(a, 10) - 1] : a;
|
|
46
|
+
}
|
|
47
|
+
if (!getLang(lang)) { console.error(C.red('unsupported language: ' + lang)); return false; }
|
|
48
|
+
lang = normalizeLang(lang);
|
|
49
|
+
|
|
50
|
+
// 2. Backend
|
|
51
|
+
let backend = opts.backend;
|
|
52
|
+
if (!backend) {
|
|
53
|
+
console.log('\n' + C.bold('Translation backend') + ':');
|
|
54
|
+
for (const b of listBackends()) {
|
|
55
|
+
console.log(' ' + b.id.padEnd(12) + (b.available() ? C.green('ready ') : C.red('no key ')) + C.dim(b.needs));
|
|
56
|
+
}
|
|
57
|
+
const def = getState().backend && getBackend(getState().backend) && getBackend(getState().backend).available()
|
|
58
|
+
? getState().backend
|
|
59
|
+
: (getBackend('openai').available() ? 'openai' : 'google');
|
|
60
|
+
backend = await ask('Pick a backend', def);
|
|
61
|
+
}
|
|
62
|
+
const b = getBackend(backend);
|
|
63
|
+
if (!b) { console.error(C.red('unknown backend: ' + backend)); return false; }
|
|
64
|
+
|
|
65
|
+
// 3. Key entry for the chosen backend, if missing (keys live ONLY in
|
|
66
|
+
// keys.json — shell env vars are never read)
|
|
67
|
+
if (!b.available() && keys.KEY_IDS.includes(b.id)) {
|
|
68
|
+
const v = opts.key || (await ask('Paste your ' + b.id + ' API key (enter to skip)', ''));
|
|
69
|
+
if (v) { keys.setKey(b.id, v); console.log(C.green('✓') + ' key saved to ' + keys.KEYS_FILE + C.dim(' (chmod 600)')); }
|
|
70
|
+
if (b.id === 'azure' && !keys.getKey('azure-region')) {
|
|
71
|
+
const r = await ask('Azure region (enter to skip)', '');
|
|
72
|
+
if (r) keys.setKey('azure-region', r);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Save config
|
|
77
|
+
setState({ target: lang, backend });
|
|
78
|
+
console.log('\n' + C.green('✓') + ' saved: lang=' + lang + ' (' + getLang(lang).name + '), backend=' + backend +
|
|
79
|
+
(b.available() ? '' : C.red(' (no key yet — will fall back to google)')));
|
|
80
|
+
|
|
81
|
+
// 5. Live verification
|
|
82
|
+
process.stdout.write(C.dim('verifying… '));
|
|
83
|
+
try {
|
|
84
|
+
const { displayContent } = await buildDisplayContent('Setup verification: translation works.\n', {
|
|
85
|
+
target: lang, backend, timeoutMs: 12000,
|
|
86
|
+
});
|
|
87
|
+
console.log('\n' + (displayContent || C.red('(nothing translated — check the backend)')));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.log(C.red('verification failed: ' + e.message));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(C.dim('\nNext: restart Claude Code (new session). Toggle with `!tt off` / `!tt on`; input translation: `tt input on`.'));
|
|
93
|
+
return true;
|
|
94
|
+
} finally {
|
|
95
|
+
if (rl) rl.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { runSetup };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Locate and parse the active Claude Code session transcript (JSONL).
|
|
3
|
+
// Claude Code writes one transcript per session at:
|
|
4
|
+
// ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
|
|
5
|
+
// where <cwd-slug> is the working dir with every non-alphanumeric char -> '-'.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
function projectsRoot() {
|
|
12
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Replicate Claude Code's directory-slug rule for a cwd.
|
|
16
|
+
// e.g. /home/roy/terminal-translate -> -home-roy-terminal-translate
|
|
17
|
+
function slugForCwd(cwd) {
|
|
18
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function newestJsonlIn(dir) {
|
|
22
|
+
let best = null;
|
|
23
|
+
let bestMtime = -1;
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(dir);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
for (const name of entries) {
|
|
31
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
32
|
+
const fp = path.join(dir, name);
|
|
33
|
+
let st;
|
|
34
|
+
try {
|
|
35
|
+
st = fs.statSync(fp);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (st.mtimeMs > bestMtime) {
|
|
40
|
+
bestMtime = st.mtimeMs;
|
|
41
|
+
best = fp;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Find the transcript file for the current session.
|
|
48
|
+
// Strategy: 1) explicit override; 2) newest .jsonl in the cwd-slug dir;
|
|
49
|
+
// 3) globally newest .jsonl across all projects (the active session is
|
|
50
|
+
// almost always the most recently written one).
|
|
51
|
+
function findTranscript(cwd) {
|
|
52
|
+
if (process.env.TT_TRANSCRIPT) return process.env.TT_TRANSCRIPT;
|
|
53
|
+
|
|
54
|
+
const root = projectsRoot();
|
|
55
|
+
const dir = path.join(root, slugForCwd(cwd || process.cwd()));
|
|
56
|
+
const local = newestJsonlIn(dir);
|
|
57
|
+
if (local) return local;
|
|
58
|
+
|
|
59
|
+
// Fallback: scan every project dir for the globally newest transcript.
|
|
60
|
+
let best = null;
|
|
61
|
+
let bestMtime = -1;
|
|
62
|
+
let projectDirs;
|
|
63
|
+
try {
|
|
64
|
+
projectDirs = fs.readdirSync(root);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
for (const d of projectDirs) {
|
|
69
|
+
const candidate = newestJsonlIn(path.join(root, d));
|
|
70
|
+
if (!candidate) continue;
|
|
71
|
+
const m = fs.statSync(candidate).mtimeMs;
|
|
72
|
+
if (m > bestMtime) {
|
|
73
|
+
bestMtime = m;
|
|
74
|
+
best = candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A "real" user prompt = something the human typed (a turn boundary),
|
|
81
|
+
// as opposed to a tool_result or a meta/system event.
|
|
82
|
+
function isRealUserPrompt(o) {
|
|
83
|
+
if (!o || o.type !== 'user') return false;
|
|
84
|
+
if (o.isMeta) return false;
|
|
85
|
+
const c = o.message && o.message.content;
|
|
86
|
+
if (typeof c === 'string') return c.trim().length > 0;
|
|
87
|
+
if (Array.isArray(c)) {
|
|
88
|
+
if (c.some((b) => b && b.type === 'tool_result')) return false;
|
|
89
|
+
return c.some((b) => b && b.type === 'text' && b.text && b.text.trim().length > 0);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readEvents(file) {
|
|
95
|
+
const raw = fs.readFileSync(file, 'utf8').split('\n');
|
|
96
|
+
const events = [];
|
|
97
|
+
for (const ln of raw) {
|
|
98
|
+
if (!ln) continue;
|
|
99
|
+
try {
|
|
100
|
+
events.push(JSON.parse(ln));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
/* ignore partial/corrupt lines */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return events;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Indices (into events) of every real user-prompt turn boundary.
|
|
109
|
+
function boundaryIndices(events) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (let i = 0; i < events.length; i++) {
|
|
112
|
+
if (isRealUserPrompt(events[i])) out.push(i);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Concatenate the assistant's natural-language text (text blocks only;
|
|
118
|
+
// thinking + tool_use excluded) for the reply that follows a given boundary,
|
|
119
|
+
// up to the next boundary.
|
|
120
|
+
function assistantTextBetween(events, startIdx, endIdx) {
|
|
121
|
+
const texts = [];
|
|
122
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
123
|
+
const o = events[i];
|
|
124
|
+
if (!o || o.type !== 'assistant') continue;
|
|
125
|
+
const c = o.message && o.message.content;
|
|
126
|
+
if (Array.isArray(c)) {
|
|
127
|
+
for (const b of c) {
|
|
128
|
+
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
129
|
+
}
|
|
130
|
+
} else if (typeof c === 'string') {
|
|
131
|
+
texts.push(c);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return texts.join('\n').trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Extract an assistant reply. back=0 -> most recent reply, back=1 -> the one
|
|
138
|
+
// before it, etc. Returns { text, total, index } where index is 0-based from
|
|
139
|
+
// the latest.
|
|
140
|
+
function extractReply(file, back) {
|
|
141
|
+
back = back || 0;
|
|
142
|
+
const events = readEvents(file);
|
|
143
|
+
const bounds = boundaryIndices(events);
|
|
144
|
+
if (bounds.length === 0) {
|
|
145
|
+
// No human prompt found; treat the whole file as one reply.
|
|
146
|
+
return { text: assistantTextBetween(events, -1, events.length), total: 1, index: 0 };
|
|
147
|
+
}
|
|
148
|
+
const pick = bounds.length - 1 - back;
|
|
149
|
+
if (pick < 0) return { text: '', total: bounds.length, index: back };
|
|
150
|
+
const start = bounds[pick];
|
|
151
|
+
const end = pick + 1 < bounds.length ? bounds[pick + 1] : events.length;
|
|
152
|
+
return {
|
|
153
|
+
text: assistantTextBetween(events, start, end),
|
|
154
|
+
total: bounds.length,
|
|
155
|
+
index: back,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
projectsRoot,
|
|
161
|
+
slugForCwd,
|
|
162
|
+
findTranscript,
|
|
163
|
+
readEvents,
|
|
164
|
+
isRealUserPrompt,
|
|
165
|
+
extractReply,
|
|
166
|
+
};
|
package/src/translate.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Translation orchestrator: content-addressed cache + backend fallback chain.
|
|
3
|
+
// Backends live in src/backends/ (openai, anthropic, deepl, azure, google,
|
|
4
|
+
// claude-code). On primary failure/timeout the chain falls through (free
|
|
5
|
+
// Google last); on total failure a line echoes its source so the caller still
|
|
6
|
+
// shows the English.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { CACHE_DIR, ensureDirs } = require('./config');
|
|
12
|
+
const { fallbackChain } = require('./backends');
|
|
13
|
+
const { normalizeLang } = require('./langs');
|
|
14
|
+
|
|
15
|
+
function cacheKey(line, target, backend) {
|
|
16
|
+
return crypto.createHash('sha1').update(backend + '|' + target + '|' + line).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function cacheGet(key) {
|
|
19
|
+
try { return fs.readFileSync(path.join(CACHE_DIR, key + '.txt'), 'utf8'); } catch (e) { return null; }
|
|
20
|
+
}
|
|
21
|
+
function cacheSet(key, val) {
|
|
22
|
+
try {
|
|
23
|
+
ensureDirs();
|
|
24
|
+
const f = path.join(CACHE_DIR, key + '.txt');
|
|
25
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
26
|
+
fs.writeFileSync(tmp, val);
|
|
27
|
+
fs.renameSync(tmp, f);
|
|
28
|
+
} catch (e) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withTimeout(promise, ms) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const t = setTimeout(() => reject(new Error('timeout')), ms);
|
|
34
|
+
promise.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Translate source lines -> translations, in order, using cache + the chosen
|
|
39
|
+
// backend with fallback. opts: {target, backend, model, timeoutMs}
|
|
40
|
+
async function translateLines(lines, opts) {
|
|
41
|
+
opts = opts || {};
|
|
42
|
+
// Normalize aliases (zh-CN -> zh-Hans, zh-TW -> zh-Hant) so cache keys are
|
|
43
|
+
// canonical regardless of how the user spelled the code.
|
|
44
|
+
const target = normalizeLang(opts.target || 'zh-Hans');
|
|
45
|
+
const primary = opts.backend || 'google';
|
|
46
|
+
const timeoutMs = opts.timeoutMs || 8000;
|
|
47
|
+
|
|
48
|
+
const out = new Array(lines.length);
|
|
49
|
+
const need = [];
|
|
50
|
+
const needIdx = [];
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const c = cacheGet(cacheKey(lines[i], target, primary));
|
|
53
|
+
if (c !== null) out[i] = c;
|
|
54
|
+
else { need.push(lines[i]); needIdx.push(i); }
|
|
55
|
+
}
|
|
56
|
+
if (need.length === 0) return out;
|
|
57
|
+
|
|
58
|
+
let fresh = null;
|
|
59
|
+
for (const backend of fallbackChain(primary)) {
|
|
60
|
+
try {
|
|
61
|
+
fresh = await withTimeout(backend.translate(need, target, opts), timeoutMs);
|
|
62
|
+
break;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
fresh = null; // try next in chain
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!fresh) fresh = need.slice(); // give up -> echo source
|
|
68
|
+
|
|
69
|
+
for (let j = 0; j < needIdx.length; j++) {
|
|
70
|
+
out[needIdx[j]] = fresh[j];
|
|
71
|
+
if (fresh[j] !== need[j]) cacheSet(cacheKey(need[j], target, primary), fresh[j]);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { translateLines, cacheKey };
|