@drone1/alt 1.1.1 → 1.1.3

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "enableAllProjectMcpServers": false,
3
+ "permissions": {
4
+ "allow": [
5
+ "Bash(npm run test:mock:*)"
6
+ ]
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@drone1/alt",
4
- "version": "1.1.1",
4
+ "version": "1.1.3",
5
5
  "description": "An AI-powered localization tool",
6
6
  "main": "src/index.mjs",
7
7
  "bin": {
@@ -10,7 +10,6 @@
10
10
  "scripts": {
11
11
  "test": "ALT_TEST=1 mocha",
12
12
  "test:targeted": "ALT_TEST=1 mocha --grep 'multiple target'",
13
- "test": "ALT_TEST=1 mocha",
14
13
  "test:coverage": "ALT_TEST=1 nyc mocha",
15
14
  "localize-display-strings": "./alt.mjs",
16
15
  "print-all-help": "rm -f help.txt && (./alt.mjs help && echo -e '\n---\n' && ./alt.mjs help translate && echo -e '\n---\n' && ./alt.mjs help list-models) > help.txt",
@@ -0,0 +1,156 @@
1
+ import * as path from 'path'
2
+ import { localize, localizeFormatted } from '../localizer/localize.js'
3
+ import { readJsonFile, writeJsonFile, normalizeOutputPath } from '../lib/io.js'
4
+ import { loadConfig } from '../lib/config.js'
5
+ import { loadReferenceFile } from '../lib/reference-loader.js'
6
+ import { assertIsObj } from '../lib/assert.js'
7
+ import { shutdown } from '../shutdown.js'
8
+
9
+ export async function runPrune({ appState, options, log }) {
10
+ let exitCode = 0
11
+ try {
12
+ // Load config
13
+ const config = await loadConfig({
14
+ configFile: options.configFile,
15
+ log
16
+ })
17
+ assertIsObj(config)
18
+
19
+ const referenceFile = options.referenceFile ?? config.referenceFile
20
+ if (!referenceFile?.length) {
21
+ throw new Error(
22
+ localize({
23
+ token: 'error-no-reference-file-specified',
24
+ lang: appState.lang,
25
+ log
26
+ })
27
+ )
28
+ }
29
+ log.D(`referenceFile=${referenceFile}`)
30
+
31
+ // Load reference file
32
+ const refFileDir = path.dirname(referenceFile)
33
+ const outputDir = path.resolve(options.outputDir ?? config.outputDir ?? refFileDir)
34
+ log.D(`outputDir=${outputDir}`)
35
+
36
+ // Create a tmp dir for storing the .mjs reference file
37
+ const { mkTmpDir } = await import('../lib/io.js')
38
+ const tmpDir = await mkTmpDir()
39
+ appState.tmpDir = tmpDir
40
+
41
+ // Resolve referenceExportedVarName
42
+ let referenceExportedVarName
43
+ const { getFileExtension } = await import('../lib/utils.js')
44
+ const referenceFileExt = getFileExtension(referenceFile)
45
+ if (['js','mjs'].includes(referenceFileExt)) {
46
+ log.D(`Searching for reference exported var name for .${referenceFileExt} extension...`)
47
+ if (options.referenceExportedVarName?.length) {
48
+ log.D(`Found reference exported var name via --reference-exported-var-name`)
49
+ referenceExportedVarName = options.referenceExportedVarName
50
+ } else if (config.referenceExportedVarName?.length) {
51
+ log.D(`Found reference exported var name in config, via 'referenceExportedVarName'`)
52
+ referenceExportedVarName = config.referenceExportedVarName
53
+ }
54
+ }
55
+ log.D(`referenceExportedVarName=${referenceExportedVarName}`)
56
+
57
+ // Load reference data
58
+ const referenceData = await loadReferenceFile({
59
+ appLang: appState.lang,
60
+ referenceFile,
61
+ referenceExportedVarName,
62
+ tmpDir,
63
+ log
64
+ })
65
+
66
+ if (!referenceData) {
67
+ throw new Error(
68
+ localizeFormatted({
69
+ token: 'error-no-reference-data-in-variable',
70
+ data: {
71
+ referenceExportedVarName,
72
+ referenceFile,
73
+ },
74
+ lang: appState.lang,
75
+ log
76
+ })
77
+ )
78
+ }
79
+
80
+ const referenceKeys = new Set(Object.keys(referenceData))
81
+ log.V(`Reference file contains ${referenceKeys.size} keys`)
82
+
83
+ // Get target languages
84
+ const targetLanguages = options.targetLanguages || config.targetLanguages
85
+ if (!targetLanguages || !targetLanguages.length) {
86
+ throw new Error(
87
+ localize({ token: 'error-no-target-languages', lang: appState.lang, log })
88
+ )
89
+ }
90
+
91
+ const normalizeOutputFilenames = options.normalizeOutputFilenames || config.normalizeOutputFilenames
92
+
93
+ let totalKeysRemoved = 0
94
+ let filesModified = 0
95
+
96
+ // Process each target language file
97
+ for (const targetLang of targetLanguages) {
98
+ const outputFilePath = normalizeOutputPath({
99
+ dir: outputDir,
100
+ filename: `${targetLang}.json`,
101
+ normalize: normalizeOutputFilenames
102
+ })
103
+ log.D(`Processing ${outputFilePath}...`)
104
+
105
+ // Read existing output data
106
+ const outputData = await readJsonFile(outputFilePath)
107
+ if (!outputData) {
108
+ log.V(`File ${outputFilePath} does not exist, skipping...`)
109
+ continue
110
+ }
111
+
112
+ const keysToRemove = []
113
+ for (const key of Object.keys(outputData)) {
114
+ if (!referenceKeys.has(key)) {
115
+ keysToRemove.push(key)
116
+ }
117
+ }
118
+
119
+ if (keysToRemove.length > 0) {
120
+ log.I(`Found ${keysToRemove.length} obsolete key(s) in ${targetLang}.json:`)
121
+ keysToRemove.forEach(key => {
122
+ log.I(` - ${key}`)
123
+ delete outputData[key]
124
+ })
125
+
126
+ // Write the pruned file
127
+ if (!options.dryRun) {
128
+ writeJsonFile(outputFilePath, outputData, log)
129
+ log.V(`Wrote ${outputFilePath}`)
130
+ filesModified++
131
+ }
132
+ totalKeysRemoved += keysToRemove.length
133
+ } else {
134
+ log.V(`No obsolete keys found in ${targetLang}.json`)
135
+ }
136
+ }
137
+
138
+ if (options.dryRun) {
139
+ log.I(`\nDry run complete. Would have removed ${totalKeysRemoved} key(s) from ${filesModified} file(s).`)
140
+ } else if (totalKeysRemoved > 0) {
141
+ log.I(`\nRemoved ${totalKeysRemoved} obsolete key(s) from ${filesModified} file(s).`)
142
+ } else {
143
+ log.I(`\nNo obsolete keys found. All target files are up to date.`)
144
+ }
145
+
146
+ } catch (error) {
147
+ log.E(error)
148
+ exitCode = 2
149
+ }
150
+
151
+ await shutdown(appState, false)
152
+
153
+ if (exitCode > 0) {
154
+ process.exit(exitCode)
155
+ }
156
+ }
@@ -366,6 +366,11 @@ export async function runTranslation({ appState, options, log }) {
366
366
  log.T(taskInfo)
367
367
  const progress = 100 * Math.floor(100 * taskInfoIdx / totalTasks) / 100
368
368
 
369
+ // Broadcast progress for CI
370
+ if (process.env.CI) {
371
+ console.log(`::notice::${progress}% - ${taskInfo.targetLang}/${taskInfo.key}`)
372
+ }
373
+
369
374
  await new Listr([
370
375
  {
371
376
  title: localizeFormatted({
package/src/main.mjs CHANGED
@@ -15,6 +15,7 @@ import { keyList, languageList } from './lib/options.js'
15
15
  import { runTranslation } from './commands/translate.js'
16
16
  import { registerSignalHandlers } from './shutdown.js'
17
17
  import { runListModels } from './commands/list-models.js'
18
+ import { runPrune } from './commands/prune.js'
18
19
 
19
20
  const __dirname = path.dirname(
20
21
  fileURLToPath(import.meta.url)
@@ -99,6 +100,10 @@ export async function run() {
99
100
  case 'list-models':
100
101
  await runListModels({ appState, options, log })
101
102
  break
103
+
104
+ case 'prune':
105
+ await runPrune({ appState, options, log })
106
+ break
102
107
  }
103
108
  }
104
109
 
@@ -145,6 +150,23 @@ export async function run() {
145
150
  .action(runCommand)
146
151
  })
147
152
 
153
+ program
154
+ .command('prune')
155
+ .description('Remove keys from target files that no longer exist in the reference file')
156
+ .option('-c, --config-file <path>', `Path to config file; defaults to "${DEFAULT_CONFIG_FILENAME}" in the current working directory if not specified`)
157
+ .option('-r, --reference-file <path>', `Path to reference file of source strings. This file can be in .js, .mjs, .json, or .jsonc formats; overrides any 'referenceFile' config setting`)
158
+ .option('-o, --output-dir <path>', `Output directory for localized files; overrides any 'outputDir' config setting`)
159
+ .option('-tl, --target-languages <list>', `Comma-separated list of language codes; overrides any 'targetLanguages' config setting`, value => languageList(value, log))
160
+ .option('-R, --reference-exported-var-name <var name>', `For .js or .mjs reference files only, this will be the exported variable, e.g. for 'export default = {...}' you'd use 'default' here, or 'data' for 'export const data = { ... }'. For .json or .jsonc reference files, this value is ignored.`, 'default')
161
+ .option('-n, --normalize-output-filenames', `Normalizes output filenames (to all lower-case); overrides any 'normalizeOutputFilenames' in config setting`, false)
162
+ .option('--dry-run', 'Show what would be removed without actually modifying files', false)
163
+ .option('-N, --no-logo', `Suppress logo printout`, true) // NB: maps to options.logo, not options.noLogo
164
+ .option('-v, --verbose', `Enables verbose spew`, false)
165
+ .option('-d, --debug', `Enables debug spew`, false)
166
+ .option('-t, --trace', `Enables trace spew`, false)
167
+ .option('--dev', `Enable dev mode, which prints stack traces with errors`, false)
168
+ .action(runCommand)
169
+
148
170
  program.parse(process.argv)
149
171
  } catch (error) {
150
172
  log.E(error)
package/test/common.mjs CHANGED
@@ -23,7 +23,7 @@ export function cleanupFile(file) {
23
23
 
24
24
  export function cleanupDir(dir) {
25
25
  if (!fs.existsSync(dir)) return
26
- fs.rmdirSync(dir, { recursive: true })
26
+ fs.rmSync(dir, { recursive: true, force: true })
27
27
  }
28
28
 
29
29
 
@@ -0,0 +1,320 @@
1
+ import { execa } from 'execa'
2
+ import { expect } from 'chai'
3
+ import fs from 'fs'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { dirname } from 'path'
7
+ import crypto from 'crypto'
8
+ import { SRC_DATA_DIR, cleanupFile, cleanupCacheFile } from './common.mjs'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+
13
+ describe('prune command', () => {
14
+ it('should remove obsolete keys from target files', async function() {
15
+ this.timeout(10000)
16
+
17
+ // Generate a random ID for the reference file
18
+ const randomId = crypto.randomBytes(4).toString('hex')
19
+ const refFileName = `ref-${randomId}.json`
20
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
21
+
22
+ try {
23
+ // Create a reference file with only 2 keys
24
+ const referenceData = {
25
+ 'msg-test': 'Nothing to do',
26
+ 'error-finished': 'Finished with %%errorsEncountered%% error%%s%%'
27
+ }
28
+ fs.writeFileSync(refFilePath, JSON.stringify(referenceData, null, 2), 'utf8')
29
+
30
+ // Create a target file with 3 keys (one obsolete)
31
+ const targetFilePath = path.join(SRC_DATA_DIR, 'fr-FR.json')
32
+ const targetData = {
33
+ 'msg-test': 'Rien à faire',
34
+ 'error-finished': 'Terminé avec %%errorsEncountered%% erreur%%s%%',
35
+ 'obsolete-key': 'This should be removed'
36
+ }
37
+ fs.writeFileSync(targetFilePath, JSON.stringify(targetData, null, 2), 'utf8')
38
+
39
+ // Run the prune command
40
+ const result = await execa('node', [
41
+ path.resolve(__dirname, '../alt.mjs'),
42
+ 'prune',
43
+ '-r',
44
+ refFilePath,
45
+ '-tl',
46
+ 'fr-FR',
47
+ '-d'
48
+ ], {
49
+ cwd: 'test'
50
+ })
51
+
52
+ // Check command executed successfully
53
+ expect(result.exitCode).to.equal(0)
54
+
55
+ // Verify the target file was pruned
56
+ const prunedContent = JSON.parse(fs.readFileSync(targetFilePath, 'utf8'))
57
+ expect(prunedContent).to.have.property('msg-test')
58
+ expect(prunedContent).to.have.property('error-finished')
59
+ expect(prunedContent).to.not.have.property('obsolete-key')
60
+
61
+ // Clean up
62
+ cleanupFile(targetFilePath)
63
+ } finally {
64
+ cleanupFile(refFilePath)
65
+ }
66
+ })
67
+
68
+ it('should handle multiple target languages', async function() {
69
+ this.timeout(10000)
70
+
71
+ const randomId = crypto.randomBytes(4).toString('hex')
72
+ const refFileName = `ref-${randomId}.json`
73
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
74
+
75
+ try {
76
+ // Create reference file
77
+ const referenceData = {
78
+ 'msg-test': 'Nothing to do'
79
+ }
80
+ fs.writeFileSync(refFilePath, JSON.stringify(referenceData, null, 2), 'utf8')
81
+
82
+ // Create multiple target files with obsolete keys
83
+ const frFilePath = path.join(SRC_DATA_DIR, 'fr-FR.json')
84
+ const esFilePath = path.join(SRC_DATA_DIR, 'es-ES.json')
85
+
86
+ const frData = {
87
+ 'msg-test': 'Rien à faire',
88
+ 'obsolete-fr': 'Obsolete French key'
89
+ }
90
+ const esData = {
91
+ 'msg-test': 'Nada que hacer',
92
+ 'obsolete-es': 'Obsolete Spanish key'
93
+ }
94
+
95
+ fs.writeFileSync(frFilePath, JSON.stringify(frData, null, 2), 'utf8')
96
+ fs.writeFileSync(esFilePath, JSON.stringify(esData, null, 2), 'utf8')
97
+
98
+ // Run the prune command
99
+ const result = await execa('node', [
100
+ path.resolve(__dirname, '../alt.mjs'),
101
+ 'prune',
102
+ '-r',
103
+ refFilePath,
104
+ '-tl',
105
+ 'fr-FR,es-ES'
106
+ ], {
107
+ cwd: 'test'
108
+ })
109
+
110
+ // Check command executed successfully
111
+ expect(result.exitCode).to.equal(0)
112
+
113
+ // Verify both files were pruned
114
+ const prunedFrContent = JSON.parse(fs.readFileSync(frFilePath, 'utf8'))
115
+ const prunedEsContent = JSON.parse(fs.readFileSync(esFilePath, 'utf8'))
116
+
117
+ expect(prunedFrContent).to.have.property('msg-test')
118
+ expect(prunedFrContent).to.not.have.property('obsolete-fr')
119
+
120
+ expect(prunedEsContent).to.have.property('msg-test')
121
+ expect(prunedEsContent).to.not.have.property('obsolete-es')
122
+
123
+ // Clean up
124
+ cleanupFile(frFilePath)
125
+ cleanupFile(esFilePath)
126
+ } finally {
127
+ cleanupFile(refFilePath)
128
+ }
129
+ })
130
+
131
+ it('should support dry-run mode without modifying files', async function() {
132
+ this.timeout(10000)
133
+
134
+ const randomId = crypto.randomBytes(4).toString('hex')
135
+ const refFileName = `ref-${randomId}.json`
136
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
137
+
138
+ try {
139
+ // Create reference file with 1 key
140
+ const referenceData = {
141
+ 'msg-test': 'Nothing to do'
142
+ }
143
+ fs.writeFileSync(refFilePath, JSON.stringify(referenceData, null, 2), 'utf8')
144
+
145
+ // Create target file with 2 keys (one obsolete)
146
+ const targetFilePath = path.join(SRC_DATA_DIR, 'fr-FR.json')
147
+ const targetData = {
148
+ 'msg-test': 'Rien à faire',
149
+ 'obsolete-key': 'This should NOT be removed in dry-run'
150
+ }
151
+ fs.writeFileSync(targetFilePath, JSON.stringify(targetData, null, 2), 'utf8')
152
+
153
+ // Run the prune command with --dry-run
154
+ const result = await execa('node', [
155
+ path.resolve(__dirname, '../alt.mjs'),
156
+ 'prune',
157
+ '-r',
158
+ refFilePath,
159
+ '-tl',
160
+ 'fr-FR',
161
+ '--dry-run'
162
+ ], {
163
+ cwd: 'test'
164
+ })
165
+
166
+ // Check command executed successfully
167
+ expect(result.exitCode).to.equal(0)
168
+
169
+ // Verify the target file was NOT modified
170
+ const unchangedContent = JSON.parse(fs.readFileSync(targetFilePath, 'utf8'))
171
+ expect(unchangedContent).to.have.property('msg-test')
172
+ expect(unchangedContent).to.have.property('obsolete-key', 'This should NOT be removed in dry-run')
173
+
174
+ // Clean up
175
+ cleanupFile(targetFilePath)
176
+ } finally {
177
+ cleanupFile(refFilePath)
178
+ }
179
+ })
180
+
181
+ it('should handle non-existent target files gracefully', async function() {
182
+ this.timeout(10000)
183
+
184
+ const randomId = crypto.randomBytes(4).toString('hex')
185
+ const refFileName = `ref-${randomId}.json`
186
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
187
+
188
+ try {
189
+ // Create reference file
190
+ const referenceData = {
191
+ 'msg-test': 'Nothing to do'
192
+ }
193
+ fs.writeFileSync(refFilePath, JSON.stringify(referenceData, null, 2), 'utf8')
194
+
195
+ // Don't create any target files
196
+
197
+ // Run the prune command
198
+ const result = await execa('node', [
199
+ path.resolve(__dirname, '../alt.mjs'),
200
+ 'prune',
201
+ '-r',
202
+ refFilePath,
203
+ '-tl',
204
+ 'fr-FR'
205
+ ], {
206
+ cwd: 'test'
207
+ })
208
+
209
+ // Check command executed successfully
210
+ expect(result.exitCode).to.equal(0)
211
+ } finally {
212
+ cleanupFile(refFilePath)
213
+ }
214
+ })
215
+
216
+ it('should keep all keys when no obsolete keys exist', async function() {
217
+ this.timeout(10000)
218
+
219
+ const randomId = crypto.randomBytes(4).toString('hex')
220
+ const refFileName = `ref-${randomId}.json`
221
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
222
+
223
+ try {
224
+ // Create reference file with 2 keys
225
+ const referenceData = {
226
+ 'msg-test': 'Nothing to do',
227
+ 'error-finished': 'Finished with %%errorsEncountered%% error%%s%%'
228
+ }
229
+ fs.writeFileSync(refFilePath, JSON.stringify(referenceData, null, 2), 'utf8')
230
+
231
+ // Create target file with same keys (no obsolete keys)
232
+ const targetFilePath = path.join(SRC_DATA_DIR, 'fr-FR.json')
233
+ const targetData = {
234
+ 'msg-test': 'Rien à faire',
235
+ 'error-finished': 'Terminé avec %%errorsEncountered%% erreur%%s%%'
236
+ }
237
+ fs.writeFileSync(targetFilePath, JSON.stringify(targetData, null, 2), 'utf8')
238
+
239
+ // Run the prune command
240
+ const result = await execa('node', [
241
+ path.resolve(__dirname, '../alt.mjs'),
242
+ 'prune',
243
+ '-r',
244
+ refFilePath,
245
+ '-tl',
246
+ 'fr-FR'
247
+ ], {
248
+ cwd: 'test'
249
+ })
250
+
251
+ // Check command executed successfully
252
+ expect(result.exitCode).to.equal(0)
253
+
254
+ // Verify all keys are still present
255
+ const unchangedContent = JSON.parse(fs.readFileSync(targetFilePath, 'utf8'))
256
+ expect(unchangedContent).to.have.property('msg-test')
257
+ expect(unchangedContent).to.have.property('error-finished')
258
+ expect(Object.keys(unchangedContent).length).to.equal(2)
259
+
260
+ // Clean up
261
+ cleanupFile(targetFilePath)
262
+ } finally {
263
+ cleanupFile(refFilePath)
264
+ }
265
+ })
266
+
267
+ it('should work with .js reference files', async function() {
268
+ this.timeout(10000)
269
+
270
+ const randomId = crypto.randomBytes(4).toString('hex')
271
+ const refFileName = `ref-${randomId}.js`
272
+ const refFilePath = path.join(SRC_DATA_DIR, refFileName)
273
+
274
+ try {
275
+ // Create a .js reference file
276
+ const refContent = `export default {
277
+ 'msg-test': 'Nothing to do',
278
+ 'error-finished': 'Finished with errors'
279
+ }`
280
+ fs.writeFileSync(refFilePath, refContent, 'utf8')
281
+
282
+ // Create target file with an obsolete key
283
+ const targetFilePath = path.join(SRC_DATA_DIR, 'fr-FR.json')
284
+ const targetData = {
285
+ 'msg-test': 'Rien à faire',
286
+ 'error-finished': 'Terminé avec erreurs',
287
+ 'obsolete-key': 'Should be removed'
288
+ }
289
+ fs.writeFileSync(targetFilePath, JSON.stringify(targetData, null, 2), 'utf8')
290
+
291
+ // Run the prune command
292
+ const result = await execa('node', [
293
+ path.resolve(__dirname, '../alt.mjs'),
294
+ 'prune',
295
+ '-r',
296
+ refFilePath,
297
+ '-tl',
298
+ 'fr-FR',
299
+ '-R',
300
+ 'default'
301
+ ], {
302
+ cwd: 'test'
303
+ })
304
+
305
+ // Check command executed successfully
306
+ expect(result.exitCode).to.equal(0)
307
+
308
+ // Verify the obsolete key was removed
309
+ const prunedContent = JSON.parse(fs.readFileSync(targetFilePath, 'utf8'))
310
+ expect(prunedContent).to.have.property('msg-test')
311
+ expect(prunedContent).to.have.property('error-finished')
312
+ expect(prunedContent).to.not.have.property('obsolete-key')
313
+
314
+ // Clean up
315
+ cleanupFile(targetFilePath)
316
+ } finally {
317
+ cleanupFile(refFilePath)
318
+ }
319
+ })
320
+ })