@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.
- package/README +98 -0
- package/package.json +58 -58
- package/src/api/auth.js +20 -11
- package/src/api/client.js +71 -30
- package/src/api/imports.js +15 -13
- package/src/api/projects.js +17 -17
- package/src/api/translations.js +50 -29
- package/src/cli.js +49 -42
- package/src/commands/init.js +436 -236
- package/src/commands/login.js +59 -48
- package/src/commands/sync.js +28 -0
- package/src/commands/translate.js +227 -247
- package/src/utils/auth.js +15 -15
- package/src/utils/config.js +115 -86
- package/src/utils/files.js +338 -116
- package/src/utils/git.js +64 -8
- package/src/utils/github.js +80 -23
- package/src/utils/import-service.js +112 -129
- package/src/utils/prompt-service.js +66 -50
- package/src/utils/sync-service.js +147 -0
- package/src/utils/translation-updater/common.js +44 -0
- package/src/utils/translation-updater/index.js +36 -0
- package/src/utils/translation-updater/json-handler.js +112 -0
- package/src/utils/translation-updater/yaml-handler.js +181 -0
- package/src/utils/translation-utils.js +237 -0
- package/src/utils/defaults.js +0 -7
- package/src/utils/helpers.js +0 -3
- package/src/utils/project-service.js +0 -11
- package/src/utils/translation-updater.js +0 -154
package/src/commands/login.js
CHANGED
|
@@ -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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
20
|
+
const existingConfig = await configUtils.getAuthConfig(basePath);
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
+
const result = await verifyApiKey(apiKey);
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
const config = {
|
|
53
|
+
api_key: apiKey,
|
|
54
|
+
last_verified: new Date().toISOString()
|
|
55
|
+
};
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
await configUtils.saveAuthConfig(config, basePath);
|
|
58
|
+
const gitignoreUpdated = await gitUtils.updateGitignore(basePath);
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
+
const projectConfig = await configUtils.getProjectConfig(basePath);
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
for (const
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
80
|
-
entry.keys = { ...entry.keys, ...localeData.keys };
|
|
81
|
-
entry.locales.add(locale);
|
|
82
|
-
}
|
|
167
|
+
allJobIds.push(...batchJobIds);
|
|
83
168
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
177
|
+
let status;
|
|
178
|
+
let retries = 0;
|
|
179
|
+
const MAX_WAIT_MINUTES = 10;
|
|
180
|
+
const startTime = Date.now();
|
|
102
181
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const isAuthenticated = await checkAuth();
|
|
182
|
+
do {
|
|
183
|
+
status = await translationUtils.checkJobStatus(jobId, true);
|
|
106
184
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
223
|
+
return { jobId, status: status.status };
|
|
224
|
+
} while (status.status === 'pending' || status.status === 'processing');
|
|
225
|
+
});
|
|
171
226
|
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
266
|
-
console.error(chalk.red(`\n❌ Translation completed with ${totalErrors} failed batches`));
|
|
267
|
-
process.exit(1);
|
|
268
|
-
}
|
|
244
|
+
await configUtils.updateLastSyncedAt();
|
|
269
245
|
|
|
270
|
-
|
|
246
|
+
console.log(chalk.green('✓ Translations complete!'));
|
|
247
|
+
console.log(`Updated ${totalTranslated} keys in ${totalLanguages} languages`);
|
|
271
248
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
+
}
|