@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.
- package/.nvmrc +1 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/assets/figlet-fonts/THIS.flf +720 -0
- package/bin.mjs +8 -0
- package/localization/.localization.cache.json +1872 -0
- package/localization/aa.json +24 -0
- package/localization/af.json +24 -0
- package/localization/agq.json +24 -0
- package/localization/ak.json +24 -0
- package/localization/am.json +24 -0
- package/localization/ar.json +24 -0
- package/localization/as.json +24 -0
- package/localization/asa.json +24 -0
- package/localization/ast.json +24 -0
- package/localization/az.json +24 -0
- package/localization/ba.json +24 -0
- package/localization/bas.json +24 -0
- package/localization/be.json +24 -0
- package/localization/bem.json +24 -0
- package/localization/bez.json +24 -0
- package/localization/bg.json +24 -0
- package/localization/bm.json +24 -0
- package/localization/bn.json +24 -0
- package/localization/bo.json +24 -0
- package/localization/br.json +24 -0
- package/localization/brx.json +24 -0
- package/localization/bs.json +24 -0
- package/localization/byn.json +24 -0
- package/localization/ca.json +24 -0
- package/localization/ccp.json +24 -0
- package/localization/cd-RU.json +24 -0
- package/localization/ceb.json +24 -0
- package/localization/cgg.json +24 -0
- package/localization/chr.json +24 -0
- package/localization/co.json +24 -0
- package/localization/config.json +76 -0
- package/localization/cs.json +24 -0
- package/localization/cu-RU.json +24 -0
- package/localization/da.json +24 -0
- package/localization/de-AT.json +24 -0
- package/localization/de-CH.json +24 -0
- package/localization/de-DE.json +24 -0
- package/localization/dua.json +24 -0
- package/localization/dv.json +24 -0
- package/localization/dz.json +24 -0
- package/localization/ebu.json +24 -0
- package/localization/en.json +26 -0
- package/localization/es-ES.json +24 -0
- package/localization/es-MX.json +24 -0
- package/localization/et.json +24 -0
- package/localization/eu.json +24 -0
- package/localization/fr-CA.json +24 -0
- package/localization/fr-CH.json +24 -0
- package/localization/fr-FR.json +24 -0
- package/localization/gsw.json +24 -0
- package/localization/hi.json +24 -0
- package/localization/hr.json +24 -0
- package/localization/hy.json +24 -0
- package/localization/ja.json +24 -0
- package/localization/km.json +24 -0
- package/localization/ksf.json +24 -0
- package/localization/ku.json +24 -0
- package/localization/kw.json +24 -0
- package/localization/my.json +24 -0
- package/localization/nl.json +24 -0
- package/localization/prs.json +24 -0
- package/localization/reference.js +21 -0
- package/localization/ru.json +24 -0
- package/localization/sq.json +24 -0
- package/localization/swc.json +24 -0
- package/localization/th.json +24 -0
- package/localization/tzm-Latn-.json +24 -0
- package/localization/uk.json +24 -0
- package/localization/vi.json +24 -0
- package/localization/zh-Hans.json +24 -0
- package/localization/zh-Hant.json +24 -0
- package/npm-shrinkwrap.json +1143 -0
- package/package.json +48 -0
- package/src/assert.js +19 -0
- package/src/cache.js +12 -0
- package/src/config.js +26 -0
- package/src/consts.js +23 -0
- package/src/context-keys.js +10 -0
- package/src/io.js +129 -0
- package/src/localizer/localize.js +135 -0
- package/src/logging.js +35 -0
- package/src/logo.js +22 -0
- package/src/main.mjs +103 -0
- package/src/options.js +18 -0
- package/src/provider.js +17 -0
- package/src/providers/anthropic.mjs +39 -0
- package/src/providers/openai.mjs +30 -0
- package/src/shutdown.js +36 -0
- package/src/translate.js +602 -0
- package/src/utils.js +108 -0
package/src/shutdown.js
ADDED
|
@@ -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
|
+
}
|
package/src/translate.js
ADDED
|
@@ -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
|
+
}
|