@localheroai/cli 0.0.1 → 0.0.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.
@@ -1,69 +1,80 @@
1
- import { promises as fs } from 'fs';
2
- import path from 'path';
3
1
  import chalk from 'chalk';
4
2
  import { createPromptService } from '../utils/prompt-service.js';
5
3
  import { updateGitignore } from '../utils/git.js';
6
- import { defaultDependencies } from '../utils/defaults.js';
7
4
  import { verifyApiKey as defaultVerifyApiKey } from '../api/auth.js';
8
5
  import { configService } from '../utils/config.js';
9
6
 
10
7
  const API_KEY_PATTERN = /^tk_[a-zA-Z0-9]{48}$/;
11
8
 
12
- export async function login(deps = defaultDependencies) {
13
- const {
14
- console = global.console,
15
- basePath = process.cwd(),
16
- promptService = createPromptService({ inquirer: await import('@inquirer/prompts') }),
17
- verifyApiKey = defaultVerifyApiKey,
18
- gitUtils = { updateGitignore },
19
- configUtils = configService
20
- } = deps;
9
+ export async function login(deps = {}) {
10
+ const {
11
+ console = global.console,
12
+ basePath = process.cwd(),
13
+ promptService = createPromptService({ inquirer: await import('@inquirer/prompts') }),
14
+ verifyApiKey = defaultVerifyApiKey,
15
+ gitUtils = { updateGitignore },
16
+ configUtils = configService,
17
+ isCalledFromInit = false
18
+ } = deps;
21
19
 
22
- const existingConfig = await configUtils.getAuthConfig(basePath);
20
+ const existingConfig = await configUtils.getAuthConfig(basePath);
23
21
 
24
- if (existingConfig?.api_key) {
25
- console.log(chalk.yellow('\n⚠️ Warning: This will replace your existing API key configuration'));
26
- }
22
+ if (existingConfig?.api_key) {
23
+ console.log(chalk.yellow('\n⚠️ Warning: This will replace your existing API key configuration'));
24
+ }
27
25
 
28
- const apiKey = process.env.LOCALHERO_API_KEY || (
29
- console.log(chalk.blue('\nℹ️ Please enter your API key from https://localhero.ai/api-keys\n')),
30
- await promptService.getApiKey()
31
- );
26
+ const apiKey = process.env.LOCALHERO_API_KEY || (
27
+ console.log('\n Get your API key from: https://localhero.ai/api-keys'),
28
+ console.log('→ New to LocalHero? Sign up at: https://localhero.ai/signup'),
29
+ console.log(chalk.gray('The API key will be saved to .localhero_key, and automatically added to your .gitignore file.\n')),
30
+ await promptService.getApiKey()
31
+ );
32
32
 
33
- if (!API_KEY_PATTERN.test(apiKey)) {
34
- throw new Error('Invalid API key format');
35
- }
33
+ if (!apiKey) {
34
+ throw new Error('User cancelled');
35
+ }
36
+
37
+ if (!API_KEY_PATTERN.test(apiKey)) {
38
+ throw new Error('Invalid API key format');
39
+ }
36
40
 
37
- const result = await verifyApiKey(apiKey);
41
+ const result = await verifyApiKey(apiKey);
38
42
 
39
- if (result.error) {
40
- throw new Error(result.error.message);
43
+ if (result.error) {
44
+ if (result.error.code === 'invalid_api_key') {
45
+ console.log(chalk.red('\n❌ ' + result.error.message));
46
+ console.log(chalk.blue('\nℹ️ Get a new API key at https://localhero.ai/api-keys'));
47
+ process.exit(1);
41
48
  }
49
+ throw new Error(result.error.message);
50
+ }
42
51
 
43
- const config = {
44
- api_key: apiKey,
45
- last_verified: new Date().toISOString()
46
- };
52
+ const config = {
53
+ api_key: apiKey,
54
+ last_verified: new Date().toISOString()
55
+ };
47
56
 
48
- await configUtils.saveAuthConfig(config, basePath);
49
- const gitignoreUpdated = await gitUtils.updateGitignore(basePath);
57
+ await configUtils.saveAuthConfig(config, basePath);
58
+ const gitignoreUpdated = await gitUtils.updateGitignore(basePath);
50
59
 
51
- console.log(chalk.green('\n✓ API key verified and saved to .localhero_key'));
52
- if (gitignoreUpdated) {
53
- console.log(chalk.green('✓ Added .localhero_key to .gitignore'));
54
- }
60
+ console.log(chalk.green('\n✓ API key verified and saved to .localhero_key'));
61
+ if (gitignoreUpdated) {
62
+ console.log(chalk.green('✓ Added .localhero_key to .gitignore'));
63
+ }
55
64
 
56
- console.log(chalk.blue(`💼️ Organization: ${result.organization.name}`));
57
- console.log(chalk.blue(`📚 Projects: ${result.organization.projects.map(p => p.name).join(', ')}`));
65
+ console.log(chalk.blue(`💼️ Organization: ${result.organization.name}`));
66
+ if (result.organization.projects.length > 0) {
67
+ console.log(chalk.blue(`\n📚 Projects: ${result.organization.projects.map(p => p.name).join(', ')}`));
68
+ }
58
69
 
59
- const projectConfig = await configUtils.getProjectConfig(basePath);
70
+ const projectConfig = await configUtils.getProjectConfig(basePath);
60
71
 
61
- if (!projectConfig) {
62
- console.log(chalk.yellow('\n⚠️ Almost there! You need to set up your project configuration.'));
63
- console.log(chalk.blue('Run this next:'));
64
- console.log(chalk.white('\n npx localhero init\n'));
65
- } else {
66
- console.log('\nYou\'re ready to start translating!');
67
- console.log('Try running: npx localhero translate');
68
- }
69
- }
72
+ if (!projectConfig && !isCalledFromInit) {
73
+ console.log(chalk.yellow('\n⚠️ Almost there! You need to set up your project configuration.'));
74
+ console.log(chalk.blue('Run this next:'));
75
+ console.log(chalk.white('\n npx @localheroai/cli init\n'));
76
+ } else if (!isCalledFromInit) {
77
+ console.log('\nYou\'re ready to start translating!');
78
+ console.log('Try running: npx @localheroai/cli translate');
79
+ }
80
+ }
@@ -0,0 +1,28 @@
1
+ import { syncService as defaultSyncService } from '../utils/sync-service.js';
2
+ import chalk from 'chalk';
3
+
4
+ export async function sync({ verbose = false } = {}, deps = { syncService: defaultSyncService }) {
5
+ const { syncService } = deps;
6
+ const { hasUpdates, updates } = await syncService.checkForUpdates({ verbose });
7
+
8
+ if (!hasUpdates) {
9
+ console.log(chalk.green('✓ All translations are up to date'));
10
+ return;
11
+ }
12
+
13
+ const result = await syncService.applyUpdates(updates, { verbose });
14
+
15
+ const { totalUpdates = 0, totalDeleted = 0 } = result;
16
+
17
+ if (!verbose) {
18
+ if (totalUpdates > 0) {
19
+ console.log(chalk.green(`✓ Updated ${totalUpdates} translations`));
20
+ }
21
+
22
+ if (totalDeleted > 0) {
23
+ console.log(chalk.green(`✓ Deleted ${totalDeleted} keys`));
24
+ }
25
+ }
26
+
27
+ return result;
28
+ }
@@ -1,282 +1,262 @@
1
1
  import chalk from 'chalk';
2
2
  import { configService } from '../utils/config.js';
3
- import { findTranslationFiles } from '../utils/files.js';
3
+ import { findTranslationFiles, parseFile, flattenTranslations } from '../utils/files.js';
4
4
  import { createTranslationJob, checkJobStatus } from '../api/translations.js';
5
- import { updateTranslationFile } from '../utils/translation-updater.js';
6
- import path from 'path';
5
+ import { updateTranslationFile } from '../utils/translation-updater/index.js';
7
6
  import { checkAuth } from '../utils/auth.js';
7
+ import { findMissingTranslations, batchKeysWithMissing, processLocaleTranslations } from '../utils/translation-utils.js';
8
+ import { syncService } from '../utils/sync-service.js';
8
9
  import { autoCommitChanges } from '../utils/github.js';
9
10
 
10
- const DEFAULT_LOCALE_REGEX = '.*?([a-z]{2}(?:-[A-Z]{2})?)\\.(?:yml|yaml|json)$';
11
- const MAX_RETRY_ATTEMPTS = 3;
12
- const BATCH_SIZE = 100;
13
-
14
- function validateConfig(config) {
15
- const required = ['projectId', 'sourceLocale', 'outputLocales', 'translationFiles'];
16
- const missing = required.filter(key => !config[key]);
11
+ const BATCH_SIZE = 50;
12
+
13
+ const defaultDeps = {
14
+ console,
15
+ configUtils: configService,
16
+ authUtils: { checkAuth },
17
+ fileUtils: { findTranslationFiles },
18
+ translationUtils: {
19
+ createTranslationJob,
20
+ checkJobStatus,
21
+ updateTranslationFile,
22
+ findMissingTranslations,
23
+ batchKeysWithMissing
24
+ },
25
+ syncService,
26
+ gitUtils: { autoCommitChanges }
27
+ };
28
+
29
+ export async function translate(options = {}, deps = defaultDeps) {
30
+ const { console, configUtils, authUtils, fileUtils, translationUtils, syncService, gitUtils } = deps;
31
+ const { verbose } = options;
32
+
33
+ const isAuthenticated = await authUtils.checkAuth();
34
+ if (!isAuthenticated) {
35
+ console.error(chalk.red('\n✖ Your API key is invalid. Please run `npx @localheroai/cli login` to authenticate.\n'));
36
+ process.exit(1);
37
+ }
38
+
39
+ const config = await configUtils.getProjectConfig();
40
+ if (!config) {
41
+ console.error(chalk.red('\n✖ No configuration found. Please run `npx @localheroai/cli init` first.\n'));
42
+ process.exit(1);
43
+ return;
44
+ }
45
+
46
+ if (!config.translationFiles?.paths) {
47
+ console.error(chalk.red('\n✖ Invalid configuration: missing translationFiles.paths. Please run `npx @localheroai/cli init` to set up your configuration.\n'));
48
+ process.exit(1);
49
+ return;
50
+ }
51
+
52
+ const { hasUpdates, updates } = await syncService.checkForUpdates({ verbose });
53
+ if (hasUpdates) {
54
+ await syncService.applyUpdates(updates, { verbose });
55
+ }
56
+
57
+ if (verbose) {
58
+ console.log(chalk.blue('\nℹ Using configuration:'));
59
+ console.log(chalk.gray(` Project ID: ${config.projectId}`));
60
+ console.log(chalk.gray(` Source locale: ${config.sourceLocale}`));
61
+ console.log(chalk.gray(` Output locales: ${config.outputLocales.join(', ')}`));
62
+ console.log(chalk.gray(` Translation files: ${config.translationFiles.paths.join(', ')}`));
63
+ }
64
+
65
+ const result = await fileUtils.findTranslationFiles(config, { verbose, returnFullResult: true });
66
+ const { sourceFiles, targetFilesByLocale, allFiles } = result;
67
+
68
+ if (!allFiles || allFiles.length === 0) {
69
+ console.error(chalk.red('\n✖ No translation files found in the specified paths.\n'));
70
+ process.exit(1);
71
+ }
72
+
73
+ if (verbose) {
74
+ console.log(chalk.blue(`\nℹ Found ${allFiles.length} translation files`));
75
+ }
76
+
77
+ if (sourceFiles.length === 0) {
78
+ console.error(chalk.red(`\n✖ No source files found for locale ${config.sourceLocale}\n`));
79
+ process.exit(1);
80
+ }
81
+
82
+ if (verbose) {
83
+ console.log(chalk.blue(`ℹ Found ${sourceFiles.length} source files for locale ${config.sourceLocale}`));
84
+ }
85
+
86
+ const missingByLocale = {};
87
+
88
+ for (const sourceFile of sourceFiles) {
89
+ const sourceContentRaw = Buffer.from(sourceFile.content, 'base64').toString();
90
+ const sourceContent = parseFile(sourceContentRaw, sourceFile.format);
91
+ const sourceKeys = flattenTranslations(sourceContent[config.sourceLocale] || sourceContent);
92
+
93
+ for (const targetLocale of config.outputLocales) {
94
+ const targetFiles = targetFilesByLocale[targetLocale] || [];
95
+ const result = processLocaleTranslations(sourceKeys, targetLocale, targetFiles, sourceFile, config.sourceLocale);
96
+
97
+ if (Object.keys(result.missingKeys).length > 0) {
98
+ if (!missingByLocale[targetLocale]) {
99
+ missingByLocale[targetLocale] = {
100
+ path: sourceFile.path,
101
+ targetPath: result.targetPath,
102
+ keys: {},
103
+ keyCount: 0
104
+ };
105
+ }
17
106
 
18
- if (missing.length) {
19
- throw new Error(`Missing required config: ${missing.join(', ')}. Run 'npx localhero init' to set up your project.`);
20
- }
107
+ missingByLocale[targetLocale].keys = {
108
+ ...missingByLocale[targetLocale].keys,
109
+ ...result.missingKeys
110
+ };
111
+ missingByLocale[targetLocale].keyCount += Object.keys(result.missingKeys).length;
112
+ }
21
113
 
22
- if (!Array.isArray(config.outputLocales) || config.outputLocales.length === 0) {
23
- throw new Error('outputLocales must be an array with at least one locale');
114
+ if (verbose && Object.keys(result.skippedKeys).length > 0) {
115
+ console.log(chalk.yellow(`\nℹ Skipped ${Object.keys(result.skippedKeys).length} keys marked as WIP in ${sourceFile.path}`));
116
+ }
24
117
  }
25
-
26
- if (config.outputLocales.length > 10) {
27
- throw new Error('Maximum 10 target languages allowed per request');
118
+ }
119
+
120
+ const missingLocales = Object.keys(missingByLocale);
121
+ if (missingLocales.length === 0) {
122
+ console.log(chalk.green('✓ All translations are up to date!'));
123
+ return;
124
+ }
125
+
126
+ if (verbose) {
127
+ console.log(chalk.blue('\nℹ Missing translations:'));
128
+ for (const [locale, data] of Object.entries(missingByLocale)) {
129
+ console.log(chalk.gray(` ${locale}: ${data.keyCount} keys`));
28
130
  }
131
+ }
29
132
 
30
- if (!config.translationFiles.paths || !Array.isArray(config.translationFiles.paths)) {
31
- throw new Error('translationFiles.paths must be an array of paths');
32
- }
33
- }
133
+ const { batches, errors } = translationUtils.batchKeysWithMissing(sourceFiles, missingByLocale, BATCH_SIZE);
34
134
 
35
- function findMissingTranslations(sourceKeys, targetKeys) {
36
- const missing = {};
37
- for (const [key, value] of Object.entries(sourceKeys)) {
38
- if (!targetKeys[key]) {
39
- missing[key] = value;
40
- }
135
+ if (errors.length > 0) {
136
+ console.error(chalk.red('\n✖ Errors occurred while preparing translation jobs:'));
137
+ for (const error of errors) {
138
+ console.error(chalk.red(` ${error.message}`));
41
139
  }
42
- return missing;
43
- }
140
+ process.exit(1);
141
+ }
142
+
143
+ let totalTranslated = 0;
144
+ let totalLanguages = 0;
145
+ const processedLocales = new Set();
146
+ const allJobIds = [];
147
+ let resultsBaseUrl = null;
148
+
149
+ for (const batch of batches) {
150
+ const targetPaths = Object.entries(missingByLocale).reduce((acc, [locale, data]) => {
151
+ acc[locale] = data.targetPath;
152
+ return acc;
153
+ }, {});
154
+
155
+ const jobRequest = {
156
+ projectId: config.projectId,
157
+ sourceFiles: batch.files,
158
+ targetLocales: batch.locales,
159
+ targetPaths
160
+ };
44
161
 
45
- async function retryWithBackoff(operation, attempt = 1) {
46
162
  try {
47
- return await operation();
48
- } catch (error) {
49
- if (attempt >= MAX_RETRY_ATTEMPTS) throw error;
50
-
51
- const backoffTime = Math.min(1000 * Math.pow(2, attempt), 30000);
52
- const jitter = Math.random() * 1000;
53
- const waitTime = backoffTime + jitter;
54
-
55
- console.log(chalk.yellow(`⚠️ API error, retrying in ${Math.round(waitTime / 1000)}s (attempt ${attempt}/${MAX_RETRY_ATTEMPTS})`));
56
- console.log(error);
57
- await new Promise(resolve => setTimeout(resolve, waitTime));
58
-
59
- return retryWithBackoff(operation, attempt + 1);
60
- }
61
- }
62
-
63
- function batchKeysWithMissing(sourceFiles, missingByLocale, batchSize = BATCH_SIZE) {
64
- const batches = [];
65
- const sourceFileEntries = new Map();
66
-
67
- for (const [locale, localeData] of Object.entries(missingByLocale)) {
68
- const sourceFile = sourceFiles.find(f => f.path === localeData.path);
69
- if (!sourceFile) continue;
70
-
71
- if (!sourceFileEntries.has(sourceFile.path)) {
72
- sourceFileEntries.set(sourceFile.path, {
73
- path: path.relative(process.cwd(), sourceFile.path),
74
- keys: {},
75
- locales: new Set()
76
- });
77
- }
163
+ const response = await translationUtils.createTranslationJob(jobRequest);
164
+ const { jobs } = response;
165
+ const batchJobIds = jobs.map(job => job.id);
78
166
 
79
- const entry = sourceFileEntries.get(sourceFile.path);
80
- entry.keys = { ...entry.keys, ...localeData.keys };
81
- entry.locales.add(locale);
82
- }
167
+ allJobIds.push(...batchJobIds);
83
168
 
84
- for (const entry of sourceFileEntries.values()) {
85
- const keys = entry.keys;
86
- const keyEntries = Object.entries(keys);
87
- for (let i = 0; i < keyEntries.length; i += batchSize) {
88
- const batchKeys = Object.fromEntries(keyEntries.slice(i, i + batchSize));
89
- batches.push([{
90
- path: entry.path,
91
- content: Buffer.from(JSON.stringify({ keys: batchKeys })).toString('base64')
92
- }]);
93
- }
94
- }
169
+ const pendingJobs = new Set(batchJobIds);
95
170
 
96
- return batches;
97
- }
171
+ while (pendingJobs.size > 0) {
172
+ const jobPromises = Array.from(pendingJobs).map(async jobId => {
173
+ if (verbose) {
174
+ console.log(chalk.blue(`\nℹ Checking job ${jobId}`));
175
+ }
98
176
 
99
- export async function translate(options = {}) {
100
- const { verbose = false, commit = false } = options;
101
- const log = verbose ? console.log : () => { };
177
+ let status;
178
+ let retries = 0;
179
+ const MAX_WAIT_MINUTES = 10;
180
+ const startTime = Date.now();
102
181
 
103
- try {
104
- const config = await configService.getValidProjectConfig();
105
- const isAuthenticated = await checkAuth();
182
+ do {
183
+ status = await translationUtils.checkJobStatus(jobId, true);
106
184
 
107
- if (!isAuthenticated) {
108
- throw new Error('No API key found. Run `npx localhero login` or set LOCALHERO_API_KEY');
109
- }
110
-
111
- console.log(chalk.blue('ℹ️ Loading configuration from localhero.json'));
112
-
113
- const { translationFiles } = config;
114
- const fileLocaleRegex = DEFAULT_LOCALE_REGEX;
115
- let allFiles = [];
116
-
117
- for (const translationPath of translationFiles.paths) {
118
- const filesInPath = await findTranslationFiles(translationPath, fileLocaleRegex);
119
- allFiles = allFiles.concat(filesInPath);
120
- }
121
-
122
- if (!allFiles.length) {
123
- console.error(chalk.red('❌ No translation files found'));
124
- process.exit(1);
125
- }
185
+ if (status.status === 'failed') {
186
+ throw new Error(`Translation job failed: ${status.error_details || 'Unknown error'}`);
187
+ }
126
188
 
127
- const sourceFiles = allFiles.filter(f => f.locale === config.sourceLocale);
128
- const targetFiles = allFiles.filter(f => config.outputLocales.includes(f.locale));
189
+ if (status.status === 'pending' || status.status === 'processing') {
190
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
191
+ if (elapsed > MAX_WAIT_MINUTES * 60) {
192
+ throw new Error(`Translation timed out after ${MAX_WAIT_MINUTES} minutes`);
193
+ }
194
+
195
+ const waitSeconds = Math.min(2 ** retries, 30);
196
+ if (verbose) {
197
+ console.log(chalk.blue(` Job ${jobId} is ${status.status}, checking again in ${waitSeconds}s...`));
198
+ }
199
+ await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
200
+ retries = Math.min(retries + 1, 5);
201
+ return { jobId, status: 'pending' };
202
+ }
129
203
 
130
- if (!sourceFiles.length) {
131
- console.error(chalk.red(`❌ No source files found for locale ${config.sourceLocale}`));
132
- process.exit(1);
133
- }
204
+ if (status.status === 'completed' && status.translations?.data && status.language?.code) {
205
+ if (status.results_url && !resultsBaseUrl) {
206
+ resultsBaseUrl = status.results_url.split('?')[0];
207
+ }
134
208
 
135
- log(chalk.blue(`✓ Found ${allFiles.length} translation files`));
136
- log(chalk.blue(`ℹ️ Analyzing target translations...`));
137
-
138
- const missingByLocale = {};
139
- for (const sourceFile of sourceFiles) {
140
- const sourceContent = JSON.parse(Buffer.from(sourceFile.content, 'base64').toString());
141
-
142
- for (const targetLocale of config.outputLocales) {
143
- const targetFile = targetFiles.find(f => f.locale === targetLocale);
144
- const targetContent = targetFile ?
145
- JSON.parse(Buffer.from(targetFile.content, 'base64').toString()) :
146
- { keys: {} };
147
-
148
- const missing = findMissingTranslations(sourceContent.keys, targetContent.keys);
149
- const missingCount = Object.keys(missing).length;
150
-
151
- if (missingCount > 0) {
152
- if (!missingByLocale[targetLocale]) {
153
- missingByLocale[targetLocale] = {
154
- keys: {},
155
- path: sourceFile.path
156
- };
157
- }
158
- missingByLocale[targetLocale].keys = {
159
- ...missingByLocale[targetLocale].keys,
160
- ...missing
161
- };
162
- console.log(chalk.blue(` ${targetLocale} (${path.basename(sourceFile.path)}): ${missingCount} missing keys`));
209
+ const languageCode = status.language.code;
210
+ if (!processedLocales.has(languageCode)) {
211
+ const targetPath = missingByLocale[languageCode].targetPath;
212
+ if (verbose) {
213
+ console.log(chalk.blue(` Updating translations for ${languageCode} in ${targetPath}`));
163
214
  }
215
+ await translationUtils.updateTranslationFile(targetPath, status.translations.data, languageCode);
216
+ totalTranslated += missingByLocale[languageCode].keyCount;
217
+ totalLanguages++;
218
+ processedLocales.add(languageCode);
219
+ }
220
+ return { jobId, status: 'completed' };
164
221
  }
165
- }
166
222
 
167
- if (Object.keys(missingByLocale).length === 0) {
168
- console.log(chalk.green('✓ All translations are up to date!'));
169
- return;
170
- }
223
+ return { jobId, status: status.status };
224
+ } while (status.status === 'pending' || status.status === 'processing');
225
+ });
171
226
 
172
- const updatedFiles = new Set();
173
- const batches = batchKeysWithMissing(sourceFiles, missingByLocale);
174
- let totalKeysProcessed = 0;
175
- let totalErrors = 0;
176
- const processedKeys = new Set();
177
- let hasShownUpdateMessage = false;
178
-
179
- for (const [batchIndex, batch] of batches.entries()) {
180
- log(chalk.blue(`\nProcessing batch ${batchIndex + 1}/${batches.length}...`));
181
-
182
- try {
183
- const { jobs } = await retryWithBackoff(() =>
184
- createTranslationJob({
185
- sourceFiles: batch,
186
- targetLocales: config.outputLocales,
187
- projectId: config.projectId
188
- })
189
- );
190
- const jobStatuses = new Map();
191
- let allCompleted = false;
192
-
193
- while (!allCompleted) {
194
- allCompleted = true;
195
- let totalProgress = 0;
196
-
197
- for (const job of jobs) {
198
- const status = await retryWithBackoff(() => checkJobStatus(job.id, true));
199
- jobStatuses.set(job.id, status);
200
-
201
- if (status.status === 'processing') {
202
- allCompleted = false;
203
- }
204
- totalProgress += status.progress.percentage;
205
- }
206
-
207
- const averageProgress = Math.round(totalProgress / jobs.length);
208
- const bar = '='.repeat(averageProgress / 2) + ' '.repeat(50 - averageProgress / 2);
209
-
210
- process.stdout.write(`\r⧗ Translating... [${bar}] ${averageProgress}%`);
211
-
212
- await new Promise(resolve => setTimeout(resolve, 2000));
213
- }
227
+ const results = await Promise.all(jobPromises);
228
+ results.forEach(result => {
229
+ if (result.status === 'completed') {
230
+ pendingJobs.delete(result.jobId);
231
+ }
232
+ });
214
233
 
215
- for (const job of jobs) {
216
- const status = jobStatuses.get(job.id);
217
- if (status.status === 'completed' && status.translations) {
218
- if (!hasShownUpdateMessage) {
219
- console.log(chalk.blue('\nℹ️ Updating translation files...'));
220
- hasShownUpdateMessage = true;
221
- }
222
-
223
- const translations = status.translations.translations;
224
- const targetLocale = status.language.code;
225
- const uniqueKey = `${targetLocale}:${Object.keys(translations).sort().join(',')}`;
226
-
227
- if (processedKeys.has(uniqueKey)) {
228
- continue;
229
- }
230
- processedKeys.add(uniqueKey);
231
-
232
- const targetFile = targetFiles.find(f => f.locale === targetLocale)?.path ||
233
- path.join(config.translationPath, `${targetLocale}.yml`);
234
-
235
- try {
236
- const updatedKeys = await updateTranslationFile(
237
- targetFile,
238
- translations,
239
- targetLocale
240
- );
241
-
242
- updatedFiles.add(targetFile);
243
- totalKeysProcessed += updatedKeys.length;
244
-
245
- if (verbose) {
246
- console.log(chalk.blue(` Updated ${targetFile}`));
247
- updatedKeys.forEach(key => console.log(chalk.gray(` - Added: ${key}`)));
248
- } else {
249
- console.log(chalk.blue(` Updated ${path.basename(targetFile)}`));
250
- }
251
- } catch (error) {
252
- console.error(chalk.yellow(`⚠️ Failed to update ${targetFile}: ${error.message}`));
253
- totalErrors++;
254
- }
255
- }
256
- }
257
- } catch (error) {
258
- totalErrors++;
259
- console.error(chalk.red(`❌ Batch ${batchIndex + 1} failed: ${error.message}`));
260
- console.log(error);
261
-
262
- }
234
+ if (pendingJobs.size > 0) {
235
+ await new Promise(resolve => setTimeout(resolve, 2000));
263
236
  }
237
+ }
238
+ } catch (error) {
239
+ console.error(chalk.red(`\n✖ Error processing translation jobs: ${error.message}\n`));
240
+ process.exit(1);
241
+ }
242
+ }
264
243
 
265
- if (totalErrors > 0) {
266
- console.error(chalk.red(`\n❌ Translation completed with ${totalErrors} failed batches`));
267
- process.exit(1);
268
- }
244
+ await configUtils.updateLastSyncedAt();
269
245
 
270
- console.log(chalk.green('\n✓ Translations complete!') + ` Updated ${totalKeysProcessed} keys in ${config.outputLocales.length} languages`);
246
+ console.log(chalk.green('✓ Translations complete!'));
247
+ console.log(`Updated ${totalTranslated} keys in ${totalLanguages} languages`);
271
248
 
272
- if (commit || process.env.GITHUB_ACTIONS === 'true') {
273
- const translationPaths = Array.from(updatedFiles).join(' ');
274
- if (translationPaths) {
275
- autoCommitChanges(translationPaths);
276
- }
277
- }
249
+ // Auto-commit changes if we're running in GitHub Actions
250
+ if (totalTranslated > 0) {
251
+ try {
252
+ gitUtils.autoCommitChanges(config.translationFiles.paths.join(' '));
278
253
  } catch (error) {
279
- console.error(chalk.red(`❌ ${error.message}`));
280
- process.exit(1);
254
+ console.warn(chalk.yellow(`\nℹ Could not auto-commit changes: ${error.message}`));
281
255
  }
282
- }
256
+ }
257
+
258
+ if (resultsBaseUrl && allJobIds.length > 0) {
259
+ const jobIdsParam = allJobIds.join(',');
260
+ console.log(`View job results at: ${resultsBaseUrl}?job_ids=${jobIdsParam}`);
261
+ }
262
+ }