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 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
- return Object.entries(params).reduce(
136
- (text, [key, value]) => text.replace(`{${key}}`, value),
137
- translation
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 (!translation) {
183
+ if (translation) {
184
+ return { contextHash, translation, needsTranslation: false };
185
+ }
156
186
 
157
- // Skip translation if context is empty and languages are the same
158
- if (!this.context && this.replaceFrom.FROM_ISO === this.replaceTarget.TARGET_ISO) {
159
- return text;
160
- }
187
+ if (!this.context && this.replaceFrom.FROM_ISO === this.replaceTarget.TARGET_ISO) {
188
+ return { contextHash, translation: text, needsTranslation: false };
189
+ }
161
190
 
162
- if (this.freeze) {
163
- console.log(`Freeze mode: [${key}] ${text}`);
164
- return text;
165
- }
191
+ if (this.freeze) {
192
+ console.log(`Freeze mode: [${key}] ${text}`);
193
+ return { contextHash, translation: text, needsTranslation: false };
194
+ }
166
195
 
167
- this.pendingTranslations.set(key, text);
168
- this.pendingCharCount += text.length; // Update character count
196
+ return { contextHash, translation: null, needsTranslation: true };
197
+ }
169
198
 
170
- // Clear existing timer
171
- if (this.debounceTimer) {
172
- clearTimeout(this.debounceTimer);
173
- }
199
+ _enqueuePendingTranslation(key, text) {
200
+ const existingText = this.pendingTranslations.get(key);
201
+ if (existingText) {
202
+ this.pendingCharCount -= existingText.length;
203
+ }
174
204
 
175
- // Set new timer - capture context at scheduling time
176
- const capturedContext = this.context;
177
- this.debounceTimer = setTimeout(() => {
178
- if (this.pendingTranslations.size > 0) {
179
- this._processBatch(capturedContext);
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
- // Process if we hit the character count threshold
184
- if (this.pendingCharCount >= this.batchThreshold) {
185
- clearTimeout(this.debounceTimer);
186
- this._processBatch(this.context);
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
- return translation;
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",
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": "echo \"Error: no test specified\" && exit 1"
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
+ });