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.
@@ -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.3",
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: { path: './translations.db' },
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
- await store.insertPlural({
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
- await store.insert({
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
- await store.insert({
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
- `Total translations: ${st.totalTranslations}`,
599
- `Failed translations: ${st.totalFailed}`,
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(` ${lang}: ${count}`);
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.5.3');
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
- // 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 {