transduck 0.5.3 → 0.6.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/dist/backend.d.ts +1 -0
- package/dist/backend.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +64 -12
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -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 +5 -1
- package/src/backend.ts +10 -0
- package/src/cli.ts +64 -12
- package/src/config.ts +4 -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 +40 -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.0",
|
|
4
4
|
"description": "AI-native translation tool using source text as keys",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@anthropic-ai/claude-agent-sdk": ">=0.1.0",
|
|
46
46
|
"@anthropic-ai/sdk": ">=0.30.0",
|
|
47
|
+
"pg": ">=8.0.0",
|
|
47
48
|
"react": ">=18.0.0",
|
|
48
49
|
"react-dom": ">=18.0.0"
|
|
49
50
|
},
|
|
@@ -59,6 +60,9 @@
|
|
|
59
60
|
},
|
|
60
61
|
"@anthropic-ai/claude-agent-sdk": {
|
|
61
62
|
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"pg": {
|
|
65
|
+
"optional": true
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
}
|
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
|
@@ -7,6 +7,7 @@ import { Command } from 'commander';
|
|
|
7
7
|
import { stringify as yamlStringify } from 'yaml';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
import { TranslationStore } from './storage.js';
|
|
10
|
+
import { SharedStore } from './shared-store.js';
|
|
10
11
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
11
12
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
12
13
|
import { getPluralCategory, interpolateVars } from './plural.js';
|
|
@@ -23,15 +24,21 @@ export interface InitOptions {
|
|
|
23
24
|
sourceLang: string;
|
|
24
25
|
targetLangs: string[];
|
|
25
26
|
provider?: number;
|
|
27
|
+
sharedUrl?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export async function runInit(opts: InitOptions): Promise<string> {
|
|
29
31
|
const providerChoice = opts.provider ?? 1;
|
|
30
32
|
|
|
33
|
+
const storage: Record<string, string> = { path: './translations.db' };
|
|
34
|
+
if (opts.sharedUrl) {
|
|
35
|
+
storage.shared_url = opts.sharedUrl;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
const config: Record<string, any> = {
|
|
32
39
|
project: { name: opts.name, context: opts.context },
|
|
33
40
|
languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
|
|
34
|
-
storage
|
|
41
|
+
storage,
|
|
35
42
|
};
|
|
36
43
|
|
|
37
44
|
if (providerChoice === 2) {
|
|
@@ -259,6 +266,17 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
259
266
|
const store = new TranslationStore(cfg.storagePath);
|
|
260
267
|
await store.initialize();
|
|
261
268
|
|
|
269
|
+
let shared: SharedStore | null = null;
|
|
270
|
+
if (cfg.sharedUrl) {
|
|
271
|
+
try {
|
|
272
|
+
shared = new SharedStore(cfg.sharedUrl);
|
|
273
|
+
await shared.initialize();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.warn(`[transduck] Could not connect to shared store: ${(err as Error).message}`);
|
|
276
|
+
shared = null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
262
280
|
const content = readFileSync(opts.filePath, 'utf-8');
|
|
263
281
|
let entries: Array<any>;
|
|
264
282
|
|
|
@@ -311,12 +329,14 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
311
329
|
if (!translatedPhs.has(p)) { allPresent = false; break; }
|
|
312
330
|
}
|
|
313
331
|
|
|
314
|
-
|
|
332
|
+
const pluralParams = {
|
|
315
333
|
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
316
334
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
317
335
|
pluralCategory: cat, translatedText: translatedText as string,
|
|
318
336
|
model: cfg.backendModel, status: allPresent ? 'translated' : 'failed',
|
|
319
|
-
}
|
|
337
|
+
};
|
|
338
|
+
await store.insertPlural(pluralParams);
|
|
339
|
+
if (shared) await shared.insertPlural(pluralParams);
|
|
320
340
|
anyStored = true;
|
|
321
341
|
}
|
|
322
342
|
|
|
@@ -342,18 +362,22 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
342
362
|
);
|
|
343
363
|
|
|
344
364
|
if (validateTranslation(entry.text, result)) {
|
|
345
|
-
|
|
365
|
+
const insertParams = {
|
|
346
366
|
sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
347
367
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
348
368
|
translatedText: result, model: cfg.backendModel, status: 'translated',
|
|
349
|
-
}
|
|
369
|
+
};
|
|
370
|
+
await store.insert(insertParams);
|
|
371
|
+
if (shared) await shared.insert(insertParams);
|
|
350
372
|
translated++;
|
|
351
373
|
} else {
|
|
352
|
-
|
|
374
|
+
const insertParams = {
|
|
353
375
|
sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
|
|
354
376
|
projectContextHash, stringContextHash, stringContext: entry.context ?? '',
|
|
355
377
|
translatedText: result, model: cfg.backendModel, status: 'failed',
|
|
356
|
-
}
|
|
378
|
+
};
|
|
379
|
+
await store.insert(insertParams);
|
|
380
|
+
if (shared) await shared.insert(insertParams);
|
|
357
381
|
failed++;
|
|
358
382
|
}
|
|
359
383
|
} catch {
|
|
@@ -364,6 +388,7 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
|
|
|
364
388
|
}
|
|
365
389
|
|
|
366
390
|
store.close();
|
|
391
|
+
if (shared) await shared.close();
|
|
367
392
|
return `Translated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`;
|
|
368
393
|
}
|
|
369
394
|
|
|
@@ -595,23 +620,47 @@ export async function runStats(opts: StatsOptions): Promise<string> {
|
|
|
595
620
|
|
|
596
621
|
const st = await store.stats();
|
|
597
622
|
const lines: string[] = [
|
|
598
|
-
|
|
599
|
-
`
|
|
623
|
+
'Local store:',
|
|
624
|
+
` Total translations: ${st.totalTranslations}`,
|
|
625
|
+
` Failed translations: ${st.totalFailed}`,
|
|
600
626
|
];
|
|
601
627
|
if (Object.keys(st.byLanguage).length > 0) {
|
|
602
|
-
lines.push('By language:');
|
|
628
|
+
lines.push(' By language:');
|
|
603
629
|
for (const [lang, count] of Object.entries(st.byLanguage).sort()) {
|
|
604
|
-
lines.push(`
|
|
630
|
+
lines.push(` ${lang}: ${count}`);
|
|
605
631
|
}
|
|
606
632
|
}
|
|
607
633
|
store.close();
|
|
634
|
+
|
|
635
|
+
if (cfg.sharedUrl) {
|
|
636
|
+
try {
|
|
637
|
+
const shared = new SharedStore(cfg.sharedUrl);
|
|
638
|
+
await shared.initialize();
|
|
639
|
+
const sharedSt = await shared.stats();
|
|
640
|
+
lines.push('');
|
|
641
|
+
lines.push('Shared store:');
|
|
642
|
+
lines.push(` Total translations: ${sharedSt.totalTranslations}`);
|
|
643
|
+
lines.push(` Failed translations: ${sharedSt.totalFailed}`);
|
|
644
|
+
if (Object.keys(sharedSt.byLanguage).length > 0) {
|
|
645
|
+
lines.push(' By language:');
|
|
646
|
+
for (const [lang, count] of Object.entries(sharedSt.byLanguage).sort()) {
|
|
647
|
+
lines.push(` ${lang}: ${count}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
await shared.close();
|
|
651
|
+
} catch (err) {
|
|
652
|
+
lines.push('');
|
|
653
|
+
lines.push(`Shared store: error connecting (${(err as Error).message})`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
608
657
|
return lines.join('\n');
|
|
609
658
|
}
|
|
610
659
|
|
|
611
660
|
// CLI entry point
|
|
612
661
|
const program = new Command();
|
|
613
662
|
|
|
614
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
663
|
+
program.name('transduck').description('AI-native translation tool').version('0.6.0');
|
|
615
664
|
|
|
616
665
|
program.command('init')
|
|
617
666
|
.description('Initialize a new transduck project')
|
|
@@ -626,6 +675,8 @@ program.command('init')
|
|
|
626
675
|
const sourceLang = await ask('Source language (e.g. EN): ');
|
|
627
676
|
const targetsRaw = await ask('Target languages (comma-separated): ');
|
|
628
677
|
|
|
678
|
+
const sharedUrl = await ask('Shared Postgres URL (optional, press Enter to skip): ');
|
|
679
|
+
|
|
629
680
|
console.log('\nTranslation provider:');
|
|
630
681
|
console.log(' 1. OpenAI (requires API key)');
|
|
631
682
|
console.log(' 2. Claude API (requires API key)');
|
|
@@ -638,6 +689,7 @@ program.command('init')
|
|
|
638
689
|
dir, name, context, sourceLang,
|
|
639
690
|
targetLangs: targetsRaw.split(',').map(s => s.trim()),
|
|
640
691
|
provider: providerChoice,
|
|
692
|
+
sharedUrl: sharedUrl.trim() || undefined,
|
|
641
693
|
});
|
|
642
694
|
console.log(output);
|
|
643
695
|
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface TransduckConfig {
|
|
|
16
16
|
backendModel: string;
|
|
17
17
|
backendTimeout: number;
|
|
18
18
|
backendMaxRetries: number;
|
|
19
|
+
sharedUrl: string | null;
|
|
19
20
|
readOnly: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -59,6 +60,8 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
59
60
|
const backendTimeout = backend.timeout_seconds ?? 10;
|
|
60
61
|
const backendMaxRetries = backend.max_retries ?? 2;
|
|
61
62
|
|
|
63
|
+
const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
|
|
64
|
+
|
|
62
65
|
let readOnly = raw.runtime?.read_only ?? false;
|
|
63
66
|
if (provider === 'claude_code') {
|
|
64
67
|
readOnly = true;
|
|
@@ -70,6 +73,7 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
70
73
|
sourceLang: String(raw.languages.source).toUpperCase(),
|
|
71
74
|
targetLangs: raw.languages.targets.map((l: string) => String(l).toUpperCase()),
|
|
72
75
|
storagePath,
|
|
76
|
+
sharedUrl,
|
|
73
77
|
provider,
|
|
74
78
|
apiKeyEnv,
|
|
75
79
|
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 {
|