@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/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@drone1/alt",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"description": "An AI-powered localization tool",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"alt": "./bin.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"localize-display-strings": "node ./bin.mjs --reference-file localization/reference.js "
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/drone1/ai-localization-tool.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"localization",
|
|
19
|
+
"i18n",
|
|
20
|
+
"translation",
|
|
21
|
+
"cli",
|
|
22
|
+
"ai",
|
|
23
|
+
"claude",
|
|
24
|
+
"openai",
|
|
25
|
+
"gemini",
|
|
26
|
+
"google",
|
|
27
|
+
"anthropic"
|
|
28
|
+
],
|
|
29
|
+
"author": "drone1",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/drone1/ai-localization-tool/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/drone1/ai-localization-tool#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"axios": "^1.8.4",
|
|
37
|
+
"chalk": "^5.4.1",
|
|
38
|
+
"commander": "^11.1.0",
|
|
39
|
+
"figlet": "^1.8.0",
|
|
40
|
+
"gradient-string": "^3.0.0",
|
|
41
|
+
"listr2": "^8.2.5",
|
|
42
|
+
"locale-codes": "^1.3.1",
|
|
43
|
+
"strip-json-comments": "^5.0.1"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=14.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/assert.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function assert(b, msg) {
|
|
2
|
+
if (!b) {
|
|
3
|
+
debugger
|
|
4
|
+
throw new Error(msg || `Assertion failed`)
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function assertIsNonEmptyString(s, msg) {
|
|
9
|
+
assert(s?.length, msg || `parameter was not a non-empty string`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertValidPath(path, msg) {
|
|
13
|
+
assertIsNonEmptyString(path, msg || `parameter was not a valid path`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertIsObj(x, msg) {
|
|
17
|
+
assert(typeof x === 'object', msg || `parameter was not an object`)
|
|
18
|
+
}
|
|
19
|
+
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readJsonFile } from './io.js'
|
|
2
|
+
|
|
3
|
+
export async function loadCache(path) {
|
|
4
|
+
const storedCache = await readJsonFile(path)
|
|
5
|
+
return {
|
|
6
|
+
referenceHash: storedCache?.referenceHash ?? '',
|
|
7
|
+
referenceKeyHashes: storedCache?.referenceKeyHashes ?? {},
|
|
8
|
+
state: storedCache?.state ?? {},
|
|
9
|
+
lastRun: storedCache?.lastRun ?? null,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
package/src/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
import { DEFAULT_CONFIG_FILENAME } from './consts.js'
|
|
3
|
+
import { readJsonFile } from './io.js'
|
|
4
|
+
|
|
5
|
+
export async function loadConfig({ configFile, refFileDir, log }) {
|
|
6
|
+
let configFilePath
|
|
7
|
+
if (configFile?.length) {
|
|
8
|
+
log.V(`Using config file specified by --config-file "${configFile}"...`)
|
|
9
|
+
configFilePath = configFile
|
|
10
|
+
} else {
|
|
11
|
+
log.V(`Using config file path based on reference file dir, "${refFileDir}"...`)
|
|
12
|
+
configFilePath = path.resolve(refFileDir, DEFAULT_CONFIG_FILENAME)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
log.V(`Attempting to load config file from "${configFilePath}"`)
|
|
16
|
+
return await readJsonFile(configFilePath) || {
|
|
17
|
+
provider: null,
|
|
18
|
+
targetLanguages: [],
|
|
19
|
+
lookForContextData: true,
|
|
20
|
+
contextPrefix: '',
|
|
21
|
+
contextSuffix: '',
|
|
22
|
+
referenceLanguage: null,
|
|
23
|
+
normalizeOutputFilenames: false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
package/src/consts.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
|
|
3
|
+
export const TRANSLATION_FAILED_RESPONSE_TEXT = '<<<TRANSLATION_FAILED>>>'
|
|
4
|
+
export const LANGTAG_ENGLISH = 'en'
|
|
5
|
+
export const LANGTAG_DEFAULT = LANGTAG_ENGLISH
|
|
6
|
+
|
|
7
|
+
export const VALID_TRANSLATION_PROVIDERS = [
|
|
8
|
+
'anthropic',
|
|
9
|
+
'openai'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export const ENV_VARS = [
|
|
13
|
+
{ name: 'ANTHROPIC_API_KEY', description: 'Your Anthropic API key' },
|
|
14
|
+
{ name: 'OPENAI_API_KEY', description: 'Your OpenAI API key' },
|
|
15
|
+
{ name: 'ALT_LANGUAGE', description: 'POSIX locale used for display' }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export const LOCALIZATION_SRC_DIR = path.resolve('localization')
|
|
19
|
+
export const DEFAULT_CACHE_FILENAME = '.localization.cache.json'
|
|
20
|
+
export const DEFAULT_CONFIG_FILENAME = 'config.json'
|
|
21
|
+
export const OVERLOADED_BACKOFF_INTERVAL_MS = 30 * 1000
|
|
22
|
+
export const CWD = process.cwd()
|
|
23
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function isContextKey({ key, contextPrefix, contextSuffix }) {
|
|
2
|
+
if (contextPrefix?.length) return key.startsWith(contextPrefix)
|
|
3
|
+
if (contextSuffix?.length) return key.endsWith(contextSuffix)
|
|
4
|
+
throw new Error(`Either the context prefix or context suffix must be defined`)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatContextKeyFromKey({ key, prefix, suffix }) {
|
|
8
|
+
return `${prefix}${key}${suffix}`
|
|
9
|
+
}
|
|
10
|
+
|
package/src/io.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as os from 'os'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as fsp from 'fs/promises'
|
|
4
|
+
import * as path from 'path'
|
|
5
|
+
import stripJsonComments from 'strip-json-comments'
|
|
6
|
+
import { ensureExtension, normalizeKey } from './utils.js'
|
|
7
|
+
import { assertIsObj, assertValidPath } from './assert.js'
|
|
8
|
+
import { pathToFileURL } from 'url'
|
|
9
|
+
import { CWD } from './consts.js'
|
|
10
|
+
|
|
11
|
+
export async function mkTmpDir() {
|
|
12
|
+
return await fsp.mkdtemp(path.join(os.tmpdir(), 'alt-'))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseJson(s) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(s)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readFileAsText(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
return await fsp.readFile(filePath, 'utf8')
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code === 'ENOENT') {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readJsonFile(filePath, isJSONComments = false) {
|
|
35
|
+
let content = await readFileAsText(filePath)
|
|
36
|
+
if (isJSONComments) content = stripJsonComments.stripJsonComments(content)
|
|
37
|
+
return parseJson(content)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function fileExists(path) {
|
|
41
|
+
try {
|
|
42
|
+
await fsp.access(path)
|
|
43
|
+
return true
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function rmDir(dir, log) {
|
|
50
|
+
try {
|
|
51
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
52
|
+
log.D(`Removed dir ${dir}`)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
log.E(`Error cleaning up temp directory "${dir}": ${error.message}`)
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// This is basically so that we can dynamically import .js files by copying them to temp .mjs files, to avoid errors from node
|
|
60
|
+
export async function copyFileToTempAndEnsureExtension({ filePath, tmpDir, ext }) {
|
|
61
|
+
try {
|
|
62
|
+
const fileName = ensureExtension(path.basename(filePath), ext)
|
|
63
|
+
const destPath = path.join(tmpDir, fileName)
|
|
64
|
+
await fsp.copyFile(filePath, destPath)
|
|
65
|
+
return destPath
|
|
66
|
+
} catch (error) {
|
|
67
|
+
log.E(`Error copying file to temp directory: ${error.message}`)
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Dynamically imports the javascript file at filePath, which can be relative or absolute
|
|
73
|
+
export async function importJsFile(filePath) {
|
|
74
|
+
if (!path.isAbsolute(filePath)) {
|
|
75
|
+
filePath = path.resolve(CWD, filePath)
|
|
76
|
+
}
|
|
77
|
+
// Convert the file path to a proper URL
|
|
78
|
+
const fileUrl = pathToFileURL(filePath)
|
|
79
|
+
return await import(fileUrl)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeOutputPath({ dir, filename, normalize }) {
|
|
83
|
+
return path.join(dir, normalize ? filename.toLowerCase() : filename)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeJsonFile(filePath, data, log) {
|
|
87
|
+
assertValidPath(filePath)
|
|
88
|
+
assertIsObj(data)
|
|
89
|
+
log.V(`Preparing to write ${filePath}...`)
|
|
90
|
+
|
|
91
|
+
// Create normalized version of data with consistent key encoding
|
|
92
|
+
log.D(`Normalizing data...`)
|
|
93
|
+
const normalizedData = {}
|
|
94
|
+
for (const [ key, value ] of Object.entries(data)) {
|
|
95
|
+
normalizedData[normalizeKey(key)] = value
|
|
96
|
+
}
|
|
97
|
+
log.D(`Done.`)
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const dir = path.dirname(filePath)
|
|
101
|
+
log.D(`Ensuring directory ${dir} exists...`)
|
|
102
|
+
if (!dirExists(dir, log)) {
|
|
103
|
+
log.D(`Directory ${dir} did not exist; creating...`)
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
105
|
+
}
|
|
106
|
+
log.D(`Done.`)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.E(err)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log.V(`Writing ${filePath}...`)
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(filePath, JSON.stringify(normalizedData, null, 2), 'utf8')
|
|
114
|
+
} catch (err) {
|
|
115
|
+
log.E(err)
|
|
116
|
+
}
|
|
117
|
+
log.D(`Done.`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function dirExists(dir, log) {
|
|
121
|
+
try {
|
|
122
|
+
log.D(`fetching stats for ${dir}...`)
|
|
123
|
+
return fs.statSync(dir).isDirectory()
|
|
124
|
+
} catch (error) {
|
|
125
|
+
log.E(error)
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fsp from 'fs/promises'
|
|
2
|
+
import * as locale from 'locale-codes'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import { obj2Str, replaceStringVarsWithObjectValues } from '../utils.js'
|
|
5
|
+
import { fileExists, readJsonFile } from '../io.js'
|
|
6
|
+
import { LANGTAG_DEFAULT } from '../consts.js'
|
|
7
|
+
|
|
8
|
+
const LocalizationMap = {}
|
|
9
|
+
|
|
10
|
+
export async function initLocalizer({ defaultAppLanguage, appLanguage, srcDir, log }) {
|
|
11
|
+
let lang
|
|
12
|
+
|
|
13
|
+
// Always load the default language, as a fallback
|
|
14
|
+
await addLocalizationDataForLanguage({ lang: defaultAppLanguage, srcPath: path.resolve(srcDir, `${defaultAppLanguage}.json`), log })
|
|
15
|
+
|
|
16
|
+
if (appLanguage?.length && appLanguage !== defaultAppLanguage) {
|
|
17
|
+
try {
|
|
18
|
+
if (!isBcp47LanguageTagValid(appLanguage)) {
|
|
19
|
+
log.W(`"${appLanguage}" is not a valid BCP47 language`)
|
|
20
|
+
throw new Error() // Print another warning and use default language
|
|
21
|
+
}
|
|
22
|
+
await addLocalizationDataForLanguage({ lang: appLanguage, srcPath: path.resolve(srcDir, `${appLanguage}.json`), log })
|
|
23
|
+
lang = appLanguage
|
|
24
|
+
} catch (err) {
|
|
25
|
+
log.W(`No localization data found for language "${appLanguage}"; falling back to "${defaultAppLanguage}"...`)
|
|
26
|
+
lang = defaultAppLanguage
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
lang = defaultAppLanguage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return lang
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isBcp47LanguageTagValid(tag) {
|
|
36
|
+
return locale.getByTag(tag)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isDefaultLanguage(tag) {
|
|
40
|
+
return tag === LANGTAG_DEFAULT
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function localizeFormatted({ token, data, lang, fallbackToken, log }) {
|
|
44
|
+
//assertIsObj(data)
|
|
45
|
+
//assertIsNonEmptyString(lang)
|
|
46
|
+
//assertValidBcp47LanguageTag(lang)
|
|
47
|
+
|
|
48
|
+
// We don't use fallbackToken directly in our call to localize(), because we don't want to inadvertently format in odd ways
|
|
49
|
+
// But let's see how we end up using this function; for now we just directly localize the fallback below, if localize() fails here
|
|
50
|
+
const str = localize({ token, lang, log })
|
|
51
|
+
|
|
52
|
+
// Directly attempt to localize the fallback (or returns '')
|
|
53
|
+
if (!str?.length) {
|
|
54
|
+
const fallbackResult = localize({ token: fallbackToken, lang, log })
|
|
55
|
+
if (fallbackResult.indexOf('%%') >= 0) {
|
|
56
|
+
log.W(`'fallbackToken' should not include formatting variables; not currently supported; string="${fallbackResult}"`)
|
|
57
|
+
}
|
|
58
|
+
return fallbackResult
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const warnings = []
|
|
62
|
+
const result = replaceStringVarsWithObjectValues({
|
|
63
|
+
format: str, data, outWarningsArray: warnings
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (warnings.length) warnings.forEach(w => log.W(`[localizeFormatted] warning: ${w.message} (code=${w.code})`))
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function localize({ token, lang, fallbackToken, log }) {
|
|
72
|
+
log.D(`[localize] ${obj2Str({ token, lang, fallbackToken })}`)
|
|
73
|
+
|
|
74
|
+
if (!isBcp47LanguageTagValid(lang)) {
|
|
75
|
+
log.D(`[localize] Invalid language "${lang}" passed; falling back to default language...`)
|
|
76
|
+
lang = LANGTAG_DEFAULT
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!token) token = ''
|
|
80
|
+
|
|
81
|
+
if (token.startsWith('#')) {
|
|
82
|
+
log.D(`[localize] removed leading '#' character`)
|
|
83
|
+
token = token.substring(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!token.length && fallbackToken) {
|
|
87
|
+
log.D(`[localize] token was empty; trying fallback...`)
|
|
88
|
+
return localize({ token: fallbackToken, lang, log })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!(lang in LocalizationMap)) {
|
|
92
|
+
log.W(`[localize] lang "${lang}" was not in LocalizationMap`)
|
|
93
|
+
|
|
94
|
+
if (!isDefaultLanguage(lang)) {
|
|
95
|
+
return localize({ token, lang: LANGTAG_DEFAULT, log })
|
|
96
|
+
}
|
|
97
|
+
return ''
|
|
98
|
+
} else if (!(token in LocalizationMap[lang])) {
|
|
99
|
+
log.D(`[localize] token "${token}" was not in LocalizationMap[${lang}]`)
|
|
100
|
+
|
|
101
|
+
// If it didn't exist, check the fallback in the same language
|
|
102
|
+
if (fallbackToken) {
|
|
103
|
+
log.D(`[localize] falling back to token ${fallbackToken}`)
|
|
104
|
+
return localize({ token: fallbackToken, lang, log })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If no fallback in the same language, attempt to find an english version of 'token'
|
|
108
|
+
if (!isDefaultLanguage(lang)) {
|
|
109
|
+
log.D(`[localize] attempting to fall back to english`)
|
|
110
|
+
// This will get the fallback in english if not found in the requested language
|
|
111
|
+
return localize({ token, lang: LANGTAG_DEFAULT, fallbackToken, log })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log.W(`Failed to find localization string for language="${lang}", token="${token}"`)
|
|
115
|
+
|
|
116
|
+
return ''
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = LocalizationMap[lang][token]
|
|
120
|
+
log.D(`[localize] success; found localization string; ${obj2Str({ token, lang, result })}`)
|
|
121
|
+
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function addLocalizationDataForLanguage({ lang, srcPath, log }) {
|
|
126
|
+
//assertValidBcp47LanguageTag(lang)
|
|
127
|
+
//assertIsObj(data)
|
|
128
|
+
//assertIsObj(LocalizationMap)
|
|
129
|
+
|
|
130
|
+
const data = await readJsonFile(srcPath)
|
|
131
|
+
if (!data) throw new Error(`Failed to load localization file "${srcPath}"`)
|
|
132
|
+
|
|
133
|
+
LocalizationMap[lang] = LocalizationMap.lang ?? {}
|
|
134
|
+
LocalizationMap[lang] = { ...LocalizationMap.lang, ...data }
|
|
135
|
+
}
|
package/src/logging.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function createLog() {
|
|
2
|
+
return {
|
|
3
|
+
// T/D/V blackholed until program options are parsed
|
|
4
|
+
T: () => { },
|
|
5
|
+
D: () => { },
|
|
6
|
+
V: () => { },
|
|
7
|
+
|
|
8
|
+
E: function(...args) {
|
|
9
|
+
console.error(...args)
|
|
10
|
+
},
|
|
11
|
+
W: function(...args) {
|
|
12
|
+
console.warn(...args)
|
|
13
|
+
},
|
|
14
|
+
I: function(...args) {
|
|
15
|
+
console.log(...args)
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function initLogFromOptions({ options, log }) {
|
|
21
|
+
// Init optional logging functions
|
|
22
|
+
log.V = (options.trace || options.debug || options.verbose) ? function(...args) {
|
|
23
|
+
console.log(...args)
|
|
24
|
+
} : () => {
|
|
25
|
+
}
|
|
26
|
+
log.D = (options.trace || options.debug) ? function(...args) {
|
|
27
|
+
console.debug(...args)
|
|
28
|
+
} : () => {
|
|
29
|
+
}
|
|
30
|
+
log.T = options.trace ? function(...args) {
|
|
31
|
+
console.debug(...args)
|
|
32
|
+
} : () => {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
package/src/logo.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import figlet from 'figlet'
|
|
2
|
+
import gradient from 'gradient-string'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import * as fsp from 'fs/promises'
|
|
5
|
+
|
|
6
|
+
export async function printLogo({ fontsSrcDir, tagline, log }) {
|
|
7
|
+
const fontName = 'THIS.flf'
|
|
8
|
+
const fontPath = path.resolve(fontsSrcDir, fontName)
|
|
9
|
+
const fontData = await fsp.readFile(fontPath, 'utf8')
|
|
10
|
+
figlet.parseFont(fontName, fontData)
|
|
11
|
+
const asciiTitle = figlet.textSync('ALT', {
|
|
12
|
+
font: fontName,
|
|
13
|
+
horizontalLayout: 'full',
|
|
14
|
+
verticalLayout: 'default',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
log.I(`\n${gradient([
|
|
18
|
+
'#000FFF',
|
|
19
|
+
'#ed00b1'
|
|
20
|
+
])(asciiTitle)}\n`)
|
|
21
|
+
}
|
|
22
|
+
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
import { program } from 'commander'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { initLocalizer } from './localizer/localize.js'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CONFIG_FILENAME,
|
|
7
|
+
ENV_VARS,
|
|
8
|
+
LANGTAG_DEFAULT,
|
|
9
|
+
LOCALIZATION_SRC_DIR,
|
|
10
|
+
} from './consts.js'
|
|
11
|
+
import { readJsonFile } from './io.js'
|
|
12
|
+
import { printLogo } from './logo.js'
|
|
13
|
+
import { createLog, initLogFromOptions } from './logging.js'
|
|
14
|
+
import { keyList, languageList } from './options.js'
|
|
15
|
+
import { runTranslation } from './translate.js'
|
|
16
|
+
import { registerSignalHandlers } from './shutdown.js'
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
|
|
20
|
+
// Main function
|
|
21
|
+
export async function run() {
|
|
22
|
+
const log = createLog()
|
|
23
|
+
|
|
24
|
+
const appState = {
|
|
25
|
+
lang: null, // The app language, for output display (unrelated to translator)
|
|
26
|
+
tmpDir: null,
|
|
27
|
+
filesToWrite: {}, // Map of file path => JSON data to write
|
|
28
|
+
errors: [],
|
|
29
|
+
log
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
registerSignalHandlers(appState)
|
|
34
|
+
|
|
35
|
+
appState.lang = await initLocalizer({
|
|
36
|
+
defaultAppLanguage: LANGTAG_DEFAULT,
|
|
37
|
+
appLanguage: process.env?.ALT_LANGUAGE,
|
|
38
|
+
srcDir: path.resolve(__dirname, LOCALIZATION_SRC_DIR),
|
|
39
|
+
log
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const p = await readJsonFile(path.resolve(__dirname, '../package.json'))
|
|
43
|
+
if (!p) throw new Error(`Couldn't read 'package.json'`)
|
|
44
|
+
|
|
45
|
+
// Define CLI options
|
|
46
|
+
program
|
|
47
|
+
.version(p.version)
|
|
48
|
+
.description(p.description)
|
|
49
|
+
.on('--help', () => {
|
|
50
|
+
log.I()
|
|
51
|
+
log.I('Environment variables:')
|
|
52
|
+
ENV_VARS.forEach(v => {
|
|
53
|
+
log.I(` ${v.name.padEnd(37)} ${v.description}`)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
.requiredOption('-r, --reference-file <path>', 'Path to reference JSONC file (default language)')
|
|
57
|
+
.option('-rl, --reference-language <language>', `The reference file's language; overrides any 'referenceLanguage' config setting`)
|
|
58
|
+
.option('-p, --provider <name>', `AI provider to use for translations (anthropic, openai); overrides any 'provider' config setting`)
|
|
59
|
+
.option('-o, --output-dir <path>', 'Output directory for localized files')
|
|
60
|
+
.option('-l, --target-languages <list>', `Comma-separated list of language codes; overrides any 'targetLanguages' config setting`, value => languageList(value, log))
|
|
61
|
+
.option('-k, --keys <list>', 'Comma-separated list of keys to process', keyList)
|
|
62
|
+
.option('-j, --reference-var-name <var name>', `The exported variable in the reference file, e.g. export default = {...} you'd use 'default'`, 'default')
|
|
63
|
+
.option('-f, --force', 'Force regeneration of all translations', false)
|
|
64
|
+
.option('-rtw, --realtime-writes', 'Write updates to disk immediately, rather than on shutdown', false)
|
|
65
|
+
.option('-m, --app-context-message <message>', `Description of your app to give context. Passed with each translation request; overrides any 'appContextMessage' config setting`)
|
|
66
|
+
.option('-y, --tty', 'Use tty/simple renderer; useful for CI', false)
|
|
67
|
+
.option('-c, --config-file <path>', `Path to config file; defaults to <output dir>/${DEFAULT_CONFIG_FILENAME}`)
|
|
68
|
+
.option('-x, --max-retries <integer>', 'Maximum retries on failure', 3)
|
|
69
|
+
.option('-n, --normalize-output-filenames', `Normalizes output filenames (to all lower-case); overrides any 'normalizeOutputFilenames' in config setting`, false)
|
|
70
|
+
.option('-v, --verbose', `Enables verbose spew`, false)
|
|
71
|
+
.option('-d, --debug', `Enables debug spew`, false)
|
|
72
|
+
.option('-t, --trace', `Enables trace spew`, false)
|
|
73
|
+
.option('--context-prefix <value>', `String to be prefixed to all keys to search for additional context, which are passed along to the AI for context`)
|
|
74
|
+
.option('--context-suffix <value>', `String to be suffixed to all keys to search for additional context, which are passed along to the AI for context`)
|
|
75
|
+
.option('--look-for-context-data', `If specified, ALT will pass any context data specified in the reference file to the AI provider for translation. At least one of --contextPrefix or --contextSuffix must be specified`, false)
|
|
76
|
+
.hook('preAction', (thisCommand) => {
|
|
77
|
+
const opts = thisCommand.opts()
|
|
78
|
+
if (opts.lookForContextData && !(opts.contextPrefix?.length || opts.contextSuffix?.length)) {
|
|
79
|
+
thisCommand.error('--lookForContextData requires at least 1 of --contextPrefix or --contextSuffix be defined and non-empty')
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.action(async (thisCommand) => {
|
|
83
|
+
}) // Dummy action() for preAction
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('translate', { isDefault: true }) // This makes it the default command
|
|
87
|
+
.action(async () => {
|
|
88
|
+
const options = program.opts()
|
|
89
|
+
initLogFromOptions({ options, log })
|
|
90
|
+
await runTranslation({ appState, options, log })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
program.parse(process.argv)
|
|
94
|
+
|
|
95
|
+
await printLogo({
|
|
96
|
+
fontsSrcDir: path.resolve(__dirname, '../assets/figlet-fonts/'),
|
|
97
|
+
tagline: p.description,
|
|
98
|
+
log
|
|
99
|
+
})
|
|
100
|
+
} catch (error) {
|
|
101
|
+
log.E(error)
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/options.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isBcp47LanguageTagValid } from './localizer/localize.js'
|
|
2
|
+
import { unique } from './utils.js'
|
|
3
|
+
|
|
4
|
+
// Helper function to parse comma-separated list
|
|
5
|
+
export function languageList(value, log) {
|
|
6
|
+
const languages = unique(value.split(',').map(item => item.trim()))
|
|
7
|
+
const invalid = languages.filter(tag => !isBcp47LanguageTagValid(tag))
|
|
8
|
+
if (invalid.length) {
|
|
9
|
+
log.E(`Found invalid language(s): ${invalid.join(', ')}`)
|
|
10
|
+
process.exit(1)
|
|
11
|
+
}
|
|
12
|
+
return languages
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function keyList(value) {
|
|
16
|
+
return value.split(',').map(item => item.trim())
|
|
17
|
+
}
|
|
18
|
+
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
import { importJsFile } from './io.js'
|
|
3
|
+
|
|
4
|
+
export async function loadTranslationProvider({__dirname, providerName, log}) {
|
|
5
|
+
const apiKeyName = `${providerName.toUpperCase()}_API_KEY`
|
|
6
|
+
const apiKey = process.env[apiKeyName]
|
|
7
|
+
if (!apiKey?.length) {
|
|
8
|
+
log.E(`${apiKeyName} environment variable is not set`)
|
|
9
|
+
process.exit(1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
apiKey,
|
|
14
|
+
api: await importJsFile(path.resolve(__dirname, `providers/${providerName}.mjs`)),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function name() {
|
|
2
|
+
return 'Anthropic'
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getTranslationRequestDetails({ messages, apiKey, log }) {
|
|
6
|
+
return {
|
|
7
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
8
|
+
params: {
|
|
9
|
+
model: 'claude-3-7-sonnet-20250219',
|
|
10
|
+
max_tokens: 1024,
|
|
11
|
+
messages: messages.map(m => ({ role: 'user', content: m })),
|
|
12
|
+
},
|
|
13
|
+
config: {
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'x-api-key': apiKey,
|
|
17
|
+
'anthropic-version': '2023-06-01',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getResult(response, log) {
|
|
24
|
+
return response.data.content[0].text.trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getHeader(headers, name) {
|
|
28
|
+
return headers[name] || headers.get?.(name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getSleepInterval(headers, log) {
|
|
32
|
+
log.T(headers)
|
|
33
|
+
if (getHeader(headers, 'x-should-retry') !== 'true')
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
const retryAfter = parseInt(getHeader(headers, 'retry-after'))
|
|
37
|
+
log.D('retryAfter', retryAfter)
|
|
38
|
+
return 1000 * retryAfter + 200
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function name() {
|
|
2
|
+
return 'OpenAI'
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getTranslationRequestDetails({ messages, apiKey, log }) {
|
|
6
|
+
return {
|
|
7
|
+
url: 'https://api.openai.com/v1/chat/completions',
|
|
8
|
+
params: {
|
|
9
|
+
model: 'gpt-4-turbo',
|
|
10
|
+
messages: messages.map((m, idx) => {
|
|
11
|
+
return {
|
|
12
|
+
role: idx === messages.length - 1 ? 'user' : 'system',
|
|
13
|
+
content: m,
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
temperature: 0.3,
|
|
17
|
+
max_tokens: 1024,
|
|
18
|
+
},
|
|
19
|
+
config: {
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getResult(response) {
|
|
29
|
+
return response.data.choices[0].message.content.trim()
|
|
30
|
+
}
|