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.
- 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/scanner.js +14 -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/scanner.ts +18 -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/scanner.test.ts +38 -0
- package/tests/shared-store.test.ts +210 -0
package/src/index.ts
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { loadConfig, type TransduckConfig } from './config.js';
|
|
3
3
|
import { TranslationStore } from './storage.js';
|
|
4
|
-
import {
|
|
4
|
+
import { SharedStore } from './shared-store.js';
|
|
5
|
+
import { TranslationResult } from './result.js';
|
|
6
|
+
import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
|
|
5
7
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
6
8
|
import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
|
|
7
9
|
|
|
8
10
|
interface State {
|
|
9
11
|
config: TransduckConfig | null;
|
|
10
12
|
store: TranslationStore | null;
|
|
13
|
+
sharedStore: SharedStore | null;
|
|
11
14
|
targetLang: string | null;
|
|
12
15
|
pendingTranslations: Map<string, Promise<string>>;
|
|
16
|
+
backgroundKeys: Set<string>;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
let state: State = {
|
|
16
20
|
config: null,
|
|
17
21
|
store: null,
|
|
22
|
+
sharedStore: null,
|
|
18
23
|
targetLang: null,
|
|
19
24
|
pendingTranslations: new Map(),
|
|
25
|
+
backgroundKeys: new Set(),
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
function hash(text: string): string {
|
|
@@ -29,6 +35,17 @@ export async function initialize(config?: TransduckConfig): Promise<void> {
|
|
|
29
35
|
await store.initialize();
|
|
30
36
|
state.config = cfg;
|
|
31
37
|
state.store = store;
|
|
38
|
+
|
|
39
|
+
if (cfg.sharedUrl) {
|
|
40
|
+
try {
|
|
41
|
+
const shared = new SharedStore(cfg.sharedUrl);
|
|
42
|
+
await shared.initialize();
|
|
43
|
+
state.sharedStore = shared;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn(`[transduck] Could not connect to shared store: ${(err as Error).message}`);
|
|
46
|
+
state.sharedStore = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
export function setLanguage(lang: string): void {
|
|
@@ -36,18 +53,36 @@ export function setLanguage(lang: string): void {
|
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export function _resetState(): void {
|
|
39
|
-
state = {
|
|
56
|
+
state = {
|
|
57
|
+
config: null, store: null, sharedStore: null, targetLang: null,
|
|
58
|
+
pendingTranslations: new Map(), backgroundKeys: new Set(),
|
|
59
|
+
};
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
export function _getStore(): TranslationStore | null {
|
|
43
63
|
return state.store;
|
|
44
64
|
}
|
|
45
65
|
|
|
66
|
+
export function _getSharedStore(): SharedStore | null {
|
|
67
|
+
return state.sharedStore;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function _setSharedStore(shared: SharedStore | null): void {
|
|
71
|
+
state.sharedStore = shared;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AitOptions {
|
|
75
|
+
context?: string;
|
|
76
|
+
vars?: Record<string, string | number>;
|
|
77
|
+
sourceLang?: string;
|
|
78
|
+
background?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
export async function ait(
|
|
47
82
|
sourceText: string,
|
|
48
|
-
|
|
83
|
+
contextOrOpts?: string | AitOptions,
|
|
49
84
|
vars?: Record<string, string | number>,
|
|
50
|
-
): Promise<
|
|
85
|
+
): Promise<TranslationResult> {
|
|
51
86
|
if (!state.config || !state.store) {
|
|
52
87
|
throw new Error('transduck not initialized. Call initialize() first.');
|
|
53
88
|
}
|
|
@@ -55,86 +90,170 @@ export async function ait(
|
|
|
55
90
|
throw new Error('Target language not set. Call setLanguage() first.');
|
|
56
91
|
}
|
|
57
92
|
|
|
93
|
+
// Parse overloaded args: ait(text, context?, vars?) OR ait(text, opts)
|
|
94
|
+
let context: string | undefined;
|
|
95
|
+
let resolvedVars: Record<string, string | number> | undefined;
|
|
96
|
+
let sourceLang: string | undefined;
|
|
97
|
+
let background = false;
|
|
98
|
+
|
|
99
|
+
if (typeof contextOrOpts === 'string') {
|
|
100
|
+
context = contextOrOpts;
|
|
101
|
+
resolvedVars = vars;
|
|
102
|
+
} else if (contextOrOpts != null) {
|
|
103
|
+
context = contextOrOpts.context;
|
|
104
|
+
resolvedVars = contextOrOpts.vars;
|
|
105
|
+
sourceLang = contextOrOpts.sourceLang;
|
|
106
|
+
background = contextOrOpts.background ?? false;
|
|
107
|
+
} else {
|
|
108
|
+
resolvedVars = vars;
|
|
109
|
+
}
|
|
110
|
+
|
|
58
111
|
const cfg = state.config;
|
|
112
|
+
const effectiveSourceLang = sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
113
|
+
const targetLang = state.targetLang;
|
|
59
114
|
|
|
60
|
-
if (
|
|
61
|
-
return
|
|
115
|
+
if (targetLang === effectiveSourceLang) {
|
|
116
|
+
return new TranslationResult(
|
|
117
|
+
interpolateVars(sourceText, resolvedVars),
|
|
118
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
119
|
+
);
|
|
62
120
|
}
|
|
63
121
|
|
|
64
122
|
const projectContextHash = hash(cfg.projectContext);
|
|
65
123
|
const stringContextHash = hash(context ?? '');
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const cached = await state.store.lookup({
|
|
69
|
-
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
124
|
+
const lookupParams = {
|
|
125
|
+
sourceText, sourceLang: effectiveSourceLang, targetLang,
|
|
70
126
|
projectContextHash, stringContextHash,
|
|
71
|
-
}
|
|
72
|
-
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Tier 1: local SQLite lookup
|
|
130
|
+
const cached = await state.store.lookup(lookupParams);
|
|
131
|
+
if (cached !== null) {
|
|
132
|
+
return new TranslationResult(
|
|
133
|
+
interpolateVars(cached, resolvedVars),
|
|
134
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Tier 2: shared Postgres lookup
|
|
139
|
+
if (state.sharedStore) {
|
|
140
|
+
const sharedCached = await state.sharedStore.lookup(lookupParams);
|
|
141
|
+
if (sharedCached !== null) {
|
|
142
|
+
// Propagate to local store
|
|
143
|
+
await state.store.insert({
|
|
144
|
+
...lookupParams, stringContext: context ?? '',
|
|
145
|
+
translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
|
|
146
|
+
});
|
|
147
|
+
return new TranslationResult(
|
|
148
|
+
interpolateVars(sharedCached, resolvedVars),
|
|
149
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
73
153
|
|
|
74
154
|
// Read-only mode: skip backend, return source text
|
|
75
155
|
if (cfg.readOnly) {
|
|
76
|
-
return
|
|
156
|
+
return new TranslationResult(
|
|
157
|
+
interpolateVars(sourceText, resolvedVars),
|
|
158
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Background mode: return immediately with pending=true
|
|
163
|
+
if (background) {
|
|
164
|
+
const bgKey = `${sourceText}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
|
|
165
|
+
if (!state.backgroundKeys.has(bgKey)) {
|
|
166
|
+
state.backgroundKeys.add(bgKey);
|
|
167
|
+
_doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
168
|
+
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
169
|
+
}
|
|
170
|
+
return new TranslationResult(
|
|
171
|
+
interpolateVars(sourceText, resolvedVars),
|
|
172
|
+
{ pending: true, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
173
|
+
);
|
|
77
174
|
}
|
|
78
175
|
|
|
79
176
|
// In-process dedup
|
|
80
|
-
const lockKey = `${sourceText}|${
|
|
177
|
+
const lockKey = `${sourceText}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
|
|
81
178
|
|
|
82
179
|
if (state.pendingTranslations.has(lockKey)) {
|
|
83
180
|
const pending = await state.pendingTranslations.get(lockKey)!;
|
|
84
|
-
return
|
|
181
|
+
return new TranslationResult(
|
|
182
|
+
interpolateVars(pending, resolvedVars),
|
|
183
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
184
|
+
);
|
|
85
185
|
}
|
|
86
186
|
|
|
87
|
-
const translationPromise = (
|
|
88
|
-
// Double-check after getting in
|
|
89
|
-
const rechecked = await state.store!.lookup({
|
|
90
|
-
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
|
|
91
|
-
projectContextHash, stringContextHash,
|
|
92
|
-
});
|
|
93
|
-
if (rechecked !== null) return rechecked;
|
|
187
|
+
const translationPromise = _doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
|
|
94
188
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
189
|
+
state.pendingTranslations.set(lockKey, translationPromise);
|
|
190
|
+
const result = await translationPromise;
|
|
191
|
+
state.pendingTranslations.delete(lockKey);
|
|
192
|
+
return new TranslationResult(
|
|
193
|
+
interpolateVars(result, resolvedVars),
|
|
194
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
100
197
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
198
|
+
async function _doTranslate(
|
|
199
|
+
sourceText: string,
|
|
200
|
+
sourceLang: string,
|
|
201
|
+
targetLang: string,
|
|
202
|
+
projectContextHash: string,
|
|
203
|
+
stringContextHash: string,
|
|
204
|
+
stringContext: string,
|
|
205
|
+
cfg: TransduckConfig,
|
|
206
|
+
): Promise<string> {
|
|
207
|
+
// Double-check local cache
|
|
208
|
+
const rechecked = await state.store!.lookup({
|
|
209
|
+
sourceText, sourceLang, targetLang, projectContextHash, stringContextHash,
|
|
210
|
+
});
|
|
211
|
+
if (rechecked !== null) return rechecked;
|
|
110
212
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
213
|
+
try {
|
|
214
|
+
const translated = await backendTranslate(
|
|
215
|
+
sourceText, sourceLang, targetLang,
|
|
216
|
+
cfg.projectContext, stringContext || null, cfg,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const insertParams = {
|
|
220
|
+
sourceText, sourceLang, targetLang,
|
|
221
|
+
projectContextHash, stringContextHash, stringContext,
|
|
222
|
+
translatedText: translated, model: cfg.backendModel,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (!validateTranslation(sourceText, translated)) {
|
|
226
|
+
console.warn(`[transduck] Validation failed for: ${sourceText} -> ${translated}`);
|
|
227
|
+
await state.store!.insert({ ...insertParams, status: 'failed' });
|
|
228
|
+
if (state.sharedStore) await state.sharedStore.insert({ ...insertParams, status: 'failed' });
|
|
119
229
|
return sourceText;
|
|
120
|
-
} finally {
|
|
121
|
-
state.pendingTranslations.delete(lockKey);
|
|
122
230
|
}
|
|
123
|
-
})();
|
|
124
231
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
232
|
+
await state.store!.insert({ ...insertParams, status: 'translated' });
|
|
233
|
+
if (state.sharedStore) await state.sharedStore.insert({ ...insertParams, status: 'translated' });
|
|
234
|
+
return translated;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.warn(`[transduck] Backend failed for: ${sourceText}`, err);
|
|
237
|
+
return sourceText;
|
|
238
|
+
}
|
|
128
239
|
}
|
|
129
240
|
|
|
130
241
|
export { createTransDuckHandler } from './handler.js';
|
|
242
|
+
export { TranslationResult } from './result.js';
|
|
243
|
+
export { SharedStore } from './shared-store.js';
|
|
244
|
+
export async function detectLanguage(text: string): Promise<string> {
|
|
245
|
+
if (!state.config) {
|
|
246
|
+
throw new Error('transduck not initialized. Call initialize() first.');
|
|
247
|
+
}
|
|
248
|
+
return backendDetectLanguage(text, state.config);
|
|
249
|
+
}
|
|
131
250
|
|
|
132
251
|
export async function aitPlural(
|
|
133
252
|
one: string,
|
|
134
253
|
other: string,
|
|
135
254
|
count: number,
|
|
136
|
-
opts?: { context?: string; vars?: Record<string, string | number
|
|
137
|
-
): Promise<
|
|
255
|
+
opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean },
|
|
256
|
+
): Promise<TranslationResult> {
|
|
138
257
|
if (!state.config || !state.store) {
|
|
139
258
|
throw new Error('transduck not initialized. Call initialize() first.');
|
|
140
259
|
}
|
|
@@ -144,6 +263,8 @@ export async function aitPlural(
|
|
|
144
263
|
|
|
145
264
|
const cfg = state.config;
|
|
146
265
|
const context = opts?.context;
|
|
266
|
+
const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
267
|
+
const background = opts?.background ?? false;
|
|
147
268
|
|
|
148
269
|
// Build vars with count
|
|
149
270
|
let vars: Record<string, string | number>;
|
|
@@ -155,13 +276,18 @@ export async function aitPlural(
|
|
|
155
276
|
vars = { ...opts.vars };
|
|
156
277
|
}
|
|
157
278
|
|
|
279
|
+
const targetLang = state.targetLang;
|
|
280
|
+
|
|
158
281
|
// Same language, 2-form language: select directly from provided forms
|
|
159
|
-
if (
|
|
160
|
-
const categories = getPluralCategories(
|
|
282
|
+
if (targetLang === effectiveSourceLang) {
|
|
283
|
+
const categories = getPluralCategories(effectiveSourceLang);
|
|
161
284
|
if (categories.size <= 2) {
|
|
162
|
-
const category = getPluralCategory(
|
|
285
|
+
const category = getPluralCategory(effectiveSourceLang, count);
|
|
163
286
|
const form = category === 'one' ? one : other;
|
|
164
|
-
return
|
|
287
|
+
return new TranslationResult(
|
|
288
|
+
interpolateVars(form, vars),
|
|
289
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
290
|
+
);
|
|
165
291
|
}
|
|
166
292
|
}
|
|
167
293
|
|
|
@@ -169,31 +295,102 @@ export async function aitPlural(
|
|
|
169
295
|
const sourceKey = one + '\x00' + other;
|
|
170
296
|
const projectContextHash = hash(cfg.projectContext);
|
|
171
297
|
const stringContextHash = hash(context ?? '');
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const cached = await state.store.lookupPlural({
|
|
175
|
-
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
298
|
+
const lookupParams = {
|
|
299
|
+
sourceText: sourceKey, sourceLang: effectiveSourceLang, targetLang,
|
|
176
300
|
projectContextHash, stringContextHash,
|
|
177
|
-
}
|
|
301
|
+
};
|
|
178
302
|
|
|
179
|
-
const category = getPluralCategory(
|
|
303
|
+
const category = getPluralCategory(targetLang, count);
|
|
304
|
+
|
|
305
|
+
// Tier 1: local cache lookup
|
|
306
|
+
const cached = await state.store.lookupPlural(lookupParams);
|
|
180
307
|
if (category in cached) {
|
|
181
|
-
return
|
|
308
|
+
return new TranslationResult(
|
|
309
|
+
interpolateVars(cached[category], vars),
|
|
310
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Tier 2: shared store lookup
|
|
315
|
+
if (state.sharedStore) {
|
|
316
|
+
const sharedCached = await state.sharedStore.lookupPlural(lookupParams);
|
|
317
|
+
if (category in sharedCached) {
|
|
318
|
+
// Propagate all forms to local store
|
|
319
|
+
for (const [cat, text] of Object.entries(sharedCached)) {
|
|
320
|
+
await state.store.insertPlural({
|
|
321
|
+
...lookupParams, stringContext: context ?? '',
|
|
322
|
+
pluralCategory: cat, translatedText: text,
|
|
323
|
+
model: cfg.backendModel, status: 'translated',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return new TranslationResult(
|
|
327
|
+
interpolateVars(sharedCached[category], vars),
|
|
328
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
182
331
|
}
|
|
183
332
|
|
|
184
333
|
// Read-only mode: skip backend, fall back to source forms
|
|
185
334
|
if (cfg.readOnly) {
|
|
186
|
-
const fallbackCategory = getPluralCategory(
|
|
335
|
+
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
|
336
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
337
|
+
return new TranslationResult(
|
|
338
|
+
interpolateVars(fallback, vars),
|
|
339
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Background mode
|
|
344
|
+
if (background) {
|
|
345
|
+
const bgKey = `plural|${sourceKey}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
|
|
346
|
+
if (!state.backgroundKeys.has(bgKey)) {
|
|
347
|
+
state.backgroundKeys.add(bgKey);
|
|
348
|
+
_doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
349
|
+
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
350
|
+
}
|
|
351
|
+
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
|
187
352
|
const fallback = fallbackCategory === 'one' ? one : other;
|
|
188
|
-
return
|
|
353
|
+
return new TranslationResult(
|
|
354
|
+
interpolateVars(fallback, vars),
|
|
355
|
+
{ pending: true, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
356
|
+
);
|
|
189
357
|
}
|
|
190
358
|
|
|
191
359
|
// Cache miss — call backend
|
|
360
|
+
const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
|
|
361
|
+
|
|
362
|
+
if (translatedText !== null && category in translatedText) {
|
|
363
|
+
return new TranslationResult(
|
|
364
|
+
interpolateVars(translatedText[category], vars),
|
|
365
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Fallback
|
|
370
|
+
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
|
371
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
372
|
+
return new TranslationResult(
|
|
373
|
+
interpolateVars(fallback, vars),
|
|
374
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function _doPluralTranslate(
|
|
379
|
+
one: string,
|
|
380
|
+
other: string,
|
|
381
|
+
sourceKey: string,
|
|
382
|
+
sourceLang: string,
|
|
383
|
+
targetLang: string,
|
|
384
|
+
projectContextHash: string,
|
|
385
|
+
stringContextHash: string,
|
|
386
|
+
stringContext: string,
|
|
387
|
+
cfg: TransduckConfig,
|
|
388
|
+
): Promise<Record<string, string> | null> {
|
|
192
389
|
try {
|
|
193
390
|
const forms = await backendTranslatePlural(
|
|
194
391
|
one, other,
|
|
195
|
-
|
|
196
|
-
cfg.projectContext,
|
|
392
|
+
sourceLang, targetLang,
|
|
393
|
+
cfg.projectContext, stringContext || null, cfg,
|
|
197
394
|
);
|
|
198
395
|
|
|
199
396
|
// Validate and store each form
|
|
@@ -205,14 +402,16 @@ export async function aitPlural(
|
|
|
205
402
|
|
|
206
403
|
if (typeof forms !== 'object' || forms === null || !('other' in forms)) {
|
|
207
404
|
console.warn(`[transduck] Invalid plural response for: ${one} / ${other}`);
|
|
208
|
-
|
|
209
|
-
const fallback = fallbackCategory === 'one' ? one : other;
|
|
210
|
-
return interpolateVars(fallback, vars);
|
|
405
|
+
return null;
|
|
211
406
|
}
|
|
212
407
|
|
|
408
|
+
const lookupParams = {
|
|
409
|
+
sourceText: sourceKey, sourceLang, targetLang,
|
|
410
|
+
projectContextHash, stringContextHash,
|
|
411
|
+
};
|
|
412
|
+
|
|
213
413
|
for (const [cat, text] of Object.entries(forms)) {
|
|
214
414
|
if (!validCategories.has(cat) || !text) continue;
|
|
215
|
-
// Validate placeholders
|
|
216
415
|
const translatedPlaceholders = extractPlaceholders(text);
|
|
217
416
|
let allPresent = true;
|
|
218
417
|
for (const p of sourcePlaceholders) {
|
|
@@ -222,38 +421,18 @@ export async function aitPlural(
|
|
|
222
421
|
}
|
|
223
422
|
}
|
|
224
423
|
const status = allPresent ? 'translated' : 'failed';
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
projectContextHash, stringContextHash, stringContext: context ?? '',
|
|
424
|
+
const insertParams = {
|
|
425
|
+
...lookupParams, stringContext,
|
|
228
426
|
pluralCategory: cat, translatedText: text,
|
|
229
427
|
model: cfg.backendModel, status,
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Select the right form
|
|
234
|
-
if (category in forms) {
|
|
235
|
-
const text = forms[category];
|
|
236
|
-
const tp = extractPlaceholders(text);
|
|
237
|
-
let allPresent = true;
|
|
238
|
-
for (const p of sourcePlaceholders) {
|
|
239
|
-
if (!tp.has(p)) {
|
|
240
|
-
allPresent = false;
|
|
241
|
-
break;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (allPresent) {
|
|
245
|
-
return interpolateVars(text, vars);
|
|
246
|
-
}
|
|
428
|
+
};
|
|
429
|
+
await state.store!.insertPlural(insertParams);
|
|
430
|
+
if (state.sharedStore) await state.sharedStore.insertPlural(insertParams);
|
|
247
431
|
}
|
|
248
432
|
|
|
249
|
-
|
|
250
|
-
const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
|
|
251
|
-
const fallback = fallbackCategory === 'one' ? one : other;
|
|
252
|
-
return interpolateVars(fallback, vars);
|
|
433
|
+
return forms;
|
|
253
434
|
} catch (err) {
|
|
254
435
|
console.warn(`[transduck] Backend failed for plural: ${one} / ${other}`, err);
|
|
255
|
-
|
|
256
|
-
const fallback = fallbackCategory === 'one' ? one : other;
|
|
257
|
-
return interpolateVars(fallback, vars);
|
|
436
|
+
return null;
|
|
258
437
|
}
|
|
259
438
|
}
|
|
@@ -50,6 +50,22 @@ export async function translate(
|
|
|
50
50
|
return response.content[0].text.trim();
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export async function detectLanguage(
|
|
54
|
+
text: string,
|
|
55
|
+
config: TransduckConfig,
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
const client = await getClient(config);
|
|
58
|
+
const response = await client.messages.create({
|
|
59
|
+
model: config.backendModel,
|
|
60
|
+
max_tokens: 16,
|
|
61
|
+
temperature: 0.0,
|
|
62
|
+
system: 'What language is this text written in? Return only the uppercase ISO 639-1 code.',
|
|
63
|
+
messages: [{ role: 'user', content: text }],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return response.content[0].text.trim().toUpperCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
export async function translatePlural(
|
|
54
70
|
one: string,
|
|
55
71
|
other: string,
|
|
@@ -54,6 +54,16 @@ export async function translate(
|
|
|
54
54
|
return translateWithSdk(prompt);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export async function detectLanguage(
|
|
58
|
+
text: string,
|
|
59
|
+
config: TransduckConfig,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
ensureToken(config);
|
|
62
|
+
const prompt = 'What language is this text written in? Return only the uppercase ISO 639-1 code.\n\n' + text;
|
|
63
|
+
const raw = await translateWithSdk(prompt);
|
|
64
|
+
return raw.trim().toUpperCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export async function translatePlural(
|
|
58
68
|
one: string,
|
|
59
69
|
other: string,
|
package/src/providers/index.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface TranslationProvider {
|
|
|
25
25
|
config: TransduckConfig,
|
|
26
26
|
_clientOverride?: any,
|
|
27
27
|
): Promise<Record<string, string>>;
|
|
28
|
+
|
|
29
|
+
detectLanguage(
|
|
30
|
+
text: string,
|
|
31
|
+
config: TransduckConfig,
|
|
32
|
+
_clientOverride?: any,
|
|
33
|
+
): Promise<string>;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -35,6 +35,30 @@ export async function translate(
|
|
|
35
35
|
return response.choices[0].message.content.trim();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export async function detectLanguage(
|
|
39
|
+
text: string,
|
|
40
|
+
config: TransduckConfig,
|
|
41
|
+
_clientOverride?: any,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
const apiKey = process.env[config.apiKeyEnv];
|
|
44
|
+
const client = _clientOverride ?? new OpenAI({
|
|
45
|
+
apiKey,
|
|
46
|
+
timeout: config.backendTimeout * 1000,
|
|
47
|
+
maxRetries: config.backendMaxRetries,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await client.chat.completions.create({
|
|
51
|
+
model: config.backendModel,
|
|
52
|
+
messages: [
|
|
53
|
+
{ role: 'system', content: 'What language is this text written in? Return only the uppercase ISO 639-1 code.' },
|
|
54
|
+
{ role: 'user', content: text },
|
|
55
|
+
],
|
|
56
|
+
temperature: 0.0,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return response.choices[0].message.content.trim().toUpperCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
export async function translatePlural(
|
|
39
63
|
one: string,
|
|
40
64
|
other: string,
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A string wrapper that carries translation metadata.
|
|
3
|
+
*
|
|
4
|
+
* Works in template literals, concatenation, and equality checks via
|
|
5
|
+
* toString() and valueOf() overrides, plus Symbol.toPrimitive.
|
|
6
|
+
*/
|
|
7
|
+
export class TranslationResult extends String {
|
|
8
|
+
readonly pending: boolean;
|
|
9
|
+
readonly sourceLang: string;
|
|
10
|
+
readonly lang: string;
|
|
11
|
+
|
|
12
|
+
constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string }) {
|
|
13
|
+
super(text);
|
|
14
|
+
this.pending = opts.pending;
|
|
15
|
+
this.sourceLang = opts.sourceLang;
|
|
16
|
+
this.lang = opts.lang;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override toString(): string {
|
|
20
|
+
return super.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override valueOf(): string {
|
|
24
|
+
return super.valueOf();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[Symbol.toPrimitive](_hint: string): string {
|
|
28
|
+
return super.valueOf();
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/scanner.ts
CHANGED
|
@@ -57,10 +57,28 @@ function shouldSkipDir(dirname: string): boolean {
|
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Join adjacent string literals so the regex can match multi-line strings.
|
|
62
|
+
*
|
|
63
|
+
* Handles:
|
|
64
|
+
* - Python implicit concatenation: "foo" \n "bar" -> "foobar"
|
|
65
|
+
* - JS + concatenation: "foo" + \n "bar" -> "foobar"
|
|
66
|
+
* - Both single and double quotes
|
|
67
|
+
*/
|
|
68
|
+
function normalizeMultilineStrings(content: string): string {
|
|
69
|
+
// Join adjacent string literals: "..." \s+ "..." or '...' \s+ '...'
|
|
70
|
+
// Also handles "..." + \s+ "..." for JS
|
|
71
|
+
return content.replace(
|
|
72
|
+
/(['"])\s*\+?\s*\n\s*\1/g,
|
|
73
|
+
'',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* Extract translatable strings from file content.
|
|
62
79
|
*/
|
|
63
80
|
export function extractStrings(content: string, filename: string): ScanEntry[] {
|
|
81
|
+
content = normalizeMultilineStrings(content);
|
|
64
82
|
const results: ScanEntry[] = [];
|
|
65
83
|
const ext = extname(filename).toLowerCase();
|
|
66
84
|
const isJs = JS_EXTENSIONS.has(ext);
|