Files
pw-generator/utils/extract-translations.php

723 lines
23 KiB
PHP

<?php
/**
* Translation Key Extraction Tool v3.1
*
* Comprehensive translation key extraction with multiple sources:
* 1. Generator API-driven keys (from generator definitions)
* 2. JavaScript files ($this.t() calls)
* 3. PHP files (static i18n keys)
* 4. Backup of existing translation keys
*
* Output format: JSON only
*
* Usage:
* php bin/extract-translations.php [options]
*
* Options:
* --output=file Output file path
* --context Include context information
* --template Generate template with placeholders
* --language=en Language for template generation
* --backup Create backup of existing translations
* --merge Merge with existing translations
* --js-path=path Path to JavaScript files (default: public/assets/js)
* --php-path=path Path to PHP files (default: src)
*
* Examples:
* php bin/extract-translations.php --output=locales/keys.json
* php bin/extract-translations.php --backup --merge --output=locales/en.json
* php bin/extract-translations.php --context --output=locales/keys-context.json
*
* @author Password Generator Project
* @version 3.1.0
*/
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Managers\PasswordGeneratorManager;
/**
* Main extraction class
*/
class TranslationKeyExtractor
{
private PasswordGeneratorManager $generatorManager;
private array $collectedKeys = [];
private array $keysWithContext = [];
private array $existingTranslations = [];
private array $stats = [
'generator_keys' => 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);
}