transduck 0.0.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/dist/backend.d.ts +40 -0
- package/dist/backend.js +85 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +383 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +43 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +212 -0
- package/dist/plural.d.ts +19 -0
- package/dist/plural.js +42 -0
- package/dist/storage.d.ts +40 -0
- package/dist/storage.js +178 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +30 -0
- package/package.json +28 -0
- package/src/backend.ts +138 -0
- package/src/cli.ts +450 -0
- package/src/config.ts +62 -0
- package/src/index.ts +251 -0
- package/src/plural.ts +47 -0
- package/src/storage.ts +229 -0
- package/src/validation.ts +30 -0
- package/tests/ait.test.ts +213 -0
- package/tests/backend.test.ts +184 -0
- package/tests/cli.test.ts +162 -0
- package/tests/config.test.ts +79 -0
- package/tests/plural.test.ts +114 -0
- package/tests/storage.test.ts +262 -0
- package/tests/validation.test.ts +47 -0
- package/tsconfig.json +16 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { TranslationStore } from './storage.js';
|
|
4
|
+
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
5
|
+
import { validateTranslation, extractPlaceholders } from './validation.js';
|
|
6
|
+
import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
|
|
7
|
+
let state = {
|
|
8
|
+
config: null,
|
|
9
|
+
store: null,
|
|
10
|
+
targetLang: null,
|
|
11
|
+
pendingTranslations: new Map(),
|
|
12
|
+
};
|
|
13
|
+
function hash(text) {
|
|
14
|
+
return createHash('sha256').update(text).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
export async function initialize(config) {
|
|
17
|
+
const cfg = config ?? loadConfig();
|
|
18
|
+
const store = new TranslationStore(cfg.storagePath);
|
|
19
|
+
await store.initialize();
|
|
20
|
+
state.config = cfg;
|
|
21
|
+
state.store = store;
|
|
22
|
+
}
|
|
23
|
+
export function setLanguage(lang) {
|
|
24
|
+
state.targetLang = lang.toUpperCase();
|
|
25
|
+
}
|
|
26
|
+
export function _resetState() {
|
|
27
|
+
state = { config: null, store: null, targetLang: null, pendingTranslations: new Map() };
|
|
28
|
+
}
|
|
29
|
+
export function _getStore() {
|
|
30
|
+
return state.store;
|
|
31
|
+
}
|
|
32
|
+
export async function ait(sourceText, context, vars) {
|
|
33
|
+
if (!state.config || !state.store) {
|
|
34
|
+
throw new Error('transduck not initialized. Call initialize() first.');
|
|
35
|
+
}
|
|
36
|
+
if (!state.targetLang) {
|
|
37
|
+
throw new Error('Target language not set. Call setLanguage() first.');
|
|
38
|
+
}
|
|
39
|
+
const cfg = state.config;
|
|
40
|
+
if (state.targetLang === cfg.sourceLang) {
|
|
41
|
+
return interpolateVars(sourceText, vars);
|
|
42
|
+
}
|
|
43
|
+
const projectContextHash = hash(cfg.projectContext);
|
|
44
|
+
const stringContextHash = hash(context ?? '');
|
|
45
|
+
// Cache lookup
|
|
46
|
+
const cached = await state.store.lookup({
|
|
47
|
+
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
48
|
+
projectContextHash, stringContextHash,
|
|
49
|
+
});
|
|
50
|
+
if (cached !== null)
|
|
51
|
+
return interpolateVars(cached, vars);
|
|
52
|
+
// In-process dedup
|
|
53
|
+
const lockKey = `${sourceText}|${cfg.sourceLang}|${state.targetLang}|${projectContextHash}|${stringContextHash}`;
|
|
54
|
+
if (state.pendingTranslations.has(lockKey)) {
|
|
55
|
+
const pending = await state.pendingTranslations.get(lockKey);
|
|
56
|
+
return interpolateVars(pending, vars);
|
|
57
|
+
}
|
|
58
|
+
const translationPromise = (async () => {
|
|
59
|
+
// Double-check after getting in
|
|
60
|
+
const rechecked = await state.store.lookup({
|
|
61
|
+
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
62
|
+
projectContextHash, stringContextHash,
|
|
63
|
+
});
|
|
64
|
+
if (rechecked !== null)
|
|
65
|
+
return rechecked;
|
|
66
|
+
const apiKey = process.env[cfg.apiKeyEnv];
|
|
67
|
+
try {
|
|
68
|
+
const translated = await backendTranslate({
|
|
69
|
+
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
70
|
+
projectContext: cfg.projectContext, stringContext: context ?? null,
|
|
71
|
+
apiKey: apiKey, model: cfg.backendModel,
|
|
72
|
+
timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
|
|
73
|
+
});
|
|
74
|
+
if (!validateTranslation(sourceText, translated)) {
|
|
75
|
+
console.warn(`[transduck] Validation failed for: ${sourceText} -> ${translated}`);
|
|
76
|
+
await state.store.insert({
|
|
77
|
+
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
78
|
+
projectContextHash, stringContextHash,
|
|
79
|
+
translatedText: translated, model: cfg.backendModel, status: 'failed',
|
|
80
|
+
});
|
|
81
|
+
return sourceText;
|
|
82
|
+
}
|
|
83
|
+
await state.store.insert({
|
|
84
|
+
sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
85
|
+
projectContextHash, stringContextHash,
|
|
86
|
+
translatedText: translated, model: cfg.backendModel, status: 'translated',
|
|
87
|
+
});
|
|
88
|
+
return translated;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.warn(`[transduck] Backend failed for: ${sourceText}`, err);
|
|
92
|
+
return sourceText;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
state.pendingTranslations.delete(lockKey);
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
state.pendingTranslations.set(lockKey, translationPromise);
|
|
99
|
+
const result = await translationPromise;
|
|
100
|
+
return interpolateVars(result, vars);
|
|
101
|
+
}
|
|
102
|
+
export async function aitPlural(one, other, count, opts) {
|
|
103
|
+
if (!state.config || !state.store) {
|
|
104
|
+
throw new Error('transduck not initialized. Call initialize() first.');
|
|
105
|
+
}
|
|
106
|
+
if (!state.targetLang) {
|
|
107
|
+
throw new Error('Target language not set. Call setLanguage() first.');
|
|
108
|
+
}
|
|
109
|
+
const cfg = state.config;
|
|
110
|
+
const context = opts?.context;
|
|
111
|
+
// Build vars with count
|
|
112
|
+
let vars;
|
|
113
|
+
if (!opts?.vars) {
|
|
114
|
+
vars = { count };
|
|
115
|
+
}
|
|
116
|
+
else if (!('count' in opts.vars)) {
|
|
117
|
+
vars = { ...opts.vars, count };
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
vars = { ...opts.vars };
|
|
121
|
+
}
|
|
122
|
+
// Same language, 2-form language: select directly from provided forms
|
|
123
|
+
if (state.targetLang === cfg.sourceLang) {
|
|
124
|
+
const categories = getPluralCategories(cfg.sourceLang);
|
|
125
|
+
if (categories.size <= 2) {
|
|
126
|
+
const category = getPluralCategory(cfg.sourceLang, count);
|
|
127
|
+
const form = category === 'one' ? one : other;
|
|
128
|
+
return interpolateVars(form, vars);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Build cache key
|
|
132
|
+
const sourceKey = one + '\x00' + other;
|
|
133
|
+
const projectContextHash = hash(cfg.projectContext);
|
|
134
|
+
const stringContextHash = hash(context ?? '');
|
|
135
|
+
// Cache lookup
|
|
136
|
+
const cached = await state.store.lookupPlural({
|
|
137
|
+
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
138
|
+
projectContextHash, stringContextHash,
|
|
139
|
+
});
|
|
140
|
+
const category = getPluralCategory(state.targetLang, count);
|
|
141
|
+
if (category in cached) {
|
|
142
|
+
return interpolateVars(cached[category], vars);
|
|
143
|
+
}
|
|
144
|
+
// Cache miss — call backend
|
|
145
|
+
const apiKey = process.env[cfg.apiKeyEnv];
|
|
146
|
+
try {
|
|
147
|
+
const forms = await backendTranslatePlural({
|
|
148
|
+
one, other,
|
|
149
|
+
sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
150
|
+
projectContext: cfg.projectContext, stringContext: context ?? null,
|
|
151
|
+
apiKey: apiKey, model: cfg.backendModel,
|
|
152
|
+
timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
|
|
153
|
+
});
|
|
154
|
+
// Validate and store each form
|
|
155
|
+
const validCategories = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
156
|
+
const sourcePlaceholders = new Set([
|
|
157
|
+
...extractPlaceholders(one),
|
|
158
|
+
...extractPlaceholders(other),
|
|
159
|
+
]);
|
|
160
|
+
if (typeof forms !== 'object' || forms === null || !('other' in forms)) {
|
|
161
|
+
console.warn(`[transduck] Invalid plural response for: ${one} / ${other}`);
|
|
162
|
+
const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
|
|
163
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
164
|
+
return interpolateVars(fallback, vars);
|
|
165
|
+
}
|
|
166
|
+
for (const [cat, text] of Object.entries(forms)) {
|
|
167
|
+
if (!validCategories.has(cat) || !text)
|
|
168
|
+
continue;
|
|
169
|
+
// Validate placeholders
|
|
170
|
+
const translatedPlaceholders = extractPlaceholders(text);
|
|
171
|
+
let allPresent = true;
|
|
172
|
+
for (const p of sourcePlaceholders) {
|
|
173
|
+
if (!translatedPlaceholders.has(p)) {
|
|
174
|
+
allPresent = false;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const status = allPresent ? 'translated' : 'failed';
|
|
179
|
+
await state.store.insertPlural({
|
|
180
|
+
sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
|
|
181
|
+
projectContextHash, stringContextHash,
|
|
182
|
+
pluralCategory: cat, translatedText: text,
|
|
183
|
+
model: cfg.backendModel, status,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Select the right form
|
|
187
|
+
if (category in forms) {
|
|
188
|
+
const text = forms[category];
|
|
189
|
+
const tp = extractPlaceholders(text);
|
|
190
|
+
let allPresent = true;
|
|
191
|
+
for (const p of sourcePlaceholders) {
|
|
192
|
+
if (!tp.has(p)) {
|
|
193
|
+
allPresent = false;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (allPresent) {
|
|
198
|
+
return interpolateVars(text, vars);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Fallback
|
|
202
|
+
const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
|
|
203
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
204
|
+
return interpolateVars(fallback, vars);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.warn(`[transduck] Backend failed for plural: ${one} / ${other}`, err);
|
|
208
|
+
const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
|
|
209
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
210
|
+
return interpolateVars(fallback, vars);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/plural.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLDR plural rule helpers and variable interpolation.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Get the CLDR plural category for a count in the given language.
|
|
6
|
+
* Uses the built-in Intl.PluralRules API.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getPluralCategory(lang: string, count: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Get all CLDR plural categories used by a language.
|
|
11
|
+
* Since Intl.PluralRules doesn't directly expose this, we probe with test values
|
|
12
|
+
* that cover all CLDR categories.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getPluralCategories(lang: string): Set<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Safely interpolate variables into a translated string.
|
|
17
|
+
* Replaces {key} with the corresponding value from vars.
|
|
18
|
+
*/
|
|
19
|
+
export declare function interpolateVars(text: string, vars?: Record<string, string | number> | null): string;
|
package/dist/plural.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLDR plural rule helpers and variable interpolation.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Get the CLDR plural category for a count in the given language.
|
|
6
|
+
* Uses the built-in Intl.PluralRules API.
|
|
7
|
+
*/
|
|
8
|
+
export function getPluralCategory(lang, count) {
|
|
9
|
+
const rules = new Intl.PluralRules(lang.toLowerCase());
|
|
10
|
+
return rules.select(count);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Get all CLDR plural categories used by a language.
|
|
14
|
+
* Since Intl.PluralRules doesn't directly expose this, we probe with test values
|
|
15
|
+
* that cover all CLDR categories.
|
|
16
|
+
*/
|
|
17
|
+
export function getPluralCategories(lang) {
|
|
18
|
+
const rules = new Intl.PluralRules(lang.toLowerCase());
|
|
19
|
+
// Test values that trigger different categories across languages
|
|
20
|
+
// Include floats to cover languages where "other" is only triggered by non-integers (e.g., Russian)
|
|
21
|
+
const testValues = [0, 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 19, 20, 21, 22, 100, 101, 102];
|
|
22
|
+
const categories = new Set();
|
|
23
|
+
for (const n of testValues) {
|
|
24
|
+
categories.add(rules.select(n));
|
|
25
|
+
}
|
|
26
|
+
// 'other' is always a valid CLDR category
|
|
27
|
+
categories.add('other');
|
|
28
|
+
return categories;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Safely interpolate variables into a translated string.
|
|
32
|
+
* Replaces {key} with the corresponding value from vars.
|
|
33
|
+
*/
|
|
34
|
+
export function interpolateVars(text, vars) {
|
|
35
|
+
if (!vars)
|
|
36
|
+
return text;
|
|
37
|
+
let result = text;
|
|
38
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
39
|
+
result = result.replaceAll(`{${key}}`, String(value));
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface LookupParams {
|
|
2
|
+
sourceText: string;
|
|
3
|
+
sourceLang: string;
|
|
4
|
+
targetLang: string;
|
|
5
|
+
projectContextHash: string;
|
|
6
|
+
stringContextHash: string;
|
|
7
|
+
}
|
|
8
|
+
export interface InsertParams extends LookupParams {
|
|
9
|
+
translatedText: string;
|
|
10
|
+
model: string;
|
|
11
|
+
status: string;
|
|
12
|
+
}
|
|
13
|
+
export interface InsertPluralParams extends LookupParams {
|
|
14
|
+
pluralCategory: string;
|
|
15
|
+
translatedText: string;
|
|
16
|
+
model: string;
|
|
17
|
+
status: string;
|
|
18
|
+
}
|
|
19
|
+
interface Stats {
|
|
20
|
+
totalTranslations: number;
|
|
21
|
+
totalFailed: number;
|
|
22
|
+
byLanguage: Record<string, number>;
|
|
23
|
+
}
|
|
24
|
+
export declare class TranslationStore {
|
|
25
|
+
private dbPath;
|
|
26
|
+
private instance;
|
|
27
|
+
private conn;
|
|
28
|
+
constructor(dbPath: string);
|
|
29
|
+
private getConn;
|
|
30
|
+
private convertRow;
|
|
31
|
+
query(sql: string, params?: string[]): Promise<Record<string, unknown>[]>;
|
|
32
|
+
initialize(): Promise<void>;
|
|
33
|
+
lookup(params: LookupParams): Promise<string | null>;
|
|
34
|
+
insert(params: InsertParams): Promise<void>;
|
|
35
|
+
lookupPlural(params: LookupParams): Promise<Record<string, string>>;
|
|
36
|
+
insertPlural(params: InsertPluralParams): Promise<void>;
|
|
37
|
+
stats(): Promise<Stats>;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { DuckDBInstance, DuckDBResultReader } from '@duckdb/node-api';
|
|
2
|
+
const SCHEMA_V2 = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS translations (
|
|
4
|
+
source_text TEXT NOT NULL,
|
|
5
|
+
source_lang TEXT NOT NULL,
|
|
6
|
+
target_lang TEXT NOT NULL,
|
|
7
|
+
project_context_hash TEXT NOT NULL,
|
|
8
|
+
string_context_hash TEXT NOT NULL,
|
|
9
|
+
plural_category TEXT NOT NULL DEFAULT '',
|
|
10
|
+
translated_text TEXT NOT NULL,
|
|
11
|
+
model TEXT NOT NULL,
|
|
12
|
+
status TEXT NOT NULL DEFAULT 'translated',
|
|
13
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
14
|
+
PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
|
|
15
|
+
);
|
|
16
|
+
`;
|
|
17
|
+
const MIGRATION_V1_TO_V2 = `
|
|
18
|
+
CREATE TABLE translations_v2 (
|
|
19
|
+
source_text TEXT NOT NULL,
|
|
20
|
+
source_lang TEXT NOT NULL,
|
|
21
|
+
target_lang TEXT NOT NULL,
|
|
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
|
+
`;
|
|
69
|
+
export class TranslationStore {
|
|
70
|
+
dbPath;
|
|
71
|
+
instance = null;
|
|
72
|
+
conn = null;
|
|
73
|
+
constructor(dbPath) {
|
|
74
|
+
this.dbPath = dbPath;
|
|
75
|
+
}
|
|
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
|
+
async initialize() {
|
|
111
|
+
this.instance = await DuckDBInstance.create(this.dbPath);
|
|
112
|
+
this.conn = await this.instance.connect();
|
|
113
|
+
// Check if table exists
|
|
114
|
+
const tables = await this.query("SELECT table_name FROM information_schema.tables WHERE table_name = 'translations'");
|
|
115
|
+
if (tables.length === 0) {
|
|
116
|
+
// Fresh database — create v2 schema
|
|
117
|
+
await this.conn.run(SCHEMA_V2);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Table exists — check if it has plural_category (v2)
|
|
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async lookup(params) {
|
|
128
|
+
const rows = await this.query(LOOKUP_SQL, [
|
|
129
|
+
params.sourceText, params.sourceLang, params.targetLang,
|
|
130
|
+
params.projectContextHash, params.stringContextHash,
|
|
131
|
+
]);
|
|
132
|
+
return rows.length > 0 ? rows[0].translated_text : null;
|
|
133
|
+
}
|
|
134
|
+
async insert(params) {
|
|
135
|
+
await this.query(INSERT_SQL, [
|
|
136
|
+
params.sourceText, params.sourceLang, params.targetLang,
|
|
137
|
+
params.projectContextHash, params.stringContextHash,
|
|
138
|
+
params.translatedText, params.model, params.status,
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
async lookupPlural(params) {
|
|
142
|
+
const rows = await this.query(LOOKUP_PLURAL_SQL, [
|
|
143
|
+
params.sourceText, params.sourceLang, params.targetLang,
|
|
144
|
+
params.projectContextHash, params.stringContextHash,
|
|
145
|
+
]);
|
|
146
|
+
const result = {};
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
result[row.plural_category] = row.translated_text;
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
async insertPlural(params) {
|
|
153
|
+
await this.query(INSERT_PLURAL_SQL, [
|
|
154
|
+
params.sourceText, params.sourceLang, params.targetLang,
|
|
155
|
+
params.projectContextHash, params.stringContextHash,
|
|
156
|
+
params.pluralCategory, params.translatedText, params.model, params.status,
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
async stats() {
|
|
160
|
+
const totalRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'translated'");
|
|
161
|
+
const failedRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'failed'");
|
|
162
|
+
const langRows = await this.query("SELECT target_lang, count(*) as c FROM translations WHERE status = 'translated' GROUP BY target_lang");
|
|
163
|
+
const byLanguage = {};
|
|
164
|
+
for (const row of langRows) {
|
|
165
|
+
byLanguage[row.target_lang] = Number(row.c);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
totalTranslations: Number(totalRows[0].c),
|
|
169
|
+
totalFailed: Number(failedRows[0].c),
|
|
170
|
+
byLanguage,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
close() {
|
|
174
|
+
// @duckdb/node-api handles cleanup via GC
|
|
175
|
+
this.conn = null;
|
|
176
|
+
this.instance = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const PLACEHOLDER_PATTERNS = [
|
|
2
|
+
/\{\{[^}]*\}\}/g, // {{ count }}
|
|
3
|
+
/\$\{[^}]+\}/g, // ${value}
|
|
4
|
+
/(?<!\{)(?<!\$)\{[^{}]+\}(?!\})/g, // {name} but not {{x}} or ${x}
|
|
5
|
+
/%[sd]/g, // %s, %d
|
|
6
|
+
];
|
|
7
|
+
export function extractPlaceholders(text) {
|
|
8
|
+
const result = new Set();
|
|
9
|
+
for (const pattern of PLACEHOLDER_PATTERNS) {
|
|
10
|
+
const matches = text.match(pattern);
|
|
11
|
+
if (matches) {
|
|
12
|
+
for (const m of matches)
|
|
13
|
+
result.add(m);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
export function validateTranslation(sourceText, translatedText) {
|
|
19
|
+
if (!translatedText || !translatedText.trim())
|
|
20
|
+
return false;
|
|
21
|
+
const sourcePlaceholders = extractPlaceholders(sourceText);
|
|
22
|
+
if (sourcePlaceholders.size === 0)
|
|
23
|
+
return true;
|
|
24
|
+
const translatedPlaceholders = extractPlaceholders(translatedText);
|
|
25
|
+
for (const p of sourcePlaceholders) {
|
|
26
|
+
if (!translatedPlaceholders.has(p))
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "transduck",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "AI-native translation tool using source text as keys",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"transduck": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@duckdb/node-api": "^1.5.0-r.1",
|
|
18
|
+
"commander": "^12.0.0",
|
|
19
|
+
"duckdb": "^1.0.0",
|
|
20
|
+
"openai": "^4.0.0",
|
|
21
|
+
"yaml": "^2.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"vitest": "^2.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|