gptrans 2.0.4 → 2.0.8
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 +2 -0
- package/index.js +75 -32
- package/package.json +2 -2
- package/test/gptrans.tAsync.test.js +143 -0
package/README.md
CHANGED
|
@@ -22,10 +22,12 @@ Whether you're building a multilingual website, a mobile app, or a localization
|
|
|
22
22
|
```bash
|
|
23
23
|
npm install gptrans
|
|
24
24
|
```
|
|
25
|
+
|
|
25
26
|
> **AI Skill**: You can also add GPTrans as a skill for AI agentic development:
|
|
26
27
|
> ```bash
|
|
27
28
|
> npx skills add https://github.com/clasen/GPTrans --skill gptrans
|
|
28
29
|
> ```
|
|
30
|
+
|
|
29
31
|
### 🌐 Environment Setup
|
|
30
32
|
|
|
31
33
|
GPTrans uses dotenv for environment configuration. Create a `.env` file in your project root and add your API keys:
|
package/index.js
CHANGED
|
@@ -131,17 +131,45 @@ class GPTrans {
|
|
|
131
131
|
t(text, params = {}) {
|
|
132
132
|
const key = this._textToKey(text);
|
|
133
133
|
const translation = this.get(key, text) || text;
|
|
134
|
+
return this._applyParams(translation, params);
|
|
135
|
+
}
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
async tAsync(text, params = {}) {
|
|
138
|
+
const key = this._textToKey(text);
|
|
139
|
+
const { contextHash, translation, needsTranslation } = this._resolveTranslationState(key, text);
|
|
140
|
+
|
|
141
|
+
if (!needsTranslation) {
|
|
142
|
+
return this._applyParams(translation || text, params);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If this key was enqueued via t(), avoid duplicate work in a later batch.
|
|
146
|
+
this._dequeuePendingTranslation(key);
|
|
147
|
+
|
|
148
|
+
const translatedText = await this._translate(text, [[key, text]], {}, this.preloadBaseLanguage);
|
|
149
|
+
const immediateTranslation = translatedText.trim();
|
|
150
|
+
this.dbTarget.set(contextHash, key, immediateTranslation);
|
|
151
|
+
|
|
152
|
+
return this._applyParams(immediateTranslation, params);
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
get(key, text) {
|
|
156
|
+
const { translation, needsTranslation } = this._resolveTranslationState(key, text);
|
|
157
|
+
if (needsTranslation) {
|
|
158
|
+
this._enqueuePendingTranslation(key, text);
|
|
159
|
+
}
|
|
160
|
+
return translation;
|
|
161
|
+
}
|
|
142
162
|
|
|
163
|
+
_applyParams(text, params = {}) {
|
|
164
|
+
return Object.entries(params).reduce(
|
|
165
|
+
(translated, [paramKey, value]) => translated.replace(`{${paramKey}}`, value),
|
|
166
|
+
text
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_resolveTranslationState(key, text) {
|
|
143
171
|
if (!text || !text.trim()) {
|
|
144
|
-
return text;
|
|
172
|
+
return { contextHash: this._hash(this.context), translation: text, needsTranslation: false };
|
|
145
173
|
}
|
|
146
174
|
|
|
147
175
|
const contextHash = this._hash(this.context);
|
|
@@ -152,41 +180,56 @@ class GPTrans {
|
|
|
152
180
|
this.dbFrom.set('_context', contextHash, this.context);
|
|
153
181
|
}
|
|
154
182
|
|
|
155
|
-
if (
|
|
183
|
+
if (translation) {
|
|
184
|
+
return { contextHash, translation, needsTranslation: false };
|
|
185
|
+
}
|
|
156
186
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
187
|
+
if (!this.context && this.replaceFrom.FROM_ISO === this.replaceTarget.TARGET_ISO) {
|
|
188
|
+
return { contextHash, translation: text, needsTranslation: false };
|
|
189
|
+
}
|
|
161
190
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
if (this.freeze) {
|
|
192
|
+
console.log(`Freeze mode: [${key}] ${text}`);
|
|
193
|
+
return { contextHash, translation: text, needsTranslation: false };
|
|
194
|
+
}
|
|
166
195
|
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
return { contextHash, translation: null, needsTranslation: true };
|
|
197
|
+
}
|
|
169
198
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
_enqueuePendingTranslation(key, text) {
|
|
200
|
+
const existingText = this.pendingTranslations.get(key);
|
|
201
|
+
if (existingText) {
|
|
202
|
+
this.pendingCharCount -= existingText.length;
|
|
203
|
+
}
|
|
174
204
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}, this.debounceTimeout);
|
|
205
|
+
this.pendingTranslations.set(key, text);
|
|
206
|
+
this.pendingCharCount += text.length;
|
|
207
|
+
|
|
208
|
+
if (this.debounceTimer) {
|
|
209
|
+
clearTimeout(this.debounceTimer);
|
|
210
|
+
}
|
|
182
211
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this._processBatch(
|
|
212
|
+
const capturedContext = this.context;
|
|
213
|
+
this.debounceTimer = setTimeout(() => {
|
|
214
|
+
if (this.pendingTranslations.size > 0) {
|
|
215
|
+
this._processBatch(capturedContext);
|
|
187
216
|
}
|
|
217
|
+
}, this.debounceTimeout);
|
|
218
|
+
|
|
219
|
+
if (this.pendingCharCount >= this.batchThreshold) {
|
|
220
|
+
clearTimeout(this.debounceTimer);
|
|
221
|
+
this._processBatch(this.context);
|
|
188
222
|
}
|
|
189
|
-
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_dequeuePendingTranslation(key) {
|
|
226
|
+
const queuedText = this.pendingTranslations.get(key);
|
|
227
|
+
if (!queuedText) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.pendingTranslations.delete(key);
|
|
232
|
+
this.pendingCharCount = Math.max(0, this.pendingCharCount - queuedText.length);
|
|
190
233
|
}
|
|
191
234
|
|
|
192
235
|
async _processBatch(context) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gptrans",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.8",
|
|
5
5
|
"description": "🚆 GPTrans - The smarter AI-powered way to translate.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"translate",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"main": "index.js",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "
|
|
29
|
+
"test": "node --test test/**/*.test.js"
|
|
30
30
|
},
|
|
31
31
|
"author": "Martin Clasen",
|
|
32
32
|
"license": "MIT",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import GPTrans from '../index.js';
|
|
4
|
+
|
|
5
|
+
function createMemoryDb() {
|
|
6
|
+
const store = new Map();
|
|
7
|
+
return {
|
|
8
|
+
get(context, key) {
|
|
9
|
+
return store.get(context)?.get(key);
|
|
10
|
+
},
|
|
11
|
+
set(context, key, value) {
|
|
12
|
+
if (!store.has(context)) {
|
|
13
|
+
store.set(context, new Map());
|
|
14
|
+
}
|
|
15
|
+
store.get(context).set(key, value);
|
|
16
|
+
},
|
|
17
|
+
entries() {
|
|
18
|
+
return Array.from(store.entries()).map(([context, pairs]) => [
|
|
19
|
+
context,
|
|
20
|
+
Object.fromEntries(pairs.entries())
|
|
21
|
+
]);
|
|
22
|
+
},
|
|
23
|
+
async del(context, key) {
|
|
24
|
+
const pairs = store.get(context);
|
|
25
|
+
if (!pairs) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
pairs.delete(key);
|
|
29
|
+
if (pairs.size === 0) {
|
|
30
|
+
store.delete(context);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createTestInstance() {
|
|
37
|
+
const gp = new GPTrans({
|
|
38
|
+
from: 'en-US',
|
|
39
|
+
target: 'es',
|
|
40
|
+
debounceTimeout: 10_000,
|
|
41
|
+
batchThreshold: 5000,
|
|
42
|
+
name: `unit_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
gp.dbFrom = createMemoryDb();
|
|
46
|
+
gp.dbTarget = createMemoryDb();
|
|
47
|
+
|
|
48
|
+
return gp;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('tAsync translates immediately and caches result', async () => {
|
|
52
|
+
const gp = createTestInstance();
|
|
53
|
+
let translateCalls = 0;
|
|
54
|
+
|
|
55
|
+
gp._translate = async (text) => {
|
|
56
|
+
translateCalls += 1;
|
|
57
|
+
assert.equal(text, 'Hello {name}');
|
|
58
|
+
return 'Hola {name}';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const first = await gp.tAsync('Hello {name}', { name: 'Martin' });
|
|
62
|
+
const second = await gp.tAsync('Hello {name}', { name: 'Martin' });
|
|
63
|
+
|
|
64
|
+
assert.equal(first, 'Hola Martin');
|
|
65
|
+
assert.equal(second, 'Hola Martin');
|
|
66
|
+
assert.equal(translateCalls, 1);
|
|
67
|
+
|
|
68
|
+
if (gp.debounceTimer) {
|
|
69
|
+
clearTimeout(gp.debounceTimer);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('tAsync removes queued batch item to avoid duplicate work', async () => {
|
|
74
|
+
const gp = createTestInstance();
|
|
75
|
+
let translateCalls = 0;
|
|
76
|
+
|
|
77
|
+
gp._translate = async () => {
|
|
78
|
+
translateCalls += 1;
|
|
79
|
+
return 'Comprar';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const key = gp._textToKey('Buy');
|
|
83
|
+
|
|
84
|
+
// Enqueue via sync API (batch/background path).
|
|
85
|
+
const syncValue = gp.t('Buy');
|
|
86
|
+
assert.equal(syncValue, 'Buy');
|
|
87
|
+
assert.equal(gp.pendingTranslations.get(key), 'Buy');
|
|
88
|
+
assert.equal(gp.pendingCharCount, 'Buy'.length);
|
|
89
|
+
|
|
90
|
+
// Force immediate translation for same key.
|
|
91
|
+
const asyncValue = await gp.tAsync('Buy');
|
|
92
|
+
assert.equal(asyncValue, 'Comprar');
|
|
93
|
+
|
|
94
|
+
assert.equal(translateCalls, 1);
|
|
95
|
+
assert.equal(gp.pendingTranslations.has(key), false);
|
|
96
|
+
assert.equal(gp.pendingCharCount, 0);
|
|
97
|
+
|
|
98
|
+
if (gp.debounceTimer) {
|
|
99
|
+
clearTimeout(gp.debounceTimer);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('preload translates missing keys from dbFrom into dbTarget', async () => {
|
|
104
|
+
const gp = createTestInstance();
|
|
105
|
+
gp.debounceTimeout = 1;
|
|
106
|
+
let translateCalls = 0;
|
|
107
|
+
|
|
108
|
+
const context = 'checkout';
|
|
109
|
+
const sourceText = 'Buy now';
|
|
110
|
+
const key = gp._textToKey(sourceText);
|
|
111
|
+
const contextHash = gp._hash(context);
|
|
112
|
+
|
|
113
|
+
gp.dbFrom.set(context, key, sourceText);
|
|
114
|
+
gp.dbFrom.set('_context', contextHash, context);
|
|
115
|
+
|
|
116
|
+
gp._translate = async (text) => {
|
|
117
|
+
translateCalls += 1;
|
|
118
|
+
assert.equal(text, sourceText);
|
|
119
|
+
return 'Comprar ahora';
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const originalMmix = GPTrans.mmix;
|
|
123
|
+
GPTrans.mmix = () => ({
|
|
124
|
+
limiter: {
|
|
125
|
+
updateSettings() {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await gp.preload();
|
|
132
|
+
|
|
133
|
+
assert.equal(translateCalls, 1);
|
|
134
|
+
assert.equal(gp.dbTarget.get(contextHash, key), 'Comprar ahora');
|
|
135
|
+
assert.deepEqual(gp.preloadReferences, []);
|
|
136
|
+
assert.equal(gp.preloadBaseLanguage, null);
|
|
137
|
+
} finally {
|
|
138
|
+
GPTrans.mmix = originalMmix;
|
|
139
|
+
if (gp.debounceTimer) {
|
|
140
|
+
clearTimeout(gp.debounceTimer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|