transduck 0.6.2 → 0.6.4
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/cli.js +1 -1
- package/dist/handler.d.ts +7 -2
- package/dist/handler.js +22 -3
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.js +26 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +90 -6
- package/dist/result.d.ts +2 -0
- package/dist/result.js +2 -0
- package/dist/scanner.js +11 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/handler.ts +22 -2
- package/src/hooks.ts +44 -0
- package/src/index.ts +102 -7
- package/src/result.ts +3 -1
- package/src/scanner.ts +13 -0
- package/tests/hooks.test.ts +274 -0
- package/tests/scanner.test.ts +31 -0
package/dist/cli.js
CHANGED
|
@@ -544,7 +544,7 @@ export async function runStats(opts) {
|
|
|
544
544
|
}
|
|
545
545
|
// CLI entry point
|
|
546
546
|
const program = new Command();
|
|
547
|
-
program.name('transduck').description('AI-native translation tool').version('0.6.
|
|
547
|
+
program.name('transduck').description('AI-native translation tool').version('0.6.4');
|
|
548
548
|
program.command('init')
|
|
549
549
|
.description('Initialize a new transduck project')
|
|
550
550
|
.action(async () => {
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type TranslationHook } from './hooks.js';
|
|
1
2
|
interface TranslationRequestString {
|
|
2
3
|
text: string;
|
|
3
4
|
context?: string;
|
|
@@ -20,6 +21,10 @@ export interface TranslationResponse {
|
|
|
20
21
|
}
|
|
21
22
|
/** @internal Reset the singleton store — for testing only. */
|
|
22
23
|
export declare function _resetHandlerStore(): void;
|
|
23
|
-
export declare function handleTranslationRequest(body: TranslationRequest, configPath?: string
|
|
24
|
-
|
|
24
|
+
export declare function handleTranslationRequest(body: TranslationRequest, configPath?: string, opts?: {
|
|
25
|
+
onTranslate?: TranslationHook;
|
|
26
|
+
}): Promise<TranslationResponse>;
|
|
27
|
+
export declare function createTransDuckHandler(configPath?: string, opts?: {
|
|
28
|
+
onTranslate?: TranslationHook;
|
|
29
|
+
}): (request: Request) => Promise<Response>;
|
|
25
30
|
export {};
|
package/dist/handler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { TranslationStore } from './storage.js';
|
|
|
4
4
|
import { SharedStore } from './shared-store.js';
|
|
5
5
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
6
6
|
import { validateTranslation } from './validation.js';
|
|
7
|
+
import { fireHooks } from './hooks.js';
|
|
7
8
|
function hash(text) {
|
|
8
9
|
return createHash('sha256').update(text).digest('hex');
|
|
9
10
|
}
|
|
@@ -40,7 +41,7 @@ export function _resetHandlerStore() {
|
|
|
40
41
|
_store = null;
|
|
41
42
|
_sharedStore = null;
|
|
42
43
|
}
|
|
43
|
-
export async function handleTranslationRequest(body, configPath) {
|
|
44
|
+
export async function handleTranslationRequest(body, configPath, opts) {
|
|
44
45
|
const cfg = loadConfig(configPath);
|
|
45
46
|
const store = await getStore(configPath);
|
|
46
47
|
const shared = await getSharedStore(configPath);
|
|
@@ -87,6 +88,15 @@ export async function handleTranslationRequest(body, configPath) {
|
|
|
87
88
|
await store.insert(insertParams);
|
|
88
89
|
if (shared)
|
|
89
90
|
await shared.insert(insertParams);
|
|
91
|
+
if (opts?.onTranslate) {
|
|
92
|
+
const event = {
|
|
93
|
+
sourceText: item.text, translatedText: translated,
|
|
94
|
+
sourceLang, lang: targetLang, context: item.context ?? null,
|
|
95
|
+
projectContext: cfg.projectContext, source: 'translated',
|
|
96
|
+
plural: false,
|
|
97
|
+
};
|
|
98
|
+
await fireHooks([], event, opts.onTranslate);
|
|
99
|
+
}
|
|
90
100
|
translations[key] = translated;
|
|
91
101
|
}
|
|
92
102
|
else {
|
|
@@ -142,6 +152,15 @@ export async function handleTranslationRequest(body, configPath) {
|
|
|
142
152
|
if (shared)
|
|
143
153
|
await shared.insertPlural(insertParams);
|
|
144
154
|
}
|
|
155
|
+
if (opts?.onTranslate && 'other' in forms) {
|
|
156
|
+
const event = {
|
|
157
|
+
sourceText: sourceKey, translatedText: forms.other ?? '',
|
|
158
|
+
sourceLang, lang: targetLang, context: item.context ?? null,
|
|
159
|
+
projectContext: cfg.projectContext, source: 'translated',
|
|
160
|
+
plural: true, pluralForms: forms,
|
|
161
|
+
};
|
|
162
|
+
await fireHooks([], event, opts.onTranslate);
|
|
163
|
+
}
|
|
145
164
|
plurals[responseKey] = forms;
|
|
146
165
|
}
|
|
147
166
|
catch {
|
|
@@ -150,11 +169,11 @@ export async function handleTranslationRequest(body, configPath) {
|
|
|
150
169
|
}
|
|
151
170
|
return { translations, plurals };
|
|
152
171
|
}
|
|
153
|
-
export function createTransDuckHandler(configPath) {
|
|
172
|
+
export function createTransDuckHandler(configPath, opts) {
|
|
154
173
|
return async function POST(request) {
|
|
155
174
|
try {
|
|
156
175
|
const body = await request.json();
|
|
157
|
-
const result = await handleTranslationRequest(body, configPath);
|
|
176
|
+
const result = await handleTranslationRequest(body, configPath, opts);
|
|
158
177
|
return new Response(JSON.stringify(result), {
|
|
159
178
|
status: 200,
|
|
160
179
|
headers: { 'Content-Type': 'application/json' },
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransDuck hooks — event types and dispatch helpers.
|
|
3
|
+
*
|
|
4
|
+
* Field names match TranslationResult for consistency.
|
|
5
|
+
*/
|
|
6
|
+
export interface TranslationEvent {
|
|
7
|
+
sourceText: string;
|
|
8
|
+
translatedText: string;
|
|
9
|
+
sourceLang: string;
|
|
10
|
+
lang: string;
|
|
11
|
+
context: string | null;
|
|
12
|
+
projectContext: string;
|
|
13
|
+
source: 'cache' | 'translated' | 'shared_cache';
|
|
14
|
+
plural: boolean;
|
|
15
|
+
pluralCategory?: string;
|
|
16
|
+
pluralForms?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export type TranslationHook = (event: TranslationEvent) => void | Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Invoke global hooks then per-call hook. Errors are logged, never raised.
|
|
21
|
+
*/
|
|
22
|
+
export declare function fireHooks(hooks: TranslationHook[], event: TranslationEvent, perCallHook?: TranslationHook): Promise<void>;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransDuck hooks — event types and dispatch helpers.
|
|
3
|
+
*
|
|
4
|
+
* Field names match TranslationResult for consistency.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Invoke global hooks then per-call hook. Errors are logged, never raised.
|
|
8
|
+
*/
|
|
9
|
+
export async function fireHooks(hooks, event, perCallHook) {
|
|
10
|
+
for (const hook of hooks) {
|
|
11
|
+
try {
|
|
12
|
+
await hook(event);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.warn('[transduck] Global translation hook error:', err);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (perCallHook) {
|
|
19
|
+
try {
|
|
20
|
+
await perCallHook(event);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.warn('[transduck] Per-call translation hook error:', err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { type TransduckConfig } from './config.js';
|
|
|
2
2
|
import { TranslationStore } from './storage.js';
|
|
3
3
|
import { SharedStore } from './shared-store.js';
|
|
4
4
|
import { TranslationResult } from './result.js';
|
|
5
|
+
import { type TranslationHook } from './hooks.js';
|
|
5
6
|
export declare function initialize(config?: TransduckConfig): Promise<void>;
|
|
6
7
|
export declare function setLanguage(lang: string): void;
|
|
7
8
|
export declare function _resetState(): void;
|
|
9
|
+
export declare function on(eventName: string, callback: TranslationHook): void;
|
|
10
|
+
export declare function off(eventName: string, callback: TranslationHook): void;
|
|
8
11
|
export declare function _getStore(): TranslationStore | null;
|
|
9
12
|
export declare function _getSharedStore(): SharedStore | null;
|
|
10
13
|
export declare function _setSharedStore(shared: SharedStore | null): void;
|
|
@@ -13,15 +16,18 @@ export interface AitOptions {
|
|
|
13
16
|
vars?: Record<string, string | number>;
|
|
14
17
|
sourceLang?: string;
|
|
15
18
|
background?: boolean;
|
|
19
|
+
onTranslate?: TranslationHook;
|
|
16
20
|
}
|
|
17
21
|
export declare function ait(sourceText: string, contextOrOpts?: string | AitOptions, vars?: Record<string, string | number>): Promise<TranslationResult>;
|
|
18
22
|
export { createTransDuckHandler } from './handler.js';
|
|
19
23
|
export { TranslationResult } from './result.js';
|
|
20
24
|
export { SharedStore } from './shared-store.js';
|
|
25
|
+
export type { TranslationEvent, TranslationHook } from './hooks.js';
|
|
21
26
|
export declare function detectLanguage(text: string): Promise<string>;
|
|
22
27
|
export declare function aitPlural(one: string, other: string, count: number, opts?: {
|
|
23
28
|
context?: string;
|
|
24
29
|
vars?: Record<string, string | number>;
|
|
25
30
|
sourceLang?: string;
|
|
26
31
|
background?: boolean;
|
|
32
|
+
onTranslate?: TranslationHook;
|
|
27
33
|
}): Promise<TranslationResult>;
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TranslationResult } from './result.js';
|
|
|
6
6
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
|
|
7
7
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
8
8
|
import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
|
|
9
|
+
import { fireHooks } from './hooks.js';
|
|
9
10
|
let state = {
|
|
10
11
|
config: null,
|
|
11
12
|
store: null,
|
|
@@ -13,6 +14,7 @@ let state = {
|
|
|
13
14
|
targetLang: null,
|
|
14
15
|
pendingTranslations: new Map(),
|
|
15
16
|
backgroundKeys: new Set(),
|
|
17
|
+
hooks: new Map(),
|
|
16
18
|
};
|
|
17
19
|
function hash(text) {
|
|
18
20
|
return createHash('sha256').update(text).digest('hex');
|
|
@@ -42,8 +44,40 @@ export function _resetState() {
|
|
|
42
44
|
state = {
|
|
43
45
|
config: null, store: null, sharedStore: null, targetLang: null,
|
|
44
46
|
pendingTranslations: new Map(), backgroundKeys: new Set(),
|
|
47
|
+
hooks: new Map(),
|
|
45
48
|
};
|
|
46
49
|
}
|
|
50
|
+
export function on(eventName, callback) {
|
|
51
|
+
const hooks = state.hooks.get(eventName) ?? [];
|
|
52
|
+
hooks.push(callback);
|
|
53
|
+
state.hooks.set(eventName, hooks);
|
|
54
|
+
}
|
|
55
|
+
export function off(eventName, callback) {
|
|
56
|
+
const hooks = state.hooks.get(eventName);
|
|
57
|
+
if (hooks) {
|
|
58
|
+
const idx = hooks.indexOf(callback);
|
|
59
|
+
if (idx !== -1)
|
|
60
|
+
hooks.splice(idx, 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function _maybeFireHooks(opts) {
|
|
64
|
+
const globalHooks = state.hooks.get('translate');
|
|
65
|
+
if (!globalHooks?.length && !opts.onTranslate)
|
|
66
|
+
return;
|
|
67
|
+
const event = {
|
|
68
|
+
sourceText: opts.sourceText,
|
|
69
|
+
translatedText: opts.translatedText,
|
|
70
|
+
sourceLang: opts.sourceLang,
|
|
71
|
+
lang: opts.lang,
|
|
72
|
+
context: opts.context,
|
|
73
|
+
projectContext: state.config?.projectContext ?? '',
|
|
74
|
+
source: opts.source,
|
|
75
|
+
plural: opts.plural ?? false,
|
|
76
|
+
pluralCategory: opts.pluralCategory,
|
|
77
|
+
pluralForms: opts.pluralForms,
|
|
78
|
+
};
|
|
79
|
+
await fireHooks(globalHooks ?? [], event, opts.onTranslate);
|
|
80
|
+
}
|
|
47
81
|
export function _getStore() {
|
|
48
82
|
return state.store;
|
|
49
83
|
}
|
|
@@ -65,6 +99,7 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
65
99
|
let resolvedVars;
|
|
66
100
|
let sourceLang;
|
|
67
101
|
let background = false;
|
|
102
|
+
let onTranslate;
|
|
68
103
|
if (typeof contextOrOpts === 'string') {
|
|
69
104
|
context = contextOrOpts;
|
|
70
105
|
resolvedVars = vars;
|
|
@@ -74,6 +109,7 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
74
109
|
resolvedVars = contextOrOpts.vars;
|
|
75
110
|
sourceLang = contextOrOpts.sourceLang;
|
|
76
111
|
background = contextOrOpts.background ?? false;
|
|
112
|
+
onTranslate = contextOrOpts.onTranslate;
|
|
77
113
|
}
|
|
78
114
|
else {
|
|
79
115
|
resolvedVars = vars;
|
|
@@ -93,7 +129,11 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
93
129
|
// Tier 1: local SQLite lookup
|
|
94
130
|
const cached = await state.store.lookup(lookupParams);
|
|
95
131
|
if (cached !== null) {
|
|
96
|
-
|
|
132
|
+
await _maybeFireHooks({
|
|
133
|
+
sourceText, translatedText: cached, sourceLang: effectiveSourceLang,
|
|
134
|
+
lang: targetLang, context: context ?? null, source: 'cache', onTranslate,
|
|
135
|
+
});
|
|
136
|
+
return new TranslationResult(interpolateVars(cached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' });
|
|
97
137
|
}
|
|
98
138
|
// Tier 2: shared Postgres lookup
|
|
99
139
|
if (state.sharedStore) {
|
|
@@ -104,7 +144,11 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
104
144
|
...lookupParams, stringContext: context ?? '',
|
|
105
145
|
translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
|
|
106
146
|
});
|
|
107
|
-
|
|
147
|
+
await _maybeFireHooks({
|
|
148
|
+
sourceText, translatedText: sharedCached, sourceLang: effectiveSourceLang,
|
|
149
|
+
lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate,
|
|
150
|
+
});
|
|
151
|
+
return new TranslationResult(interpolateVars(sharedCached, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' });
|
|
108
152
|
}
|
|
109
153
|
}
|
|
110
154
|
// Read-only mode: skip backend, return source text
|
|
@@ -117,6 +161,14 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
117
161
|
if (!state.backgroundKeys.has(bgKey)) {
|
|
118
162
|
state.backgroundKeys.add(bgKey);
|
|
119
163
|
_doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
164
|
+
.then(async (translated) => {
|
|
165
|
+
if (translated !== sourceText) {
|
|
166
|
+
await _maybeFireHooks({
|
|
167
|
+
sourceText, translatedText: translated, sourceLang: effectiveSourceLang,
|
|
168
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
})
|
|
120
172
|
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
121
173
|
}
|
|
122
174
|
return new TranslationResult(interpolateVars(sourceText, resolvedVars), { pending: true, sourceLang: effectiveSourceLang, lang: targetLang });
|
|
@@ -131,7 +183,14 @@ export async function ait(sourceText, contextOrOpts, vars) {
|
|
|
131
183
|
state.pendingTranslations.set(lockKey, translationPromise);
|
|
132
184
|
const result = await translationPromise;
|
|
133
185
|
state.pendingTranslations.delete(lockKey);
|
|
134
|
-
|
|
186
|
+
const isTranslated = result !== sourceText;
|
|
187
|
+
if (isTranslated) {
|
|
188
|
+
await _maybeFireHooks({
|
|
189
|
+
sourceText, translatedText: result, sourceLang: effectiveSourceLang,
|
|
190
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return new TranslationResult(interpolateVars(result, resolvedVars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: isTranslated ? 'translated' : undefined });
|
|
135
194
|
}
|
|
136
195
|
async function _doTranslate(sourceText, sourceLang, targetLang, projectContextHash, stringContextHash, stringContext, cfg) {
|
|
137
196
|
// Double-check local cache
|
|
@@ -184,6 +243,7 @@ export async function aitPlural(one, other, count, opts) {
|
|
|
184
243
|
const context = opts?.context;
|
|
185
244
|
const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
186
245
|
const background = opts?.background ?? false;
|
|
246
|
+
const onTranslatePlural = opts?.onTranslate;
|
|
187
247
|
// Build vars with count
|
|
188
248
|
let vars;
|
|
189
249
|
if (!opts?.vars) {
|
|
@@ -217,7 +277,12 @@ export async function aitPlural(one, other, count, opts) {
|
|
|
217
277
|
// Tier 1: local cache lookup
|
|
218
278
|
const cached = await state.store.lookupPlural(lookupParams);
|
|
219
279
|
if (category in cached) {
|
|
220
|
-
|
|
280
|
+
await _maybeFireHooks({
|
|
281
|
+
sourceText: sourceKey, translatedText: cached[category], sourceLang: effectiveSourceLang,
|
|
282
|
+
lang: targetLang, context: context ?? null, source: 'cache', onTranslate: onTranslatePlural,
|
|
283
|
+
plural: true, pluralCategory: category, pluralForms: cached,
|
|
284
|
+
});
|
|
285
|
+
return new TranslationResult(interpolateVars(cached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' });
|
|
221
286
|
}
|
|
222
287
|
// Tier 2: shared store lookup
|
|
223
288
|
if (state.sharedStore) {
|
|
@@ -231,7 +296,12 @@ export async function aitPlural(one, other, count, opts) {
|
|
|
231
296
|
model: cfg.backendModel, status: 'translated',
|
|
232
297
|
});
|
|
233
298
|
}
|
|
234
|
-
|
|
299
|
+
await _maybeFireHooks({
|
|
300
|
+
sourceText: sourceKey, translatedText: sharedCached[category], sourceLang: effectiveSourceLang,
|
|
301
|
+
lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate: onTranslatePlural,
|
|
302
|
+
plural: true, pluralCategory: category, pluralForms: sharedCached,
|
|
303
|
+
});
|
|
304
|
+
return new TranslationResult(interpolateVars(sharedCached[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' });
|
|
235
305
|
}
|
|
236
306
|
}
|
|
237
307
|
// Read-only mode: skip backend, fall back to source forms
|
|
@@ -246,6 +316,15 @@ export async function aitPlural(one, other, count, opts) {
|
|
|
246
316
|
if (!state.backgroundKeys.has(bgKey)) {
|
|
247
317
|
state.backgroundKeys.add(bgKey);
|
|
248
318
|
_doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
319
|
+
.then(async (forms) => {
|
|
320
|
+
if (forms !== null && category in forms) {
|
|
321
|
+
await _maybeFireHooks({
|
|
322
|
+
sourceText: sourceKey, translatedText: forms[category], sourceLang: effectiveSourceLang,
|
|
323
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
|
|
324
|
+
plural: true, pluralCategory: category, pluralForms: forms,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
})
|
|
249
328
|
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
250
329
|
}
|
|
251
330
|
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
|
@@ -255,7 +334,12 @@ export async function aitPlural(one, other, count, opts) {
|
|
|
255
334
|
// Cache miss — call backend
|
|
256
335
|
const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
|
|
257
336
|
if (translatedText !== null && category in translatedText) {
|
|
258
|
-
|
|
337
|
+
await _maybeFireHooks({
|
|
338
|
+
sourceText: sourceKey, translatedText: translatedText[category], sourceLang: effectiveSourceLang,
|
|
339
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
|
|
340
|
+
plural: true, pluralCategory: category, pluralForms: translatedText,
|
|
341
|
+
});
|
|
342
|
+
return new TranslationResult(interpolateVars(translatedText[category], vars), { pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'translated' });
|
|
259
343
|
}
|
|
260
344
|
// Fallback
|
|
261
345
|
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
package/dist/result.d.ts
CHANGED
|
@@ -8,10 +8,12 @@ export declare class TranslationResult extends String {
|
|
|
8
8
|
readonly pending: boolean;
|
|
9
9
|
readonly sourceLang: string;
|
|
10
10
|
readonly lang: string;
|
|
11
|
+
readonly source: string | null;
|
|
11
12
|
constructor(text: string, opts: {
|
|
12
13
|
pending: boolean;
|
|
13
14
|
sourceLang: string;
|
|
14
15
|
lang: string;
|
|
16
|
+
source?: string | null;
|
|
15
17
|
});
|
|
16
18
|
toString(): string;
|
|
17
19
|
valueOf(): string;
|
package/dist/result.js
CHANGED
|
@@ -8,11 +8,13 @@ export class TranslationResult extends String {
|
|
|
8
8
|
pending;
|
|
9
9
|
sourceLang;
|
|
10
10
|
lang;
|
|
11
|
+
source;
|
|
11
12
|
constructor(text, opts) {
|
|
12
13
|
super(text);
|
|
13
14
|
this.pending = opts.pending;
|
|
14
15
|
this.sourceLang = opts.sourceLang;
|
|
15
16
|
this.lang = opts.lang;
|
|
17
|
+
this.source = opts.source ?? null;
|
|
16
18
|
}
|
|
17
19
|
toString() {
|
|
18
20
|
return super.toString();
|
package/dist/scanner.js
CHANGED
|
@@ -12,6 +12,8 @@ const AIT_POSITIONAL_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
|
12
12
|
const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
|
|
13
13
|
// {% ait "text" %} or {% ait "text" context="ctx" %}
|
|
14
14
|
const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
|
|
15
|
+
// {% ait_plural "one" "other" %} or {% ait_plural "one" "other" context="ctx" %}
|
|
16
|
+
const DJANGO_PLURAL_TAG = /\{%\s*ait_plural\s+(['"])(.*?)\1\s+(['"])(.*?)\3(?:\s+context=(['"])(.*?)\5)?\s*%\}/g;
|
|
15
17
|
// t("text") or t("text", "ctx") — only matched in files with transduck/react import
|
|
16
18
|
const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
17
19
|
// tPlural("one", "other") — only matched in files with transduck/react import
|
|
@@ -81,6 +83,15 @@ export function extractStrings(content, filename) {
|
|
|
81
83
|
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
82
84
|
results.push({ text, context, line: lineNum });
|
|
83
85
|
}
|
|
86
|
+
const djangoPluralRegex = new RegExp(DJANGO_PLURAL_TAG.source, 'g');
|
|
87
|
+
while ((match = djangoPluralRegex.exec(content)) !== null) {
|
|
88
|
+
const one = match[2];
|
|
89
|
+
const other = match[4];
|
|
90
|
+
const context = match[6] || null;
|
|
91
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
92
|
+
results.push({ plural: true, one, other, context, line: lineNum });
|
|
93
|
+
pluralSpans.push([match.index, match.index + match[0].length]);
|
|
94
|
+
}
|
|
84
95
|
}
|
|
85
96
|
// 3. Find ait() calls
|
|
86
97
|
const pattern = isJs ? AIT_POSITIONAL_CTX : AIT_KEYWORD_CTX;
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -661,7 +661,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
|
|
|
661
661
|
// CLI entry point
|
|
662
662
|
const program = new Command();
|
|
663
663
|
|
|
664
|
-
program.name('transduck').description('AI-native translation tool').version('0.6.
|
|
664
|
+
program.name('transduck').description('AI-native translation tool').version('0.6.4');
|
|
665
665
|
|
|
666
666
|
program.command('init')
|
|
667
667
|
.description('Initialize a new transduck project')
|
package/src/handler.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { TranslationStore } from './storage.js';
|
|
|
4
4
|
import { SharedStore } from './shared-store.js';
|
|
5
5
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
6
6
|
import { validateTranslation } from './validation.js';
|
|
7
|
+
import { type TranslationEvent, type TranslationHook, fireHooks } from './hooks.js';
|
|
7
8
|
|
|
8
9
|
function hash(text: string): string {
|
|
9
10
|
return createHash('sha256').update(text).digest('hex');
|
|
@@ -70,6 +71,7 @@ export function _resetHandlerStore(): void {
|
|
|
70
71
|
export async function handleTranslationRequest(
|
|
71
72
|
body: TranslationRequest,
|
|
72
73
|
configPath?: string,
|
|
74
|
+
opts?: { onTranslate?: TranslationHook },
|
|
73
75
|
): Promise<TranslationResponse> {
|
|
74
76
|
const cfg = loadConfig(configPath);
|
|
75
77
|
const store = await getStore(configPath);
|
|
@@ -126,6 +128,15 @@ export async function handleTranslationRequest(
|
|
|
126
128
|
};
|
|
127
129
|
await store.insert(insertParams);
|
|
128
130
|
if (shared) await shared.insert(insertParams);
|
|
131
|
+
if (opts?.onTranslate) {
|
|
132
|
+
const event: TranslationEvent = {
|
|
133
|
+
sourceText: item.text, translatedText: translated,
|
|
134
|
+
sourceLang, lang: targetLang, context: item.context ?? null,
|
|
135
|
+
projectContext: cfg.projectContext, source: 'translated',
|
|
136
|
+
plural: false,
|
|
137
|
+
};
|
|
138
|
+
await fireHooks([], event, opts.onTranslate);
|
|
139
|
+
}
|
|
129
140
|
translations[key] = translated;
|
|
130
141
|
} else {
|
|
131
142
|
translations[key] = item.text;
|
|
@@ -186,6 +197,15 @@ export async function handleTranslationRequest(
|
|
|
186
197
|
await store.insertPlural(insertParams);
|
|
187
198
|
if (shared) await shared.insertPlural(insertParams);
|
|
188
199
|
}
|
|
200
|
+
if (opts?.onTranslate && 'other' in forms) {
|
|
201
|
+
const event: TranslationEvent = {
|
|
202
|
+
sourceText: sourceKey, translatedText: forms.other ?? '',
|
|
203
|
+
sourceLang, lang: targetLang, context: item.context ?? null,
|
|
204
|
+
projectContext: cfg.projectContext, source: 'translated',
|
|
205
|
+
plural: true, pluralForms: forms,
|
|
206
|
+
};
|
|
207
|
+
await fireHooks([], event, opts.onTranslate);
|
|
208
|
+
}
|
|
189
209
|
plurals[responseKey] = forms;
|
|
190
210
|
} catch {
|
|
191
211
|
plurals[responseKey] = { one: item.one, other: item.other };
|
|
@@ -195,11 +215,11 @@ export async function handleTranslationRequest(
|
|
|
195
215
|
return { translations, plurals };
|
|
196
216
|
}
|
|
197
217
|
|
|
198
|
-
export function createTransDuckHandler(configPath?: string) {
|
|
218
|
+
export function createTransDuckHandler(configPath?: string, opts?: { onTranslate?: TranslationHook }) {
|
|
199
219
|
return async function POST(request: Request): Promise<Response> {
|
|
200
220
|
try {
|
|
201
221
|
const body = await request.json() as TranslationRequest;
|
|
202
|
-
const result = await handleTranslationRequest(body, configPath);
|
|
222
|
+
const result = await handleTranslationRequest(body, configPath, opts);
|
|
203
223
|
return new Response(JSON.stringify(result), {
|
|
204
224
|
status: 200,
|
|
205
225
|
headers: { 'Content-Type': 'application/json' },
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransDuck hooks — event types and dispatch helpers.
|
|
3
|
+
*
|
|
4
|
+
* Field names match TranslationResult for consistency.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TranslationEvent {
|
|
8
|
+
sourceText: string;
|
|
9
|
+
translatedText: string;
|
|
10
|
+
sourceLang: string;
|
|
11
|
+
lang: string; // target language — matches TranslationResult.lang
|
|
12
|
+
context: string | null;
|
|
13
|
+
projectContext: string;
|
|
14
|
+
source: 'cache' | 'translated' | 'shared_cache';
|
|
15
|
+
plural: boolean;
|
|
16
|
+
pluralCategory?: string;
|
|
17
|
+
pluralForms?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TranslationHook = (event: TranslationEvent) => void | Promise<void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Invoke global hooks then per-call hook. Errors are logged, never raised.
|
|
24
|
+
*/
|
|
25
|
+
export async function fireHooks(
|
|
26
|
+
hooks: TranslationHook[],
|
|
27
|
+
event: TranslationEvent,
|
|
28
|
+
perCallHook?: TranslationHook,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
for (const hook of hooks) {
|
|
31
|
+
try {
|
|
32
|
+
await hook(event);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.warn('[transduck] Global translation hook error:', err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (perCallHook) {
|
|
38
|
+
try {
|
|
39
|
+
await perCallHook(event);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.warn('[transduck] Per-call translation hook error:', err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { TranslationResult } from './result.js';
|
|
|
6
6
|
import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
|
|
7
7
|
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
8
8
|
import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
|
|
9
|
+
import { type TranslationEvent, type TranslationHook, fireHooks } from './hooks.js';
|
|
9
10
|
|
|
10
11
|
interface State {
|
|
11
12
|
config: TransduckConfig | null;
|
|
@@ -14,6 +15,7 @@ interface State {
|
|
|
14
15
|
targetLang: string | null;
|
|
15
16
|
pendingTranslations: Map<string, Promise<string>>;
|
|
16
17
|
backgroundKeys: Set<string>;
|
|
18
|
+
hooks: Map<string, TranslationHook[]>;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
let state: State = {
|
|
@@ -23,6 +25,7 @@ let state: State = {
|
|
|
23
25
|
targetLang: null,
|
|
24
26
|
pendingTranslations: new Map(),
|
|
25
27
|
backgroundKeys: new Set(),
|
|
28
|
+
hooks: new Map(),
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
function hash(text: string): string {
|
|
@@ -56,9 +59,47 @@ export function _resetState(): void {
|
|
|
56
59
|
state = {
|
|
57
60
|
config: null, store: null, sharedStore: null, targetLang: null,
|
|
58
61
|
pendingTranslations: new Map(), backgroundKeys: new Set(),
|
|
62
|
+
hooks: new Map(),
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
export function on(eventName: string, callback: TranslationHook): void {
|
|
67
|
+
const hooks = state.hooks.get(eventName) ?? [];
|
|
68
|
+
hooks.push(callback);
|
|
69
|
+
state.hooks.set(eventName, hooks);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function off(eventName: string, callback: TranslationHook): void {
|
|
73
|
+
const hooks = state.hooks.get(eventName);
|
|
74
|
+
if (hooks) {
|
|
75
|
+
const idx = hooks.indexOf(callback);
|
|
76
|
+
if (idx !== -1) hooks.splice(idx, 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function _maybeFireHooks(opts: {
|
|
81
|
+
sourceText: string; translatedText: string; sourceLang: string; lang: string;
|
|
82
|
+
context: string | null; source: 'cache' | 'translated' | 'shared_cache';
|
|
83
|
+
onTranslate?: TranslationHook; plural?: boolean;
|
|
84
|
+
pluralCategory?: string; pluralForms?: Record<string, string>;
|
|
85
|
+
}): Promise<void> {
|
|
86
|
+
const globalHooks = state.hooks.get('translate');
|
|
87
|
+
if (!globalHooks?.length && !opts.onTranslate) return;
|
|
88
|
+
const event: TranslationEvent = {
|
|
89
|
+
sourceText: opts.sourceText,
|
|
90
|
+
translatedText: opts.translatedText,
|
|
91
|
+
sourceLang: opts.sourceLang,
|
|
92
|
+
lang: opts.lang,
|
|
93
|
+
context: opts.context,
|
|
94
|
+
projectContext: state.config?.projectContext ?? '',
|
|
95
|
+
source: opts.source,
|
|
96
|
+
plural: opts.plural ?? false,
|
|
97
|
+
pluralCategory: opts.pluralCategory,
|
|
98
|
+
pluralForms: opts.pluralForms,
|
|
99
|
+
};
|
|
100
|
+
await fireHooks(globalHooks ?? [], event, opts.onTranslate);
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
export function _getStore(): TranslationStore | null {
|
|
63
104
|
return state.store;
|
|
64
105
|
}
|
|
@@ -76,6 +117,7 @@ export interface AitOptions {
|
|
|
76
117
|
vars?: Record<string, string | number>;
|
|
77
118
|
sourceLang?: string;
|
|
78
119
|
background?: boolean;
|
|
120
|
+
onTranslate?: TranslationHook;
|
|
79
121
|
}
|
|
80
122
|
|
|
81
123
|
export async function ait(
|
|
@@ -96,6 +138,8 @@ export async function ait(
|
|
|
96
138
|
let sourceLang: string | undefined;
|
|
97
139
|
let background = false;
|
|
98
140
|
|
|
141
|
+
let onTranslate: TranslationHook | undefined;
|
|
142
|
+
|
|
99
143
|
if (typeof contextOrOpts === 'string') {
|
|
100
144
|
context = contextOrOpts;
|
|
101
145
|
resolvedVars = vars;
|
|
@@ -104,6 +148,7 @@ export async function ait(
|
|
|
104
148
|
resolvedVars = contextOrOpts.vars;
|
|
105
149
|
sourceLang = contextOrOpts.sourceLang;
|
|
106
150
|
background = contextOrOpts.background ?? false;
|
|
151
|
+
onTranslate = contextOrOpts.onTranslate;
|
|
107
152
|
} else {
|
|
108
153
|
resolvedVars = vars;
|
|
109
154
|
}
|
|
@@ -129,9 +174,13 @@ export async function ait(
|
|
|
129
174
|
// Tier 1: local SQLite lookup
|
|
130
175
|
const cached = await state.store.lookup(lookupParams);
|
|
131
176
|
if (cached !== null) {
|
|
177
|
+
await _maybeFireHooks({
|
|
178
|
+
sourceText, translatedText: cached, sourceLang: effectiveSourceLang,
|
|
179
|
+
lang: targetLang, context: context ?? null, source: 'cache', onTranslate,
|
|
180
|
+
});
|
|
132
181
|
return new TranslationResult(
|
|
133
182
|
interpolateVars(cached, resolvedVars),
|
|
134
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
183
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' },
|
|
135
184
|
);
|
|
136
185
|
}
|
|
137
186
|
|
|
@@ -144,9 +193,13 @@ export async function ait(
|
|
|
144
193
|
...lookupParams, stringContext: context ?? '',
|
|
145
194
|
translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
|
|
146
195
|
});
|
|
196
|
+
await _maybeFireHooks({
|
|
197
|
+
sourceText, translatedText: sharedCached, sourceLang: effectiveSourceLang,
|
|
198
|
+
lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate,
|
|
199
|
+
});
|
|
147
200
|
return new TranslationResult(
|
|
148
201
|
interpolateVars(sharedCached, resolvedVars),
|
|
149
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
202
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' },
|
|
150
203
|
);
|
|
151
204
|
}
|
|
152
205
|
}
|
|
@@ -165,6 +218,14 @@ export async function ait(
|
|
|
165
218
|
if (!state.backgroundKeys.has(bgKey)) {
|
|
166
219
|
state.backgroundKeys.add(bgKey);
|
|
167
220
|
_doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
221
|
+
.then(async (translated) => {
|
|
222
|
+
if (translated !== sourceText) {
|
|
223
|
+
await _maybeFireHooks({
|
|
224
|
+
sourceText, translatedText: translated, sourceLang: effectiveSourceLang,
|
|
225
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
})
|
|
168
229
|
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
169
230
|
}
|
|
170
231
|
return new TranslationResult(
|
|
@@ -189,9 +250,17 @@ export async function ait(
|
|
|
189
250
|
state.pendingTranslations.set(lockKey, translationPromise);
|
|
190
251
|
const result = await translationPromise;
|
|
191
252
|
state.pendingTranslations.delete(lockKey);
|
|
253
|
+
|
|
254
|
+
const isTranslated = result !== sourceText;
|
|
255
|
+
if (isTranslated) {
|
|
256
|
+
await _maybeFireHooks({
|
|
257
|
+
sourceText, translatedText: result, sourceLang: effectiveSourceLang,
|
|
258
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
192
261
|
return new TranslationResult(
|
|
193
262
|
interpolateVars(result, resolvedVars),
|
|
194
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
263
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: isTranslated ? 'translated' : undefined },
|
|
195
264
|
);
|
|
196
265
|
}
|
|
197
266
|
|
|
@@ -241,6 +310,7 @@ async function _doTranslate(
|
|
|
241
310
|
export { createTransDuckHandler } from './handler.js';
|
|
242
311
|
export { TranslationResult } from './result.js';
|
|
243
312
|
export { SharedStore } from './shared-store.js';
|
|
313
|
+
export type { TranslationEvent, TranslationHook } from './hooks.js';
|
|
244
314
|
export async function detectLanguage(text: string): Promise<string> {
|
|
245
315
|
if (!state.config) {
|
|
246
316
|
throw new Error('transduck not initialized. Call initialize() first.');
|
|
@@ -252,7 +322,7 @@ export async function aitPlural(
|
|
|
252
322
|
one: string,
|
|
253
323
|
other: string,
|
|
254
324
|
count: number,
|
|
255
|
-
opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean },
|
|
325
|
+
opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean; onTranslate?: TranslationHook },
|
|
256
326
|
): Promise<TranslationResult> {
|
|
257
327
|
if (!state.config || !state.store) {
|
|
258
328
|
throw new Error('transduck not initialized. Call initialize() first.');
|
|
@@ -265,6 +335,7 @@ export async function aitPlural(
|
|
|
265
335
|
const context = opts?.context;
|
|
266
336
|
const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
|
|
267
337
|
const background = opts?.background ?? false;
|
|
338
|
+
const onTranslatePlural = opts?.onTranslate;
|
|
268
339
|
|
|
269
340
|
// Build vars with count
|
|
270
341
|
let vars: Record<string, string | number>;
|
|
@@ -305,9 +376,14 @@ export async function aitPlural(
|
|
|
305
376
|
// Tier 1: local cache lookup
|
|
306
377
|
const cached = await state.store.lookupPlural(lookupParams);
|
|
307
378
|
if (category in cached) {
|
|
379
|
+
await _maybeFireHooks({
|
|
380
|
+
sourceText: sourceKey, translatedText: cached[category], sourceLang: effectiveSourceLang,
|
|
381
|
+
lang: targetLang, context: context ?? null, source: 'cache', onTranslate: onTranslatePlural,
|
|
382
|
+
plural: true, pluralCategory: category, pluralForms: cached,
|
|
383
|
+
});
|
|
308
384
|
return new TranslationResult(
|
|
309
385
|
interpolateVars(cached[category], vars),
|
|
310
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
386
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'cache' },
|
|
311
387
|
);
|
|
312
388
|
}
|
|
313
389
|
|
|
@@ -323,9 +399,14 @@ export async function aitPlural(
|
|
|
323
399
|
model: cfg.backendModel, status: 'translated',
|
|
324
400
|
});
|
|
325
401
|
}
|
|
402
|
+
await _maybeFireHooks({
|
|
403
|
+
sourceText: sourceKey, translatedText: sharedCached[category], sourceLang: effectiveSourceLang,
|
|
404
|
+
lang: targetLang, context: context ?? null, source: 'shared_cache', onTranslate: onTranslatePlural,
|
|
405
|
+
plural: true, pluralCategory: category, pluralForms: sharedCached,
|
|
406
|
+
});
|
|
326
407
|
return new TranslationResult(
|
|
327
408
|
interpolateVars(sharedCached[category], vars),
|
|
328
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
409
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'shared_cache' },
|
|
329
410
|
);
|
|
330
411
|
}
|
|
331
412
|
}
|
|
@@ -346,6 +427,15 @@ export async function aitPlural(
|
|
|
346
427
|
if (!state.backgroundKeys.has(bgKey)) {
|
|
347
428
|
state.backgroundKeys.add(bgKey);
|
|
348
429
|
_doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
|
|
430
|
+
.then(async (forms) => {
|
|
431
|
+
if (forms !== null && category in forms) {
|
|
432
|
+
await _maybeFireHooks({
|
|
433
|
+
sourceText: sourceKey, translatedText: forms[category], sourceLang: effectiveSourceLang,
|
|
434
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
|
|
435
|
+
plural: true, pluralCategory: category, pluralForms: forms,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
})
|
|
349
439
|
.finally(() => state.backgroundKeys.delete(bgKey));
|
|
350
440
|
}
|
|
351
441
|
const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
|
|
@@ -360,9 +450,14 @@ export async function aitPlural(
|
|
|
360
450
|
const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
|
|
361
451
|
|
|
362
452
|
if (translatedText !== null && category in translatedText) {
|
|
453
|
+
await _maybeFireHooks({
|
|
454
|
+
sourceText: sourceKey, translatedText: translatedText[category], sourceLang: effectiveSourceLang,
|
|
455
|
+
lang: targetLang, context: context ?? null, source: 'translated', onTranslate: onTranslatePlural,
|
|
456
|
+
plural: true, pluralCategory: category, pluralForms: translatedText,
|
|
457
|
+
});
|
|
363
458
|
return new TranslationResult(
|
|
364
459
|
interpolateVars(translatedText[category], vars),
|
|
365
|
-
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
|
|
460
|
+
{ pending: false, sourceLang: effectiveSourceLang, lang: targetLang, source: 'translated' },
|
|
366
461
|
);
|
|
367
462
|
}
|
|
368
463
|
|
package/src/result.ts
CHANGED
|
@@ -8,12 +8,14 @@ export class TranslationResult extends String {
|
|
|
8
8
|
readonly pending: boolean;
|
|
9
9
|
readonly sourceLang: string;
|
|
10
10
|
readonly lang: string;
|
|
11
|
+
readonly source: string | null;
|
|
11
12
|
|
|
12
|
-
constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string }) {
|
|
13
|
+
constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string; source?: string | null }) {
|
|
13
14
|
super(text);
|
|
14
15
|
this.pending = opts.pending;
|
|
15
16
|
this.sourceLang = opts.sourceLang;
|
|
16
17
|
this.lang = opts.lang;
|
|
18
|
+
this.source = opts.source ?? null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
override toString(): string {
|
package/src/scanner.ts
CHANGED
|
@@ -31,6 +31,9 @@ const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
|
|
|
31
31
|
// {% ait "text" %} or {% ait "text" context="ctx" %}
|
|
32
32
|
const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
|
|
33
33
|
|
|
34
|
+
// {% ait_plural "one" "other" %} or {% ait_plural "one" "other" context="ctx" %}
|
|
35
|
+
const DJANGO_PLURAL_TAG = /\{%\s*ait_plural\s+(['"])(.*?)\1\s+(['"])(.*?)\3(?:\s+context=(['"])(.*?)\5)?\s*%\}/g;
|
|
36
|
+
|
|
34
37
|
// t("text") or t("text", "ctx") — only matched in files with transduck/react import
|
|
35
38
|
const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
36
39
|
|
|
@@ -113,6 +116,16 @@ export function extractStrings(content: string, filename: string): ScanEntry[] {
|
|
|
113
116
|
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
114
117
|
results.push({ text, context, line: lineNum });
|
|
115
118
|
}
|
|
119
|
+
|
|
120
|
+
const djangoPluralRegex = new RegExp(DJANGO_PLURAL_TAG.source, 'g');
|
|
121
|
+
while ((match = djangoPluralRegex.exec(content)) !== null) {
|
|
122
|
+
const one = match[2];
|
|
123
|
+
const other = match[4];
|
|
124
|
+
const context = match[6] || null;
|
|
125
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
126
|
+
results.push({ plural: true, one, other, context, line: lineNum });
|
|
127
|
+
pluralSpans.push([match.index, match.index + match[0].length]);
|
|
128
|
+
}
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
// 3. Find ait() calls
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { mkdtempSync } from 'fs';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import type { TransduckConfig } from '../src/config.js';
|
|
7
|
+
import type { TranslationEvent, TranslationHook } from '../src/hooks.js';
|
|
8
|
+
|
|
9
|
+
function hash(text: string): string {
|
|
10
|
+
return createHash('sha256').update(text).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): TransduckConfig {
|
|
14
|
+
return {
|
|
15
|
+
projectName: 'test',
|
|
16
|
+
projectContext: 'A test site',
|
|
17
|
+
sourceLang: 'EN',
|
|
18
|
+
targetLangs: ['DE', 'ES'],
|
|
19
|
+
storagePath: join(tmpDir, 'test.db'),
|
|
20
|
+
provider: 'openai',
|
|
21
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
22
|
+
tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
23
|
+
backendModel: 'gpt-4.1-mini',
|
|
24
|
+
backendTimeout: 10,
|
|
25
|
+
backendMaxRetries: 2,
|
|
26
|
+
sharedUrl: null,
|
|
27
|
+
readOnly: false,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('hooks', () => {
|
|
33
|
+
let tmpDir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
const mod = await import('../src/index.js');
|
|
37
|
+
mod._resetState();
|
|
38
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'transduck-hooks-test-'));
|
|
39
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function setup() {
|
|
43
|
+
const mod = await import('../src/index.js');
|
|
44
|
+
const cfg = makeConfig(tmpDir);
|
|
45
|
+
await mod.initialize(cfg);
|
|
46
|
+
mod.setLanguage('DE');
|
|
47
|
+
return mod;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function preInsert(sourceText = 'Hello', translatedText = 'Hallo', context = '') {
|
|
51
|
+
const mod = await import('../src/index.js');
|
|
52
|
+
const store = mod._getStore()!;
|
|
53
|
+
await store.insert({
|
|
54
|
+
sourceText,
|
|
55
|
+
sourceLang: 'EN',
|
|
56
|
+
targetLang: 'DE',
|
|
57
|
+
projectContextHash: hash('A test site'),
|
|
58
|
+
stringContextHash: hash(context),
|
|
59
|
+
stringContext: context,
|
|
60
|
+
translatedText,
|
|
61
|
+
model: 'gpt-4.1-mini',
|
|
62
|
+
status: 'translated',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Global hook tests ---
|
|
67
|
+
|
|
68
|
+
it('global hook fires on cache hit', async () => {
|
|
69
|
+
const mod = await setup();
|
|
70
|
+
await preInsert();
|
|
71
|
+
const events: TranslationEvent[] = [];
|
|
72
|
+
mod.on('translate', (e: TranslationEvent) => { events.push(e); });
|
|
73
|
+
await mod.ait('Hello');
|
|
74
|
+
expect(events).toHaveLength(1);
|
|
75
|
+
expect(events[0].source).toBe('cache');
|
|
76
|
+
expect(events[0].translatedText).toBe('Hallo');
|
|
77
|
+
expect(events[0].sourceText).toBe('Hello');
|
|
78
|
+
expect(events[0].sourceLang).toBe('EN');
|
|
79
|
+
expect(events[0].lang).toBe('DE');
|
|
80
|
+
expect(events[0].projectContext).toBe('A test site');
|
|
81
|
+
expect(events[0].plural).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('global hook fires on fresh translation', async () => {
|
|
85
|
+
const mod = await setup();
|
|
86
|
+
vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
|
|
87
|
+
const events: TranslationEvent[] = [];
|
|
88
|
+
mod.on('translate', (e: TranslationEvent) => { events.push(e); });
|
|
89
|
+
await mod.ait('Hello');
|
|
90
|
+
expect(events).toHaveLength(1);
|
|
91
|
+
expect(events[0].source).toBe('translated');
|
|
92
|
+
expect(events[0].translatedText).toBe('Hallo');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('global hook receives context and project_context', async () => {
|
|
96
|
+
const mod = await setup();
|
|
97
|
+
vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Buchen');
|
|
98
|
+
const events: TranslationEvent[] = [];
|
|
99
|
+
mod.on('translate', (e: TranslationEvent) => { events.push(e); });
|
|
100
|
+
await mod.ait('Book', { context: 'Button to book a trip' });
|
|
101
|
+
expect(events[0].context).toBe('Button to book a trip');
|
|
102
|
+
expect(events[0].projectContext).toBe('A test site');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('off() removes a hook', async () => {
|
|
106
|
+
const mod = await setup();
|
|
107
|
+
await preInsert();
|
|
108
|
+
const events: TranslationEvent[] = [];
|
|
109
|
+
const hook: TranslationHook = (e) => { events.push(e); };
|
|
110
|
+
mod.on('translate', hook);
|
|
111
|
+
await mod.ait('Hello');
|
|
112
|
+
expect(events).toHaveLength(1);
|
|
113
|
+
mod.off('translate', hook);
|
|
114
|
+
await mod.ait('Hello');
|
|
115
|
+
expect(events).toHaveLength(1); // no new event
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('multiple global hooks fire in order', async () => {
|
|
119
|
+
const mod = await setup();
|
|
120
|
+
await preInsert();
|
|
121
|
+
const order: string[] = [];
|
|
122
|
+
mod.on('translate', () => { order.push('first'); });
|
|
123
|
+
mod.on('translate', () => { order.push('second'); });
|
|
124
|
+
await mod.ait('Hello');
|
|
125
|
+
expect(order).toEqual(['first', 'second']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// --- Per-call hook tests ---
|
|
129
|
+
|
|
130
|
+
it('per-call hook fires on cache hit', async () => {
|
|
131
|
+
const mod = await setup();
|
|
132
|
+
await preInsert();
|
|
133
|
+
const events: TranslationEvent[] = [];
|
|
134
|
+
await mod.ait('Hello', { onTranslate: (e: TranslationEvent) => { events.push(e); } });
|
|
135
|
+
expect(events).toHaveLength(1);
|
|
136
|
+
expect(events[0].translatedText).toBe('Hallo');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('per-call hook fires on fresh translation', async () => {
|
|
140
|
+
const mod = await setup();
|
|
141
|
+
vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
|
|
142
|
+
const events: TranslationEvent[] = [];
|
|
143
|
+
await mod.ait('Hello', { onTranslate: (e: TranslationEvent) => { events.push(e); } });
|
|
144
|
+
expect(events).toHaveLength(1);
|
|
145
|
+
expect(events[0].source).toBe('translated');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- Global + per-call combination ---
|
|
149
|
+
|
|
150
|
+
it('global fires before per-call', async () => {
|
|
151
|
+
const mod = await setup();
|
|
152
|
+
await preInsert();
|
|
153
|
+
const order: string[] = [];
|
|
154
|
+
mod.on('translate', () => { order.push('global'); });
|
|
155
|
+
await mod.ait('Hello', { onTranslate: () => { order.push('per_call'); } });
|
|
156
|
+
expect(order).toEqual(['global', 'per_call']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- Error isolation ---
|
|
160
|
+
|
|
161
|
+
it('hook error does not crash ait()', async () => {
|
|
162
|
+
const mod = await setup();
|
|
163
|
+
await preInsert();
|
|
164
|
+
mod.on('translate', () => { throw new Error('hook exploded'); });
|
|
165
|
+
const result = await mod.ait('Hello');
|
|
166
|
+
expect(result.toString()).toBe('Hallo');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('per-call hook error does not crash ait()', async () => {
|
|
170
|
+
const mod = await setup();
|
|
171
|
+
await preInsert();
|
|
172
|
+
const result = await mod.ait('Hello', {
|
|
173
|
+
onTranslate: () => { throw new Error('boom'); },
|
|
174
|
+
});
|
|
175
|
+
expect(result.toString()).toBe('Hallo');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// --- Async hooks ---
|
|
179
|
+
|
|
180
|
+
it('async hooks are awaited', async () => {
|
|
181
|
+
const mod = await setup();
|
|
182
|
+
await preInsert();
|
|
183
|
+
let hookCompleted = false;
|
|
184
|
+
mod.on('translate', async () => {
|
|
185
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
186
|
+
hookCompleted = true;
|
|
187
|
+
});
|
|
188
|
+
await mod.ait('Hello');
|
|
189
|
+
expect(hookCompleted).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// --- TranslationResult.source property ---
|
|
193
|
+
|
|
194
|
+
it('result has source="cache" on cache hit', async () => {
|
|
195
|
+
const mod = await setup();
|
|
196
|
+
await preInsert();
|
|
197
|
+
const result = await mod.ait('Hello');
|
|
198
|
+
expect(result.source).toBe('cache');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('result has source="translated" on fresh translation', async () => {
|
|
202
|
+
const mod = await setup();
|
|
203
|
+
vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
|
|
204
|
+
const result = await mod.ait('Hello');
|
|
205
|
+
expect(result.source).toBe('translated');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- Plural hook tests ---
|
|
209
|
+
|
|
210
|
+
it('plural hook fires on fresh translation', async () => {
|
|
211
|
+
const mod = await setup();
|
|
212
|
+
vi.spyOn(await import('../src/backend.js'), 'translatePlural').mockResolvedValue({
|
|
213
|
+
one: '{count} Nacht',
|
|
214
|
+
other: '{count} Nächte',
|
|
215
|
+
});
|
|
216
|
+
const events: TranslationEvent[] = [];
|
|
217
|
+
mod.on('translate', (e: TranslationEvent) => { events.push(e); });
|
|
218
|
+
await mod.aitPlural('{count} night', '{count} nights', 1);
|
|
219
|
+
expect(events).toHaveLength(1);
|
|
220
|
+
expect(events[0].plural).toBe(true);
|
|
221
|
+
expect(events[0].pluralCategory).toBe('one');
|
|
222
|
+
expect(events[0].pluralForms).toEqual({ one: '{count} Nacht', other: '{count} Nächte' });
|
|
223
|
+
expect(events[0].source).toBe('translated');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('plural per-call hook fires', async () => {
|
|
227
|
+
const mod = await setup();
|
|
228
|
+
vi.spyOn(await import('../src/backend.js'), 'translatePlural').mockResolvedValue({
|
|
229
|
+
one: '{count} Nacht',
|
|
230
|
+
other: '{count} Nächte',
|
|
231
|
+
});
|
|
232
|
+
const events: TranslationEvent[] = [];
|
|
233
|
+
await mod.aitPlural('{count} night', '{count} nights', 7, {
|
|
234
|
+
onTranslate: (e: TranslationEvent) => { events.push(e); },
|
|
235
|
+
});
|
|
236
|
+
expect(events).toHaveLength(1);
|
|
237
|
+
expect(events[0].plural).toBe(true);
|
|
238
|
+
expect(events[0].pluralCategory).toBe('other');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('plural hook fires on cache hit', async () => {
|
|
242
|
+
const mod = await setup();
|
|
243
|
+
const store = mod._getStore()!;
|
|
244
|
+
const sourceKey = '{count} night\x00{count} nights';
|
|
245
|
+
const lookupParams = {
|
|
246
|
+
sourceText: sourceKey,
|
|
247
|
+
sourceLang: 'EN',
|
|
248
|
+
targetLang: 'DE',
|
|
249
|
+
projectContextHash: hash('A test site'),
|
|
250
|
+
stringContextHash: hash(''),
|
|
251
|
+
stringContext: '',
|
|
252
|
+
model: 'gpt-4.1-mini',
|
|
253
|
+
status: 'translated',
|
|
254
|
+
};
|
|
255
|
+
await store.insertPlural({ ...lookupParams, pluralCategory: 'one', translatedText: '{count} Nacht' });
|
|
256
|
+
await store.insertPlural({ ...lookupParams, pluralCategory: 'other', translatedText: '{count} Nächte' });
|
|
257
|
+
|
|
258
|
+
const events: TranslationEvent[] = [];
|
|
259
|
+
mod.on('translate', (e: TranslationEvent) => { events.push(e); });
|
|
260
|
+
await mod.aitPlural('{count} night', '{count} nights', 1);
|
|
261
|
+
expect(events).toHaveLength(1);
|
|
262
|
+
expect(events[0].source).toBe('cache');
|
|
263
|
+
expect(events[0].pluralCategory).toBe('one');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// --- No hooks = no overhead ---
|
|
267
|
+
|
|
268
|
+
it('no hooks still works fine', async () => {
|
|
269
|
+
const mod = await setup();
|
|
270
|
+
vi.spyOn(await import('../src/backend.js'), 'translate').mockResolvedValue('Hallo');
|
|
271
|
+
const result = await mod.ait('Hello');
|
|
272
|
+
expect(result.toString()).toBe('Hallo');
|
|
273
|
+
});
|
|
274
|
+
});
|
package/tests/scanner.test.ts
CHANGED
|
@@ -156,6 +156,37 @@ ait_plural("{count} item", "{count} items", count=n)
|
|
|
156
156
|
expect(result[0].other).toBe('{count} long items description here');
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it('extracts Django plural template tag', () => {
|
|
160
|
+
const result = extractStrings('{% ait_plural "{count} night" "{count} nights" %}', 'test.html');
|
|
161
|
+
expect(result).toHaveLength(1);
|
|
162
|
+
expect(result[0].plural).toBe(true);
|
|
163
|
+
expect(result[0].one).toBe('{count} night');
|
|
164
|
+
expect(result[0].other).toBe('{count} nights');
|
|
165
|
+
expect(result[0].context).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('extracts Django plural template tag with context', () => {
|
|
169
|
+
const result = extractStrings('{% ait_plural "{count} night" "{count} nights" context="Hotel stay duration" %}', 'test.html');
|
|
170
|
+
expect(result).toHaveLength(1);
|
|
171
|
+
expect(result[0].plural).toBe(true);
|
|
172
|
+
expect(result[0].one).toBe('{count} night');
|
|
173
|
+
expect(result[0].other).toBe('{count} nights');
|
|
174
|
+
expect(result[0].context).toBe('Hotel stay duration');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('extracts Django plural tag with single quotes', () => {
|
|
178
|
+
const result = extractStrings("{% ait_plural '{count} item' '{count} items' %}", 'test.jinja2');
|
|
179
|
+
expect(result).toHaveLength(1);
|
|
180
|
+
expect(result[0].plural).toBe(true);
|
|
181
|
+
expect(result[0].one).toBe('{count} item');
|
|
182
|
+
expect(result[0].other).toBe('{count} items');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not extract Django plural tag in .py files', () => {
|
|
186
|
+
const result = extractStrings('{% ait_plural "{count} night" "{count} nights" %}', 'test.py');
|
|
187
|
+
expect(result).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
159
190
|
it('extracts multi-line single-quoted strings', () => {
|
|
160
191
|
const code = `ait(\n 'This is a long '\n 'paragraph'\n)`;
|
|
161
192
|
const result = extractStrings(code, 'test.py');
|