i18ntk 4.2.2 → 4.3.1

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,379 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * i18ntk Python Command
5
+ * Specialized command for Python i18n management
6
+ *
7
+ * Usage: i18ntk-py [options]
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const SecurityUtils = require('../utils/security');
14
+ const { getConfig, saveConfig } = require('../utils/config-helper');
15
+ const I18nHelper = require('../utils/i18n-helper');
16
+ const SetupEnforcer = require('../utils/setup-enforcer');
17
+ const { program } = require('../utils/mini-commander');
18
+
19
+ (async () => {
20
+ try {
21
+ await SetupEnforcer.checkSetupCompleteAsync();
22
+ } catch (error) {
23
+ console.error('Setup check failed:', error.message);
24
+ process.exit(1);
25
+ }
26
+ })();
27
+
28
+ class I18ntkPythonCommand {
29
+ constructor() {
30
+ this.config = null;
31
+ this.sourceDir = './locales';
32
+ this.pythonPatterns = [
33
+ /_(?:gettext)?\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
34
+ /gettext\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
35
+ /gettext_lazy\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
36
+ /ngettext\s*\(\s*["'`]([^"'`]+)["'`]\s*,/g,
37
+ /lazy_gettext\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
38
+ /ugettext\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g
39
+ ];
40
+ }
41
+
42
+ async init() {
43
+ console.log('šŸ”§ Initializing i18ntk Python command...');
44
+
45
+ program
46
+ .name('i18ntk-py')
47
+ .description('i18ntk specialized for Python applications')
48
+ .version('1.10.1')
49
+ .option('-s, --source-dir <dir>', 'Source directory to scan', './')
50
+ .option('-l, --locales-dir <dir>', 'Locales directory', './locales')
51
+ .option('--framework <type>', 'Python framework type', 'auto')
52
+ .option('--dry-run', 'Show what would be done without making changes')
53
+ .option('--debug', 'Enable debug output')
54
+ .option('--extract-only', 'Only extract translations, don\'t analyze')
55
+ .option('--django', 'Force Django mode')
56
+ .option('--flask', 'Force Flask mode')
57
+ .option('--generic', 'Force generic Python mode')
58
+ .parse();
59
+
60
+ this.options = program.opts();
61
+ this.sourceDir = path.resolve(this.options.sourceDir);
62
+ this.localesDir = path.resolve(this.options.localesDir);
63
+
64
+ await this.validateSourceDir();
65
+ await this.loadConfig();
66
+ }
67
+
68
+ async validateSourceDir() {
69
+ if (!SecurityUtils.safeExistsSync(this.sourceDir, path.dirname(this.sourceDir))) {
70
+ console.error(`āŒ Source directory not found: ${this.sourceDir}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ const stats = fs.statSync(this.sourceDir);
75
+ if (!stats.isDirectory()) {
76
+ console.error(`āŒ Source path is not a directory: ${this.sourceDir}`);
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ async loadConfig() {
82
+ try {
83
+ this.config = await getConfig();
84
+ this.config.python = this.config.python || {};
85
+ } catch (error) {
86
+ console.warn('āš ļø Could not load config, using defaults');
87
+ this.config = { python: {} };
88
+ }
89
+ }
90
+
91
+ async detectFramework() {
92
+ if (this.options.django) return 'django';
93
+ if (this.options.flask) return 'flask';
94
+ if (this.options.generic) return 'generic';
95
+
96
+ console.log('šŸ” Detecting Python framework...');
97
+
98
+ // Check for Django
99
+ const djangoIndicators = [
100
+ 'manage.py',
101
+ 'settings.py',
102
+ 'requirements.txt',
103
+ 'django'
104
+ ];
105
+
106
+ // Check for Flask
107
+ const flaskIndicators = [
108
+ 'app.py',
109
+ 'requirements.txt',
110
+ 'flask'
111
+ ];
112
+
113
+ let framework = 'generic';
114
+
115
+ try {
116
+ // Check requirements.txt
117
+ const requirementsPath = path.join(this.sourceDir, 'requirements.txt');
118
+ if (SecurityUtils.safeExistsSync(requirementsPath, this.sourceDir)) {
119
+ const requirements = SecurityUtils.safeReadFileSync(requirementsPath, this.sourceDir, 'utf8');
120
+ if (requirements.includes('Django')) framework = 'django';
121
+ else if (requirements.includes('Flask')) framework = 'flask';
122
+ }
123
+
124
+ // Check for Django files
125
+ for (const indicator of djangoIndicators) {
126
+ if (this.findFiles(indicator).length > 0) {
127
+ framework = 'django';
128
+ break;
129
+ }
130
+ }
131
+
132
+ // Check for Flask files
133
+ for (const indicator of flaskIndicators) {
134
+ if (this.findFiles(indicator).length > 0) {
135
+ framework = 'flask';
136
+ break;
137
+ }
138
+ }
139
+
140
+ } catch (error) {
141
+ if (this.options.debug) {
142
+ console.error('Debug: Framework detection error:', error.message);
143
+ }
144
+ }
145
+
146
+ console.log(`āœ… Detected framework: ${framework}`);
147
+ return framework;
148
+ }
149
+
150
+ findFiles(pattern) {
151
+ const results = [];
152
+
153
+ function scanDir(dir) {
154
+ if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
155
+
156
+ const items = fs.readdirSync(dir);
157
+ for (const item of items) {
158
+ const fullPath = path.join(dir, item);
159
+ const stat = fs.statSync(fullPath);
160
+
161
+ if (stat.isDirectory() && !item.startsWith('.') && item !== 'node_modules') {
162
+ scanDir(fullPath);
163
+ } else if (item.includes(pattern) || fullPath.includes(pattern)) {
164
+ results.push(fullPath);
165
+ }
166
+ }
167
+ }
168
+
169
+ scanDir(this.sourceDir);
170
+ return results;
171
+ }
172
+
173
+ async extractTranslations() {
174
+ console.log('šŸ“¦ Extracting Python translations...');
175
+
176
+ const pythonFiles = this.findFiles('.py');
177
+ const translations = new Set();
178
+
179
+ for (const file of pythonFiles) {
180
+ try {
181
+ const content = SecurityUtils.safeReadFileSync(file, path.dirname(file), 'utf8');
182
+
183
+ for (const pattern of this.pythonPatterns) {
184
+ let match;
185
+ while ((match = pattern.exec(content)) !== null) {
186
+ translations.add(match[1]);
187
+ }
188
+ }
189
+
190
+ // Reset regex state for next file
191
+ for (const pattern of this.pythonPatterns) {
192
+ pattern.lastIndex = 0;
193
+ }
194
+
195
+ } catch (error) {
196
+ if (this.options.debug) {
197
+ console.error(`Error reading ${file}:`, error.message);
198
+ }
199
+ }
200
+ }
201
+
202
+ console.log(`šŸ“Š Found ${translations.size} unique translation keys`);
203
+ return Array.from(translations);
204
+ }
205
+
206
+ async createLocaleStructure() {
207
+ if (!SecurityUtils.safeExistsSync(this.localesDir, path.dirname(this.localesDir))) {
208
+ if (this.options.dryRun) {
209
+ console.log(`šŸ“ Would create directory: ${this.localesDir}`);
210
+ return;
211
+ }
212
+
213
+ fs.mkdirSync(this.localesDir, { recursive: true });
214
+ console.log(`šŸ“ Created locales directory: ${this.localesDir}`);
215
+ }
216
+
217
+ const languages = ['en', 'es', 'fr', 'de', 'ja', 'ru', 'zh'];
218
+
219
+ for (const lang of languages) {
220
+ const langDir = path.join(this.localesDir, lang);
221
+ if (!SecurityUtils.safeExistsSync(langDir, this.localesDir)) {
222
+ if (this.options.dryRun) {
223
+ console.log(`šŸ“ Would create directory: ${langDir}`);
224
+ continue;
225
+ }
226
+
227
+ fs.mkdirSync(langDir, { recursive: true });
228
+
229
+ // Create basic translation files
230
+ const commonFile = path.join(langDir, 'common.json');
231
+ const initialContent = {
232
+ "python": {
233
+ "welcome": `Welcome to Python (${lang})`,
234
+ "framework_detected": "Framework detected: {framework}",
235
+ "files_processed": "Processed {count} files"
236
+ }
237
+ };
238
+
239
+ SecurityUtils.safeWriteFileSync(commonFile, JSON.stringify(initialContent, null, 2), this.localesDir);
240
+ }
241
+ }
242
+ }
243
+
244
+ async analyzeFramework(framework) {
245
+ console.log(`šŸ” Analyzing ${framework} project...`);
246
+
247
+ const analysis = {
248
+ framework,
249
+ files: {
250
+ total: 0,
251
+ python: 0,
252
+ templates: 0,
253
+ config: 0
254
+ },
255
+ patterns: {
256
+ gettext: 0,
257
+ gettext_lazy: 0,
258
+ ngettext: 0,
259
+ django: 0,
260
+ flask: 0
261
+ },
262
+ recommendations: []
263
+ };
264
+
265
+ // Count Python files
266
+ const pythonFiles = this.findFiles('.py');
267
+ analysis.files.python = pythonFiles.length;
268
+ analysis.files.total += pythonFiles.length;
269
+
270
+ // Count template files
271
+ const templateExtensions = ['.html', '.jinja', '.j2'];
272
+ let templateFiles = [];
273
+ for (const ext of templateExtensions) {
274
+ templateFiles = templateFiles.concat(this.findFiles(ext));
275
+ }
276
+ analysis.files.templates = templateFiles.length;
277
+ analysis.files.total += templateFiles.length;
278
+
279
+ // Analyze patterns
280
+ for (const file of pythonFiles) {
281
+ try {
282
+ const content = SecurityUtils.safeReadFileSync(file, path.dirname(file), 'utf8');
283
+
284
+ if (content.includes('gettext(')) analysis.patterns.gettext++;
285
+ if (content.includes('gettext_lazy(')) analysis.patterns.gettext_lazy++;
286
+ if (content.includes('ngettext(')) analysis.patterns.ngettext++;
287
+ if (content.includes('django')) analysis.patterns.django++;
288
+ if (content.includes('flask')) analysis.patterns.flask++;
289
+
290
+ } catch (error) {
291
+ // Skip unreadable files
292
+ }
293
+ }
294
+
295
+ // Generate recommendations
296
+ if (framework === 'django' && analysis.patterns.gettext === 0) {
297
+ analysis.recommendations.push('Consider adding Django gettext for i18n support');
298
+ }
299
+
300
+ if (framework === 'flask' && analysis.patterns.gettext === 0) {
301
+ analysis.recommendations.push('Consider adding Flask-Babel for i18n support');
302
+ }
303
+
304
+ return analysis;
305
+ }
306
+
307
+ async generateReport(analysis, translations) {
308
+ const report = {
309
+ timestamp: new Date().toISOString(),
310
+ framework: analysis.framework,
311
+ summary: {
312
+ totalFiles: analysis.files.total,
313
+ pythonFiles: analysis.files.python,
314
+ templateFiles: analysis.files.templates,
315
+ translationKeys: translations.length
316
+ },
317
+ patterns: analysis.patterns,
318
+ recommendations: analysis.recommendations,
319
+ files: {
320
+ python: this.findFiles('.py'),
321
+ templates: this.findFiles('.html').concat(this.findFiles('.jinja')).concat(this.findFiles('.j2'))
322
+ },
323
+ translations: translations.sort()
324
+ };
325
+
326
+ const reportPath = path.join(this.sourceDir, 'i18ntk-py-report.json');
327
+
328
+ if (this.options.dryRun) {
329
+ console.log(`šŸ“Š Would create report: ${reportPath}`);
330
+ console.log('šŸ“‹ Report contents:', JSON.stringify(report, null, 2));
331
+ } else {
332
+ SecurityUtils.safeWriteFileSync(reportPath, JSON.stringify(report, null, 2), this.sourceDir);
333
+ console.log(`šŸ“Š Report saved: ${reportPath}`);
334
+ }
335
+
336
+ return report;
337
+ }
338
+
339
+ async run() {
340
+ try {
341
+ console.log('šŸš€ i18ntk Python Command v1.10.1');
342
+ console.log('='.repeat(50));
343
+
344
+ await this.init();
345
+
346
+ const framework = await this.detectFramework();
347
+ const translations = await this.extractTranslations();
348
+
349
+ if (!this.options.extractOnly) {
350
+ await this.createLocaleStructure();
351
+ const analysis = await this.analyzeFramework(framework);
352
+ const report = await this.generateReport(analysis, translations);
353
+
354
+ console.log('\nāœ… Analysis complete!');
355
+ console.log(`šŸ“Š Framework: ${framework}`);
356
+ console.log(`šŸ“„ Python files: ${analysis.files.python}`);
357
+ console.log(`šŸŽÆ Translation keys: ${translations.length}`);
358
+ console.log(`šŸ“‹ Report: ${this.options.dryRun ? 'Not saved (dry-run)' : 'Saved to i18ntk-py-report.json'}`);
359
+ } else {
360
+ console.log(`šŸ“¦ Extracted ${translations.length} translation keys`);
361
+ }
362
+
363
+ } catch (error) {
364
+ console.error('āŒ Error:', error.message);
365
+ if (this.options.debug) {
366
+ console.error(error.stack);
367
+ }
368
+ process.exit(1);
369
+ }
370
+ }
371
+ }
372
+
373
+ // Run if called directly
374
+ if (require.main === module) {
375
+ const cmd = new I18ntkPythonCommand();
376
+ cmd.run();
377
+ }
378
+
379
+ module.exports = I18ntkPythonCommand;
@@ -344,7 +344,7 @@ function normalizeLanguageCode(language) {
344
344
 
345
345
  function parseLanguagePrefix(value) {
346
346
  if (typeof value !== 'string') return null;
347
- const match = value.match(/^\s*\[([A-Z]{2,3}(?:[-_][A-Z0-9]{2,4})?)\]\s*(.+)$/);
347
+ const match = value.match(/^\s*\[([A-Z]{2,3}(?:[-_][A-Z0-9]{2,4})?)\]\s*(.+)$/i);
348
348
  if (!match) return null;
349
349
  return {
350
350
  raw: match[1],
@@ -368,6 +368,28 @@ function isLanguagePrefixedEnglish(value, args = {}) {
368
368
  return hasLatinWordContent(prefix.text, 1) || isLikelyEnglish(prefix.text, args);
369
369
  }
370
370
 
371
+ function isSafeUnchangedSourceCopy(value, args = {}) {
372
+ const text = String(value ?? '').trim();
373
+ if (!text) return false;
374
+
375
+ const normalized = text.toLowerCase();
376
+ const allowedTerms = new Set(['api']);
377
+ if (Array.isArray(args.allowedEnglishTerms)) {
378
+ args.allowedEnglishTerms
379
+ .filter(term => typeof term === 'string' && term.trim())
380
+ .forEach(term => allowedTerms.add(term.trim().toLowerCase()));
381
+ }
382
+ if (allowedTerms.has(normalized)) return true;
383
+
384
+ const tokens = text.match(/[A-Za-z0-9]+/g) || [];
385
+ if (tokens.length === 0) return true;
386
+
387
+ return tokens.every((token) => {
388
+ if (!/[A-Za-z]/.test(token)) return true;
389
+ return token.length <= 8 && /^[A-Z0-9]{2,}$/.test(token);
390
+ });
391
+ }
392
+
371
393
  function isBrokenTranslationValue(value) {
372
394
  if (typeof value !== 'string') return false;
373
395
  const text = value.trim();
@@ -399,7 +421,9 @@ function shouldTranslateTargetValue(sourceValue, targetValue, args) {
399
421
  if (isUntranslatedMarker(targetValue)) return true;
400
422
  if (isBrokenTranslationValue(targetValue)) return true;
401
423
  if (isLanguagePrefixedEnglish(targetValue, args)) return true;
402
- if (targetValue.trim() === String(sourceValue ?? '').trim()) return true;
424
+ if (targetValue.trim() === String(sourceValue ?? '').trim()) {
425
+ return !isSafeUnchangedSourceCopy(targetValue, args);
426
+ }
403
427
  if (args.onlyMissingOrEnglish !== false && isLikelyEnglish(targetValue, args)) return true;
404
428
  return args.onlyMissingOrEnglish === false;
405
429
  }
@@ -410,7 +434,9 @@ function getResidualUntranslatedReason(sourceValue, targetValue, args) {
410
434
  if (isUntranslatedMarker(targetValue)) return 'marker';
411
435
  if (isBrokenTranslationValue(targetValue)) return 'broken';
412
436
  if (isLanguagePrefixedEnglish(targetValue, args)) return 'language_prefix';
413
- if (targetValue.trim() === String(sourceValue ?? '').trim()) return 'source_copy';
437
+ if (targetValue.trim() === String(sourceValue ?? '').trim()) {
438
+ return isSafeUnchangedSourceCopy(targetValue, args) ? null : 'source_copy';
439
+ }
414
440
  return null;
415
441
  }
416
442
 
@@ -157,10 +157,13 @@ class CommandRouter {
157
157
  }
158
158
 
159
159
  try {
160
- // Route command to appropriate handler
161
- const result = await this.routeCommand(command, options, executionContext);
162
-
163
- // Handle command completion based on execution context
160
+ // Route command to appropriate handler
161
+ const result = await this.routeCommand(command, options, executionContext);
162
+ if (result && result.success === false) {
163
+ throw new Error(result.error || result.message || `${command} command failed`);
164
+ }
165
+
166
+ // Handle command completion based on execution context
164
167
  console.log('\n' + t('operations.completed'));
165
168
 
166
169
  if (isManagerExecution && !this.isNonInteractiveMode && this.prompt) {