0, 'js_keys' => 0, 'php_keys' => 0, 'existing_keys' => 0, 'new_keys' => 0, 'obsolete_keys' => 0 ]; public function __construct(PasswordGeneratorManager $generatorManager) { $this->generatorManager = $generatorManager; } /** * Extract all i18n keys from all sources */ public function extractAll(array $options = []): array { $jsPath = $options['js_path'] ?? 'public/assets/js'; $phpPath = $options['php_path'] ?? 'src'; $existingFile = $options['existing_file'] ?? null; echo "=== Extraction Sources ===\n"; echo "JavaScript path: {$jsPath}\n"; echo "PHP path: {$phpPath}\n"; if ($existingFile && file_exists($existingFile)) { echo "Existing translations: {$existingFile}\n"; $this->loadExistingTranslations($existingFile); } echo "\n"; // Extract from generators (API-driven) echo "=== Extracting from Password Generators ===\n"; $this->extractFromGenerators(); // Extract from JavaScript files echo "\n=== Extracting from JavaScript Files ===\n"; $this->extractFromJavaScriptFiles($jsPath); // Extract from PHP files echo "\n=== Extracting from PHP Files ===\n"; $this->extractFromPhpFiles($phpPath); // Compare with existing if (!empty($this->existingTranslations)) { echo "\n=== Comparing with Existing Translations ===\n"; $this->compareWithExisting(); } // Sort keys alphabetically ksort($this->collectedKeys); ksort($this->keysWithContext); return [ 'keys' => $this->collectedKeys, 'keysWithContext' => $this->keysWithContext, 'stats' => $this->getStatistics(), 'existingTranslations' => $this->existingTranslations ]; } /** * Load existing translations for comparison/merging */ private function loadExistingTranslations(string $file): void { try { if (!str_ends_with($file, '.json')) { throw new Exception('Only JSON format is supported'); } $content = file_get_contents($file); $this->existingTranslations = json_decode($content, true) ?? []; // Remove comment keys $this->existingTranslations = array_filter( $this->existingTranslations, fn($key) => !str_starts_with($key, '_comment'), ARRAY_FILTER_USE_KEY ); $this->stats['existing_keys'] = count($this->existingTranslations); echo "āœ… Loaded {$this->stats['existing_keys']} existing translations\n"; } catch (Exception $e) { echo "āš ļø Failed to load existing translations: {$e->getMessage()}\n"; } } /** * Extract keys from password generators using API-driven approach */ private function extractFromGenerators(): void { $generators = $this->generatorManager->getAvailableTypes(); foreach ($generators as $generator) { $this->extractFromGenerator($generator); } } /** * Extract keys from a single generator */ private function extractFromGenerator($generator): void { $generatorId = $generator->getId(); echo "šŸ“¦ {$generatorId}...\n"; // Get all keys using the generator's built-in method $keys = $generator->getAllI18nKeys(); // Add keys with context information foreach ($keys as $key) { $this->addKey($key, 'generator', $generatorId); } $this->stats['generator_keys'] += count($keys); echo " → Found " . count($keys) . " keys\n"; } /** * Extract i18n keys from JavaScript files * Looks for: this.t('key'), $this.t('key'), app.t('key') */ private function extractFromJavaScriptFiles(string $basePath): void { if (!is_dir($basePath)) { echo "āš ļø JavaScript path not found: {$basePath}\n"; return; } $files = $this->findFiles($basePath, ['js']); echo "Found " . count($files) . " JavaScript files\n"; foreach ($files as $file) { $this->extractFromJavaScriptFile($file); } } /** * Extract keys from a single JavaScript file */ private function extractFromJavaScriptFile(string $file): void { $content = file_get_contents($file); $filename = basename($file); // Pattern for this.t('key'), $this.t('key'), this.t("key") $patterns = [ '/(?:this|\\$this)\\.t\\([\'"]([^\'"]+)[\'"]\\)/', '/app\\.t\\([\'"]([^\'"]+)[\'"]\\)/', '/t\\([\'"]([^\'"]+)[\'"]\\)/', // Simple t() calls ]; $foundKeys = []; foreach ($patterns as $pattern) { if (preg_match_all($pattern, $content, $matches)) { $foundKeys = array_merge($foundKeys, $matches[1]); } } $foundKeys = array_unique($foundKeys); if (!empty($foundKeys)) { echo " šŸ“„ {$filename}: " . count($foundKeys) . " keys\n"; foreach ($foundKeys as $key) { $this->addKey($key, 'javascript', $filename); } $this->stats['js_keys'] += count($foundKeys); } } /** * Extract i18n keys from PHP files * Looks for static string keys in specific patterns */ private function extractFromPhpFiles(string $basePath): void { if (!is_dir($basePath)) { echo "āš ļø PHP path not found: {$basePath}\n"; return; } $files = $this->findFiles($basePath, ['php']); echo "Found " . count($files) . " PHP files\n"; foreach ($files as $file) { $this->extractFromPhpFile($file); } } /** * Extract keys from a single PHP file * Looks for static keys in comments or constants */ private function extractFromPhpFile(string $file): void { $content = file_get_contents($file); $filename = basename($file); // Pattern for static keys in comments like: // i18n: key_name $patterns = [ '/\\/\\/\\s*i18n:\\s*([a-z0-9_]+)/i', '/\\/\\*\\*.*?@i18n\\s+([a-z0-9_]+).*?\\*\\//s', // Add more patterns as needed ]; $foundKeys = []; foreach ($patterns as $pattern) { if (preg_match_all($pattern, $content, $matches)) { $foundKeys = array_merge($foundKeys, $matches[1]); } } $foundKeys = array_unique($foundKeys); if (!empty($foundKeys)) { echo " šŸ“„ {$filename}: " . count($foundKeys) . " keys\n"; foreach ($foundKeys as $key) { $this->addKey($key, 'php', $filename); } $this->stats['php_keys'] += count($foundKeys); } } /** * Find files with specific extensions recursively */ private function findFiles(string $dir, array $extensions): array { $files = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->isFile()) { $ext = strtolower($file->getExtension()); if (in_array($ext, $extensions)) { $files[] = $file->getPathname(); } } } return $files; } /** * Add a key to the collection with context */ private function addKey(string $key, string $source, string $sourceDetail = ''): void { // Initialize with existing translation if available if (!isset($this->collectedKeys[$key])) { $this->collectedKeys[$key] = $this->existingTranslations[$key] ?? ''; } // Add or update context if (!isset($this->keysWithContext[$key])) { $this->keysWithContext[$key] = [ 'key' => $key, 'sources' => [], 'type' => $this->getKeyType($key), 'description' => $this->getKeyDescription($key) ]; } // Add source information $this->keysWithContext[$key]['sources'][] = [ 'type' => $source, 'detail' => $sourceDetail ]; } /** * Get key type from key pattern */ private function getKeyType(string $key): string { if (str_ends_with($key, '_name')) { return 'generator_metadata'; } if (str_ends_with($key, '_description')) { return 'generator_metadata'; } if (str_contains($key, '_option_') && str_ends_with($key, '_label')) { return 'option_label'; } if (str_contains($key, '_option_') && str_ends_with($key, '_description')) { return 'option_description'; } if (str_contains($key, '_option_') && str_contains($key, '_choice_')) { return 'option_choice_label'; } if (str_ends_with($key, '_validation_error')) { return 'validation_error'; } if (str_contains($key, '_options_group_') && str_ends_with($key, '_title')) { return 'group_title'; } return 'general'; } /** * Get key description */ private function getKeyDescription(string $key): string { $type = $this->getKeyType($key); switch ($type) { case 'generator_metadata': return str_ends_with($key, '_name') ? 'Generator display name' : 'Generator description'; case 'option_label': preg_match('/_option_(.+)_label$/', $key, $matches); return 'Label for option: ' . ($matches[1] ?? 'unknown'); case 'option_description': preg_match('/_option_(.+)_description$/', $key, $matches); return 'Description for option: ' . ($matches[1] ?? 'unknown'); case 'option_choice_label': preg_match('/_option_(.+)_choice_(.+)$/', $key, $matches); return 'Choice label for: ' . ($matches[1] ?? 'unknown') . ' = ' . ($matches[2] ?? 'unknown'); case 'validation_error': preg_match('/_option_(.+)_validation_error$/', $key, $matches); return 'Validation error for: ' . ($matches[1] ?? 'unknown'); case 'group_title': preg_match('/_options_group_(.+)_title$/', $key, $matches); return 'Group title for: ' . ($matches[1] ?? 'unknown'); default: return 'General UI text'; } } /** * Compare extracted keys with existing translations */ private function compareWithExisting(): void { $extractedKeys = array_keys($this->collectedKeys); $existingKeys = array_keys($this->existingTranslations); $newKeys = array_diff($extractedKeys, $existingKeys); $obsoleteKeys = array_diff($existingKeys, $extractedKeys); $this->stats['new_keys'] = count($newKeys); $this->stats['obsolete_keys'] = count($obsoleteKeys); if (!empty($newKeys)) { echo "šŸ†• New keys: " . count($newKeys) . "\n"; foreach (array_slice($newKeys, 0, 10) as $key) { echo " + {$key}\n"; } if (count($newKeys) > 10) { echo " ... and " . (count($newKeys) - 10) . " more\n"; } } if (!empty($obsoleteKeys)) { echo "\nāš ļø Obsolete keys (not found in code): " . count($obsoleteKeys) . "\n"; foreach (array_slice($obsoleteKeys, 0, 10) as $key) { echo " - {$key}\n"; } if (count($obsoleteKeys) > 10) { echo " ... and " . (count($obsoleteKeys) - 10) . " more\n"; } } } /** * Get extraction statistics */ private function getStatistics(): array { $byType = []; $bySource = []; foreach ($this->keysWithContext as $keyData) { $type = $keyData['type']; $byType[$type] = ($byType[$type] ?? 0) + 1; foreach ($keyData['sources'] as $source) { $sourceType = $source['type']; $bySource[$sourceType] = ($bySource[$sourceType] ?? 0) + 1; } } return array_merge($this->stats, [ 'total_keys' => count($this->collectedKeys), 'by_type' => $byType, 'by_source' => $bySource ]); } /** * Create backup of existing translations */ public function createBackup(string $file): bool { if (!file_exists($file)) { return false; } $backupFile = $file . '.backup.' . date('Y-m-d_His'); if (copy($file, $backupFile)) { echo "šŸ’¾ Created backup: {$backupFile}\n"; return true; } return false; } /** * Export keys to JSON format */ public function exportToJson(): string { return json_encode($this->collectedKeys, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * Export keys with context to JSON format */ public function exportToJsonWithContext(): string { return json_encode([ 'keys' => $this->collectedKeys, 'context' => array_values($this->keysWithContext), 'stats' => $this->getStatistics() ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * Generate template translation file with placeholders */ public function generateTemplate(string $language = 'en'): string { $template = []; $lastGenerator = ''; foreach ($this->keysWithContext as $keyData) { $key = $keyData['key']; // Add section comments for generators if (str_contains($key, '_') && !str_starts_with($key, '_comment')) { $generatorId = explode('_', $key)[0]; if ($generatorId !== $lastGenerator) { $template["_comment_{$generatorId}"] = strtoupper($generatorId) . " Generator"; $lastGenerator = $generatorId; } } // Use existing translation or generate placeholder $value = $this->existingTranslations[$key] ?? $this->generatePlaceholder($keyData); $template[$key] = $value; } return json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * Generate placeholder text for a key */ private function generatePlaceholder(array $keyData): string { $type = $keyData['type']; $key = $keyData['key']; // Extract meaningful part from key if (preg_match('/_option_([^_]+)_/', $key, $matches)) { $field = str_replace('_', ' ', $matches[1]); $field = ucwords($field); } else { $field = 'unknown'; } switch ($type) { case 'generator_metadata': return str_ends_with($key, '_name') ? 'Generator Name' : 'Generator description text'; case 'option_label': return $field; case 'option_description': return "Description for {$field}"; case 'option_choice_label': if (preg_match('/_choice_([^_]+)$/', $key, $matches)) { return ucfirst(str_replace('_', ' ', $matches[1])); } return ucfirst($field); case 'validation_error': return "Invalid value for {$field}"; case 'group_title': return $field . " Options"; default: return ''; } } } /** * CLI Interface */ class CLI { private array $options = []; public function __construct(array $argv) { $this->parseArguments($argv); } private function parseArguments(array $argv): void { for ($i = 1; $i < count($argv); $i++) { if (str_starts_with($argv[$i], '--')) { $parts = explode('=', substr($argv[$i], 2), 2); $this->options[$parts[0]] = $parts[1] ?? true; } } } public function getOption(string $name, $default = null) { return $this->options[$name] ?? $default; } public function hasOption(string $name): bool { return isset($this->options[$name]); } } /** * Main execution */ try { echo "=============================================\n"; echo "Translation Key Extraction Tool v3.1\n"; echo "JSON Format Only\n"; echo "=============================================\n\n"; $cli = new CLI($argv); // Get options $output = $cli->getOption('output'); $withContext = $cli->hasOption('context'); $template = $cli->hasOption('template'); $language = $cli->getOption('language', 'en'); $backup = $cli->hasOption('backup'); $merge = $cli->hasOption('merge'); $jsPath = $cli->getOption('js-path', 'public/assets/js'); $phpPath = $cli->getOption('php-path', 'src'); // Initialize generator manager $generatorManager = new PasswordGeneratorManager(); // Extract keys $extractor = new TranslationKeyExtractor($generatorManager); $extractOptions = [ 'js_path' => $jsPath, 'php_path' => $phpPath ]; // Load existing translations if merging or backing up if (($merge || $backup) && $output && file_exists($output)) { $extractOptions['existing_file'] = $output; // Create backup if requested if ($backup) { $extractor->createBackup($output); } } $result = $extractor->extractAll($extractOptions); // Display statistics echo "\n=== Extraction Statistics ===\n"; echo "Total keys: {$result['stats']['total_keys']}\n"; echo "Generator keys: {$result['stats']['generator_keys']}\n"; echo "JavaScript keys: {$result['stats']['js_keys']}\n"; echo "PHP keys: {$result['stats']['php_keys']}\n"; if ($result['stats']['existing_keys'] > 0) { echo "Existing keys: {$result['stats']['existing_keys']}\n"; echo "New keys: {$result['stats']['new_keys']}\n"; echo "Obsolete keys: {$result['stats']['obsolete_keys']}\n"; } echo "\nBy type:\n"; foreach ($result['stats']['by_type'] as $type => $count) { echo " - {$type}: {$count}\n"; } echo "\nBy source:\n"; foreach ($result['stats']['by_source'] as $source => $count) { echo " - {$source}: {$count}\n"; } // Generate output $outputContent = ''; if ($template) { echo "\n=== Generating template file ===\n"; $outputContent = $extractor->generateTemplate($language); } else { echo "\n=== Generating JSON output ===\n"; $outputContent = $withContext ? $extractor->exportToJsonWithContext() : $extractor->exportToJson(); } // Save or display if ($output) { file_put_contents($output, $outputContent); echo "āœ… Saved to: {$output}\n"; } else { echo "\n=== Output ===\n"; echo $outputContent; echo "\n"; } echo "\nāœ… Extraction complete!\n"; } catch (Exception $e) { echo "ERROR: {$e->getMessage()}\n"; echo $e->getTraceAsString() . "\n"; echo "\nUsage:\n"; echo " php bin/extract-translations.php [options]\n\n"; echo "Options:\n"; echo " --output=file Output file path (JSON only)\n"; echo " --context Include context information\n"; echo " --template Generate template with placeholders\n"; echo " --language=en Language for template generation\n"; echo " --backup Create backup of existing translations\n"; echo " --merge Merge with existing translations\n"; echo " --js-path=path Path to JavaScript files (default: public/assets/js)\n"; echo " --php-path=path Path to PHP files (default: src)\n\n"; echo "Examples:\n"; echo " php bin/extract-translations.php --output=locales/keys.json\n"; echo " php bin/extract-translations.php --backup --merge --output=locales/en.json\n"; echo " php bin/extract-translations.php --context --output=locales/keys-context.json\n"; echo " php bin/extract-translations.php --template --output=locales/en-template.json\n"; exit(1); }