transduck 0.5.2 → 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.
Files changed (42) hide show
  1. package/dist/backend.d.ts +1 -0
  2. package/dist/backend.js +6 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +64 -12
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.js +2 -0
  7. package/dist/handler.d.ts +2 -0
  8. package/dist/handler.js +79 -41
  9. package/dist/index.d.ts +17 -2
  10. package/dist/index.js +191 -92
  11. package/dist/providers/claude-api.d.ts +1 -0
  12. package/dist/providers/claude-api.js +11 -0
  13. package/dist/providers/claude-code.d.ts +1 -0
  14. package/dist/providers/claude-code.js +6 -0
  15. package/dist/providers/index.d.ts +1 -0
  16. package/dist/providers/openai-provider.d.ts +1 -0
  17. package/dist/providers/openai-provider.js +17 -0
  18. package/dist/result.d.ts +19 -0
  19. package/dist/result.js +26 -0
  20. package/dist/scanner.js +14 -0
  21. package/dist/shared-store.d.ts +18 -0
  22. package/dist/shared-store.js +126 -0
  23. package/package.json +5 -1
  24. package/src/backend.ts +10 -0
  25. package/src/cli.ts +64 -12
  26. package/src/config.ts +4 -0
  27. package/src/handler.ts +81 -54
  28. package/src/index.ts +277 -98
  29. package/src/providers/claude-api.ts +16 -0
  30. package/src/providers/claude-code.ts +10 -0
  31. package/src/providers/index.ts +6 -0
  32. package/src/providers/openai-provider.ts +24 -0
  33. package/src/result.ts +30 -0
  34. package/src/scanner.ts +18 -0
  35. package/src/shared-store.ts +157 -0
  36. package/tests/ait.test.ts +152 -14
  37. package/tests/backend.test.ts +34 -1
  38. package/tests/cli.test.ts +33 -0
  39. package/tests/config.test.ts +40 -0
  40. package/tests/result.test.ts +62 -0
  41. package/tests/scanner.test.ts +38 -0
  42. 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.5.2",
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: { path: './translations.db' },
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
- await store.insertPlural({
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
- await store.insert({
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
- await store.insert({
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
- `Total translations: ${st.totalTranslations}`,
599
- `Failed translations: ${st.totalFailed}`,
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(` ${lang}: ${count}`);
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.5.2');
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
- // Cache lookup
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.sourceLang,
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
- await store.insert({
97
- sourceText: item.text,
98
- sourceLang: cfg.sourceLang,
99
- targetLang,
100
- projectContextHash,
101
- stringContextHash,
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
- // Cache lookup
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.other,
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
- await store.insertPlural({
150
- sourceText: sourceKey,
151
- sourceLang: cfg.sourceLang,
152
- targetLang,
153
- projectContextHash,
154
- stringContextHash,
155
- stringContext: item.context ?? '',
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 {