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/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
+ }
@@ -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 {};
@@ -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,2 @@
1
+ export declare function extractPlaceholders(text: string): Set<string>;
2
+ export declare function validateTranslation(sourceText: string, translatedText: string): boolean;
@@ -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
+ }