kythia-core 0.10.1-beta → 0.11.0-beta

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,367 @@
1
+ /**
2
+ * šŸ•µļøā€ā™‚ļø Translation Integrity Linter
3
+ *
4
+ * @file src/cli/commands/LangCheckCommand.js
5
+ * @copyright Ā© 2025 kenndeclouv
6
+ * @assistant chaa & graa
7
+ * @version 0.11.0-beta
8
+ *
9
+ * @description
10
+ * Performs a deep AST analysis of the codebase to find `t()` translation function calls.
11
+ * Verifies that every used key exists in the language files (JSON) and reports usage errors.
12
+ *
13
+ * ✨ Core Features:
14
+ * - AST Parsing: Uses Babel parser for accurate key detection (handles dynamic patterns).
15
+ * - Key Verification: Recursively checks nested JSON structures.
16
+ * - Unused Key Detection: Reports keys defined in JSON but never used in code.
17
+ */
18
+
19
+ const Command = require('../Command');
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const glob = require('glob');
23
+ const parser = require('@babel/parser');
24
+ const traverse = require('@babel/traverse').default;
25
+
26
+ function getAllKeys(obj, prefix = '') {
27
+ Object.keys(obj).forEach((key) => {
28
+ if (key === '_value' || key === 'text') {
29
+ if (Object.keys(obj).length === 1) return;
30
+ if (prefix) allDefinedKeys.add(prefix);
31
+ return;
32
+ }
33
+ const fullKey = prefix ? `${prefix}.${key}` : key;
34
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
35
+ if (key !== 'jobs' && key !== 'shop') {
36
+ getAllKeys(obj[key], fullKey);
37
+ } else {
38
+ allDefinedKeys.add(fullKey);
39
+ }
40
+ } else {
41
+ allDefinedKeys.add(fullKey);
42
+ }
43
+ });
44
+ }
45
+
46
+ class LangCheckCommand extends Command {
47
+ signature = 'lang:check';
48
+ description =
49
+ 'Lint translation key usage in code and language files (AST-based)';
50
+
51
+ async handle() {
52
+ const PROJECT_ROOT = path.join(__dirname, '..', '..', '..');
53
+ const SCAN_DIRECTORIES = ['addons', 'src'];
54
+ const LANG_DIR = path.join(PROJECT_ROOT, 'src', 'lang');
55
+ const DEFAULT_LANG = 'en';
56
+ const IGNORE_PATTERNS = [
57
+ '**/node_modules/**',
58
+ '**/dist/**',
59
+ '**/tests/**',
60
+ '**/assets/**',
61
+ '**/dashboard/web/public/**',
62
+ '**/temp/**',
63
+ '**/leetMap.js',
64
+ '**/generate_*.js',
65
+ '**/refactor_*.js',
66
+ '**/undo_*.js',
67
+ '**/*.d.ts',
68
+ ];
69
+
70
+ const locales = {};
71
+ const usedStaticKeys = new Set();
72
+ const usedDynamicKeys = new Set();
73
+ const unanalyzableKeys = new Set();
74
+ let filesScanned = 0;
75
+ let filesWithErrors = 0;
76
+
77
+ console.log('--- Kythia AST Translation Linter ---');
78
+
79
+ function hasNestedKey(obj, pathExpr) {
80
+ if (!obj || !pathExpr) return false;
81
+ const parts = pathExpr.split('.');
82
+ let current = obj;
83
+ for (const part of parts) {
84
+ if (
85
+ typeof current !== 'object' ||
86
+ current === null ||
87
+ !Object.hasOwn(current, part)
88
+ ) {
89
+ return false;
90
+ }
91
+ current = current[part];
92
+ }
93
+ return true;
94
+ }
95
+
96
+ function _loadLocales() {
97
+ console.log(`\nšŸ” Reading language files from: ${LANG_DIR}`);
98
+ try {
99
+ const langFiles = fs
100
+ .readdirSync(LANG_DIR)
101
+ .filter(
102
+ (file) =>
103
+ file.endsWith('.json') &&
104
+ !file.includes('_flat') &&
105
+ !file.includes('_FLAT'),
106
+ );
107
+ if (langFiles.length === 0) {
108
+ console.error(
109
+ '\x1b[31m%s\x1b[0m',
110
+ 'āŒ No .json files found in the language folder.',
111
+ );
112
+ return false;
113
+ }
114
+ for (const file of langFiles) {
115
+ const lang = file.replace('.json', '');
116
+ const content = fs.readFileSync(path.join(LANG_DIR, file), 'utf8');
117
+ try {
118
+ locales[lang] = JSON.parse(content);
119
+ console.log(` > Successfully loaded: ${file}`);
120
+ } catch (jsonError) {
121
+ console.error(
122
+ `\x1b[31m%s\x1b[0m`,
123
+ `āŒ Failed to parse JSON: ${file} - ${jsonError.message}`,
124
+ );
125
+ filesWithErrors++;
126
+ }
127
+ }
128
+ if (!locales[DEFAULT_LANG]) {
129
+ console.error(
130
+ `\x1b[31m%s\x1b[0m`,
131
+ `āŒ Default language (${DEFAULT_LANG}) not found!`,
132
+ );
133
+ return false;
134
+ }
135
+ return true;
136
+ } catch (error) {
137
+ console.error(
138
+ '\x1b[31m%s\x1b[0m',
139
+ `āŒ Failed to load language files: ${error.message}`,
140
+ );
141
+ return false;
142
+ }
143
+ }
144
+
145
+ if (!_loadLocales()) {
146
+ console.error('\x1b[31mCannot proceed (language files invalid).\x1b[0m');
147
+ process.exit(1);
148
+ }
149
+
150
+ console.log(
151
+ `\nScanning .js/.ts files in: ${SCAN_DIRECTORIES.join(', ')}...`,
152
+ );
153
+ SCAN_DIRECTORIES.forEach((dirName) => {
154
+ const dirPath = path.join(PROJECT_ROOT, dirName);
155
+ const files = glob.sync(`${dirPath}/**/*.{js,ts}`, {
156
+ ignore: IGNORE_PATTERNS,
157
+ dot: true,
158
+ });
159
+ files.forEach((filePath) => {
160
+ filesScanned++;
161
+ process.stdout.write(`\rScanning: ${filesScanned} files...`);
162
+
163
+ try {
164
+ const code = fs.readFileSync(filePath, 'utf8');
165
+ const ast = parser.parse(code, {
166
+ sourceType: 'module',
167
+ plugins: [
168
+ 'typescript',
169
+ 'jsx',
170
+ 'classProperties',
171
+ 'objectRestSpread',
172
+ ],
173
+ errorRecovery: true,
174
+ });
175
+
176
+ traverse(ast, {
177
+ CallExpression(nodePath) {
178
+ const node = nodePath.node;
179
+ if (
180
+ node.callee.type === 'Identifier' &&
181
+ node.callee.name === 't'
182
+ ) {
183
+ if (node.arguments.length >= 2) {
184
+ const keyArg = node.arguments[1];
185
+ if (keyArg.type === 'StringLiteral') {
186
+ usedStaticKeys.add(keyArg.value);
187
+ } else if (keyArg.type === 'TemplateLiteral') {
188
+ let pattern = '';
189
+ keyArg.quasis.forEach((quasi, _i) => {
190
+ pattern += quasi.value.raw;
191
+ if (!quasi.tail) {
192
+ pattern += '*';
193
+ }
194
+ });
195
+ pattern = pattern.replace(/_/g, '.');
196
+ usedDynamicKeys.add(pattern);
197
+ } else if (
198
+ keyArg.type === 'BinaryExpression' &&
199
+ keyArg.operator === '+'
200
+ ) {
201
+ if (keyArg.left.type === 'StringLiteral') {
202
+ const pattern = `${keyArg.left.value.replace(/_/g, '.')}*`;
203
+ usedDynamicKeys.add(pattern);
204
+ } else {
205
+ unanalyzableKeys.add(
206
+ `Complex (+) at ${path.relative(PROJECT_ROOT, filePath)}:${node.loc?.start.line}`,
207
+ );
208
+ }
209
+ } else {
210
+ unanalyzableKeys.add(
211
+ `Variable/Other at ${path.relative(PROJECT_ROOT, filePath)}:${node.loc?.start.line}`,
212
+ );
213
+ }
214
+ }
215
+ }
216
+ },
217
+ });
218
+ } catch (parseError) {
219
+ if (parseError.message.includes('Unexpected token')) {
220
+ console.warn(
221
+ `\n\x1b[33m[WARN] Syntax Error parsing ${path.relative(
222
+ PROJECT_ROOT,
223
+ filePath,
224
+ )}:${parseError.loc?.line} - ${parseError.message}\x1b[0m`,
225
+ );
226
+ } else {
227
+ console.error(
228
+ `\n\x1b[31m[ERROR] Failed to parse ${path.relative(
229
+ PROJECT_ROOT,
230
+ filePath,
231
+ )}: ${parseError.message}\x1b[0m`,
232
+ );
233
+ }
234
+ filesWithErrors++;
235
+ }
236
+ });
237
+ });
238
+ process.stdout.write(`${'\r'.padEnd(process.stdout.columns || 60)}\r`);
239
+
240
+ console.log(`\nScan completed. Total ${filesScanned} files processed.`);
241
+ console.log(` > Found \x1b[33m${usedStaticKeys.size}\x1b[0m static keys.`);
242
+ console.log(
243
+ ` > Found \x1b[33m${usedDynamicKeys.size}\x1b[0m dynamic key patterns (check manually!).`,
244
+ );
245
+
246
+ if (unanalyzableKeys.size > 0) {
247
+ console.log(
248
+ ` > \x1b[31m${unanalyzableKeys.size}\x1b[0m t() calls could not be analyzed (variable/complex).`,
249
+ );
250
+ }
251
+
252
+ console.log('\nVerifying static keys against language files...');
253
+
254
+ let totalMissingStatic = 0;
255
+ for (const lang in locales) {
256
+ const missingInLang = [];
257
+ for (const staticKey of usedStaticKeys) {
258
+ if (!hasNestedKey(locales[lang], staticKey)) {
259
+ missingInLang.push(staticKey);
260
+ }
261
+ }
262
+ if (missingInLang.length > 0) {
263
+ console.log(
264
+ `\nāŒ \x1b[31m[${lang.toUpperCase()}] Found ${missingInLang.length} missing static keys:\x1b[0m`,
265
+ );
266
+ missingInLang.sort().forEach((key) => {
267
+ console.log(` - ${key}`);
268
+ });
269
+ totalMissingStatic += missingInLang.length;
270
+ filesWithErrors++;
271
+ } else {
272
+ console.log(
273
+ `\nāœ… \x1b[32m[${lang.toUpperCase()}] All static keys found!\x1b[0m`,
274
+ );
275
+ }
276
+ }
277
+
278
+ if (usedDynamicKeys.size > 0) {
279
+ console.log(
280
+ `\n\nāš ļø \x1b[33mDynamic Key Patterns Detected (Check Manually):\x1b[0m`,
281
+ );
282
+ [...usedDynamicKeys].sort().forEach((pattern) => {
283
+ console.log(` - ${pattern}`);
284
+ });
285
+ console.log(
286
+ ` (Ensure all possible keys from these patterns exist in the language files)`,
287
+ );
288
+ }
289
+
290
+ if (unanalyzableKeys.size > 0) {
291
+ console.log(
292
+ `\n\nāš ļø \x1b[31mComplex/Unanalyzable t() Calls (Check Manually):\x1b[0m`,
293
+ );
294
+ [...unanalyzableKeys].sort().forEach((loc) => {
295
+ console.log(` - ${loc}`);
296
+ });
297
+ }
298
+
299
+ console.log(`\nChecking UNUSED keys (based on ${DEFAULT_LANG}.json)...`);
300
+
301
+ const defaultLocale = locales[DEFAULT_LANG];
302
+ const allDefinedKeys = new Set();
303
+
304
+ if (defaultLocale) {
305
+ try {
306
+ getAllKeys(defaultLocale);
307
+ } catch (e) {
308
+ console.error('Error collecting defined keys:', e);
309
+ }
310
+
311
+ const unusedKeys = [];
312
+ for (const definedKey of allDefinedKeys) {
313
+ if (!usedStaticKeys.has(definedKey)) {
314
+ let matchedByDynamic = false;
315
+ for (const dynamicPattern of usedDynamicKeys) {
316
+ const regexPattern = `^${dynamicPattern
317
+ .replace(/\./g, '\\.')
318
+ .replace(/\*/g, '[^.]+?')}$`;
319
+ if (new RegExp(regexPattern).test(definedKey)) {
320
+ matchedByDynamic = true;
321
+ break;
322
+ }
323
+ }
324
+ if (!matchedByDynamic) {
325
+ unusedKeys.push(definedKey);
326
+ }
327
+ }
328
+ }
329
+ if (unusedKeys.length > 0) {
330
+ console.log(
331
+ `\nāš ļø \x1b[33mFound ${unusedKeys.length} UNUSED keys in ${DEFAULT_LANG}.json (don't match static/dynamic patterns):\x1b[0m`,
332
+ );
333
+ unusedKeys.sort().forEach((key) => {
334
+ console.log(` - ${key}`);
335
+ });
336
+ } else {
337
+ console.log(
338
+ `\nāœ… \x1b[32m[${DEFAULT_LANG.toUpperCase()}] No unused keys found.\x1b[0m`,
339
+ );
340
+ }
341
+ } else {
342
+ console.warn(
343
+ `\n\x1b[33m[WARN] Cannot check unused keys because ${DEFAULT_LANG}.json failed to load.\x1b[0m`,
344
+ );
345
+ }
346
+
347
+ console.log('\n--- Done ---');
348
+
349
+ if (filesWithErrors > 0 || totalMissingStatic > 0) {
350
+ console.log(
351
+ `\x1b[31mTotal ${totalMissingStatic} missing static key errors + ${filesWithErrors - totalMissingStatic} file errors found. Please fix them.\x1b[0m`,
352
+ );
353
+ process.exit(1);
354
+ } else {
355
+ console.log(
356
+ '\x1b[32mCongratulations! Language files (for static keys) are already synced with the code.\x1b[0m',
357
+ );
358
+ if (usedDynamicKeys.size > 0 || unanalyzableKeys.size > 0) {
359
+ console.log(
360
+ "\x1b[33mHowever, don't forget to manually check the reported dynamic/complex keys above!\x1b[0m",
361
+ );
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ module.exports = LangCheckCommand;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * 🌐 AI Localization Assistant
3
+ *
4
+ * @file src/cli/commands/LangTranslateCommand.js
5
+ * @copyright Ā© 2025 kenndeclouv
6
+ * @assistant chaa & graa
7
+ * @version 0.11.0-beta
8
+ *
9
+ * @description
10
+ * Automates the translation of the core language file (`en.json`) to a target language
11
+ * using Google's Gemini AI. Handles JSON flattening, batch processing, and placeholder preservation.
12
+ *
13
+ * ✨ Core Features:
14
+ * - AI-Powered: Uses Gemini 2.5 Flash for natural translations.
15
+ * - Smart Batching: Splits large files to avoid token limits.
16
+ * - Context Aware: Preserves keys and complex placeholders.
17
+ */
18
+
19
+ const Command = require('../Command');
20
+ const pc = require('picocolors');
21
+
22
+ class LangTranslateCommand extends Command {
23
+ signature = 'lang:translate <target>';
24
+ description = 'Translate en.json to target language using Gemini AI';
25
+
26
+ async handle(_options, target) {
27
+ require('@dotenvx/dotenvx/config');
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const { GoogleGenAI } = require('@google/genai');
31
+
32
+ const API_KEYS = (process.env.GEMINI_API_KEYS || '')
33
+ .split(',')
34
+ .filter(Boolean);
35
+
36
+ const targetLang = target || 'ja';
37
+
38
+ const TARGET_LANGUAGE =
39
+ targetLang === 'ja' ? 'Japan (ja)' : `${targetLang}`;
40
+
41
+ const rootDir = process.cwd();
42
+ const INPUT_FILE_SAFE = path.join(
43
+ rootDir,
44
+ 'addons',
45
+ 'core',
46
+ 'lang',
47
+ 'en.json',
48
+ );
49
+ const OUTPUT_FILE_SAFE = path.join(
50
+ rootDir,
51
+ 'addons',
52
+ 'core',
53
+ 'lang',
54
+ `${targetLang}.json`,
55
+ );
56
+
57
+ const BATCH_SIZE = 80;
58
+ const GEMINI_MODEL = 'gemini-2.5-flash';
59
+ const DELAY_BETWEEN_BATCHES_MS = 5000;
60
+ const DELAY_ON_ERROR_MS = 5000;
61
+
62
+ if (API_KEYS.length === 0) {
63
+ console.error(pc.red('āŒ FATAL: GEMINI_API_KEYS not found in .env!'));
64
+ process.exit(1);
65
+ }
66
+
67
+ let keyIndex = 0;
68
+ function getNextGenAI(nextIndex = null) {
69
+ if (typeof nextIndex === 'number') {
70
+ keyIndex = nextIndex % API_KEYS.length;
71
+ }
72
+ const apiKey = API_KEYS[keyIndex];
73
+ keyIndex = (keyIndex + 1) % API_KEYS.length;
74
+ console.log(
75
+ `[Key Rotator] Using API Key #${keyIndex === 0 ? API_KEYS.length : keyIndex}`,
76
+ );
77
+ return new GoogleGenAI({ apiKey });
78
+ }
79
+
80
+ function flattenObject(obj, parentKey = '', result = {}) {
81
+ for (const key in obj) {
82
+ const newKey = parentKey ? `${parentKey}.${key}` : key;
83
+ if (
84
+ typeof obj[key] === 'object' &&
85
+ obj[key] !== null &&
86
+ !Array.isArray(obj[key])
87
+ ) {
88
+ flattenObject(obj[key], newKey, result);
89
+ } else {
90
+ result[newKey] = obj[key];
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+
96
+ function unflattenObject(obj) {
97
+ const result = {};
98
+ for (const key in obj) {
99
+ const keys = key.split('.');
100
+ keys.reduce((acc, cur, i) => {
101
+ if (i === keys.length - 1) {
102
+ acc[cur] = obj[key];
103
+ } else {
104
+ acc[cur] = acc[cur] || {};
105
+ }
106
+ return acc[cur];
107
+ }, result);
108
+ }
109
+ return result;
110
+ }
111
+
112
+ async function translateBatch(batch) {
113
+ const placeholderMap = new Map();
114
+ let placeholderCounter = 0;
115
+
116
+ const processedBatch = JSON.parse(
117
+ JSON.stringify(batch),
118
+ (_key, value) => {
119
+ if (typeof value !== 'string') return value;
120
+ return value.replace(/{([^{}]*)}/g, (match) => {
121
+ if (!placeholderMap.has(match)) {
122
+ placeholderMap.set(`__P_${placeholderCounter}__`, match);
123
+ placeholderCounter++;
124
+ }
125
+ for (const [token, ph] of placeholderMap.entries()) {
126
+ if (ph === match) return token;
127
+ }
128
+ return match;
129
+ });
130
+ },
131
+ );
132
+
133
+ const prompt = `
134
+ You are a professional localization expert. Translate the JSON values from english to ${TARGET_LANGUAGE}.
135
+
136
+ - **Target Locale:** ${targetLang} (choose naturally)
137
+ - **DO NOT** translate the JSON keys.
138
+ - **DO NOT** translate any placeholder tokens that look like \`__P_N__\`. Keep them exactly as they are.
139
+ - **KEEP** all original markdown (\`##\`, \`*\`, \`\\\`\`, \`\n\`).
140
+ - Respond ONLY with the translated JSON object, in a VALID JSON format.
141
+
142
+ Input:
143
+ ${JSON.stringify(processedBatch, null, 2)}
144
+
145
+ Output:
146
+ `;
147
+
148
+ let attempt = 1;
149
+ let usedKeyIndex = keyIndex;
150
+
151
+ while (true) {
152
+ let genAI = getNextGenAI();
153
+ const GEMINI_API_CLIENT = genAI;
154
+ try {
155
+ console.log(`[Batch] Attempt #${attempt}...`);
156
+
157
+ const response = await GEMINI_API_CLIENT.models.generateContent({
158
+ model: GEMINI_MODEL,
159
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
160
+ });
161
+
162
+ let text;
163
+ if (response && typeof response.text === 'function') {
164
+ text = response.text();
165
+ } else if (response && typeof response.text === 'string') {
166
+ text = response.text;
167
+ }
168
+ text = typeof text === 'string' ? text.trim() : '';
169
+
170
+ if (text.startsWith('```json')) {
171
+ text = text.substring(7, text.length - 3).trim();
172
+ } else if (text.startsWith('```')) {
173
+ text = text
174
+ .replace(/^```[a-z]*\n?/, '')
175
+ .replace(/```$/, '')
176
+ .trim();
177
+ }
178
+
179
+ let translatedBatch = JSON.parse(text);
180
+ translatedBatch = JSON.parse(
181
+ JSON.stringify(translatedBatch),
182
+ (_key, value) => {
183
+ if (typeof value !== 'string') return value;
184
+ return value.replace(
185
+ /__P_(\d+)__/g,
186
+ (match) => placeholderMap.get(match) || match,
187
+ );
188
+ },
189
+ );
190
+
191
+ return translatedBatch;
192
+ } catch (e) {
193
+ const errorMessage = e.message || '';
194
+ console.error(
195
+ pc.red(`āŒ Error in batch (Attempt ${attempt})...`),
196
+ errorMessage,
197
+ );
198
+
199
+ if (
200
+ errorMessage.includes('429') ||
201
+ errorMessage.includes('RESOURCE_EXHAUSTED')
202
+ ) {
203
+ usedKeyIndex = (usedKeyIndex + 1) % API_KEYS.length;
204
+ console.warn(
205
+ pc.yellow(
206
+ `[RATE LIMIT] Got 429! Rotating to next API key [#${usedKeyIndex + 1}] and retrying.`,
207
+ ),
208
+ );
209
+ genAI = getNextGenAI(usedKeyIndex);
210
+ } else {
211
+ console.warn(
212
+ pc.yellow(
213
+ `[OTHER ERROR] Waiting ${DELAY_ON_ERROR_MS / 1000} seconds...`,
214
+ ),
215
+ );
216
+ await new Promise((resolve) =>
217
+ setTimeout(resolve, DELAY_ON_ERROR_MS),
218
+ );
219
+ }
220
+ attempt++;
221
+ }
222
+ }
223
+ }
224
+
225
+ console.log(
226
+ pc.cyan(`šŸš€ Starting translation process (to ${targetLang})...`),
227
+ );
228
+ console.log(pc.dim(` Input: ${INPUT_FILE_SAFE}`));
229
+ console.log(pc.dim(` Output: ${OUTPUT_FILE_SAFE}`));
230
+
231
+ if (!fs.existsSync(INPUT_FILE_SAFE)) {
232
+ console.error(pc.red(`āŒ Input file not found: ${INPUT_FILE_SAFE}`));
233
+ process.exit(1);
234
+ }
235
+
236
+ const idJsonString = fs.readFileSync(INPUT_FILE_SAFE, 'utf8');
237
+ const idJson = JSON.parse(idJsonString);
238
+
239
+ console.log(pc.dim('Flattening JSON...'));
240
+ const flatIdJson = flattenObject(idJson);
241
+ const flatLangJson = {};
242
+ const allKeys = Object.keys(flatIdJson);
243
+
244
+ let existingLangJson = {};
245
+ if (fs.existsSync(OUTPUT_FILE_SAFE)) {
246
+ console.log(
247
+ pc.yellow(`[INFO] File ${OUTPUT_FILE_SAFE} exists, continuing work...`),
248
+ );
249
+ try {
250
+ existingLangJson = flattenObject(
251
+ JSON.parse(fs.readFileSync(OUTPUT_FILE_SAFE, 'utf8')),
252
+ );
253
+ } catch (_e) {
254
+ console.warn(
255
+ pc.yellow(
256
+ `[WARN] File ${OUTPUT_FILE_SAFE} is corrupted, will be overwritten.`,
257
+ ),
258
+ );
259
+ }
260
+ }
261
+
262
+ const keysToTranslate = allKeys.filter(
263
+ (key) =>
264
+ typeof flatIdJson[key] === 'string' &&
265
+ (!existingLangJson[key] || existingLangJson[key] === flatIdJson[key]),
266
+ );
267
+
268
+ allKeys.forEach((key) => {
269
+ if (!keysToTranslate.includes(key)) {
270
+ flatLangJson[key] = existingLangJson[key] || flatIdJson[key];
271
+ }
272
+ });
273
+
274
+ const totalBatches = Math.ceil(keysToTranslate.length / BATCH_SIZE);
275
+
276
+ console.log(pc.green(`āœ… Total of ${allKeys.length} keys.`));
277
+ console.log(
278
+ pc.green(
279
+ `āœ… Found ${keysToTranslate.length} keys that need translation.`,
280
+ ),
281
+ );
282
+ console.log(
283
+ pc.green(
284
+ `āœ… Divided into ${totalBatches} batches (up to ${BATCH_SIZE} keys per batch).`,
285
+ ),
286
+ );
287
+
288
+ for (let i = 0; i < totalBatches; i++) {
289
+ console.log(
290
+ pc.cyan(`--- šŸƒ Working on Batch ${i + 1} / ${totalBatches} ---`),
291
+ );
292
+ const batchKeys = keysToTranslate.slice(
293
+ i * BATCH_SIZE,
294
+ (i + 1) * BATCH_SIZE,
295
+ );
296
+ const batchToTranslate = {};
297
+ batchKeys.forEach((key) => {
298
+ batchToTranslate[key] = flatIdJson[key];
299
+ });
300
+
301
+ if (Object.keys(batchToTranslate).length > 0) {
302
+ const translatedBatch = await translateBatch(batchToTranslate);
303
+ if (translatedBatch) {
304
+ Object.assign(flatLangJson, translatedBatch);
305
+ } else {
306
+ Object.assign(flatLangJson, batchToTranslate);
307
+ }
308
+ }
309
+
310
+ if (i < totalBatches - 1) {
311
+ console.log(
312
+ pc.yellow(
313
+ `--- 😓 Waiting ${DELAY_BETWEEN_BATCHES_MS / 1000} seconds between batches ---`,
314
+ ),
315
+ );
316
+ await new Promise((resolve) =>
317
+ setTimeout(resolve, DELAY_BETWEEN_BATCHES_MS),
318
+ );
319
+ }
320
+ }
321
+
322
+ console.log(pc.dim('Unflattening JSON...'));
323
+ const langJson = unflattenObject(flatLangJson);
324
+
325
+ console.log(pc.green(`Saving to file: ${OUTPUT_FILE_SAFE}`));
326
+ fs.writeFileSync(
327
+ OUTPUT_FILE_SAFE,
328
+ JSON.stringify(langJson, null, 2),
329
+ 'utf8',
330
+ );
331
+
332
+ console.log(pc.green('šŸŽ‰ Done! Translation file generated successfully.'));
333
+ }
334
+ }
335
+
336
+ module.exports = LangTranslateCommand;