transduck 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli.js +3 -3
- 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/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/storage.test.ts +27 -59
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered translation for apps and websites. Fast. Cheap. No keys to manage.
|
|
4
4
|
|
|
5
|
-
TransDuck translates your app's strings using AI, then caches them locally in
|
|
5
|
+
TransDuck translates your app's strings using AI, then caches them locally in LMDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
import { initialize, setLanguage, ait, aitPlural } from 'transduck';
|
|
@@ -19,7 +19,7 @@ await aitPlural('{count} night', '{count} nights', 7); // → "7 Nächte"
|
|
|
19
19
|
|
|
20
20
|
- **No keys to manage** — your source text is the key
|
|
21
21
|
- **No translation files** — translations live in a single in-memory database file
|
|
22
|
-
- **Translate once, pay once** — cached lookups take ~
|
|
22
|
+
- **Translate once, pay once** — cached lookups take ~0.015ms, zero API costs after first call
|
|
23
23
|
- **AI that understands context** — project and per-string context for accurate translations
|
|
24
24
|
- **Pluralization that works everywhere** — handles Russian (4 forms), Arabic (6 forms) automatically
|
|
25
25
|
- **Built for vibe coding** — AI coding tools read the docs and wrap strings with `ait()` across your project
|
|
@@ -80,7 +80,7 @@ transduck stats # check your translation database
|
|
|
80
80
|
pip install transduck
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Works with Django, Flask, FastAPI, and Jinja. Same
|
|
83
|
+
Works with Django, Flask, FastAPI, and Jinja. Same LMDB database format — share translations between Python and JS.
|
|
84
84
|
|
|
85
85
|
## Links
|
|
86
86
|
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function runInit(opts) {
|
|
|
18
18
|
const config = {
|
|
19
19
|
project: { name: opts.name, context: opts.context },
|
|
20
20
|
languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
|
|
21
|
-
storage: { path: './translations.
|
|
21
|
+
storage: { path: './translations.lmdb' },
|
|
22
22
|
};
|
|
23
23
|
if (providerChoice === 2) {
|
|
24
24
|
config.backend = {
|
|
@@ -46,7 +46,7 @@ export async function runInit(opts) {
|
|
|
46
46
|
}
|
|
47
47
|
const configPath = join(opts.dir, 'transduck.yaml');
|
|
48
48
|
writeFileSync(configPath, yamlStringify(config));
|
|
49
|
-
const dbPath = join(opts.dir, 'translations.
|
|
49
|
+
const dbPath = join(opts.dir, 'translations.lmdb');
|
|
50
50
|
const store = new TranslationStore(dbPath);
|
|
51
51
|
await store.initialize();
|
|
52
52
|
store.close();
|
|
@@ -454,7 +454,7 @@ export async function runStats(opts) {
|
|
|
454
454
|
}
|
|
455
455
|
// CLI entry point
|
|
456
456
|
const program = new Command();
|
|
457
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
457
|
+
program.name('transduck').description('AI-native translation tool').version('0.3.1');
|
|
458
458
|
program.command('init')
|
|
459
459
|
.description('Initialize a new transduck project')
|
|
460
460
|
.action(async () => {
|
package/dist/storage.d.ts
CHANGED
|
@@ -23,18 +23,23 @@ interface Stats {
|
|
|
23
23
|
}
|
|
24
24
|
export declare class TranslationStore {
|
|
25
25
|
private dbPath;
|
|
26
|
-
private
|
|
27
|
-
private conn;
|
|
26
|
+
private db;
|
|
28
27
|
constructor(dbPath: string);
|
|
29
|
-
private getConn;
|
|
30
|
-
private convertRow;
|
|
31
|
-
query(sql: string, params?: string[]): Promise<Record<string, unknown>[]>;
|
|
32
28
|
initialize(): Promise<void>;
|
|
29
|
+
private getDb;
|
|
33
30
|
lookup(params: LookupParams): Promise<string | null>;
|
|
34
31
|
insert(params: InsertParams): Promise<void>;
|
|
35
32
|
lookupPlural(params: LookupParams): Promise<Record<string, string>>;
|
|
36
33
|
insertPlural(params: InsertPluralParams): Promise<void>;
|
|
37
34
|
stats(): Promise<Stats>;
|
|
35
|
+
/**
|
|
36
|
+
* Count entries matching optional filters.
|
|
37
|
+
*/
|
|
38
|
+
count(targetLang?: string, failedOnly?: boolean): number;
|
|
39
|
+
/**
|
|
40
|
+
* Delete entries matching optional filters. Returns count deleted.
|
|
41
|
+
*/
|
|
42
|
+
clear(targetLang?: string, failedOnly?: boolean): Promise<number>;
|
|
38
43
|
close(): void;
|
|
39
44
|
}
|
|
40
45
|
export {};
|
package/dist/storage.js
CHANGED
|
@@ -1,178 +1,162 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
project_context_hash TEXT NOT NULL,
|
|
23
|
-
string_context_hash TEXT NOT NULL,
|
|
24
|
-
plural_category TEXT NOT NULL DEFAULT '',
|
|
25
|
-
translated_text TEXT NOT NULL,
|
|
26
|
-
model TEXT NOT NULL,
|
|
27
|
-
status TEXT NOT NULL DEFAULT 'translated',
|
|
28
|
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
-
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
|
|
30
|
-
);
|
|
31
|
-
INSERT INTO translations_v2
|
|
32
|
-
SELECT source_text, source_lang, target_lang, project_context_hash,
|
|
33
|
-
string_context_hash, '' as plural_category, translated_text,
|
|
34
|
-
model, status, created_at
|
|
35
|
-
FROM translations;
|
|
36
|
-
DROP TABLE translations;
|
|
37
|
-
ALTER TABLE translations_v2 RENAME TO translations;
|
|
38
|
-
`;
|
|
39
|
-
const CHECK_PLURAL_COLUMN = `
|
|
40
|
-
SELECT column_name FROM information_schema.columns
|
|
41
|
-
WHERE table_name = 'translations' AND column_name = 'plural_category'
|
|
42
|
-
`;
|
|
43
|
-
const LOOKUP_SQL = `
|
|
44
|
-
SELECT translated_text FROM translations
|
|
45
|
-
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
46
|
-
AND project_context_hash = $4 AND string_context_hash = $5
|
|
47
|
-
AND plural_category = '' AND status = 'translated'
|
|
48
|
-
`;
|
|
49
|
-
const INSERT_SQL = `
|
|
50
|
-
INSERT INTO translations
|
|
51
|
-
(source_text, source_lang, target_lang, project_context_hash,
|
|
52
|
-
string_context_hash, plural_category, translated_text, model, status)
|
|
53
|
-
VALUES ($1, $2, $3, $4, $5, '', $6, $7, $8)
|
|
54
|
-
ON CONFLICT DO NOTHING
|
|
55
|
-
`;
|
|
56
|
-
const LOOKUP_PLURAL_SQL = `
|
|
57
|
-
SELECT plural_category, translated_text FROM translations
|
|
58
|
-
WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
|
|
59
|
-
AND project_context_hash = $4 AND string_context_hash = $5
|
|
60
|
-
AND plural_category != '' AND status = 'translated'
|
|
61
|
-
`;
|
|
62
|
-
const INSERT_PLURAL_SQL = `
|
|
63
|
-
INSERT INTO translations
|
|
64
|
-
(source_text, source_lang, target_lang, project_context_hash,
|
|
65
|
-
string_context_hash, plural_category, translated_text, model, status)
|
|
66
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
67
|
-
ON CONFLICT DO NOTHING
|
|
68
|
-
`;
|
|
1
|
+
import { open } from 'lmdb';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
// 1 GB default map size — plenty for translation caches
|
|
4
|
+
const DEFAULT_MAP_SIZE = 1024 * 1024 * 1024;
|
|
5
|
+
function contentHash(sourceText, projectContextHash, stringContextHash) {
|
|
6
|
+
return createHash('sha256')
|
|
7
|
+
.update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
|
|
8
|
+
.digest('hex');
|
|
9
|
+
}
|
|
10
|
+
function makeKey(sourceLang, targetLang, chash, pluralCategory) {
|
|
11
|
+
return `${sourceLang}|${targetLang}|${chash}|${pluralCategory}`;
|
|
12
|
+
}
|
|
13
|
+
function parseKey(key) {
|
|
14
|
+
const parts = key.split('|');
|
|
15
|
+
return {
|
|
16
|
+
sourceLang: parts[0],
|
|
17
|
+
targetLang: parts[1],
|
|
18
|
+
contentHash: parts[2],
|
|
19
|
+
pluralCategory: parts[3] ?? '',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
69
22
|
export class TranslationStore {
|
|
70
23
|
dbPath;
|
|
71
|
-
|
|
72
|
-
conn = null;
|
|
24
|
+
db = null;
|
|
73
25
|
constructor(dbPath) {
|
|
74
26
|
this.dbPath = dbPath;
|
|
75
27
|
}
|
|
76
|
-
async getConn() {
|
|
77
|
-
if (!this.conn)
|
|
78
|
-
throw new Error('Store not initialized');
|
|
79
|
-
return this.conn;
|
|
80
|
-
}
|
|
81
|
-
convertRow(row) {
|
|
82
|
-
const converted = {};
|
|
83
|
-
for (const [key, value] of Object.entries(row)) {
|
|
84
|
-
if (typeof value === 'bigint') {
|
|
85
|
-
converted[key] = Number(value);
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
converted[key] = value;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return converted;
|
|
92
|
-
}
|
|
93
|
-
async query(sql, params = []) {
|
|
94
|
-
const conn = await this.getConn();
|
|
95
|
-
let result;
|
|
96
|
-
if (params.length === 0) {
|
|
97
|
-
result = await conn.run(sql);
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
const stmt = await conn.prepare(sql);
|
|
101
|
-
for (let i = 0; i < params.length; i++) {
|
|
102
|
-
stmt.bindVarchar(i + 1, params[i]);
|
|
103
|
-
}
|
|
104
|
-
result = await stmt.run();
|
|
105
|
-
}
|
|
106
|
-
const reader = new DuckDBResultReader(result);
|
|
107
|
-
await reader.readAll();
|
|
108
|
-
return [...reader.getRowObjects()].map(row => this.convertRow(row));
|
|
109
|
-
}
|
|
110
28
|
async initialize() {
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const hasPlural = await this.query(CHECK_PLURAL_COLUMN);
|
|
122
|
-
if (hasPlural.length === 0) {
|
|
123
|
-
// Migrate from v1 to v2
|
|
124
|
-
await this.conn.run(MIGRATION_V1_TO_V2);
|
|
125
|
-
}
|
|
29
|
+
this.db = open({
|
|
30
|
+
path: this.dbPath,
|
|
31
|
+
mapSize: DEFAULT_MAP_SIZE,
|
|
32
|
+
// Use msgpack (default) for efficient storage
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
getDb() {
|
|
36
|
+
if (!this.db)
|
|
37
|
+
throw new Error('Store not initialized');
|
|
38
|
+
return this.db;
|
|
126
39
|
}
|
|
127
40
|
async lookup(params) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
41
|
+
const db = this.getDb();
|
|
42
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
43
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
44
|
+
const entry = db.get(key);
|
|
45
|
+
if (entry && entry.status === 'translated') {
|
|
46
|
+
return entry.translated_text;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
133
49
|
}
|
|
134
50
|
async insert(params) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
51
|
+
const db = this.getDb();
|
|
52
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
53
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
54
|
+
// ON CONFLICT DO NOTHING — only insert if key doesn't exist
|
|
55
|
+
if (db.get(key) !== undefined)
|
|
56
|
+
return;
|
|
57
|
+
await db.put(key, {
|
|
58
|
+
source_text: params.sourceText,
|
|
59
|
+
translated_text: params.translatedText,
|
|
60
|
+
model: params.model,
|
|
61
|
+
status: params.status,
|
|
62
|
+
created_at: new Date().toISOString(),
|
|
63
|
+
project_context_hash: params.projectContextHash,
|
|
64
|
+
string_context_hash: params.stringContextHash,
|
|
65
|
+
});
|
|
140
66
|
}
|
|
141
67
|
async lookupPlural(params) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
]);
|
|
68
|
+
const db = this.getDb();
|
|
69
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
70
|
+
const prefix = makeKey(params.sourceLang, params.targetLang, chash, '');
|
|
146
71
|
const result = {};
|
|
147
|
-
for (const
|
|
148
|
-
|
|
72
|
+
for (const { key, value } of db.getRange({ start: prefix })) {
|
|
73
|
+
const keyStr = key;
|
|
74
|
+
if (!keyStr.startsWith(prefix))
|
|
75
|
+
break;
|
|
76
|
+
const parsed = parseKey(keyStr);
|
|
77
|
+
// Skip regular entries (empty plural_category)
|
|
78
|
+
if (!parsed.pluralCategory)
|
|
79
|
+
continue;
|
|
80
|
+
if (value.status === 'translated') {
|
|
81
|
+
result[parsed.pluralCategory] = value.translated_text;
|
|
82
|
+
}
|
|
149
83
|
}
|
|
150
84
|
return result;
|
|
151
85
|
}
|
|
152
86
|
async insertPlural(params) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
87
|
+
const db = this.getDb();
|
|
88
|
+
const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
|
|
89
|
+
const key = makeKey(params.sourceLang, params.targetLang, chash, params.pluralCategory);
|
|
90
|
+
// ON CONFLICT DO NOTHING
|
|
91
|
+
if (db.get(key) !== undefined)
|
|
92
|
+
return;
|
|
93
|
+
await db.put(key, {
|
|
94
|
+
source_text: params.sourceText,
|
|
95
|
+
translated_text: params.translatedText,
|
|
96
|
+
model: params.model,
|
|
97
|
+
status: params.status,
|
|
98
|
+
created_at: new Date().toISOString(),
|
|
99
|
+
project_context_hash: params.projectContextHash,
|
|
100
|
+
string_context_hash: params.stringContextHash,
|
|
101
|
+
});
|
|
158
102
|
}
|
|
159
103
|
async stats() {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
104
|
+
const db = this.getDb();
|
|
105
|
+
let totalTranslations = 0;
|
|
106
|
+
let totalFailed = 0;
|
|
163
107
|
const byLanguage = {};
|
|
164
|
-
for (const
|
|
165
|
-
|
|
108
|
+
for (const { key, value } of db.getRange({})) {
|
|
109
|
+
const parsed = parseKey(key);
|
|
110
|
+
if (value.status === 'translated') {
|
|
111
|
+
totalTranslations++;
|
|
112
|
+
byLanguage[parsed.targetLang] = (byLanguage[parsed.targetLang] ?? 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
else if (value.status === 'failed') {
|
|
115
|
+
totalFailed++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { totalTranslations, totalFailed, byLanguage };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Count entries matching optional filters.
|
|
122
|
+
*/
|
|
123
|
+
count(targetLang, failedOnly) {
|
|
124
|
+
const db = this.getDb();
|
|
125
|
+
let n = 0;
|
|
126
|
+
for (const { key, value } of db.getRange({})) {
|
|
127
|
+
const parsed = parseKey(key);
|
|
128
|
+
if (targetLang && parsed.targetLang !== targetLang)
|
|
129
|
+
continue;
|
|
130
|
+
if (failedOnly && value.status !== 'failed')
|
|
131
|
+
continue;
|
|
132
|
+
n++;
|
|
166
133
|
}
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Delete entries matching optional filters. Returns count deleted.
|
|
138
|
+
*/
|
|
139
|
+
async clear(targetLang, failedOnly) {
|
|
140
|
+
const db = this.getDb();
|
|
141
|
+
const toDelete = [];
|
|
142
|
+
for (const { key, value } of db.getRange({})) {
|
|
143
|
+
const keyStr = key;
|
|
144
|
+
const parsed = parseKey(keyStr);
|
|
145
|
+
if (targetLang && parsed.targetLang !== targetLang)
|
|
146
|
+
continue;
|
|
147
|
+
if (failedOnly && value.status !== 'failed')
|
|
148
|
+
continue;
|
|
149
|
+
toDelete.push(keyStr);
|
|
150
|
+
}
|
|
151
|
+
for (const k of toDelete) {
|
|
152
|
+
await db.remove(k);
|
|
153
|
+
}
|
|
154
|
+
return toDelete.length;
|
|
172
155
|
}
|
|
173
156
|
close() {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
157
|
+
if (this.db) {
|
|
158
|
+
this.db.close();
|
|
159
|
+
this.db = null;
|
|
160
|
+
}
|
|
177
161
|
}
|
|
178
162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transduck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI-native translation tool using source text as keys",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
"test:watch": "vitest"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@duckdb/node-api": "^1.5.0-r.1",
|
|
28
27
|
"commander": "^12.0.0",
|
|
28
|
+
"lmdb": "^3.5.2",
|
|
29
29
|
"openai": "^4.0.0",
|
|
30
30
|
"yaml": "^2.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@testing-library/react": "^16.0.0",
|
|
33
34
|
"@types/node": "^22.0.0",
|
|
34
35
|
"@types/react": "^19.0.0",
|
|
35
36
|
"@types/react-dom": "^19.0.0",
|
|
36
|
-
"@testing-library/react": "^16.0.0",
|
|
37
37
|
"jsdom": "^25.0.0",
|
|
38
38
|
"react": "^19.0.0",
|
|
39
39
|
"react-dom": "^19.0.0",
|
|
@@ -41,15 +41,23 @@
|
|
|
41
41
|
"vitest": "^2.0.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"
|
|
45
|
-
"react-dom": ">=18.0.0",
|
|
44
|
+
"@anthropic-ai/claude-agent-sdk": ">=0.1.0",
|
|
46
45
|
"@anthropic-ai/sdk": ">=0.30.0",
|
|
47
|
-
"
|
|
46
|
+
"react": ">=18.0.0",
|
|
47
|
+
"react-dom": ">=18.0.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
|
-
"react": {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"react-dom": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@anthropic-ai/sdk": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"@anthropic-ai/claude-agent-sdk": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/cli.ts
CHANGED
|
@@ -31,7 +31,7 @@ export async function runInit(opts: InitOptions): Promise<string> {
|
|
|
31
31
|
const config: Record<string, any> = {
|
|
32
32
|
project: { name: opts.name, context: opts.context },
|
|
33
33
|
languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
|
|
34
|
-
storage: { path: './translations.
|
|
34
|
+
storage: { path: './translations.lmdb' },
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
if (providerChoice === 2) {
|
|
@@ -60,7 +60,7 @@ export async function runInit(opts: InitOptions): Promise<string> {
|
|
|
60
60
|
const configPath = join(opts.dir, 'transduck.yaml');
|
|
61
61
|
writeFileSync(configPath, yamlStringify(config));
|
|
62
62
|
|
|
63
|
-
const dbPath = join(opts.dir, 'translations.
|
|
63
|
+
const dbPath = join(opts.dir, 'translations.lmdb');
|
|
64
64
|
const store = new TranslationStore(dbPath);
|
|
65
65
|
await store.initialize();
|
|
66
66
|
store.close();
|
|
@@ -562,7 +562,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
|
|
|
562
562
|
// CLI entry point
|
|
563
563
|
const program = new Command();
|
|
564
564
|
|
|
565
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
565
|
+
program.name('transduck').description('AI-native translation tool').version('0.3.1');
|
|
566
566
|
|
|
567
567
|
program.command('init')
|
|
568
568
|
.description('Initialize a new transduck project')
|
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',
|
package/tests/cli.test.ts
CHANGED
|
@@ -27,7 +27,7 @@ languages:
|
|
|
27
27
|
targets:
|
|
28
28
|
- DE
|
|
29
29
|
storage:
|
|
30
|
-
path: ./translations.
|
|
30
|
+
path: ./translations.lmdb
|
|
31
31
|
backend:
|
|
32
32
|
api_key_env: OPENAI_API_KEY
|
|
33
33
|
model: gpt-4.1-mini
|
|
@@ -55,7 +55,7 @@ describe('CLI functions', () => {
|
|
|
55
55
|
});
|
|
56
56
|
const { existsSync } = await import('fs');
|
|
57
57
|
expect(existsSync(join(tmpDir, 'transduck.yaml'))).toBe(true);
|
|
58
|
-
expect(existsSync(join(tmpDir, 'translations.
|
|
58
|
+
expect(existsSync(join(tmpDir, 'translations.lmdb'))).toBe(true);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('stats on empty db shows zero', async () => {
|
|
@@ -68,7 +68,7 @@ describe('CLI functions', () => {
|
|
|
68
68
|
|
|
69
69
|
it('translate returns cached with vars interpolated', async () => {
|
|
70
70
|
const configPath = writeConfig(tmpDir);
|
|
71
|
-
const dbPath = join(tmpDir, 'translations.
|
|
71
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
72
72
|
|
|
73
73
|
// Pre-populate the DB
|
|
74
74
|
const store = new TranslationStore(dbPath);
|
|
@@ -91,7 +91,7 @@ describe('CLI functions', () => {
|
|
|
91
91
|
|
|
92
92
|
it('translate-plural returns cached plural form', async () => {
|
|
93
93
|
const configPath = writeConfig(tmpDir);
|
|
94
|
-
const dbPath = join(tmpDir, 'translations.
|
|
94
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
95
95
|
|
|
96
96
|
// Pre-populate the DB with plural forms
|
|
97
97
|
const store = new TranslationStore(dbPath);
|
|
@@ -132,7 +132,7 @@ describe('CLI functions', () => {
|
|
|
132
132
|
|
|
133
133
|
it('warm handles plural entries', async () => {
|
|
134
134
|
const configPath = writeConfig(tmpDir);
|
|
135
|
-
const dbPath = join(tmpDir, 'translations.
|
|
135
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
136
136
|
|
|
137
137
|
// Pre-populate some plural forms so warm skips them
|
|
138
138
|
const store = new TranslationStore(dbPath);
|
|
@@ -202,7 +202,7 @@ describe('CLI functions', () => {
|
|
|
202
202
|
writeFileSync(join(srcDir, 'app.py'), 'ait("Hello")\n');
|
|
203
203
|
|
|
204
204
|
// Pre-populate the DB so warm skips
|
|
205
|
-
const dbPath = join(tmpDir, 'translations.
|
|
205
|
+
const dbPath = join(tmpDir, 'translations.lmdb');
|
|
206
206
|
const store = new TranslationStore(dbPath);
|
|
207
207
|
await store.initialize();
|
|
208
208
|
await store.insert({
|
package/tests/config.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ languages:
|
|
|
19
19
|
- DE
|
|
20
20
|
- ES
|
|
21
21
|
storage:
|
|
22
|
-
path: ./translations.
|
|
22
|
+
path: ./translations.lmdb
|
|
23
23
|
backend:
|
|
24
24
|
api_key_env: OPENAI_API_KEY
|
|
25
25
|
model: gpt-4.1-mini
|
|
@@ -60,7 +60,7 @@ describe('loadConfig', () => {
|
|
|
60
60
|
const configPath = join(tmpDir, 'transduck.yaml');
|
|
61
61
|
writeFileSync(configPath, VALID_YAML);
|
|
62
62
|
const cfg = loadConfig(configPath);
|
|
63
|
-
expect(cfg.storagePath).toBe(join(tmpDir, 'translations.
|
|
63
|
+
expect(cfg.storagePath).toBe(join(tmpDir, 'translations.lmdb'));
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('discovers config from TRANSDUCK_CONFIG env var', () => {
|
package/tests/handler.test.ts
CHANGED
|
@@ -21,7 +21,7 @@ languages:
|
|
|
21
21
|
targets:
|
|
22
22
|
- DE
|
|
23
23
|
storage:
|
|
24
|
-
path: ./translations.
|
|
24
|
+
path: ./translations.lmdb
|
|
25
25
|
backend:
|
|
26
26
|
api_key_env: OPENAI_API_KEY
|
|
27
27
|
model: gpt-4.1-mini
|
|
@@ -48,7 +48,7 @@ describe('handleTranslationRequest', () => {
|
|
|
48
48
|
const { createHash } = await import('crypto');
|
|
49
49
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
50
50
|
|
|
51
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
51
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
52
52
|
await store.initialize();
|
|
53
53
|
await store.insert({
|
|
54
54
|
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
@@ -83,7 +83,7 @@ describe('handleTranslationRequest', () => {
|
|
|
83
83
|
const { createHash } = await import('crypto');
|
|
84
84
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
85
85
|
|
|
86
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
86
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
87
87
|
await store.initialize();
|
|
88
88
|
await store.insert({
|
|
89
89
|
sourceText: 'Book', sourceLang: 'EN', targetLang: 'DE',
|
|
@@ -106,7 +106,7 @@ describe('handleTranslationRequest', () => {
|
|
|
106
106
|
const { createHash } = await import('crypto');
|
|
107
107
|
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
108
108
|
|
|
109
|
-
const store = new TranslationStore(join(tmpDir, 'translations.
|
|
109
|
+
const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
|
|
110
110
|
await store.initialize();
|
|
111
111
|
const sourceKey = '{count} item\x00{count} items';
|
|
112
112
|
await store.insertPlural({
|
package/tests/providers.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
|
|
|
20
20
|
projectContext: 'A test site',
|
|
21
21
|
sourceLang: 'EN',
|
|
22
22
|
targetLangs: ['DE'],
|
|
23
|
-
storagePath: '/tmp/test.
|
|
23
|
+
storagePath: '/tmp/test.lmdb',
|
|
24
24
|
provider: 'openai',
|
|
25
25
|
apiKeyEnv: 'OPENAI_API_KEY',
|
|
26
26
|
tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
@@ -207,7 +207,7 @@ languages:
|
|
|
207
207
|
source: EN
|
|
208
208
|
targets: [DE]
|
|
209
209
|
storage:
|
|
210
|
-
path: ./translations.
|
|
210
|
+
path: ./translations.lmdb
|
|
211
211
|
backend:
|
|
212
212
|
api_key_env: OPENAI_API_KEY
|
|
213
213
|
model: gpt-4.1-mini
|
|
@@ -230,7 +230,7 @@ languages:
|
|
|
230
230
|
source: EN
|
|
231
231
|
targets: [DE]
|
|
232
232
|
storage:
|
|
233
|
-
path: ./translations.
|
|
233
|
+
path: ./translations.lmdb
|
|
234
234
|
backend:
|
|
235
235
|
provider: claude_api
|
|
236
236
|
api_key_env: ANTHROPIC_API_KEY
|
|
@@ -255,7 +255,7 @@ languages:
|
|
|
255
255
|
source: EN
|
|
256
256
|
targets: [DE]
|
|
257
257
|
storage:
|
|
258
|
-
path: ./translations.
|
|
258
|
+
path: ./translations.lmdb
|
|
259
259
|
backend:
|
|
260
260
|
provider: claude_code
|
|
261
261
|
token_env: CLAUDE_CODE_OAUTH_TOKEN
|
|
@@ -277,7 +277,7 @@ languages:
|
|
|
277
277
|
source: EN
|
|
278
278
|
targets: [DE]
|
|
279
279
|
storage:
|
|
280
|
-
path: ./translations.
|
|
280
|
+
path: ./translations.lmdb
|
|
281
281
|
backend:
|
|
282
282
|
provider: claude_code
|
|
283
283
|
`);
|
package/tests/storage.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { mkdtempSync } from 'fs';
|
|
@@ -14,13 +14,17 @@ describe('TranslationStore', () => {
|
|
|
14
14
|
|
|
15
15
|
beforeEach(async () => {
|
|
16
16
|
const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-test-'));
|
|
17
|
-
store = new TranslationStore(join(tmpDir, 'test.
|
|
17
|
+
store = new TranslationStore(join(tmpDir, 'test.lmdb'));
|
|
18
18
|
await store.initialize();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
store.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('starts empty', async () => {
|
|
26
|
+
const stats = await store.stats();
|
|
27
|
+
expect(stats.totalTranslations).toBe(0);
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
it('inserts and looks up translation', async () => {
|
|
@@ -195,68 +199,32 @@ describe('TranslationStore', () => {
|
|
|
195
199
|
await store.insertPlural(entry); // should not throw
|
|
196
200
|
});
|
|
197
201
|
|
|
198
|
-
// ---
|
|
199
|
-
|
|
200
|
-
it('migrates v1 schema to v2', async () => {
|
|
201
|
-
// Create a fresh store with v1 schema
|
|
202
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-migration-'));
|
|
203
|
-
const dbPath = join(tmpDir, 'migrate.duckdb');
|
|
204
|
-
|
|
205
|
-
// Manually create v1 schema (no plural_category column)
|
|
206
|
-
const { DuckDBInstance } = await import('@duckdb/node-api');
|
|
207
|
-
const instance = await DuckDBInstance.create(dbPath);
|
|
208
|
-
const conn = await instance.connect();
|
|
209
|
-
await conn.run(`
|
|
210
|
-
CREATE TABLE translations (
|
|
211
|
-
source_text TEXT NOT NULL,
|
|
212
|
-
source_lang TEXT NOT NULL,
|
|
213
|
-
target_lang TEXT NOT NULL,
|
|
214
|
-
project_context_hash TEXT NOT NULL,
|
|
215
|
-
string_context_hash TEXT NOT NULL,
|
|
216
|
-
translated_text TEXT NOT NULL,
|
|
217
|
-
model TEXT NOT NULL,
|
|
218
|
-
status TEXT NOT NULL DEFAULT 'translated',
|
|
219
|
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
220
|
-
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash)
|
|
221
|
-
)
|
|
222
|
-
`);
|
|
223
|
-
// Insert a v1 row
|
|
224
|
-
await conn.run(`
|
|
225
|
-
INSERT INTO translations (source_text, source_lang, target_lang, project_context_hash, string_context_hash, translated_text, model, status)
|
|
226
|
-
VALUES ('Hello', 'EN', 'DE', '${hash('ctx')}', '${hash('')}', 'Hallo', 'gpt-4.1-mini', 'translated')
|
|
227
|
-
`);
|
|
228
|
-
|
|
229
|
-
// Close the raw connection
|
|
230
|
-
// (DuckDB node-api handles cleanup via GC, just null out)
|
|
202
|
+
// --- Count and clear ---
|
|
231
203
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
await migratedStore.initialize();
|
|
235
|
-
|
|
236
|
-
// Old data should still be accessible
|
|
237
|
-
const result = await migratedStore.lookup({
|
|
204
|
+
it('counts and clears entries', async () => {
|
|
205
|
+
await store.insert({
|
|
238
206
|
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
239
207
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
208
|
+
translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
|
|
240
209
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// Plural methods should work
|
|
244
|
-
await migratedStore.insertPlural({
|
|
245
|
-
sourceText: 'test\x00tests',
|
|
246
|
-
sourceLang: 'EN', targetLang: 'DE',
|
|
210
|
+
await store.insert({
|
|
211
|
+
sourceText: 'Bad', sourceLang: 'EN', targetLang: 'DE',
|
|
247
212
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
248
|
-
|
|
249
|
-
translatedText: 'Tests',
|
|
250
|
-
model: 'gpt-4.1-mini', status: 'translated',
|
|
213
|
+
translatedText: 'schlecht', model: 'gpt-4.1-mini', status: 'failed',
|
|
251
214
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
sourceText: 'test\x00tests',
|
|
255
|
-
sourceLang: 'EN', targetLang: 'DE',
|
|
215
|
+
await store.insert({
|
|
216
|
+
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'ES',
|
|
256
217
|
projectContextHash: hash('ctx'), stringContextHash: hash(''),
|
|
218
|
+
translatedText: 'Hola', model: 'gpt-4.1-mini', status: 'translated',
|
|
257
219
|
});
|
|
258
|
-
expect(pluralResult).toEqual({ other: 'Tests' });
|
|
259
220
|
|
|
260
|
-
|
|
221
|
+
expect(store.count()).toBe(3);
|
|
222
|
+
expect(store.count('DE')).toBe(2);
|
|
223
|
+
expect(store.count(undefined, true)).toBe(1);
|
|
224
|
+
expect(store.count('DE', true)).toBe(1);
|
|
225
|
+
|
|
226
|
+
const deleted = await store.clear('DE', true);
|
|
227
|
+
expect(deleted).toBe(1);
|
|
228
|
+
expect(store.count()).toBe(2);
|
|
261
229
|
});
|
|
262
230
|
});
|