@drone1/alt 0.4.0

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.
Files changed (96) hide show
  1. package/.nvmrc +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +247 -0
  4. package/assets/figlet-fonts/THIS.flf +720 -0
  5. package/bin.mjs +8 -0
  6. package/localization/.localization.cache.json +1872 -0
  7. package/localization/aa.json +24 -0
  8. package/localization/af.json +24 -0
  9. package/localization/agq.json +24 -0
  10. package/localization/ak.json +24 -0
  11. package/localization/am.json +24 -0
  12. package/localization/ar.json +24 -0
  13. package/localization/as.json +24 -0
  14. package/localization/asa.json +24 -0
  15. package/localization/ast.json +24 -0
  16. package/localization/az.json +24 -0
  17. package/localization/ba.json +24 -0
  18. package/localization/bas.json +24 -0
  19. package/localization/be.json +24 -0
  20. package/localization/bem.json +24 -0
  21. package/localization/bez.json +24 -0
  22. package/localization/bg.json +24 -0
  23. package/localization/bm.json +24 -0
  24. package/localization/bn.json +24 -0
  25. package/localization/bo.json +24 -0
  26. package/localization/br.json +24 -0
  27. package/localization/brx.json +24 -0
  28. package/localization/bs.json +24 -0
  29. package/localization/byn.json +24 -0
  30. package/localization/ca.json +24 -0
  31. package/localization/ccp.json +24 -0
  32. package/localization/cd-RU.json +24 -0
  33. package/localization/ceb.json +24 -0
  34. package/localization/cgg.json +24 -0
  35. package/localization/chr.json +24 -0
  36. package/localization/co.json +24 -0
  37. package/localization/config.json +76 -0
  38. package/localization/cs.json +24 -0
  39. package/localization/cu-RU.json +24 -0
  40. package/localization/da.json +24 -0
  41. package/localization/de-AT.json +24 -0
  42. package/localization/de-CH.json +24 -0
  43. package/localization/de-DE.json +24 -0
  44. package/localization/dua.json +24 -0
  45. package/localization/dv.json +24 -0
  46. package/localization/dz.json +24 -0
  47. package/localization/ebu.json +24 -0
  48. package/localization/en.json +26 -0
  49. package/localization/es-ES.json +24 -0
  50. package/localization/es-MX.json +24 -0
  51. package/localization/et.json +24 -0
  52. package/localization/eu.json +24 -0
  53. package/localization/fr-CA.json +24 -0
  54. package/localization/fr-CH.json +24 -0
  55. package/localization/fr-FR.json +24 -0
  56. package/localization/gsw.json +24 -0
  57. package/localization/hi.json +24 -0
  58. package/localization/hr.json +24 -0
  59. package/localization/hy.json +24 -0
  60. package/localization/ja.json +24 -0
  61. package/localization/km.json +24 -0
  62. package/localization/ksf.json +24 -0
  63. package/localization/ku.json +24 -0
  64. package/localization/kw.json +24 -0
  65. package/localization/my.json +24 -0
  66. package/localization/nl.json +24 -0
  67. package/localization/prs.json +24 -0
  68. package/localization/reference.js +21 -0
  69. package/localization/ru.json +24 -0
  70. package/localization/sq.json +24 -0
  71. package/localization/swc.json +24 -0
  72. package/localization/th.json +24 -0
  73. package/localization/tzm-Latn-.json +24 -0
  74. package/localization/uk.json +24 -0
  75. package/localization/vi.json +24 -0
  76. package/localization/zh-Hans.json +24 -0
  77. package/localization/zh-Hant.json +24 -0
  78. package/npm-shrinkwrap.json +1143 -0
  79. package/package.json +48 -0
  80. package/src/assert.js +19 -0
  81. package/src/cache.js +12 -0
  82. package/src/config.js +26 -0
  83. package/src/consts.js +23 -0
  84. package/src/context-keys.js +10 -0
  85. package/src/io.js +129 -0
  86. package/src/localizer/localize.js +135 -0
  87. package/src/logging.js +35 -0
  88. package/src/logo.js +22 -0
  89. package/src/main.mjs +103 -0
  90. package/src/options.js +18 -0
  91. package/src/provider.js +17 -0
  92. package/src/providers/anthropic.mjs +39 -0
  93. package/src/providers/openai.mjs +30 -0
  94. package/src/shutdown.js +36 -0
  95. package/src/translate.js +602 -0
  96. package/src/utils.js +108 -0
@@ -0,0 +1,36 @@
1
+ import { rmDir, writeJsonFile } from './io.js'
2
+
3
+ export function shutdown(appState, kill) {
4
+ const { log, errors, tmpDir, filesToWrite } = appState
5
+
6
+ if (kill) log.I('Forcing shutdown...')
7
+
8
+ if (errors.length) {
9
+ log.E(`ALT encountered some errors: ${errors.join('\n')}`)
10
+ }
11
+
12
+ // Write any data to disk
13
+ //log.D('filesToWrite keys:', Object.keys(filesToWrite))
14
+ let filesWrittenCount = 0
15
+ for (const path of Object.keys(filesToWrite)) {
16
+ log.D('path:', path)
17
+ const json = filesToWrite[path]
18
+ log.T('json:', json)
19
+ writeJsonFile(path, json, appState.log)
20
+ ++filesWrittenCount
21
+ }
22
+
23
+ log.D(`Wrote ${filesWrittenCount} files to disk.`)
24
+
25
+ if (tmpDir?.length) {
26
+ rmDir(tmpDir, log)
27
+ }
28
+
29
+ if (kill) process.exit(1)
30
+ }
31
+
32
+ export function registerSignalHandlers(appState) {
33
+ // NB: Using async fs API's isn't reliable here; use the sync API otherwise only the first file can be written to disk
34
+ process.on('SIGINT', () => shutdown(appState, true))
35
+ process.on('SIGTERM', () => shutdown(appState, true))
36
+ }
@@ -0,0 +1,602 @@
1
+ import * as path from 'path'
2
+ import axios from 'axios'
3
+ import { fileURLToPath } from 'url'
4
+ import { Listr } from 'listr2'
5
+ import { localize, localizeFormatted } from './localizer/localize.js'
6
+ import {
7
+ DEFAULT_CACHE_FILENAME,
8
+ OVERLOADED_BACKOFF_INTERVAL_MS,
9
+ TRANSLATION_FAILED_RESPONSE_TEXT,
10
+ VALID_TRANSLATION_PROVIDERS
11
+ } from './consts.js'
12
+ import { assertValidPath } from './assert.js'
13
+ import { copyFileToTempAndEnsureExtension, importJsFile, mkTmpDir, normalizeOutputPath, readFileAsText, readJsonFile, rmDir, writeJsonFile } from './io.js'
14
+ import { calculateHash, normalizeData, sleep } from './utils.js'
15
+ import { formatContextKeyFromKey, isContextKey } from './context-keys.js'
16
+ import { loadConfig } from './config.js'
17
+ import { loadTranslationProvider } from './provider.js'
18
+ import { loadCache } from './cache.js'
19
+ import { shutdown } from './shutdown.js'
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
22
+
23
+ export async function runTranslation({ appState, options, log }) {
24
+ let exitCode = 0
25
+ try {
26
+ const refFileDir = path.dirname(options.referenceFile)
27
+ let outputDir = options.outputDir ?? refFileDir
28
+
29
+ // Load config file or create default
30
+ const config = await loadConfig({
31
+ configFile: options.configFile,
32
+ refFileDir,
33
+ log
34
+ })
35
+
36
+ // Validate provider
37
+ const providerName = options.provider ?? config.provider
38
+ if (!VALID_TRANSLATION_PROVIDERS.includes(providerName)) {
39
+ log.E(`Error: Unknown provider "${providerName}". Supported providers: ${VALID_TRANSLATION_PROVIDERS.join(', ')}`)
40
+ process.exit(2)
41
+ }
42
+
43
+ const referenceLanguage = options.referenceLanguage || config.referenceLanguage
44
+ if (!referenceLanguage || !referenceLanguage.length) {
45
+ log.E(`Error: No reference language specified. Use --reference-language option or add 'referenceLanguages' to your config file`)
46
+ process.exit(2)
47
+ }
48
+
49
+ // Get target languages from CLI or config
50
+ const targetLanguages = options.targetLanguages || config.targetLanguages
51
+ if (!targetLanguages || !targetLanguages.length) {
52
+ log.E(`Error: No target languages specified. Use --target-languages option or add 'targetLanguages' to your config file`)
53
+ process.exit(2)
54
+ }
55
+
56
+ const normalizeOutputFilenames = options.normalizeOutputFilenames || config.normalizeOutputFilenames
57
+
58
+ // No app context message is OK
59
+ const appContextMessage = options.appContextMessage ?? config.appContextMessage ?? null
60
+ log.D(`appContextMessage:`, appContextMessage)
61
+
62
+ const cacheFilePath = path.resolve(outputDir, DEFAULT_CACHE_FILENAME)
63
+
64
+ log.V(`Attempting to load cache file from "${cacheFilePath}"`)
65
+ const readOnlyCache = await loadCache(cacheFilePath)
66
+ log.D(`Loaded cache file`)
67
+
68
+ // Create a tmp dir for storing the .mjs reference file; we can't dynamically import .js files directly, so we make a copy...
69
+ const tmpDir = await mkTmpDir()
70
+ appState.tmpDir = tmpDir
71
+
72
+ // Copy to a temp location first so we can ensure it has an .mjs extension
73
+ const tmpReferencePath = await copyFileToTempAndEnsureExtension({
74
+ filePath: options.referenceFile,
75
+ tmpDir,
76
+ ext: 'mjs',
77
+ })
78
+ const referenceContent = normalizeData(JSON.parse(JSON.stringify(await importJsFile(tmpReferencePath))), log) // TODO: Don't do this
79
+ const referenceData = referenceContent[options.referenceVarName]
80
+ if (!referenceData) {
81
+ log.E(`No reference data found in variable "${options.referenceVarName}" in ${options.referenceFile}`)
82
+ process.exit(2)
83
+ }
84
+
85
+ const referenceHash = calculateHash(await readFileAsText(options.referenceFile))
86
+ const referenceChanged = referenceHash !== readOnlyCache.referenceHash
87
+ if (referenceChanged) {
88
+ log.V('Reference file has changed since last run')
89
+ }
90
+
91
+ // Clone the cache for writing to
92
+ const writableCache = JSON.parse(JSON.stringify(readOnlyCache))
93
+
94
+ writableCache.referenceHash = referenceHash
95
+ writableCache.lastRun = new Date().toISOString()
96
+
97
+ // Always write this file, since it changes every run ('lastRun')
98
+ assertValidPath(cacheFilePath)
99
+ appState.filesToWrite[cacheFilePath] = writableCache
100
+
101
+ const { apiKey, api: translationProvider } = await loadTranslationProvider({ __dirname, providerName, log })
102
+ log.V(`translation provider "${providerName}" loaded`)
103
+
104
+ const addContextToTranslation = options.lookForContextData || config.lookForContextData
105
+
106
+ const workQueue = []
107
+ const errors = appState.errors
108
+
109
+ // Process each language
110
+ for (const targetLang of targetLanguages) {
111
+ log.D(`Processing language ${targetLang}...`)
112
+ const outputFilePath = normalizeOutputPath({
113
+ dir: outputDir,
114
+ filename: `${targetLang}.json`,
115
+ normalize: normalizeOutputFilenames
116
+ })
117
+ log.D(`outputFilePath=${outputFilePath}`)
118
+
119
+ // Read existing output data
120
+ let outputData = normalizeData(await readJsonFile(outputFilePath)) || {}
121
+ let outputFileDidNotExist = false
122
+ if (!outputData) {
123
+ outputFileDidNotExist = true
124
+ }
125
+
126
+ // Initialize language in cache if it doesn't exist
127
+ if (!writableCache.state[targetLang]) {
128
+ log.V(`target language ${targetLang} not in cache; update needed...`)
129
+ writableCache.state[targetLang] = { keyHashes: {} }
130
+ }
131
+
132
+ let keysToProcess = options.keys?.length
133
+ ? options.keys
134
+ : Object.keys(referenceData)
135
+
136
+ const contextPrefix = options.contextPrefix ?? config.contextPrefix
137
+ const contextSuffix = options.contextSuffix ?? config.contextSuffix
138
+
139
+ if (addContextToTranslation) {
140
+ keysToProcess = keysToProcess
141
+ .filter(key => !isContextKey({
142
+ key,
143
+ contextPrefix,
144
+ contextSuffix
145
+ }))
146
+ }
147
+
148
+ log.T(`keys to process: ${keysToProcess.join(',')}`)
149
+ for (const key of keysToProcess) {
150
+ const contextKey = formatContextKeyFromKey({
151
+ key,
152
+ prefix: contextPrefix,
153
+ suffix: contextSuffix
154
+ })
155
+ log.T(`contextKey=${contextKey}`)
156
+ const storedHashForReferenceValue = readOnlyCache?.referenceKeyHashes?.[key]
157
+ const storedHashForTargetLangAndValue = readOnlyCache.state[targetLang]?.keyHashes?.[key]
158
+ const refValue = referenceData[key]
159
+ const refContextValue = (contextKey in referenceData) ? referenceData[contextKey] : null
160
+ const referenceValueHash = calculateHash(`${refValue}${refContextValue?.length ? `_${refContextValue}` : ''}`) // If either of the ref value or the context value change, we'll update
161
+ const curValue = (key in outputData) ? outputData[key] : null
162
+
163
+ // Skip non-string values (objects, arrays, etc.)
164
+ if (typeof refValue !== 'string') {
165
+ errors.push(`Value for reference key "${key}" was not a string! Skipping...`)
166
+ continue
167
+ }
168
+
169
+ const currentValueHash = curValue?.length ? calculateHash(curValue) : null
170
+
171
+ // Check if translation needs update
172
+ const missingOutputKey = curValue === null
173
+ const missingOutputValueHash = storedHashForTargetLangAndValue === null
174
+
175
+ // Calculate reference value hash and compare with stored hash
176
+ const userMissingReferenceValueHash = !storedHashForReferenceValue?.length
177
+ const userModifiedReferenceValue = Boolean(referenceValueHash) && Boolean(storedHashForReferenceValue) && referenceValueHash !== storedHashForReferenceValue
178
+ const userModifiedTargetValue = Boolean(storedHashForTargetLangAndValue) && Boolean(currentValueHash) && currentValueHash !== storedHashForTargetLangAndValue
179
+
180
+ log.D(`Reference key: "${key}"`)
181
+ log.D('storedHashForReferenceValue', storedHashForReferenceValue)
182
+ log.D('referenceValueHash ', referenceValueHash)
183
+ log.D('userMissingReferenceValueHash', userMissingReferenceValueHash)
184
+ log.D('userModifiedReferenceValue', userModifiedReferenceValue)
185
+ log.D('curValue', curValue)
186
+ log.D('currentValueHash', currentValueHash)
187
+ log.D('storedHashForTargetLangAndValue', storedHashForTargetLangAndValue)
188
+ log.D('userModifiedTargetValue ', userModifiedTargetValue)
189
+
190
+ // Map reason key => true/false
191
+ const possibleReasonsForTranslationMap = {
192
+ forced: options.force,
193
+ outputFileDidNotExist,
194
+ userMissingReferenceValueHash,
195
+ userModifiedReferenceValue,
196
+ missingOutputKey,
197
+ missingOutputValueHash
198
+ }
199
+ log.D(`possibleReasonsForTranslationMap`, possibleReasonsForTranslationMap)
200
+
201
+ // Filter out keys which are not true
202
+ let reasonsForTranslationMap = {}
203
+ let needsTranslation = false
204
+ Object.keys(possibleReasonsForTranslationMap)
205
+ .forEach(k => {
206
+ if (possibleReasonsForTranslationMap[k]) {
207
+ reasonsForTranslationMap[k] = true
208
+ needsTranslation = true
209
+ }
210
+ })
211
+ log.D(`reasonsForTranslationMap`, reasonsForTranslationMap)
212
+
213
+ if (needsTranslation && !userModifiedTargetValue) {
214
+ log.D(`Translation needed for ${targetLang}/${key}...`)
215
+ if (reasonsForTranslationMap.forced) log.D(`Forcing update...`)
216
+ if (reasonsForTranslationMap.missingOutputKey) log.D(`No "${key}" in output data...`)
217
+ if (!reasonsForTranslationMap.storedHashForTargetLangAndValue) log.D(`Hash was not found in storage...`)
218
+
219
+ const newTask = {
220
+ key,
221
+ sourceLang: referenceLanguage,
222
+ targetLang,
223
+ reasonsForTranslationMap,
224
+ outputData,
225
+ outputFilePath,
226
+ writableCache,
227
+ cacheFilePath,
228
+ state: {
229
+ translationProvider,
230
+ apiKey,
231
+ appContextMessage,
232
+ storedHashForReferenceValue,
233
+ refValue,
234
+ refContextValue,
235
+ referenceValueHash,
236
+ userMissingReferenceValueHash,
237
+ userModifiedReferenceValue,
238
+ curValue,
239
+ currentValueHash,
240
+ storedHashForTargetLangAndValue
241
+ }
242
+ }
243
+
244
+ workQueue.push(newTask)
245
+ } else {
246
+ if (userModifiedTargetValue) log.D(`User modified target value: hashes differ (${currentValueHash} / ${storedHashForTargetLangAndValue})...`)
247
+ log.V(`[${targetLang}] ${key} no translation needed.`)
248
+ }
249
+ }
250
+ }
251
+
252
+ let nextTaskDelayMs = 0
253
+ let totalTasks = workQueue.length
254
+ let errorsEncountered = 0
255
+ for (const taskInfoIdx in workQueue) {
256
+ const taskInfo = workQueue[taskInfoIdx]
257
+ log.T(taskInfo)
258
+ const progress = 100 * Math.floor(100 * taskInfoIdx / totalTasks) / 100
259
+
260
+ await new Listr([
261
+ {
262
+ title: localizeFormatted({
263
+ token: 'msg-processing-lang-and-key',
264
+ data: { progress, targetLang: taskInfo.targetLang, key: taskInfo.key },
265
+ lang: appState.lang,
266
+ log
267
+ }),
268
+ task: async (ctx, task) => {
269
+ ctx.nextTaskDelayMs = nextTaskDelayMs
270
+
271
+ return task.newListr([
272
+ {
273
+ title: localize({ token: 'msg-translating', lang: appState.lang, log }),
274
+ task: async (_, task) => {
275
+ const translationResult = await processTranslationTask({
276
+ appState, taskInfo, listrTask: task, listrCtx: ctx, options, log
277
+ })
278
+
279
+ nextTaskDelayMs = translationResult.nextTaskDelayMs
280
+
281
+ if (translationResult.error) {
282
+ ++errorsEncountered
283
+ throw new Error(translationResult.error)
284
+ }
285
+
286
+ // NOTE: Perhaps not needed anymore?
287
+ // This will allow the app to shut down with non-tty/non-simple rendering, where rendering can fall far behind, if all keys are already processed and Promises are resolving
288
+ // immediately but rendering is far behind
289
+ await sleep(1)
290
+ },
291
+ concurrent: false, // Process languages one by one
292
+ rendererOptions: { collapse: false, clearOutput: false },
293
+ exitOnError: false
294
+ }
295
+ ])
296
+ }
297
+ }
298
+ ], {
299
+ concurrent: false, // Process languages one by one
300
+ ...(options.tty ? { renderer: 'simple' } : {}),
301
+ rendererOptions: { collapse: false, clearOutput: false },
302
+ registerSignalListeners: true,
303
+ collapseSubtasks: false
304
+ }).run()
305
+ }
306
+
307
+ if (totalTasks > 0) {
308
+ let str = `[100%] `
309
+ if (errorsEncountered > 0) {
310
+ str += localizeFormatted({
311
+ token: 'msg-finished-with-errors',
312
+ data: { errorsEncountered, s: errorsEncountered > 1 ? 's' : '' },
313
+ lang: appState.lang,
314
+ log
315
+ })
316
+ } else {
317
+ str += `Done`
318
+ }
319
+ log.I(`\x1B[38;2;44;190;78m✔\x1B[0m ${str}`)
320
+ } else {
321
+ log.I(`\x1B[38;2;44;190;78m✔\x1B[0m ${localize({ token: 'msg-nothing-to-do', lang: appState.lang, log })}`)
322
+ }
323
+ } catch (error) {
324
+ log.E('Error:', error)
325
+ exitCode = 2
326
+ }
327
+
328
+ await shutdown(appState, false)
329
+
330
+ if (exitCode > 0) {
331
+ process.exit(exitCode)
332
+ }
333
+ }
334
+
335
+ export async function processTranslationTask({ appState, taskInfo, listrTask, listrCtx, options, log }) {
336
+ const { key, sourceLang, targetLang, reasonsForTranslationMap, outputData, outputFilePath, writableCache, cacheFilePath, state } = taskInfo
337
+ const {
338
+ translationProvider,
339
+ apiKey,
340
+ appContextMessage,
341
+ storedHashForReferenceValue,
342
+ refValue,
343
+ refContextValue,
344
+ referenceValueHash,
345
+ userMissingReferenceValueHash,
346
+ userModifiedReferenceValue,
347
+ curValue,
348
+ currentValueHash,
349
+ storedHashForTargetLangAndValue
350
+ } = state
351
+
352
+ let reasons = Object.keys(reasonsForTranslationMap)
353
+ .map(k => localize({ token: `msg-translation-reason-${k}`, lang: appState.lang, log }))
354
+ .join(', ')
355
+ listrTask.output = reasons
356
+
357
+ const {
358
+ success,
359
+ translated,
360
+ nextTaskDelayMs,
361
+ newValue,
362
+ error
363
+ } = await translateKeyForLanguage({
364
+ appState,
365
+ listrTask,
366
+ ctx: listrCtx,
367
+ translationProvider,
368
+ apiKey,
369
+ referenceValueHash,
370
+ storedHashForReferenceValue,
371
+ storedHashForTargetLangAndValue,
372
+ sourceLang,
373
+ targetLang,
374
+ key,
375
+ refValue,
376
+ refContextValue,
377
+ curValue,
378
+ options,
379
+ log
380
+ })
381
+
382
+ let outputDataModified = false
383
+
384
+ if (success) {
385
+ if (translated) {
386
+ outputDataModified = true
387
+ outputData[key] = newValue
388
+
389
+ // Write real-time translation updates
390
+ if (options.realtimeWrites) {
391
+ await writeJsonFile(outputFilePath, outputData, log)
392
+ log.V(`Wrote ${outputFilePath}`)
393
+ }
394
+
395
+ const hashForTranslated = calculateHash(newValue)
396
+ log.D(`Updating hash for translated ${targetLang}.${key}: ${hashForTranslated}`)
397
+ writableCache.state[targetLang].keyHashes[key] = hashForTranslated
398
+ listrTask.output = localizeFormatted({ token: 'msg-show-translation-result', data: { key, newValue }, lang: appState.lang, log })
399
+
400
+ // Update the hash for the reference key, so we can monitor if the user changed a specific key
401
+ writableCache.referenceKeyHashes[key] = referenceValueHash
402
+
403
+ // Update state file every time, in case the user kills the process
404
+ if (options.realtimeWrites) {
405
+ await writeJsonFile(cacheFilePath, writableCache, log)
406
+ log.V(`Wrote ${cacheFilePath}`)
407
+ }
408
+ } else {
409
+ log.V(`Keeping existing translation and hash for ${targetLang}/${key}...`)
410
+
411
+ // Allow the user to directly edit/tweak output key values
412
+ listrTask.output = localizeFormatted({ token: 'msg-no-updated-needed-for-key', data: { key }, lang: appState.lang, log })
413
+ }
414
+ }
415
+
416
+ log.D('realtimeWrites', options.realtimeWrites)
417
+ log.D(outputDataModified)
418
+ if (!options.realtimeWrites && outputDataModified && !(outputFilePath in appState.filesToWrite)) {
419
+ log.D(`Noting write-on-quit needed for ${outputFilePath}...`)
420
+ appState.filesToWrite[outputFilePath] = outputData
421
+ }
422
+
423
+ return { nextTaskDelayMs, error }
424
+ }
425
+
426
+ async function translateKeyForLanguage({
427
+ appState,
428
+ listrTask,
429
+ ctx,
430
+ translationProvider,
431
+ apiKey,
432
+ appContextMessage,
433
+ sourceLang,
434
+ targetLang,
435
+ key,
436
+ refValue,
437
+ refContextValue,
438
+ options: { maxRetries },
439
+ log
440
+ }) {
441
+ const result = { success: false, translated: false, newValue: null, nextTaskDelayMs: 0, error: null }
442
+
443
+ // Call translation provider
444
+ log.D(`[${targetLang}] Translating "${key}"...`)
445
+ listrTask.output = localizeFormatted({ token: 'msg-translating-key', data: { key }, lang: appState.lang, log })
446
+
447
+ const providerName = translationProvider.name()
448
+ let translated = null
449
+ let newValue
450
+
451
+ // Because of the simple (naive) way we handle being rate-limited and backing off, we kind of want to retry forever but not forever.
452
+ // A single key may need to retry many times, since the algorithm is quite simple: if a task is told to retry after 10s,
453
+ // any subsequent tasks that run will delay 10s also, then those concurrent remaining tasks will all hammer at once, some
454
+ // will complete (maybe), then we'll wait again, then hammer again. A more proper solution may or may not be forthcoming...
455
+ for (let attempt = 0; !newValue && attempt <= maxRetries; ++attempt) {
456
+ const attemptStr = attempt > 0 ? ` [Attempt: ${attempt + 1}]` : ''
457
+ log.D(`[translate] attempt=${attempt}`)
458
+
459
+ log.D('next task delay', ctx.nextTaskDelayMs)
460
+ if (ctx.nextTaskDelayMs > 0) {
461
+ const msg = localizeFormatted({
462
+ token: 'msg-rate-limited-sleeping',
463
+ data: { interval: Math.floor(ctx.nextTaskDelayMs / 1000), attemptStr }, lang: appState.lang, log
464
+ })
465
+ listrTask.output = msg
466
+ log.D(msg)
467
+ await sleep(ctx.nextTaskDelayMs)
468
+ }
469
+
470
+ const translateResult = await translate({
471
+ appState,
472
+ listrTask,
473
+ ctx,
474
+ provider: translationProvider,
475
+ text: refValue,
476
+ context: refContextValue,
477
+ sourceLang,
478
+ targetLang,
479
+ appContextMessage,
480
+ apiKey,
481
+ maxRetries: maxRetries,
482
+ attemptStr,
483
+ log
484
+ })
485
+
486
+ if (translateResult.backoffInterval > 0) {
487
+ log.D(`backing off... interval: ${translateResult.backoffInterval}`)
488
+ //listrTask.output = 'Rate limited'
489
+ log.D(`ctx.nextTaskDelayMs=${ctx.nextTaskDelayMs}`)
490
+ result.nextTaskDelayMs = Math.max(ctx.nextTaskDelayMs, translateResult.backoffInterval)
491
+ } else {
492
+ newValue = translateResult.translated
493
+ result.success = true
494
+ }
495
+ }
496
+
497
+ if (newValue?.length) {
498
+ log.D('translated text', newValue)
499
+ result.translated = true
500
+ result.newValue = newValue
501
+ } else {
502
+ result.error = `Translation was empty; target language=${targetLang}; key=${key}; text=${refValue}`
503
+ }
504
+
505
+ return result
506
+ }
507
+
508
+ async function translateTextViaProvider({
509
+ appState,
510
+ provider,
511
+ listrTask,
512
+ sourceLang,
513
+ targetLang,
514
+ appContextMessage,
515
+ context,
516
+ text,
517
+ log,
518
+ apiKey,
519
+ attemptStr,
520
+ result,
521
+ providerName
522
+ }) {
523
+ try {
524
+ const providerName = provider.name()
525
+ listrTask.output = localize({ token: 'msg-preparing-endpoint-config', lang: appState.lang, log })
526
+ const messages = []
527
+ messages.push(
528
+ `You are a professional translator for an application's text from ${sourceLang} to ${targetLang}. `
529
+ + `Translate the text accurately without adding explanations or additional content. Only return the text. `
530
+ //+ `If and only if you absolutely cannot translate the text, you can respond with "${TRANSLATION_FAILED_RESPONSE_TEXT}" -- but please try to translate the text if you can. It would be greatly
531
+ // appreciated.`, // With this, the AI seems to be lazy and use it way too often
532
+ )
533
+ if (appContextMessage?.length) {
534
+ messages.push(`Here is some high-level information about the application you are translating text for: ${appContextMessage}`)
535
+ }
536
+ if (context) {
537
+ messages.push(`Here is some additional context for the string you are going to translate: ${context}`)
538
+ }
539
+ messages.push(
540
+ `Here we go. Translate the following text from ${sourceLang} to ${targetLang}:`
541
+ + `\n\n${text}`
542
+ )
543
+ log.D(`prompt: `, messages)
544
+ const { url, params, config } = provider.getTranslationRequestDetails({ messages, apiKey, log })
545
+ log.T('url: ', url, 'params: ', params, 'config: ', config)
546
+ listrTask.output = localizeFormatted({ token: 'msg-hitting-provider-endpoint', data: { providerName, attemptStr }, lang: appState.lang, log })
547
+ const response = await axios.post(url, params, config)
548
+ log.T('response headers', response.headers)
549
+ const translated = provider.getResult(response, log)
550
+ if (!translated?.length) throw new Error(`${providerName} translated text to empty string. You may need to top up your credits.`)
551
+ log.D(`${translated}`)
552
+ if (translated === TRANSLATION_FAILED_RESPONSE_TEXT) throw new Error(`${providerName} failed to translate string to ${targetLang}; string: ${text}`)
553
+ result.translated = translated
554
+ } catch (error) {
555
+ const response = error?.response
556
+ if (response) {
557
+ if (response.status === 429) {
558
+ result.backoffInterval = provider.getSleepInterval(response.headers, log)
559
+ log.D(`Rate limited; retrying in ${result.backoffInterval}`)
560
+ } else if (error.response.status === 529) { // Unofficial 'overloaded' code
561
+ result.backoffInterval = OVERLOADED_BACKOFF_INTERVAL_MS
562
+ log.D(`Overloaded; retrying in ${result.backoffInterval}`)
563
+ listrTask.output = `${providerName} overloaded; retrying in ${result.backoffInterval / 1000}s `
564
+ }
565
+ } else {
566
+ log.W(`API failed. Error:`, error.message)
567
+ }
568
+ }
569
+ }
570
+
571
+ async function translate({
572
+ appState,
573
+ listrTask,
574
+ ctx,
575
+ provider,
576
+ appContextMessage,
577
+ text,
578
+ context,
579
+ sourceLang,
580
+ targetLang,
581
+ apiKey,
582
+ attemptStr,
583
+ log
584
+ }) {
585
+ log.D(`[translate] sourceLang=${sourceLang}; targetLang=${targetLang}; text=${text}`)
586
+ const result = { translated: null, backoffInterval: 0 }
587
+
588
+ const providerName = provider.name()
589
+
590
+ if (sourceLang === targetLang) {
591
+ log.D(`Using reference value since source & target language are the same`)
592
+ result.translated = text
593
+ } else {
594
+ await translateTextViaProvider({
595
+ appState, provider, listrTask, sourceLang, targetLang, appContextMessage, context, text, log, apiKey, attemptStr, result, providerName
596
+ })
597
+ }
598
+
599
+ log.D(`[translate] `, result)
600
+
601
+ return result
602
+ }