transduck 0.2.5 → 0.4.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/README.md +3 -3
- package/dist/cli.js +3 -3
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +1 -1
- package/dist/react/provider.d.ts +27 -3
- package/dist/react/provider.js +104 -20
- package/dist/storage.d.ts +10 -5
- package/dist/storage.js +137 -153
- package/package.json +18 -10
- package/src/cli.ts +3 -3
- package/src/react/index.ts +2 -1
- package/src/react/provider.tsx +142 -18
- package/src/storage.ts +146 -167
- package/tests/ait.test.ts +1 -1
- package/tests/backend.test.ts +1 -1
- package/tests/cli.test.ts +6 -6
- package/tests/config.test.ts +2 -2
- package/tests/handler.test.ts +4 -4
- package/tests/providers.test.ts +5 -5
- package/tests/react-provider.test.tsx +363 -2
- package/tests/storage.test.ts +27 -59
package/src/react/provider.tsx
CHANGED
|
@@ -8,10 +8,14 @@ interface ReactState {
|
|
|
8
8
|
language: string;
|
|
9
9
|
sourceLang: string;
|
|
10
10
|
endpoint: string;
|
|
11
|
+
projectName: string;
|
|
11
12
|
cache: Map<string, string>;
|
|
12
13
|
pluralCache: Map<string, Record<string, string>>;
|
|
13
14
|
pendingStrings: Set<string>; // "text||context"
|
|
14
15
|
pendingPlurals: Set<string>; // "one\x00other||context"
|
|
16
|
+
knownKeys: Set<string>;
|
|
17
|
+
knownPlurals: Set<string>;
|
|
18
|
+
isLanguageSwitch: boolean;
|
|
15
19
|
triggerFetch: (() => void) | null;
|
|
16
20
|
}
|
|
17
21
|
|
|
@@ -19,24 +23,62 @@ let _state: ReactState = {
|
|
|
19
23
|
language: '',
|
|
20
24
|
sourceLang: 'EN',
|
|
21
25
|
endpoint: '/api/translations',
|
|
26
|
+
projectName: 'default',
|
|
22
27
|
cache: new Map(),
|
|
23
28
|
pluralCache: new Map(),
|
|
24
29
|
pendingStrings: new Set(),
|
|
25
30
|
pendingPlurals: new Set(),
|
|
31
|
+
knownKeys: new Set(),
|
|
32
|
+
knownPlurals: new Set(),
|
|
33
|
+
isLanguageSwitch: false,
|
|
26
34
|
triggerFetch: null,
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
// --- Context for triggering child re-renders ---
|
|
30
|
-
// The context value
|
|
38
|
+
// The context value changes when translations are loaded or language switches.
|
|
31
39
|
// Components that call useTransDuck() subscribe to this and re-render automatically.
|
|
32
|
-
|
|
40
|
+
|
|
41
|
+
interface TransDuckContextValue {
|
|
42
|
+
version: number;
|
|
43
|
+
language: string;
|
|
44
|
+
setLanguage: (lang: string) => void;
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultContext: TransDuckContextValue = {
|
|
49
|
+
version: 0,
|
|
50
|
+
language: '',
|
|
51
|
+
setLanguage: () => {},
|
|
52
|
+
isLoading: false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const TransDuckContext = createContext<TransDuckContextValue>(defaultContext);
|
|
33
56
|
|
|
34
57
|
/**
|
|
35
|
-
* Hook that subscribes to translation updates
|
|
36
|
-
*
|
|
58
|
+
* Hook that subscribes to translation updates and returns translation
|
|
59
|
+
* functions along with language state.
|
|
37
60
|
*/
|
|
38
|
-
export
|
|
39
|
-
|
|
61
|
+
export interface UseTransDuckReturn {
|
|
62
|
+
t: typeof t;
|
|
63
|
+
tPlural: typeof tPlural;
|
|
64
|
+
ait: typeof t;
|
|
65
|
+
aitPlural: typeof tPlural;
|
|
66
|
+
language: string;
|
|
67
|
+
setLanguage: (lang: string) => void;
|
|
68
|
+
isLoading: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useTransDuck(): UseTransDuckReturn {
|
|
72
|
+
const ctx = useContext(TransDuckContext);
|
|
73
|
+
return {
|
|
74
|
+
t,
|
|
75
|
+
tPlural,
|
|
76
|
+
ait,
|
|
77
|
+
aitPlural,
|
|
78
|
+
language: ctx.language,
|
|
79
|
+
setLanguage: ctx.setLanguage,
|
|
80
|
+
isLoading: ctx.isLoading,
|
|
81
|
+
};
|
|
40
82
|
}
|
|
41
83
|
|
|
42
84
|
export function _resetReactState(): void {
|
|
@@ -44,14 +86,22 @@ export function _resetReactState(): void {
|
|
|
44
86
|
language: '',
|
|
45
87
|
sourceLang: 'EN',
|
|
46
88
|
endpoint: '/api/translations',
|
|
89
|
+
projectName: 'default',
|
|
47
90
|
cache: new Map(),
|
|
48
91
|
pluralCache: new Map(),
|
|
49
92
|
pendingStrings: new Set(),
|
|
50
93
|
pendingPlurals: new Set(),
|
|
94
|
+
knownKeys: new Set(),
|
|
95
|
+
knownPlurals: new Set(),
|
|
96
|
+
isLanguageSwitch: false,
|
|
51
97
|
triggerFetch: null,
|
|
52
98
|
};
|
|
53
99
|
}
|
|
54
100
|
|
|
101
|
+
export function _getReactState(): ReactState {
|
|
102
|
+
return _state;
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
|
|
56
106
|
if (!vars) return text;
|
|
57
107
|
let result = text;
|
|
@@ -68,13 +118,14 @@ export function t(
|
|
|
68
118
|
context?: string,
|
|
69
119
|
vars?: Record<string, string | number>,
|
|
70
120
|
): string {
|
|
121
|
+
const key = `${sourceText}||${context ?? ''}`;
|
|
122
|
+
_state.knownKeys.add(key);
|
|
123
|
+
|
|
71
124
|
// Same language — return source
|
|
72
125
|
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
73
126
|
return interpolateVars(sourceText, vars);
|
|
74
127
|
}
|
|
75
128
|
|
|
76
|
-
const key = `${sourceText}||${context ?? ''}`;
|
|
77
|
-
|
|
78
129
|
// Cache hit
|
|
79
130
|
const cached = _state.cache.get(key);
|
|
80
131
|
if (cached !== undefined) {
|
|
@@ -104,6 +155,10 @@ export function tPlural(
|
|
|
104
155
|
vars = { ...opts.vars };
|
|
105
156
|
}
|
|
106
157
|
|
|
158
|
+
const sourceKey = `${one}\x00${other}`;
|
|
159
|
+
const cacheKey = `${sourceKey}||${context ?? ''}`;
|
|
160
|
+
_state.knownPlurals.add(cacheKey);
|
|
161
|
+
|
|
107
162
|
// Same language
|
|
108
163
|
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
109
164
|
const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
|
|
@@ -111,9 +166,6 @@ export function tPlural(
|
|
|
111
166
|
return interpolateVars(form, vars);
|
|
112
167
|
}
|
|
113
168
|
|
|
114
|
-
const sourceKey = `${one}\x00${other}`;
|
|
115
|
-
const cacheKey = `${sourceKey}||${context ?? ''}`;
|
|
116
|
-
|
|
117
169
|
// Cache hit
|
|
118
170
|
const cachedForms = _state.pluralCache.get(cacheKey);
|
|
119
171
|
if (cachedForms) {
|
|
@@ -191,21 +243,62 @@ export function TransDuckProvider({
|
|
|
191
243
|
children,
|
|
192
244
|
}: TransDuckProviderProps) {
|
|
193
245
|
const [version, setVersion] = useState(0);
|
|
246
|
+
const [languageState, setLanguageState] = useState(language.toUpperCase());
|
|
247
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
194
248
|
|
|
195
249
|
// Update module-level state synchronously so t() works during first render
|
|
196
250
|
_state.endpoint = endpoint;
|
|
197
251
|
_state.sourceLang = sourceLang;
|
|
252
|
+
_state.projectName = projectName;
|
|
198
253
|
|
|
254
|
+
// Synchronous first-render initialization only.
|
|
255
|
+
// Subsequent language changes are handled by switchLanguage / useEffect prop sync.
|
|
199
256
|
const upperLang = language.toUpperCase();
|
|
200
|
-
if (_state.language
|
|
257
|
+
if (!_state.language) {
|
|
201
258
|
_state.language = upperLang;
|
|
202
|
-
_state.cache.clear();
|
|
203
|
-
_state.pluralCache.clear();
|
|
204
259
|
loadFromLocalStorage(projectName, upperLang);
|
|
205
260
|
}
|
|
206
261
|
|
|
262
|
+
const switchLanguage = useCallback((lang: string) => {
|
|
263
|
+
const upper = lang.toUpperCase();
|
|
264
|
+
|
|
265
|
+
// Same-language no-op
|
|
266
|
+
if (upper === _state.language) return;
|
|
267
|
+
|
|
268
|
+
// Update React state
|
|
269
|
+
setLanguageState(upper);
|
|
270
|
+
|
|
271
|
+
// Update module-level state synchronously
|
|
272
|
+
_state.language = upper;
|
|
273
|
+
_state.cache.clear();
|
|
274
|
+
_state.pluralCache.clear();
|
|
275
|
+
|
|
276
|
+
// Source-language guard: skip fetch, t() short-circuits
|
|
277
|
+
if (upper === _state.sourceLang.toUpperCase()) {
|
|
278
|
+
setVersion(v => v + 1);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Load cached translations from localStorage
|
|
283
|
+
loadFromLocalStorage(_state.projectName, upper);
|
|
284
|
+
|
|
285
|
+
// Mark as language switch and re-queue known keys
|
|
286
|
+
_state.isLanguageSwitch = true;
|
|
287
|
+
setIsLoading(true);
|
|
288
|
+
for (const key of _state.knownKeys) {
|
|
289
|
+
_state.pendingStrings.add(key);
|
|
290
|
+
}
|
|
291
|
+
for (const key of _state.knownPlurals) {
|
|
292
|
+
_state.pendingPlurals.add(key);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Bump version to trigger re-render and useEffect flush
|
|
296
|
+
setVersion(v => v + 1);
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
207
299
|
// Set up trigger
|
|
208
300
|
const doFetch = useCallback(async () => {
|
|
301
|
+
const fetchLanguage = _state.language;
|
|
209
302
|
const stringsToFetch = new Set(_state.pendingStrings);
|
|
210
303
|
const pluralsToFetch = new Set(_state.pendingPlurals);
|
|
211
304
|
_state.pendingStrings.clear();
|
|
@@ -219,7 +312,13 @@ export function TransDuckProvider({
|
|
|
219
312
|
if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
|
|
220
313
|
}
|
|
221
314
|
|
|
222
|
-
if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
|
|
315
|
+
if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
|
|
316
|
+
if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
|
|
317
|
+
_state.isLanguageSwitch = false;
|
|
318
|
+
setIsLoading(false);
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
223
322
|
|
|
224
323
|
// Build request body
|
|
225
324
|
const strings = Array.from(stringsToFetch).map(key => {
|
|
@@ -250,6 +349,9 @@ export function TransDuckProvider({
|
|
|
250
349
|
|
|
251
350
|
const data = await response.json();
|
|
252
351
|
|
|
352
|
+
// Stale fetch protection: language changed while we were fetching
|
|
353
|
+
if (_state.language !== fetchLanguage) return;
|
|
354
|
+
|
|
253
355
|
// Store translations
|
|
254
356
|
if (data.translations) {
|
|
255
357
|
for (const [key, value] of Object.entries(data.translations)) {
|
|
@@ -262,16 +364,31 @@ export function TransDuckProvider({
|
|
|
262
364
|
}
|
|
263
365
|
}
|
|
264
366
|
|
|
265
|
-
saveToLocalStorage(projectName, _state.language);
|
|
367
|
+
saveToLocalStorage(_state.projectName, _state.language);
|
|
266
368
|
setVersion(v => v + 1);
|
|
267
369
|
} catch (err) {
|
|
268
370
|
console.warn('[transduck] Translation fetch error:', err);
|
|
371
|
+
} finally {
|
|
372
|
+
if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
|
|
373
|
+
_state.isLanguageSwitch = false;
|
|
374
|
+
setIsLoading(false);
|
|
375
|
+
}
|
|
269
376
|
}
|
|
270
|
-
}, [endpoint
|
|
377
|
+
}, [endpoint]);
|
|
271
378
|
|
|
272
379
|
// Register trigger
|
|
273
380
|
_state.triggerFetch = doFetch;
|
|
274
381
|
|
|
382
|
+
// Sync language prop changes — only when the prop itself changes
|
|
383
|
+
const prevPropRef = React.useRef(upperLang);
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const upper = language.toUpperCase();
|
|
386
|
+
if (upper !== prevPropRef.current) {
|
|
387
|
+
prevPropRef.current = upper;
|
|
388
|
+
switchLanguage(upper);
|
|
389
|
+
}
|
|
390
|
+
}, [language, switchLanguage]);
|
|
391
|
+
|
|
275
392
|
// Flush pending after render
|
|
276
393
|
useEffect(() => {
|
|
277
394
|
if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
|
|
@@ -279,8 +396,15 @@ export function TransDuckProvider({
|
|
|
279
396
|
}
|
|
280
397
|
});
|
|
281
398
|
|
|
399
|
+
const contextValue: TransDuckContextValue = {
|
|
400
|
+
version,
|
|
401
|
+
language: languageState,
|
|
402
|
+
setLanguage: switchLanguage,
|
|
403
|
+
isLoading,
|
|
404
|
+
};
|
|
405
|
+
|
|
282
406
|
return (
|
|
283
|
-
<TransDuckContext.Provider value={
|
|
407
|
+
<TransDuckContext.Provider value={contextValue}>
|
|
284
408
|
{children}
|
|
285
409
|
</TransDuckContext.Provider>
|
|
286
410
|
);
|
package/src/storage.ts
CHANGED
|
@@ -1,79 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
source_text TEXT NOT NULL,
|
|
7
|
-
source_lang TEXT NOT NULL,
|
|
8
|
-
target_lang TEXT NOT NULL,
|
|
9
|
-
project_context_hash TEXT NOT NULL,
|
|
10
|
-
string_context_hash TEXT NOT NULL,
|
|
11
|
-
plural_category TEXT NOT NULL DEFAULT '',
|
|
12
|
-
translated_text TEXT NOT NULL,
|
|
13
|
-
model TEXT NOT NULL,
|
|
14
|
-
status TEXT NOT NULL DEFAULT 'translated',
|
|
15
|
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
16
|
-
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
|
|
17
|
-
);
|
|
18
|
-
`;
|
|
19
|
-
|
|
20
|
-
const MIGRATION_V1_TO_V2 = `
|
|
21
|
-
CREATE TABLE translations_v2 (
|
|
22
|
-
source_text TEXT NOT NULL,
|
|
23
|
-
source_lang TEXT NOT NULL,
|
|
24
|
-
target_lang TEXT NOT NULL,
|
|
25
|
-
project_context_hash TEXT NOT NULL,
|
|
26
|
-
string_context_hash TEXT NOT NULL,
|
|
27
|
-
plural_category TEXT NOT NULL DEFAULT '',
|
|
28
|
-
translated_text TEXT NOT NULL,
|
|
29
|
-
model TEXT NOT NULL,
|
|
30
|
-
status TEXT NOT NULL DEFAULT 'translated',
|
|
31
|
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
32
|
-
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
|
|
33
|
-
);
|
|
34
|
-
INSERT INTO translations_v2
|
|
35
|
-
SELECT source_text, source_lang, target_lang, project_context_hash,
|
|
36
|
-
string_context_hash, '' as plural_category, translated_text,
|
|
37
|
-
model, status, created_at
|
|
38
|
-
FROM translations;
|
|
39
|
-
DROP TABLE translations;
|
|
40
|
-
ALTER TABLE translations_v2 RENAME TO translations;
|
|
41
|
-
`;
|
|
42
|
-
|
|
43
|
-
const CHECK_PLURAL_COLUMN = `
|
|
44
|
-
SELECT column_name FROM information_schema.columns
|
|
45
|
-
WHERE table_name = 'translations' AND column_name = 'plural_category'
|
|
46
|
-
`;
|
|
47
|
-
|
|
48
|
-
const LOOKUP_SQL = `
|
|
49
|
-
SELECT translated_text FROM translations
|
|
50
|
-
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
51
|
-
AND project_context_hash = $4 AND string_context_hash = $5
|
|
52
|
-
AND plural_category = '' AND status = 'translated'
|
|
53
|
-
`;
|
|
54
|
-
|
|
55
|
-
const INSERT_SQL = `
|
|
56
|
-
INSERT INTO translations
|
|
57
|
-
(source_text, source_lang, target_lang, project_context_hash,
|
|
58
|
-
string_context_hash, plural_category, translated_text, model, status)
|
|
59
|
-
VALUES ($1, $2, $3, $4, $5, '', $6, $7, $8)
|
|
60
|
-
ON CONFLICT DO NOTHING
|
|
61
|
-
`;
|
|
62
|
-
|
|
63
|
-
const LOOKUP_PLURAL_SQL = `
|
|
64
|
-
SELECT plural_category, translated_text FROM translations
|
|
65
|
-
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
66
|
-
AND project_context_hash = $4 AND string_context_hash = $5
|
|
67
|
-
AND plural_category != '' AND status = 'translated'
|
|
68
|
-
`;
|
|
69
|
-
|
|
70
|
-
const INSERT_PLURAL_SQL = `
|
|
71
|
-
INSERT INTO translations
|
|
72
|
-
(source_text, source_lang, target_lang, project_context_hash,
|
|
73
|
-
string_context_hash, plural_category, translated_text, model, status)
|
|
74
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
75
|
-
ON CONFLICT DO NOTHING
|
|
76
|
-
`;
|
|
1
|
+
import { open, type Database, type RootDatabase } from 'lmdb';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
|
|
4
|
+
// 1 GB default map size — plenty for translation caches
|
|
5
|
+
const DEFAULT_MAP_SIZE = 1024 * 1024 * 1024;
|
|
77
6
|
|
|
78
7
|
export interface LookupParams {
|
|
79
8
|
sourceText: string;
|
|
@@ -96,134 +25,184 @@ export interface InsertPluralParams extends LookupParams {
|
|
|
96
25
|
status: string;
|
|
97
26
|
}
|
|
98
27
|
|
|
28
|
+
interface StoredEntry {
|
|
29
|
+
source_text: string;
|
|
30
|
+
translated_text: string;
|
|
31
|
+
model: string;
|
|
32
|
+
status: string;
|
|
33
|
+
created_at: string;
|
|
34
|
+
project_context_hash: string;
|
|
35
|
+
string_context_hash: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
99
38
|
interface Stats {
|
|
100
39
|
totalTranslations: number;
|
|
101
40
|
totalFailed: number;
|
|
102
41
|
byLanguage: Record<string, number>;
|
|
103
42
|
}
|
|
104
43
|
|
|
44
|
+
function contentHash(sourceText: string, projectContextHash: string, stringContextHash: string): string {
|
|
45
|
+
return createHash('sha256')
|
|
46
|
+
.update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
|
|
47
|
+
.digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeKey(sourceLang: string, targetLang: string, chash: string, pluralCategory: string): string {
|
|
51
|
+
return `${sourceLang}|${targetLang}|${chash}|${pluralCategory}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseKey(key: string): { sourceLang: string; targetLang: string; contentHash: string; pluralCategory: string } {
|
|
55
|
+
const parts = key.split('|');
|
|
56
|
+
return {
|
|
57
|
+
sourceLang: parts[0],
|
|
58
|
+
targetLang: parts[1],
|
|
59
|
+
contentHash: parts[2],
|
|
60
|
+
pluralCategory: parts[3] ?? '',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
105
64
|
export class TranslationStore {
|
|
106
65
|
private dbPath: string;
|
|
107
|
-
private
|
|
108
|
-
private conn: DuckDBConnection | null = null;
|
|
66
|
+
private db: RootDatabase<StoredEntry, string> | null = null;
|
|
109
67
|
|
|
110
68
|
constructor(dbPath: string) {
|
|
111
69
|
this.dbPath = dbPath;
|
|
112
70
|
}
|
|
113
71
|
|
|
114
|
-
private async getConn(): Promise<DuckDBConnection> {
|
|
115
|
-
if (!this.conn) throw new Error('Store not initialized');
|
|
116
|
-
return this.conn;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
private convertRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
120
|
-
const converted: Record<string, unknown> = {};
|
|
121
|
-
for (const [key, value] of Object.entries(row)) {
|
|
122
|
-
if (typeof value === 'bigint') {
|
|
123
|
-
converted[key] = Number(value);
|
|
124
|
-
} else {
|
|
125
|
-
converted[key] = value;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return converted;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async query(sql: string, params: string[] = []): Promise<Record<string, unknown>[]> {
|
|
132
|
-
const conn = await this.getConn();
|
|
133
|
-
let result;
|
|
134
|
-
if (params.length === 0) {
|
|
135
|
-
result = await conn.run(sql);
|
|
136
|
-
} else {
|
|
137
|
-
const stmt = await conn.prepare(sql);
|
|
138
|
-
for (let i = 0; i < params.length; i++) {
|
|
139
|
-
stmt.bindVarchar(i + 1, params[i]);
|
|
140
|
-
}
|
|
141
|
-
result = await stmt.run();
|
|
142
|
-
}
|
|
143
|
-
const reader = new DuckDBResultReader(result);
|
|
144
|
-
await reader.readAll();
|
|
145
|
-
return [...reader.getRowObjects()].map(row => this.convertRow(row));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
72
|
async initialize(): Promise<void> {
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
if (tables.length === 0) {
|
|
158
|
-
// Fresh database — create v2 schema
|
|
159
|
-
await this.conn.run(SCHEMA_V2);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
73
|
+
this.db = open<StoredEntry, string>({
|
|
74
|
+
path: this.dbPath,
|
|
75
|
+
mapSize: DEFAULT_MAP_SIZE,
|
|
76
|
+
// Use msgpack (default) for efficient storage
|
|
77
|
+
});
|
|
78
|
+
}
|
|
162
79
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// Migrate from v1 to v2
|
|
167
|
-
await this.conn.run(MIGRATION_V1_TO_V2);
|
|
168
|
-
}
|
|
80
|
+
private getDb(): RootDatabase<StoredEntry, string> {
|
|
81
|
+
if (!this.db) throw new Error('Store not initialized');
|
|
82
|
+
return this.db;
|
|
169
83
|
}
|
|
170
84
|
|
|
171
85
|
async lookup(params: LookupParams): Promise<string | null> {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
86
|
+
const db = this.getDb();
|
|
87
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
88
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
89
|
+
const entry = db.get(key);
|
|
90
|
+
if (entry && entry.status === 'translated') {
|
|
91
|
+
return entry.translated_text;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
177
94
|
}
|
|
178
95
|
|
|
179
96
|
async insert(params: InsertParams): Promise<void> {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
97
|
+
const db = this.getDb();
|
|
98
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
99
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
100
|
+
// ON CONFLICT DO NOTHING — only insert if key doesn't exist
|
|
101
|
+
if (db.get(key) !== undefined) return;
|
|
102
|
+
await db.put(key, {
|
|
103
|
+
source_text: params.sourceText,
|
|
104
|
+
translated_text: params.translatedText,
|
|
105
|
+
model: params.model,
|
|
106
|
+
status: params.status,
|
|
107
|
+
created_at: new Date().toISOString(),
|
|
108
|
+
project_context_hash: params.projectContextHash,
|
|
109
|
+
string_context_hash: params.stringContextHash,
|
|
110
|
+
});
|
|
185
111
|
}
|
|
186
112
|
|
|
187
113
|
async lookupPlural(params: LookupParams): Promise<Record<string, string>> {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
]);
|
|
114
|
+
const db = this.getDb();
|
|
115
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
116
|
+
const prefix = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
192
117
|
const result: Record<string, string> = {};
|
|
193
|
-
|
|
194
|
-
|
|
118
|
+
|
|
119
|
+
for (const { key, value } of db.getRange({ start: prefix })) {
|
|
120
|
+
const keyStr = key as string;
|
|
121
|
+
if (!keyStr.startsWith(prefix)) break;
|
|
122
|
+
const parsed = parseKey(keyStr);
|
|
123
|
+
// Skip regular entries (empty plural_category)
|
|
124
|
+
if (!parsed.pluralCategory) continue;
|
|
125
|
+
if (value.status === 'translated') {
|
|
126
|
+
result[parsed.pluralCategory] = value.translated_text;
|
|
127
|
+
}
|
|
195
128
|
}
|
|
196
129
|
return result;
|
|
197
130
|
}
|
|
198
131
|
|
|
199
132
|
async insertPlural(params: InsertPluralParams): Promise<void> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
133
|
+
const db = this.getDb();
|
|
134
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
135
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, params.pluralCategory);
|
|
136
|
+
// ON CONFLICT DO NOTHING
|
|
137
|
+
if (db.get(key) !== undefined) return;
|
|
138
|
+
await db.put(key, {
|
|
139
|
+
source_text: params.sourceText,
|
|
140
|
+
translated_text: params.translatedText,
|
|
141
|
+
model: params.model,
|
|
142
|
+
status: params.status,
|
|
143
|
+
created_at: new Date().toISOString(),
|
|
144
|
+
project_context_hash: params.projectContextHash,
|
|
145
|
+
string_context_hash: params.stringContextHash,
|
|
146
|
+
});
|
|
205
147
|
}
|
|
206
148
|
|
|
207
149
|
async stats(): Promise<Stats> {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
"SELECT target_lang, count(*) as c FROM translations WHERE status = 'translated' GROUP BY target_lang"
|
|
212
|
-
);
|
|
150
|
+
const db = this.getDb();
|
|
151
|
+
let totalTranslations = 0;
|
|
152
|
+
let totalFailed = 0;
|
|
213
153
|
const byLanguage: Record<string, number> = {};
|
|
214
|
-
|
|
215
|
-
|
|
154
|
+
|
|
155
|
+
for (const { key, value } of db.getRange({})) {
|
|
156
|
+
const parsed = parseKey(key as string);
|
|
157
|
+
if (value.status === 'translated') {
|
|
158
|
+
totalTranslations++;
|
|
159
|
+
byLanguage[parsed.targetLang] = (byLanguage[parsed.targetLang] ?? 0) + 1;
|
|
160
|
+
} else if (value.status === 'failed') {
|
|
161
|
+
totalFailed++;
|
|
162
|
+
}
|
|
216
163
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
164
|
+
|
|
165
|
+
return { totalTranslations, totalFailed, byLanguage };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Count entries matching optional filters.
|
|
170
|
+
*/
|
|
171
|
+
count(targetLang?: string, failedOnly?: boolean): number {
|
|
172
|
+
const db = this.getDb();
|
|
173
|
+
let n = 0;
|
|
174
|
+
for (const { key, value } of db.getRange({})) {
|
|
175
|
+
const parsed = parseKey(key as string);
|
|
176
|
+
if (targetLang && parsed.targetLang !== targetLang) continue;
|
|
177
|
+
if (failedOnly && value.status !== 'failed') continue;
|
|
178
|
+
n++;
|
|
179
|
+
}
|
|
180
|
+
return n;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Delete entries matching optional filters. Returns count deleted.
|
|
185
|
+
*/
|
|
186
|
+
async clear(targetLang?: string, failedOnly?: boolean): Promise<number> {
|
|
187
|
+
const db = this.getDb();
|
|
188
|
+
const toDelete: string[] = [];
|
|
189
|
+
for (const { key, value } of db.getRange({})) {
|
|
190
|
+
const keyStr = key as string;
|
|
191
|
+
const parsed = parseKey(keyStr);
|
|
192
|
+
if (targetLang && parsed.targetLang !== targetLang) continue;
|
|
193
|
+
if (failedOnly && value.status !== 'failed') continue;
|
|
194
|
+
toDelete.push(keyStr);
|
|
195
|
+
}
|
|
196
|
+
for (const k of toDelete) {
|
|
197
|
+
await db.remove(k);
|
|
198
|
+
}
|
|
199
|
+
return toDelete.length;
|
|
222
200
|
}
|
|
223
201
|
|
|
224
202
|
close(): void {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
203
|
+
if (this.db) {
|
|
204
|
+
this.db.close();
|
|
205
|
+
this.db = null;
|
|
206
|
+
}
|
|
228
207
|
}
|
|
229
208
|
}
|
package/tests/ait.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ function makeConfig(tmpDir: string): TransduckConfig {
|
|
|
15
15
|
projectContext: 'A test site',
|
|
16
16
|
sourceLang: 'EN',
|
|
17
17
|
targetLangs: ['DE', 'ES'],
|
|
18
|
-
storagePath: join(tmpDir, 'test.
|
|
18
|
+
storagePath: join(tmpDir, 'test.lmdb'),
|
|
19
19
|
provider: 'openai',
|
|
20
20
|
apiKeyEnv: 'OPENAI_API_KEY',
|
|
21
21
|
tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
|
package/tests/backend.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ function makeConfig(): TransduckConfig {
|
|
|
8
8
|
projectContext: 'A travel site about Mallorca',
|
|
9
9
|
sourceLang: 'EN',
|
|
10
10
|
targetLangs: ['DE'],
|
|
11
|
-
storagePath: '/tmp/test.
|
|
11
|
+
storagePath: '/tmp/test.lmdb',
|
|
12
12
|
provider: 'openai',
|
|
13
13
|
apiKeyEnv: 'OPENAI_API_KEY',
|
|
14
14
|
tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
|