transduck 0.5.3 → 0.6.1
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/dist/backend.d.ts +1 -0
- package/dist/backend.js +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -12
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/handler.d.ts +2 -0
- package/dist/handler.js +79 -41
- package/dist/index.d.ts +17 -2
- package/dist/index.js +191 -92
- package/dist/providers/claude-api.d.ts +1 -0
- package/dist/providers/claude-api.js +11 -0
- package/dist/providers/claude-code.d.ts +1 -0
- package/dist/providers/claude-code.js +6 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/openai-provider.d.ts +1 -0
- package/dist/providers/openai-provider.js +17 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +26 -0
- package/dist/shared-store.d.ts +18 -0
- package/dist/shared-store.js +126 -0
- package/package.json +8 -2
- package/src/backend.ts +10 -0
- package/src/cli.ts +65 -12
- package/src/config.ts +5 -0
- package/src/handler.ts +81 -54
- package/src/index.ts +277 -98
- package/src/providers/claude-api.ts +16 -0
- package/src/providers/claude-code.ts +10 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/openai-provider.ts +24 -0
- package/src/result.ts +30 -0
- package/src/shared-store.ts +157 -0
- package/tests/ait.test.ts +152 -14
- package/tests/backend.test.ts +34 -1
- package/tests/cli.test.ts +33 -0
- package/tests/config.test.ts +54 -0
- package/tests/result.test.ts +62 -0
- package/tests/shared-store.test.ts +210 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
const TABLE_NAME = 'transduck_translations';
|
|
3
|
+
const CREATE_TABLE = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
|
5
|
+
source_lang TEXT NOT NULL,
|
|
6
|
+
target_lang TEXT NOT NULL,
|
|
7
|
+
content_hash TEXT NOT NULL,
|
|
8
|
+
plural_category TEXT NOT NULL DEFAULT '',
|
|
9
|
+
source_text TEXT NOT NULL,
|
|
10
|
+
translated_text TEXT NOT NULL,
|
|
11
|
+
model TEXT NOT NULL,
|
|
12
|
+
status TEXT NOT NULL,
|
|
13
|
+
created_at TEXT NOT NULL,
|
|
14
|
+
project_context_hash TEXT NOT NULL,
|
|
15
|
+
string_context_hash TEXT NOT NULL,
|
|
16
|
+
string_context TEXT NOT NULL DEFAULT '',
|
|
17
|
+
PRIMARY KEY (source_lang, target_lang, content_hash, plural_category)
|
|
18
|
+
)`;
|
|
19
|
+
function contentHash(sourceText, projectContextHash, stringContextHash) {
|
|
20
|
+
return createHash('sha256')
|
|
21
|
+
.update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
|
|
22
|
+
.digest('hex');
|
|
23
|
+
}
|
|
24
|
+
export class SharedStore {
|
|
25
|
+
pool;
|
|
26
|
+
url;
|
|
27
|
+
constructor(url) {
|
|
28
|
+
this.url = url;
|
|
29
|
+
}
|
|
30
|
+
async initialize() {
|
|
31
|
+
if (!this.pool) {
|
|
32
|
+
let pg;
|
|
33
|
+
try {
|
|
34
|
+
pg = await import('pg');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error('pg is required for shared Postgres storage. Install it with: npm install pg');
|
|
38
|
+
}
|
|
39
|
+
const Pool = pg.default?.Pool ?? pg.Pool;
|
|
40
|
+
this.pool = new Pool({ connectionString: this.url });
|
|
41
|
+
}
|
|
42
|
+
await this.getPool().query(CREATE_TABLE);
|
|
43
|
+
}
|
|
44
|
+
getPool() {
|
|
45
|
+
if (!this.pool)
|
|
46
|
+
throw new Error('SharedStore not initialized. Call initialize() first.');
|
|
47
|
+
return this.pool;
|
|
48
|
+
}
|
|
49
|
+
async lookup(params) {
|
|
50
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
51
|
+
const result = await this.getPool().query(`SELECT translated_text FROM ${TABLE_NAME}
|
|
52
|
+
WHERE source_lang = $1 AND target_lang = $2 AND content_hash = $3
|
|
53
|
+
AND plural_category = '' AND status = 'translated'`, [params.sourceLang, params.targetLang, chash]);
|
|
54
|
+
if (result.rows.length > 0) {
|
|
55
|
+
return result.rows[0].translated_text;
|
|
56
|
+
}
|
|
57
|
+
const altResult = await this.getPool().query(`SELECT 1 FROM ${TABLE_NAME}
|
|
58
|
+
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
59
|
+
AND status = 'translated' LIMIT 1`, [params.sourceText, params.sourceLang, params.targetLang]);
|
|
60
|
+
if (altResult.rows.length > 0) {
|
|
61
|
+
console.debug(`Translation exists in shared store for '${params.sourceText}' (${params.sourceLang}→${params.targetLang}) but with a different project context hash. Check project.context in transduck.yaml.`);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
async insert(params) {
|
|
66
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
67
|
+
await this.getPool().query(`INSERT INTO ${TABLE_NAME}
|
|
68
|
+
(source_lang, target_lang, content_hash, plural_category, source_text,
|
|
69
|
+
translated_text, model, status, created_at, project_context_hash,
|
|
70
|
+
string_context_hash, string_context)
|
|
71
|
+
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11)
|
|
72
|
+
ON CONFLICT DO NOTHING`, [
|
|
73
|
+
params.sourceLang, params.targetLang, chash,
|
|
74
|
+
params.sourceText, params.translatedText, params.model, params.status,
|
|
75
|
+
new Date().toISOString(),
|
|
76
|
+
params.projectContextHash, params.stringContextHash, params.stringContext ?? '',
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
async lookupPlural(params) {
|
|
80
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
81
|
+
const result = await this.getPool().query(`SELECT plural_category, translated_text FROM ${TABLE_NAME}
|
|
82
|
+
WHERE source_lang = $1 AND target_lang = $2 AND content_hash = $3
|
|
83
|
+
AND plural_category != '' AND status = 'translated'`, [params.sourceLang, params.targetLang, chash]);
|
|
84
|
+
const forms = {};
|
|
85
|
+
for (const row of result.rows) {
|
|
86
|
+
forms[row.plural_category] = row.translated_text;
|
|
87
|
+
}
|
|
88
|
+
return forms;
|
|
89
|
+
}
|
|
90
|
+
async insertPlural(params) {
|
|
91
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
92
|
+
await this.getPool().query(`INSERT INTO ${TABLE_NAME}
|
|
93
|
+
(source_lang, target_lang, content_hash, plural_category, source_text,
|
|
94
|
+
translated_text, model, status, created_at, project_context_hash,
|
|
95
|
+
string_context_hash, string_context)
|
|
96
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
97
|
+
ON CONFLICT DO NOTHING`, [
|
|
98
|
+
params.sourceLang, params.targetLang, chash, params.pluralCategory,
|
|
99
|
+
params.sourceText, params.translatedText, params.model, params.status,
|
|
100
|
+
new Date().toISOString(),
|
|
101
|
+
params.projectContextHash, params.stringContextHash, params.stringContext ?? '',
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
async stats() {
|
|
105
|
+
const pool = this.getPool();
|
|
106
|
+
const result = await pool.query(`SELECT target_lang, status, COUNT(*) as count FROM ${TABLE_NAME} GROUP BY target_lang, status`);
|
|
107
|
+
let totalTranslations = 0;
|
|
108
|
+
let totalFailed = 0;
|
|
109
|
+
const byLanguage = {};
|
|
110
|
+
for (const row of result.rows) {
|
|
111
|
+
const count = parseInt(row.count, 10);
|
|
112
|
+
if (row.status === 'translated') {
|
|
113
|
+
totalTranslations += count;
|
|
114
|
+
byLanguage[row.target_lang] = (byLanguage[row.target_lang] ?? 0) + count;
|
|
115
|
+
}
|
|
116
|
+
else if (row.status === 'failed') {
|
|
117
|
+
totalFailed += count;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { totalTranslations, totalFailed, byLanguage };
|
|
121
|
+
}
|
|
122
|
+
async close() {
|
|
123
|
+
if (this.pool)
|
|
124
|
+
await this.pool.end();
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transduck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "AI-native translation tool using source text as keys",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
"test:watch": "vitest"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"commander": "^12.0.0",
|
|
28
27
|
"better-sqlite3": "^11.0.0",
|
|
28
|
+
"commander": "^12.0.0",
|
|
29
|
+
"dotenv": "^16.6.1",
|
|
29
30
|
"openai": "^4.0.0",
|
|
30
31
|
"yaml": "^2.0.0"
|
|
31
32
|
},
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"@testing-library/react": "^16.0.0",
|
|
34
35
|
"@types/better-sqlite3": "^7.0.0",
|
|
35
36
|
"@types/node": "^22.0.0",
|
|
37
|
+
"@types/pg": "^8.18.0",
|
|
36
38
|
"@types/react": "^19.0.0",
|
|
37
39
|
"@types/react-dom": "^19.0.0",
|
|
38
40
|
"jsdom": "^25.0.0",
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
"peerDependencies": {
|
|
45
47
|
"@anthropic-ai/claude-agent-sdk": ">=0.1.0",
|
|
46
48
|
"@anthropic-ai/sdk": ">=0.30.0",
|
|
49
|
+
"pg": ">=8.0.0",
|
|
47
50
|
"react": ">=18.0.0",
|
|
48
51
|
"react-dom": ">=18.0.0"
|
|
49
52
|
},
|
|
@@ -59,6 +62,9 @@
|
|
|
59
62
|
},
|
|
60
63
|
"@anthropic-ai/claude-agent-sdk": {
|
|
61
64
|
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"pg": {
|
|
67
|
+
"optional": true
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
}
|
package/src/backend.ts
CHANGED
|
@@ -29,6 +29,16 @@ function checkApiKey(config: TransduckConfig): void {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export async function detectLanguage(
|
|
33
|
+
text: string,
|
|
34
|
+
config: TransduckConfig,
|
|
35
|
+
_clientOverride?: any,
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
if (!_clientOverride) checkApiKey(config);
|
|
38
|
+
const provider = await getProvider(config);
|
|
39
|
+
return provider.detectLanguage(text, config, _clientOverride);
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export async function translate(
|
|
33
43
|
sourceText: string,
|
|
34
44
|
sourceLang: string,
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import 'dotenv/config';
|
|
3
4
|
import { createHash } from 'crypto';
|
|
4
5
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
5
6
|
import { resolve, join, dirname } from 'path';
|
|
@@ -7,6 +8,7 @@ import { Command } from 'commander';
|
|
|
7
8
|
import { stringify as yamlStringify } from 'yaml';
|
|
8
9
|
import { loadConfig } from './config.js';
|
|
9
10
|
import { TranslationStore } from './storage.js';
|
|
11
|
+
import { SharedStore } from './shared-store.js';
|
|
10
12
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
11
13
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
12
14
|
import { getPluralCategory, interpolateVars } from './plural.js';
|
|
@@ -23,15 +25,21 @@ export interface InitOptions {
|
|
|
23
25
|
sourceLang: string;
|
|
24
26
|
targetLangs: string[];
|
|
25
27
|
provider?: number;
|
|
28
|
+
sharedUrl?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export async function runInit(opts: InitOptions): Promise<string> {
|
|
29
32
|
const providerChoice = opts.provider ?? 1;
|
|
30
33
|
|
|
34
|
+
const storage: Record<string, string> = { path: './translations.db' };
|
|
35
|
+
if (opts.sharedUrl) {
|
|
36
|
+
storage.shared_url = opts.sharedUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
const config: Record<string, any> = {
|
|
32
40
|
project: { name: opts.name, context: opts.context },
|
|
33
41
|
languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
|
|
34
|
-
storage
|
|
42
|
+
storage,
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
if (providerChoice === 2) {
|
|
@@ -259,6 +267,17 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
259
267
|
const store = new TranslationStore(cfg.storagePath);
|
|
260
268
|
await store.initialize();
|
|
261
269
|
|
|
270
|
+
let shared: SharedStore | null = null;
|
|
271
|
+
if (cfg.sharedUrl) {
|
|
272
|
+
try {
|
|
273
|
+
shared = new SharedStore(cfg.sharedUrl);
|
|
274
|
+
await shared.initialize();
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.warn(`[transduck] Could not connect to shared store: ${(err as Error).message}`);
|
|
277
|
+
shared = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
262
281
|
const content = readFileSync(opts.filePath, 'utf-8');
|
|
263
282
|
let entries: Array<any>;
|
|
264
283
|
|
|
@@ -311,12 +330,14 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
311
330
|
if (!translatedPhs.has(p)) { allPresent = false; break; }
|
|
312
331
|
}
|
|
313
332
|
|
|
314
|
-
|
|
333
|
+
const pluralParams = {
|
|
315
334
|
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
316
335
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
317
336
|
pluralCategory: cat, translatedText: translatedText as string,
|
|
318
337
|
model: cfg.backendModel, status: allPresent ? 'translated' : 'failed',
|
|
319
|
-
}
|
|
338
|
+
};
|
|
339
|
+
await store.insertPlural(pluralParams);
|
|
340
|
+
if (shared) await shared.insertPlural(pluralParams);
|
|
320
341
|
anyStored = true;
|
|
321
342
|
}
|
|
322
343
|
|
|
@@ -342,18 +363,22 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
342
363
|
);
|
|
343
364
|
|
|
344
365
|
if (validateTranslation(entry.text, result)) {
|
|
345
|
-
|
|
366
|
+
const insertParams = {
|
|
346
367
|
sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
347
368
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
348
369
|
translatedText: result, model: cfg.backendModel, status: 'translated',
|
|
349
|
-
}
|
|
370
|
+
};
|
|
371
|
+
await store.insert(insertParams);
|
|
372
|
+
if (shared) await shared.insert(insertParams);
|
|
350
373
|
translated++;
|
|
351
374
|
} else {
|
|
352
|
-
|
|
375
|
+
const insertParams = {
|
|
353
376
|
sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
354
377
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
355
378
|
translatedText: result, model: cfg.backendModel, status: 'failed',
|
|
356
|
-
}
|
|
379
|
+
};
|
|
380
|
+
await store.insert(insertParams);
|
|
381
|
+
if (shared) await shared.insert(insertParams);
|
|
357
382
|
failed++;
|
|
358
383
|
}
|
|
359
384
|
} catch {
|
|
@@ -364,6 +389,7 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
364
389
|
}
|
|
365
390
|
|
|
366
391
|
store.close();
|
|
392
|
+
if (shared) await shared.close();
|
|
367
393
|
return `Translated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`;
|
|
368
394
|
}
|
|
369
395
|
|
|
@@ -595,23 +621,47 @@ export async function runStats(opts: StatsOptions): Promise<string> {
|
|
|
595
621
|
|
|
596
622
|
const st = await store.stats();
|
|
597
623
|
const lines: string[] = [
|
|
598
|
-
|
|
599
|
-
`
|
|
624
|
+
'Local store:',
|
|
625
|
+
` Total translations: ${st.totalTranslations}`,
|
|
626
|
+
` Failed translations: ${st.totalFailed}`,
|
|
600
627
|
];
|
|
601
628
|
if (Object.keys(st.byLanguage).length > 0) {
|
|
602
|
-
lines.push('By language:');
|
|
629
|
+
lines.push(' By language:');
|
|
603
630
|
for (const [lang, count] of Object.entries(st.byLanguage).sort()) {
|
|
604
|
-
lines.push(`
|
|
631
|
+
lines.push(` ${lang}: ${count}`);
|
|
605
632
|
}
|
|
606
633
|
}
|
|
607
634
|
store.close();
|
|
635
|
+
|
|
636
|
+
if (cfg.sharedUrl) {
|
|
637
|
+
try {
|
|
638
|
+
const shared = new SharedStore(cfg.sharedUrl);
|
|
639
|
+
await shared.initialize();
|
|
640
|
+
const sharedSt = await shared.stats();
|
|
641
|
+
lines.push('');
|
|
642
|
+
lines.push('Shared store:');
|
|
643
|
+
lines.push(` Total translations: ${sharedSt.totalTranslations}`);
|
|
644
|
+
lines.push(` Failed translations: ${sharedSt.totalFailed}`);
|
|
645
|
+
if (Object.keys(sharedSt.byLanguage).length > 0) {
|
|
646
|
+
lines.push(' By language:');
|
|
647
|
+
for (const [lang, count] of Object.entries(sharedSt.byLanguage).sort()) {
|
|
648
|
+
lines.push(` ${lang}: ${count}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
await shared.close();
|
|
652
|
+
} catch (err) {
|
|
653
|
+
lines.push('');
|
|
654
|
+
lines.push(`Shared store: error connecting (${(err as Error).message})`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
608
658
|
return lines.join('\n');
|
|
609
659
|
}
|
|
610
660
|
|
|
611
661
|
// CLI entry point
|
|
612
662
|
const program = new Command();
|
|
613
663
|
|
|
614
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
664
|
+
program.name('transduck').description('AI-native translation tool').version('0.6.1');
|
|
615
665
|
|
|
616
666
|
program.command('init')
|
|
617
667
|
.description('Initialize a new transduck project')
|
|
@@ -626,6 +676,8 @@ program.command('init')
|
|
|
626
676
|
const sourceLang = await ask('Source language (e.g. EN): ');
|
|
627
677
|
const targetsRaw = await ask('Target languages (comma-separated): ');
|
|
628
678
|
|
|
679
|
+
const sharedUrl = await ask('Shared Postgres URL (optional, press Enter to skip): ');
|
|
680
|
+
|
|
629
681
|
console.log('\nTranslation provider:');
|
|
630
682
|
console.log(' 1. OpenAI (requires API key)');
|
|
631
683
|
console.log(' 2. Claude API (requires API key)');
|
|
@@ -638,6 +690,7 @@ program.command('init')
|
|
|
638
690
|
dir, name, context, sourceLang,
|
|
639
691
|
targetLangs: targetsRaw.split(',').map(s => s.trim()),
|
|
640
692
|
provider: providerChoice,
|
|
693
|
+
sharedUrl: sharedUrl.trim() || undefined,
|
|
641
694
|
});
|
|
642
695
|
console.log(output);
|
|
643
696
|
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
1
2
|
import { readFileSync, existsSync } from 'fs';
|
|
2
3
|
import { resolve, dirname, join } from 'path';
|
|
3
4
|
import { parse as parseYaml } from 'yaml';
|
|
@@ -16,6 +17,7 @@ export interface TransduckConfig {
|
|
|
16
17
|
backendModel: string;
|
|
17
18
|
backendTimeout: number;
|
|
18
19
|
backendMaxRetries: number;
|
|
20
|
+
sharedUrl: string | null;
|
|
19
21
|
readOnly: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -59,6 +61,8 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
59
61
|
const backendTimeout = backend.timeout_seconds ?? 10;
|
|
60
62
|
const backendMaxRetries = backend.max_retries ?? 2;
|
|
61
63
|
|
|
64
|
+
const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
|
|
65
|
+
|
|
62
66
|
let readOnly = raw.runtime?.read_only ?? false;
|
|
63
67
|
if (provider === 'claude_code') {
|
|
64
68
|
readOnly = true;
|
|
@@ -70,6 +74,7 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
70
74
|
sourceLang: String(raw.languages.source).toUpperCase(),
|
|
71
75
|
targetLangs: raw.languages.targets.map((l: string) => String(l).toUpperCase()),
|
|
72
76
|
storagePath,
|
|
77
|
+
sharedUrl,
|
|
73
78
|
provider,
|
|
74
79
|
apiKeyEnv,
|
|
75
80
|
tokenEnv,
|
package/src/handler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { loadConfig } from './config.js';
|
|
3
3
|
import { TranslationStore } from './storage.js';
|
|
4
|
+
import { SharedStore } from './shared-store.js';
|
|
4
5
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
5
6
|
import { validateTranslation } from './validation.js';
|
|
6
7
|
|
|
@@ -11,12 +12,14 @@ function hash(text: string): string {
|
|
|
11
12
|
interface TranslationRequestString {
|
|
12
13
|
text: string;
|
|
13
14
|
context?: string;
|
|
15
|
+
sourceLang?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface TranslationRequestPlural {
|
|
17
19
|
one: string;
|
|
18
20
|
other: string;
|
|
19
21
|
context?: string | null;
|
|
22
|
+
sourceLang?: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface TranslationRequest {
|
|
@@ -31,6 +34,7 @@ export interface TranslationResponse {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
let _store: TranslationStore | null = null;
|
|
37
|
+
let _sharedStore: SharedStore | null = null;
|
|
34
38
|
|
|
35
39
|
async function getStore(configPath?: string) {
|
|
36
40
|
if (!_store) {
|
|
@@ -41,12 +45,26 @@ async function getStore(configPath?: string) {
|
|
|
41
45
|
return _store;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
async function getSharedStore(configPath?: string): Promise<SharedStore | null> {
|
|
49
|
+
if (_sharedStore) return _sharedStore;
|
|
50
|
+
const cfg = loadConfig(configPath);
|
|
51
|
+
if (!cfg.sharedUrl) return null;
|
|
52
|
+
try {
|
|
53
|
+
_sharedStore = new SharedStore(cfg.sharedUrl);
|
|
54
|
+
await _sharedStore.initialize();
|
|
55
|
+
return _sharedStore;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** @internal Reset the singleton store — for testing only. */
|
|
45
62
|
export function _resetHandlerStore(): void {
|
|
46
63
|
if (_store) {
|
|
47
64
|
_store.close();
|
|
48
65
|
}
|
|
49
66
|
_store = null;
|
|
67
|
+
_sharedStore = null;
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
export async function handleTranslationRequest(
|
|
@@ -55,6 +73,7 @@ export async function handleTranslationRequest(
|
|
|
55
73
|
): Promise<TranslationResponse> {
|
|
56
74
|
const cfg = loadConfig(configPath);
|
|
57
75
|
const store = await getStore(configPath);
|
|
76
|
+
const shared = await getSharedStore(configPath);
|
|
58
77
|
const targetLang = body.language.toUpperCase();
|
|
59
78
|
|
|
60
79
|
const projectContextHash = hash(cfg.projectContext);
|
|
@@ -64,46 +83,49 @@ export async function handleTranslationRequest(
|
|
|
64
83
|
|
|
65
84
|
// Translate regular strings
|
|
66
85
|
for (const item of body.strings ?? []) {
|
|
86
|
+
const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
67
87
|
const stringContextHash = hash(item.context ?? '');
|
|
68
88
|
const key = `${item.text}||${item.context ?? ''}`;
|
|
89
|
+
const lookupParams = {
|
|
90
|
+
sourceText: item.text, sourceLang, targetLang,
|
|
91
|
+
projectContextHash, stringContextHash,
|
|
92
|
+
};
|
|
69
93
|
|
|
70
|
-
//
|
|
71
|
-
const cached = await store.lookup(
|
|
72
|
-
sourceText: item.text,
|
|
73
|
-
sourceLang: cfg.sourceLang,
|
|
74
|
-
targetLang,
|
|
75
|
-
projectContextHash,
|
|
76
|
-
stringContextHash,
|
|
77
|
-
});
|
|
78
|
-
|
|
94
|
+
// Tier 1: local cache
|
|
95
|
+
const cached = await store.lookup(lookupParams);
|
|
79
96
|
if (cached !== null) {
|
|
80
97
|
translations[key] = cached;
|
|
81
98
|
continue;
|
|
82
99
|
}
|
|
83
100
|
|
|
101
|
+
// Tier 2: shared store
|
|
102
|
+
if (shared) {
|
|
103
|
+
const sharedCached = await shared.lookup(lookupParams);
|
|
104
|
+
if (sharedCached !== null) {
|
|
105
|
+
// Propagate to local
|
|
106
|
+
await store.insert({
|
|
107
|
+
...lookupParams, stringContext: item.context ?? '',
|
|
108
|
+
translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
|
|
109
|
+
});
|
|
110
|
+
translations[key] = sharedCached;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
// Backend call
|
|
85
116
|
try {
|
|
86
117
|
const translated = await backendTranslate(
|
|
87
|
-
item.text,
|
|
88
|
-
cfg.
|
|
89
|
-
targetLang,
|
|
90
|
-
cfg.projectContext,
|
|
91
|
-
item.context ?? null,
|
|
92
|
-
cfg,
|
|
118
|
+
item.text, sourceLang, targetLang,
|
|
119
|
+
cfg.projectContext, item.context ?? null, cfg,
|
|
93
120
|
);
|
|
94
121
|
|
|
95
122
|
if (validateTranslation(item.text, translated)) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
stringContext: item.context ?? '',
|
|
103
|
-
translatedText: translated,
|
|
104
|
-
model: cfg.backendModel,
|
|
105
|
-
status: 'translated',
|
|
106
|
-
});
|
|
123
|
+
const insertParams = {
|
|
124
|
+
...lookupParams, stringContext: item.context ?? '',
|
|
125
|
+
translatedText: translated, model: cfg.backendModel, status: 'translated',
|
|
126
|
+
};
|
|
127
|
+
await store.insert(insertParams);
|
|
128
|
+
if (shared) await shared.insert(insertParams);
|
|
107
129
|
translations[key] = translated;
|
|
108
130
|
} else {
|
|
109
131
|
translations[key] = item.text;
|
|
@@ -115,49 +137,54 @@ export async function handleTranslationRequest(
|
|
|
115
137
|
|
|
116
138
|
// Translate plurals
|
|
117
139
|
for (const item of body.plurals ?? []) {
|
|
140
|
+
const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
118
141
|
const stringContextHash = hash(item.context ?? '');
|
|
119
142
|
const sourceKey = item.one + '\x00' + item.other;
|
|
120
143
|
const responseKey = `${sourceKey}||${item.context ?? ''}`;
|
|
144
|
+
const lookupParams = {
|
|
145
|
+
sourceText: sourceKey, sourceLang, targetLang,
|
|
146
|
+
projectContextHash, stringContextHash,
|
|
147
|
+
};
|
|
121
148
|
|
|
122
|
-
//
|
|
123
|
-
const cachedForms = await store.lookupPlural(
|
|
124
|
-
sourceText: sourceKey,
|
|
125
|
-
sourceLang: cfg.sourceLang,
|
|
126
|
-
targetLang,
|
|
127
|
-
projectContextHash,
|
|
128
|
-
stringContextHash,
|
|
129
|
-
});
|
|
130
|
-
|
|
149
|
+
// Tier 1: local cache
|
|
150
|
+
const cachedForms = await store.lookupPlural(lookupParams);
|
|
131
151
|
if (Object.keys(cachedForms).length > 0) {
|
|
132
152
|
plurals[responseKey] = cachedForms;
|
|
133
153
|
continue;
|
|
134
154
|
}
|
|
135
155
|
|
|
156
|
+
// Tier 2: shared store
|
|
157
|
+
if (shared) {
|
|
158
|
+
const sharedForms = await shared.lookupPlural(lookupParams);
|
|
159
|
+
if (Object.keys(sharedForms).length > 0) {
|
|
160
|
+
// Propagate to local
|
|
161
|
+
for (const [cat, text] of Object.entries(sharedForms)) {
|
|
162
|
+
await store.insertPlural({
|
|
163
|
+
...lookupParams, stringContext: item.context ?? '',
|
|
164
|
+
pluralCategory: cat, translatedText: text,
|
|
165
|
+
model: cfg.backendModel, status: 'translated',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
plurals[responseKey] = sharedForms;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
136
173
|
// Backend call
|
|
137
174
|
try {
|
|
138
175
|
const forms = await backendTranslatePlural(
|
|
139
|
-
item.one,
|
|
140
|
-
item.
|
|
141
|
-
cfg.sourceLang,
|
|
142
|
-
targetLang,
|
|
143
|
-
cfg.projectContext,
|
|
144
|
-
item.context ?? null,
|
|
145
|
-
cfg,
|
|
176
|
+
item.one, item.other, sourceLang, targetLang,
|
|
177
|
+
cfg.projectContext, item.context ?? null, cfg,
|
|
146
178
|
);
|
|
147
179
|
|
|
148
180
|
for (const [cat, translatedText] of Object.entries(forms)) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
pluralCategory: cat,
|
|
157
|
-
translatedText: translatedText as string,
|
|
158
|
-
model: cfg.backendModel,
|
|
159
|
-
status: 'translated',
|
|
160
|
-
});
|
|
181
|
+
const insertParams = {
|
|
182
|
+
...lookupParams, stringContext: item.context ?? '',
|
|
183
|
+
pluralCategory: cat, translatedText: translatedText as string,
|
|
184
|
+
model: cfg.backendModel, status: 'translated',
|
|
185
|
+
};
|
|
186
|
+
await store.insertPlural(insertParams);
|
|
187
|
+
if (shared) await shared.insertPlural(insertParams);
|
|
161
188
|
}
|
|
162
189
|
plurals[responseKey] = forms;
|
|
163
190
|
} catch {
|