diff --git a/ProcessMaker/Console/Commands/SyncTranslations.php b/ProcessMaker/Console/Commands/SyncTranslations.php index 94687bdaf6..2789a4b5cc 100644 --- a/ProcessMaker/Console/Commands/SyncTranslations.php +++ b/ProcessMaker/Console/Commands/SyncTranslations.php @@ -4,7 +4,10 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\File; +use ProcessMaker\Helpers\SyncJsonTranslations; +use ProcessMaker\Helpers\SyncPhpTranslations; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; class SyncTranslations extends Command { @@ -31,231 +34,133 @@ class SyncTranslations extends Command */ public function handle() { - // Check if exists resources core - $translationsCore = base_path() . '/resources-core'; - $existsLangOrig = $this->fileExists(lang_path() . '.orig'); + $this->info('Starting translation synchronization...'); + $this->newLine(); - if (!$this->fileExists($translationsCore)) { - $this->error('The folder resources-core not exists.'); + $jsonResults = (new SyncJsonTranslations())->sync(); + // dump("JSON Results: ", $jsonResults); + $phpResults = (new SyncPhpTranslations())->sync(); - return; - } - //Search files - $this->listFiles($translationsCore . '/lang'); - - // updating languages by default - foreach ($this->files as $pathFile) { - if (!(str_contains($pathFile, '.json') || str_contains($pathFile, '.php')) || str_contains($pathFile, '.bak.')) { - continue; - } - // updating resources/lang - $this->syncFile(str_replace('/resources-core/', '/resources/', $pathFile), $pathFile); - if ($existsLangOrig) { - // updating resources/lang.orig - $this->syncFile(str_replace(['/resources-core/', '/lang/'], ['/resources/', '/lang.orig/'], $pathFile), $pathFile); - } - } + $this->displayJsonResults($jsonResults); + $this->newLine(); + $this->displayPhpResults($phpResults); - // updating all languages with new labels - $this->files = []; - $translationsCore = lang_path(); - $this->listFiles($translationsCore); - foreach ($this->files as $pathFile) { - if (!(str_contains($pathFile, '.json') || str_contains($pathFile, '.php')) || str_contains($pathFile, '.bak.')) { - continue; - } - // updating resources/lang - $backup = str_replace('/resources/', '/resources-core/', preg_replace('/(?<=lang).+?(?=json)/', '/en.', $pathFile)); - $path1 = explode('/lang/', $backup); - $path2 = explode('/', $path1[1]); - if (is_array($path2)) { - $backup = str_replace('/' . $path2[0] . '/', '/en/', $backup); - } + $this->newLine(); + $this->info('Translation synchronization completed!'); - $this->syncFile($pathFile, $backup); - if ($existsLangOrig) { - // updating resources/lang.orig - $this->syncFile(str_replace('/lang/', '/lang.orig/', $pathFile), $backup); - } - } + return 0; } - private function listFiles($dir) + /** + * Display JSON translation results in a table format + * + * @param array $results + * @return void + */ + private function displayJsonResults(array $results): void { - $files = scandir($dir); + $this->info('JSON Translation Files:'); - foreach ($files as $value) { - $path = $dir . '/' . $value; - if (!is_dir($path)) { - $this->files[] = $path; - } elseif ($value != '.' && $value != '..') { - $this->listFiles($path); + $table = new Table($this->output); + $table->setHeaders(['Language', 'File', 'Action', 'New Keys', 'Total Keys', 'Backup', 'Status']); + $additionalChanges = Arr::get($results, 'en.otherLanguageResults'); + + foreach ($results as $language => $result) { + if (isset($additionalChanges[$language])) { + if ($result['action'] !== 'no_changes') { + $this->addRowToTable($table, $result, $language); + } + $this->addRowToTable($table, $additionalChanges[$language], $language); + } else { + $this->addRowToTable($table, $result, $language); } } - } - private function fileExists($path) - { - return File::exists($path); + $table->render(); } - private function parseFile($path) + private function addRowToTable(Table $table, array $result, string $language) { - $pathInfo = pathinfo($path); - - $lines = []; - - try { - if ($pathInfo['extension'] === 'json') { - $lines = json_decode(file_get_contents($path), true); - } elseif ($pathInfo['extension'] === 'php') { - $lines = include $path; - } - } catch (\Exception $e) { - $lines = []; - $this->error($path . '. Not found.'); + $status = $result['error'] ? 'Error' : 'Success'; + $action = $this->formatAction($result['action']); + $backup = $result['backup_created'] ? 'Yes' : 'No'; + + $table->addRow([ + $language, + $result['filename'], + $action, + $result['new_keys'], + $result['total_keys'], + $backup, + $status, + ]); + + if ($result['error']) { + $table->addRow(new TableSeparator()); + $table->addRow(['', '', '', '', '', '', '' . $result['error'] . '']); } - - $lines = Arr::dot($lines); - - return collect($lines); } /** - * Synchronize translations between target and backup files + * Display PHP translation results in a table format * - * @param string $target Path to target file - * @param string $backup Path to backup file - * @return bool - * @throws \Exception + * @param array $results + * @return void */ - private function syncFile($target, $backup) + private function displayPhpResults(array $results): void { - if (str_contains($target, '.bak.')) { - // Clean up old backup if everything succeeded - if (file_exists($target)) { - unlink($target); - $this->info('Removed bak: ' . $target); - } - $this->info("Skipping backup file: {$target}"); - - return true; - } - // Create backup before modifications - $backupPath = $target . '.bak.' . date('Y-m-d-His'); - try { - if (!copy($target, $backupPath)) { - $this->error("Failed to create backup file: {$backupPath}"); - - return false; + $this->info('PHP Translation Files:'); + + $table = new Table($this->output); + $table->setHeaders(['Language', 'Files Processed', 'Copied', 'Merged', 'No Changes', 'Errors', 'Status']); + + foreach ($results as $language => $result) { + $errorCount = count($result['errors']); + $status = $errorCount > 0 ? 'Errors' : 'Success'; + + $table->addRow([ + $language, + $result['files_processed'], + $result['files_copied'], + $result['files_merged'], + $result['files_no_changes'], + $errorCount, + $status, + ]); + + // Add error details if any + if ($errorCount > 0) { + $table->addRow(new TableSeparator()); + foreach ($result['errors'] as $error) { + $table->addRow(['', '', '', '', '', '', '' . $error . '']); + } } - $this->info("Backup created: {$backupPath}"); - } catch (\Exception $e) { - $this->error('Error creating backup: ' . $e->getMessage()); - - return false; - } - - $pathInfo = pathinfo($target); - - try { - $targetTranslations = $this->parseFile($target); - $origin = $this->parseFile($backup); - } catch (\Exception $e) { - $this->error('Error parsing files: ' . $e->getMessage()); - - return false; } - // Get keys that are in origin but not in target - $diff = $origin->diffKeys($targetTranslations); - - if ($diff->isNotEmpty()) { - $this->info('Found ' . $diff->count() . " new translations to add in {$target}"); - - // only files en.json to en.json have translations others are empty - $clear = true; - if (str_contains($target, 'en.json') && str_contains($backup, 'en.json')) { - $clear = false; - } - - // Add new keys to targetTranslations - foreach ($diff as $key => $value) { - $targetTranslations[$key] = $clear ? '' : $value; - } - } - - try { - $contents = $this->generateFile($targetTranslations, $pathInfo['extension']); - - // Validate content before saving - if (empty($contents)) { - throw new \Exception('Generated content is empty'); - } - - // Use atomic file writing - $tempFile = $target . '.tmp'; - if (file_put_contents($tempFile, $contents) === false) { - throw new \Exception('Failed to write temporary file'); - } - - if (!rename($tempFile, $target)) { - unlink($tempFile); - throw new \Exception('Failed to move temporary file'); - } - - $this->info("Successfully updated: {$target}"); - - // Clean up old backup if everything succeeded - if (file_exists($backupPath)) { - unlink($backupPath); - $this->info('Removed backup file after successful update'); - } - - if ($pathInfo['extension'] == 'php') { - $this->clearCache(); - } - - return true; - } catch (\Exception $e) { - // Restore from backup if something went wrong - if (file_exists($backupPath)) { - copy($backupPath, $target); - $this->info('Restored from backup due to error'); - } - $this->error('Error saving file: ' . $e->getMessage()); - - return false; - } + $table->render(); } - private function generateFile($lines, $type) - { - $array = []; - - foreach ($lines as $key => $line) { - $array[$key] = $line; - } - - if ($type === 'json') { - return json_encode($array, JSON_PRETTY_PRINT); - } elseif ($type === 'php') { - $contents = " $value) { - $key = addslashes($key); - $value = addslashes($value); - $contents .= "\t'$key' => '$value',\n"; - } - $contents .= '];'; - - return $contents; - } - } - - private function clearCache() + /** + * Format action for display + * + * @param string $action + * @return string + */ + private function formatAction(string $action): string { - if (function_exists('opcache_reset')) { - return opcache_reset(); + switch ($action) { + case 'copied': + return 'Copied'; + case 'merged': + return 'Merged'; + case 'updated': + return 'Updated'; + case 'no_changes': + return 'No Changes'; + case 'error': + return 'Error'; + default: + return $action; } } } diff --git a/ProcessMaker/Helpers/SyncJsonTranslations.php b/ProcessMaker/Helpers/SyncJsonTranslations.php new file mode 100644 index 0000000000..24976e1df6 --- /dev/null +++ b/ProcessMaker/Helpers/SyncJsonTranslations.php @@ -0,0 +1,249 @@ +getLanguageCodes(); + + foreach ($languageCodes as $languageCode) { + $fileResult = $this->processLanguageFile($languageCode); + + // Handle both old format (direct result) and new format (wrapped result) + if (isset($fileResult['result'])) { + $results[$languageCode] = $fileResult['result']; + + // If processing en.json and there were other language updates, merge those results + if ($languageCode === 'en' && isset($fileResult['otherLanguageResults'])) { + $results[$languageCode]['otherLanguageResults'] = $fileResult['otherLanguageResults']; + } + } else { + // Handle old format for backward compatibility + $results[$languageCode] = $fileResult; + } + } + + return $results; + } + + /** + * Process a single JSON translation file + * + * @param string $languageCode + * @return array + */ + protected function processLanguageFile(string $languageCode): array + { + $filename = $languageCode . '.json'; + $result = [ + 'filename' => $filename, + 'action' => 'none', + 'new_keys' => 0, + 'total_keys' => 0, + 'backup_created' => false, + 'error' => null, + ]; + + try { + // Get content from resources-core + $resourcesCoreContent = $this->getResourcesCoreContent($filename); + if (!$resourcesCoreContent) { + $result['error'] = 'Source file not found in resources-core'; + + return $result; + } + + // Decode resources-core content + $resourcesCoreTranslations = json_decode($resourcesCoreContent, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $result['error'] = 'Invalid JSON in resources-core file: ' . json_last_error_msg(); + + return $result; + } + + // Check if destination file exists + if (!$this->destinationFileExists($filename)) { + // Copy the entire file from resources-core + if ($this->copyFileFromResourcesCore($filename)) { + $result['action'] = 'copied'; + $result['total_keys'] = count($resourcesCoreTranslations); + } else { + $result['error'] = 'Failed to copy file from resources-core'; + } + + return $result; + } + + // Get existing destination content + $destinationContent = $this->getDestinationContent($filename); + if (!$destinationContent) { + $result['error'] = 'Failed to read destination file'; + + return $result; + } + + // Decode destination content + $destinationTranslations = json_decode($destinationContent, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $result['error'] = 'Invalid JSON in destination file: ' . json_last_error_msg(); + + return $result; + } + + // Merge translations (only add new keys) + $newKeysCount = 0; + $mergedTranslations = $destinationTranslations; + + foreach ($resourcesCoreTranslations as $key => $value) { + if (!array_key_exists($key, $mergedTranslations)) { + $mergedTranslations[$key] = $value; + $newKeysCount++; + } + } + + // Only update if there are new keys + if ($newKeysCount > 0) { + // Create backup before modifying the file + $backupCreated = $this->createBackup($filename); + $result['backup_created'] = $backupCreated; + + $mergedContent = json_encode($mergedTranslations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if ($this->saveToDestination($filename, $mergedContent)) { + $result['action'] = 'merged'; + $result['new_keys'] = $newKeysCount; + $result['total_keys'] = count($mergedTranslations); + + // Clean up old backups after successful save + $this->cleanupOldBackups($filename); + } else { + $result['error'] = 'Failed to save merged translations'; + } + } else { + $result['action'] = 'no_changes'; + $result['total_keys'] = count($mergedTranslations); + } + + // If processing en.json, sync missing keys to other language files + if ($languageCode === 'en' && $result['action'] !== 'none' && $result['error'] === null) { + $otherLanguageResults = $this->syncMissingKeysToOtherLanguages($mergedTranslations); + + return [ + 'result' => $result, + 'otherLanguageResults' => $otherLanguageResults, + ]; + } + } catch (\Exception $e) { + $result['error'] = 'Exception occurred: ' . $e->getMessage(); + } + + return ['result' => $result]; + } + + /** + * Get all language files from the lang disk + * + * @return array + */ + protected function getLanguageFilesFromLangDisk(): array + { + $languageFiles = []; + $files = $this->langDisk->files(); + + foreach ($files as $file) { + if (preg_match('/^([a-z]{2})\.json$/', $file, $matches)) { + $languageFiles[] = $matches[1]; + } + } + + return $languageFiles; + } + + /** + * Sync missing keys from en.json to other language files + * + * @param array $enTranslations + * @return array + */ + protected function syncMissingKeysToOtherLanguages(array $enTranslations): array + { + $results = []; + $languageFiles = $this->getLanguageFilesFromLangDisk(); + + foreach ($languageFiles as $languageCode) { + // Skip en.json as it's the source + if ($languageCode === 'en') { + continue; + } + + $filename = $languageCode . '.json'; + $result = [ + 'filename' => $filename, + 'action' => 'no_changes', + 'new_keys' => 0, + 'total_keys' => 0, + 'backup_created' => false, + 'error' => null, + ]; + + // Get existing content + $existingContent = $this->getDestinationContent($filename); + if (!$existingContent) { + $result['error'] = 'File not found'; + $results[$languageCode] = $result; + continue; // Skip if file doesn't exist + } + + // Decode existing content + $existingTranslations = json_decode($existingContent, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $result['error'] = 'Invalid JSON in destination file: ' . json_last_error_msg(); + $results[$languageCode] = $result; + continue; // Skip if invalid JSON + } + + // Check for missing keys and add them with empty strings + $newKeysCount = 0; + $updatedTranslations = $existingTranslations; + + foreach ($enTranslations as $key => $value) { + if (!array_key_exists($key, $updatedTranslations)) { + $updatedTranslations[$key] = ''; + $newKeysCount++; + } + } + + // Save updated translations if there are new keys + if ($newKeysCount > 0) { + // Create backup before modifying the file + $backupCreated = $this->createBackup($filename); + $result['backup_created'] = $backupCreated; + + $updatedContent = json_encode($updatedTranslations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if ($this->saveToDestination($filename, $updatedContent)) { + $result['action'] = 'updated'; + $result['new_keys'] = $newKeysCount; + $result['total_keys'] = count($updatedTranslations); + + // Clean up old backups after successful save + $this->cleanupOldBackups($filename); + } else { + $result['error'] = 'Failed to save updated translations'; + } + } else { + $result['total_keys'] = count($updatedTranslations); + } + + $results[$languageCode] = $result; + } + + return $results; + } +} diff --git a/ProcessMaker/Helpers/SyncPhpTranslations.php b/ProcessMaker/Helpers/SyncPhpTranslations.php new file mode 100644 index 0000000000..3d827929bb --- /dev/null +++ b/ProcessMaker/Helpers/SyncPhpTranslations.php @@ -0,0 +1,324 @@ +getLanguageCodes(); + + foreach ($languageCodes as $languageCode) { + $results[$languageCode] = $this->processLanguageFile($languageCode); + } + + return $results; + } + + /** + * Process a single language's PHP translation files + * + * @param string $languageCode + * @return array + */ + protected function processLanguageFile(string $languageCode): array + { + $result = [ + 'language' => $languageCode, + 'files_processed' => 0, + 'files_copied' => 0, + 'files_merged' => 0, + 'files_no_changes' => 0, + 'errors' => [], + 'details' => [], + ]; + + $sourceDir = $this->resourcesCorePath . '/' . $languageCode; + $destinationDir = $languageCode; + + // Check if source directory exists + if (!is_dir($sourceDir)) { + $result['errors'][] = "Source directory not found: {$sourceDir}"; + + return $result; + } + + // Get all PHP files from source directory + $phpFiles = glob($sourceDir . '/*.php'); + + foreach ($phpFiles as $sourceFile) { + $filename = basename($sourceFile); + $fileResult = $this->processPhpFile($languageCode, $filename); + + $result['files_processed']++; + $result['details'][$filename] = $fileResult; + + switch ($fileResult['action']) { + case 'copied': + $result['files_copied']++; + break; + case 'merged': + $result['files_merged']++; + break; + case 'no_changes': + $result['files_no_changes']++; + break; + case 'error': + $result['errors'][] = "{$filename}: {$fileResult['error']}"; + break; + } + } + + return $result; + } + + /** + * Process a single PHP translation file + * + * @param string $languageCode + * @param string $filename + * @return array + */ + private function processPhpFile(string $languageCode, string $filename): array + { + $fileResult = [ + 'filename' => $filename, + 'action' => 'none', + 'new_keys' => 0, + 'total_keys' => 0, + 'backup_created' => false, + 'error' => null, + ]; + + try { + // Get source content + $sourcePath = $this->resourcesCorePath . '/' . $languageCode . '/' . $filename; + $sourceContent = file_get_contents($sourcePath); + + if ($sourceContent === false) { + $fileResult['error'] = 'Failed to read source file'; + $fileResult['action'] = 'error'; + + return $fileResult; + } + + // Parse source PHP array + $sourceTranslations = $this->parsePhpArray($sourceContent); + if ($sourceTranslations === null) { + $fileResult['error'] = 'Invalid PHP array in source file'; + $fileResult['action'] = 'error'; + + return $fileResult; + } + + // Check if destination file exists + $destinationPath = $languageCode . '/' . $filename; + if (!$this->destinationFileExists($destinationPath)) { + // Copy the entire file from resources-core + if ($this->copyPhpFileFromResourcesCore($languageCode, $filename)) { + $fileResult['action'] = 'copied'; + $fileResult['total_keys'] = count($sourceTranslations); + } else { + $fileResult['error'] = 'Failed to copy file from resources-core'; + $fileResult['action'] = 'error'; + } + + return $fileResult; + } + + // Get existing destination content + $destinationContent = $this->getDestinationContent($destinationPath); + if (!$destinationContent) { + $fileResult['error'] = 'Failed to read destination file'; + $fileResult['action'] = 'error'; + + return $fileResult; + } + + // Parse destination PHP array + $destinationTranslations = $this->parsePhpArray($destinationContent); + if ($destinationTranslations === null) { + $fileResult['error'] = 'Invalid PHP array in destination file'; + $fileResult['action'] = 'error'; + + return $fileResult; + } + + // Merge translations (only add new keys) + $newKeysCount = 0; + $mergedTranslations = $destinationTranslations; + + foreach ($sourceTranslations as $key => $value) { + if (!array_key_exists($key, $mergedTranslations)) { + $mergedTranslations[$key] = $value; + $newKeysCount++; + } + } + + // Only update if there are new keys + if ($newKeysCount > 0) { + // Create backup before modifying the file + $backupCreated = $this->createBackup($destinationPath); + $fileResult['backup_created'] = $backupCreated; + + $mergedContent = $this->generatePhpArray($mergedTranslations, $sourceContent); + if ($this->saveToDestination($destinationPath, $mergedContent)) { + $fileResult['action'] = 'merged'; + $fileResult['new_keys'] = $newKeysCount; + $fileResult['total_keys'] = count($mergedTranslations); + + // Clean up old backups after successful save + $this->cleanupOldBackups($destinationPath); + } else { + $fileResult['error'] = 'Failed to save merged translations'; + $fileResult['action'] = 'error'; + } + } else { + $fileResult['action'] = 'no_changes'; + $fileResult['total_keys'] = count($mergedTranslations); + } + } catch (\Exception $e) { + $fileResult['error'] = 'Exception occurred: ' . $e->getMessage(); + $fileResult['action'] = 'error'; + } + + return $fileResult; + } + + /** + * Parse PHP array from file content + * + * @param string $content + * @return array|null + */ + private function parsePhpArray(string $content): ?array + { + // Create a temporary file to use PHP's include functionality safely + $tempFile = tempnam(sys_get_temp_dir(), 'php_trans_'); + file_put_contents($tempFile, $content); + + try { + $translations = include $tempFile; + unlink($tempFile); + + if (is_array($translations)) { + return $translations; + } + } catch (\Throwable $e) { + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + + return null; + } + + /** + * Generate PHP array content + * + * @param array $translations + * @param string $originalContent + * @return string + */ + private function generatePhpArray(array $translations, string $originalContent): string + { + // Extract the header comment from original content + $headerComment = ''; + if (preg_match('/^<\?php\s*(?:\/\*.*?\*\/\s*)?return\s*\[/s', $originalContent, $matches)) { + $headerComment = $matches[0]; + } else { + $headerComment = " $value) { + $escapedKey = $this->escapePhpString($key); + $escapedValue = $this->formatPhpValue($value, 1); + $content .= " {$escapedKey} => {$escapedValue},\n"; + } + + $content .= "\n];\n"; + + return $content; + } + + /** + * Format PHP value for output (handles strings and arrays) + * + * @param mixed $value + * @param int $indentLevel + * @return string + */ + private function formatPhpValue($value, int $indentLevel = 0): string + { + if (is_array($value)) { + return $this->formatPhpArray($value, $indentLevel); + } + + return $this->escapePhpString($value); + } + + /** + * Format PHP array for output + * + * @param array $array + * @param int $indentLevel + * @return string + */ + private function formatPhpArray(array $array, int $indentLevel = 0): string + { + $indent = str_repeat(' ', $indentLevel); + $nextIndent = str_repeat(' ', $indentLevel + 1); + + $content = "[\n"; + + foreach ($array as $key => $value) { + $escapedKey = $this->escapePhpString($key); + $formattedValue = $this->formatPhpValue($value, $indentLevel + 1); + $content .= "{$nextIndent}{$escapedKey} => {$formattedValue},\n"; + } + + $content .= "{$indent}]"; + + return $content; + } + + /** + * Escape PHP string for output + * + * @param string $string + * @return string + */ + private function escapePhpString(string $string): string + { + return "'" . str_replace("'", "\\'", $string) . "'"; + } + + /** + * Copy PHP file from resources-core to destination + * + * @param string $languageCode + * @param string $filename + * @return bool + */ + private function copyPhpFileFromResourcesCore(string $languageCode, string $filename): bool + { + $sourcePath = $this->resourcesCorePath . '/' . $languageCode . '/' . $filename; + $destinationPath = $languageCode . '/' . $filename; + + if (!file_exists($sourcePath)) { + return false; + } + + $content = file_get_contents($sourcePath); + + return $this->saveToDestination($destinationPath, $content); + } +} diff --git a/ProcessMaker/Helpers/SyncTranslationsBase.php b/ProcessMaker/Helpers/SyncTranslationsBase.php new file mode 100644 index 0000000000..cfbaa72467 --- /dev/null +++ b/ProcessMaker/Helpers/SyncTranslationsBase.php @@ -0,0 +1,214 @@ +langDisk = Storage::disk('lang'); + $this->resourcesCorePath = Config::get('app.resources_core_path') . '/lang'; + } + + /** + * Create a backup of a file before modification + * + * @param string $filepath + * @return bool + */ + protected function createBackup(string $filepath): bool + { + if (!$this->langDisk->exists($filepath)) { + return false; + } + + $timestamp = microtime(true); + $backupPath = $filepath . '.bak.' . $timestamp; + + $content = $this->langDisk->get($filepath); + $success = $this->langDisk->put($backupPath, $content); + + return $success; + } + + /** + * Clean up old backup files, keeping only the most recent MAX_BACKUPS + * + * @param string $filepath + * @return void + */ + protected function cleanupOldBackups(string $filepath): void + { + $backupPattern = $filepath . '.bak.*'; + $backups = []; + + // Get all backup files for this file + $files = $this->langDisk->allFiles(); + foreach ($files as $file) { + if (preg_match('/^' . preg_quote($filepath, '/') . '\.bak\.(\d+(?:\.\d+)?)$/', $file, $matches)) { + $backups[] = [ + 'path' => $file, + 'timestamp' => (float) $matches[1], + ]; + } + } + + // Only cleanup if we have more than MAX_BACKUPS + if (count($backups) <= self::MAX_BACKUPS) { + return; + } + + // Sort by timestamp (oldest first) + usort($backups, function ($a, $b) { + return $a['timestamp'] - $b['timestamp']; + }); + + // Remove oldest backups, keeping only the most recent MAX_BACKUPS + $toRemove = array_slice($backups, 0, count($backups) - self::MAX_BACKUPS); + foreach ($toRemove as $backup) { + $this->langDisk->delete($backup['path']); + } + } + + /** + * Get all language codes from resources-core + * + * @return array + */ + protected function getLanguageCodes(): array + { + $languages = []; + + // Get JSON files + $jsonFiles = glob($this->resourcesCorePath . '/*.json'); + foreach ($jsonFiles as $file) { + $filename = basename($file); + if (preg_match('/^([a-z]{2})\.json$/', $filename, $matches)) { + $languages[] = $matches[1]; + } + } + + // Get PHP directories + $directories = glob($this->resourcesCorePath . '/*', GLOB_ONLYDIR); + foreach ($directories as $dir) { + $dirname = basename($dir); + if (preg_match('/^([a-z]{2})$/', $dirname, $matches)) { + if (!in_array($matches[1], $languages)) { + $languages[] = $matches[1]; + } + } + } + + return array_unique($languages); + } + + /** + * Check if a file exists in the destination + * + * @param string $filename + * @return bool + */ + protected function destinationFileExists(string $filename): bool + { + return $this->langDisk->exists($filename); + } + + /** + * Copy a file from resources-core to destination + * + * @param string $filename + * @return bool + */ + protected function copyFileFromResourcesCore(string $filename): bool + { + $sourcePath = $this->resourcesCorePath . '/' . $filename; + + if (!file_exists($sourcePath)) { + return false; + } + + $content = file_get_contents($sourcePath); + + return $this->langDisk->put($filename, $content); + } + + /** + * Get file content from destination + * + * @param string $filename + * @return string|null + */ + protected function getDestinationContent(string $filename): ?string + { + if (!$this->langDisk->exists($filename)) { + return null; + } + + return $this->langDisk->get($filename); + } + + /** + * Get file content from resources-core + * + * @param string $filename + * @return string|null + */ + protected function getResourcesCoreContent(string $filename): ?string + { + $sourcePath = $this->resourcesCorePath . '/' . $filename; + + if (!file_exists($sourcePath)) { + return null; + } + + return file_get_contents($sourcePath); + } + + /** + * Save content to destination + * + * @param string $filename + * @param string $content + * @return bool + */ + protected function saveToDestination(string $filename, string $content): bool + { + return $this->langDisk->put($filename, $content); + } + + /** + * Process all translation files + * + * @return array + */ + abstract public function sync(): array; + + /** + * Process a single translation file + * + * @param string $languageCode + * @return array + */ + abstract protected function processLanguageFile(string $languageCode): array; +} diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index 2ca2efc3ec..e5dca8e0c6 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -41,6 +41,9 @@ public function makeCurrent(IsTenant $tenant): void $app = app(); $app->setStoragePath($tenantStoragePath); + // Use tenant's translation files + $app->useLangPath(resource_path('lang/tenant_' . $tenant->id)); + // Create the tenant storage directory if it doesn't exist // TODO: Move these to somewhere else - should not be run on every request if (!file_exists($tenantStoragePath)) { @@ -76,10 +79,7 @@ public function makeCurrent(IsTenant $tenant): void 'filesystems.disks.samlidp.root' => storage_path('samlidp'), 'filesystems.disks.decision_tables.root' => storage_path('decision-tables'), 'filesystems.disks.decision_tables.url' => $tenant->config['app.url'] . '/storage/decision-tables', - 'filesystems.disks.tenant_translations' => [ - 'driver' => 'local', - 'root' => storage_path('lang'), - ], + 'filesystems.disks.lang.root' => lang_path(), 'l5-swagger.defaults.paths.docs' => storage_path('api-docs'), 'app.instance' => self::$originalConfig[$tenant->id]['app.instance'] . '_' . $tenant->id, ]; @@ -122,14 +122,6 @@ public function makeCurrent(IsTenant $tenant): void $app->extend(Dispatcher::class, function ($dispatcher, $app) use ($tenant) { return new TenantAwareDispatcher($app, $dispatcher, $tenant->id); }); - - // Use tenant's translation files - $app->useLangPath(resource_path('lang/tenant_' . $tenant->id)); - - // May not be needed anymore - // $app->extend('translation.loader', function ($loader, $app) use ($tenant) { - // return new TenantAwareTranslationLoader($loader, $tenant->id); - // }); } /** diff --git a/config/app.php b/config/app.php index f94062de38..f4dc53ee74 100644 --- a/config/app.php +++ b/config/app.php @@ -295,4 +295,6 @@ 'json_optimization_decode' => env('JSON_OPTIMIZATION_DECODE', false), 'multitenancy' => env('MULTITENANCY', false), + + 'resources_core_path' => base_path('resources-core'), ]; diff --git a/config/filesystems.php b/config/filesystems.php index 182dde1afe..e79015155f 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -121,6 +121,11 @@ 'visibility' => 'private', ], + 'lang' => [ + 'driver' => 'local', + 'root' => lang_path(), + ], + // Note, this storage path is for all tenants. It is not modififed in SwitchTenant.php // Used for license.json since, for now, its the same for all tenants 'root' => [ diff --git a/resources/lang/en.json b/resources/lang/en.json index fddddbc8f6..2d4af364e2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1,5 +1,7 @@ { " pending": " pending", + "Test New Language String": "Test New Language String", + "Test Another New Language String": "Test Another New Language String", ":count tasks were not reassigned because the task settings prevent them from being reassigned": ":count tasks were not reassigned because the task settings prevent them from being reassigned", ":user has completed the task :task_name": ":user has completed the task :task_name", ":user rolled back :failed_task_name to :new_task_name": ":user rolled back :failed_task_name to :new_task_name", @@ -2659,4 +2661,4 @@ "Your profile was saved.": "Your profile was saved.", "Zoom In": "Zoom In", "Zoom Out": "Zoom Out" -} \ No newline at end of file +} diff --git a/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php new file mode 100644 index 0000000000..00ef26b2f4 --- /dev/null +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -0,0 +1,485 @@ +tempDir = sys_get_temp_dir() . '/sync_translations_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir . '/lang', 0755, true); + + // Set the resources-core path to our temp directory + Config::set('app.resources_core_path', $this->tempDir); + + // Create fake storage for lang disk + Storage::fake('lang'); + + $this->syncTranslations = new SyncJsonTranslations(); + } + + protected function tearDown(): void + { + // Clean up temporary directory + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + + parent::tearDown(); + } + + /** + * Test copying a new translation file when destination doesn't exist + */ + public function testCopyNewTranslationFile() + { + // Create a test JSON file in resources-core + $testTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + ]; + + $this->createTestFile('en.json', $testTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals('copied', $results['en']['action']); + $this->assertEquals(3, $results['en']['total_keys']); + $this->assertNull($results['en']['error']); + + // Verify file was copied correctly + $this->assertTrue(Storage::disk('lang')->exists('en.json')); + $copiedContent = Storage::disk('lang')->get('en.json'); + $copiedTranslations = json_decode($copiedContent, true); + $this->assertEquals($testTranslations, $copiedTranslations); + } + + /** + * Test merging new translations into existing file + */ + public function testMergeNewTranslations() + { + // Create existing translations in destination + $existingTranslations = [ + 'hello' => 'Hello Customized', + 'world' => 'World', + ]; + foreach (['en', 'es', 'fr', 'de'] as $language) { + Storage::disk('lang')->put($language . '.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + } + + // Create resources-core with additional translations + $resourcesCoreTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + 'goodbye' => 'Goodbye', + ]; + $this->createTestFile('en.json', $resourcesCoreTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals('merged', $results['en']['action']); + $this->assertEquals(2, $results['en']['new_keys']); + $this->assertEquals(4, $results['en']['total_keys']); + $this->assertNull($results['en']['error']); + + $otherLanguageResults = $results['en']['otherLanguageResults']; + + // Make sure the results show the other language files were updated + $this->assertArrayHasKey('es', $otherLanguageResults); + $this->assertEquals('updated', $otherLanguageResults['es']['action']); + $this->assertEquals(2, $otherLanguageResults['es']['new_keys']); + $this->assertEquals(4, $otherLanguageResults['es']['total_keys']); + $this->assertNull($otherLanguageResults['es']['error']); + + $this->assertArrayHasKey('fr', $otherLanguageResults); + $this->assertEquals('updated', $otherLanguageResults['fr']['action']); + $this->assertEquals(2, $otherLanguageResults['fr']['new_keys']); + $this->assertEquals(4, $otherLanguageResults['fr']['total_keys']); + $this->assertNull($otherLanguageResults['fr']['error']); + + $this->assertArrayHasKey('de', $otherLanguageResults); + $this->assertEquals('updated', $otherLanguageResults['de']['action']); + $this->assertEquals(2, $otherLanguageResults['de']['new_keys']); + $this->assertEquals(4, $otherLanguageResults['de']['total_keys']); + $this->assertNull($otherLanguageResults['de']['error']); + + // Verify merged content + $mergedContent = Storage::disk('lang')->get('en.json'); + $mergedTranslations = json_decode($mergedContent, true); + $expectedMerged = [ + 'hello' => 'Hello Customized', + 'world' => 'World', + 'welcome' => 'Welcome', + 'goodbye' => 'Goodbye', + ]; + $this->assertEquals($expectedMerged, $mergedTranslations); + + // Verify that the other language files have the new keys + // with empty values because the file being merged is en.json. + + $esTranslations = Storage::disk('lang')->get('es.json'); + $esTranslations = json_decode($esTranslations, true); + $this->assertEquals('World', $esTranslations['world']); + $this->assertEquals('', $esTranslations['welcome']); + $this->assertEquals('', $esTranslations['goodbye']); + + $frTranslations = Storage::disk('lang')->get('fr.json'); + $frTranslations = json_decode($frTranslations, true); + $this->assertEquals('World', $esTranslations['world']); + $this->assertEquals('', $frTranslations['welcome']); + $this->assertEquals('', $frTranslations['goodbye']); + + $deTranslations = Storage::disk('lang')->get('de.json'); + $deTranslations = json_decode($deTranslations, true); + $this->assertEquals('World', $esTranslations['world']); + $this->assertEquals('', $deTranslations['welcome']); + $this->assertEquals('', $deTranslations['goodbye']); + } + + /** + * Test no changes when all translations already exist + */ + public function testNoChangesWhenAllTranslationsExist() + { + // Create existing translations in destination + $existingTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + ]; + Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + + // Create resources-core with same translations + $this->createTestFile('en.json', $existingTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals('no_changes', $results['en']['action']); + $this->assertEquals(0, $results['en']['new_keys']); + $this->assertEquals(3, $results['en']['total_keys']); + $this->assertNull($results['en']['error']); + } + + /** + * Test handling invalid JSON in resources-core + */ + public function testInvalidJsonInResourcesCore() + { + // Create invalid JSON file in resources-core + file_put_contents($this->tempDir . '/lang/en.json', '{"hello": "Hello", "world":}'); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals('none', $results['en']['action']); + $this->assertNotNull($results['en']['error']); + $this->assertStringContainsString('Invalid JSON', $results['en']['error']); + } + + /** + * Test handling invalid JSON in destination + */ + public function testInvalidJsonInDestination() + { + // Create invalid JSON in destination + Storage::disk('lang')->put('en.json', '{"hello": "Hello", "world":}'); + + // Create valid JSON in resources-core + $validTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + ]; + $this->createTestFile('en.json', $validTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals('none', $results['en']['action']); + $this->assertNotNull($results['en']['error']); + $this->assertStringContainsString('Invalid JSON', $results['en']['error']); + } + + /** + * Test processing multiple language files + */ + public function testProcessMultipleLanguages() + { + // Create multiple language files in resources-core + $this->createTestFile('en.json', ['hello' => 'Hello', 'world' => 'World']); + $this->createTestFile('es.json', ['hola' => 'Hola', 'mundo' => 'Mundo']); + $this->createTestFile('fr.json', ['bonjour' => 'Bonjour', 'monde' => 'Monde']); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertArrayHasKey('es', $results); + $this->assertArrayHasKey('fr', $results); + + $this->assertEquals('copied', $results['en']['action']); + $this->assertEquals('copied', $results['es']['action']); + $this->assertEquals('copied', $results['fr']['action']); + + // Verify all files were created + $this->assertTrue(Storage::disk('lang')->exists('en.json')); + $this->assertTrue(Storage::disk('lang')->exists('es.json')); + $this->assertTrue(Storage::disk('lang')->exists('fr.json')); + } + + /** + * Test preserving existing custom translations + */ + public function testPreserveExistingCustomTranslations() + { + // Create existing translations with custom values + $existingTranslations = [ + 'hello' => 'Custom Hello', + 'world' => 'Custom World', + 'custom_key' => 'Custom Value', + ]; + Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + + // Create resources-core with different values for existing keys and new keys + $resourcesCoreTranslations = [ + 'hello' => 'Default Hello', + 'world' => 'Default World', + 'welcome' => 'Welcome', + 'goodbye' => 'Goodbye', + ]; + $this->createTestFile('en.json', $resourcesCoreTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('merged', $results['en']['action']); + $this->assertEquals(2, $results['en']['new_keys']); // welcome and goodbye + $this->assertEquals(5, $results['en']['total_keys']); // hello, world, custom_key, welcome, goodbye + + // Verify custom translations were preserved + $mergedContent = Storage::disk('lang')->get('en.json'); + $mergedTranslations = json_decode($mergedContent, true); + + $this->assertEquals('Custom Hello', $mergedTranslations['hello']); // Preserved custom value + $this->assertEquals('Custom World', $mergedTranslations['world']); // Preserved custom value + $this->assertEquals('Custom Value', $mergedTranslations['custom_key']); // Preserved custom key + $this->assertEquals('Welcome', $mergedTranslations['welcome']); // New from resources-core + $this->assertEquals('Goodbye', $mergedTranslations['goodbye']); // New from resources-core + } + + /** + * Test backup creation when merging translations + */ + public function testBackupCreationWhenMerging() + { + // Create existing translations in destination + $existingTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + ]; + foreach (['en', 'es', 'fr', 'de'] as $language) { + Storage::disk('lang')->put($language . '.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + } + + // Create resources-core with additional translations + $resourcesCoreTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + ]; + $this->createTestFile('en.json', $resourcesCoreTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('merged', $results['en']['action']); + $this->assertTrue($results['en']['backup_created']); + + // Verify backup file was created + $backupFiles = $this->getBackupFiles('en.json'); + $this->assertCount(1, $backupFiles); + + // Verify backup content matches original + $backupContent = Storage::disk('lang')->get($backupFiles[0]); + $backupTranslations = json_decode($backupContent, true); + $this->assertEquals($existingTranslations, $backupTranslations); + + // Also, verify that the other language files have backups + $esBackupFiles = $this->getBackupFiles('es.json'); + $this->assertCount(1, $esBackupFiles); + + $frBackupFiles = $this->getBackupFiles('fr.json'); + $this->assertCount(1, $frBackupFiles); + + $deBackupFiles = $this->getBackupFiles('de.json'); + $this->assertCount(1, $deBackupFiles); + } + + /** + * Test no backup creation when no changes are made + */ + public function testNoBackupCreationWhenNoChanges() + { + // Create existing translations in destination + $existingTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + ]; + Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + + // Create resources-core with same translations + $this->createTestFile('en.json', $existingTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('no_changes', $results['en']['action']); + $this->assertFalse($results['en']['backup_created']); + + // Verify no backup files were created + $backupFiles = $this->getBackupFiles('en.json'); + $this->assertCount(0, $backupFiles); + } + + /** + * Test backup rotation (keeping only 3 most recent backups) + */ + public function testBackupRotation() + { + // Create existing translations in destination + $existingTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + ]; + Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + + // Run sync multiple times to create multiple backups + for ($i = 0; $i < 7; $i++) { + // Create resources-core with additional translations (different each time) + $resourcesCoreTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + ]; + + // Add a new key each time to ensure changes are detected + $resourcesCoreTranslations['new_key_' . $i] = 'Value ' . $i; + + $this->createTestFile('en.json', $resourcesCoreTranslations); + + $results = $this->syncTranslations->sync(); + + // Small delay to ensure different timestamps + usleep(1000); + } + + // Verify only 3 backup files exist + $backupFiles = $this->getBackupFiles('en.json'); + $this->assertCount(5, $backupFiles); + } + + /** + * Test backup creation when copying new files + */ + public function testNoBackupCreationWhenCopying() + { + // Create a test JSON file in resources-core + $testTranslations = [ + 'hello' => 'Hello', + 'world' => 'World', + ]; + $this->createTestFile('en.json', $testTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('copied', $results['en']['action']); + $this->assertFalse($results['en']['backup_created']); + + // Verify no backup files were created + $backupFiles = $this->getBackupFiles('en.json'); + $this->assertCount(0, $backupFiles); + } + + /** + * Helper method to get backup files for a given file + */ + private function getBackupFiles(string $filename): array + { + $backupFiles = []; + $files = Storage::disk('lang')->files(); + + foreach ($files as $file) { + if (preg_match('/^' . preg_quote($filename, '/') . '\.bak\.\d+(?:\.\d+)?$/', $file)) { + $backupFiles[] = $file; + } + } + + return $backupFiles; + } + + /** + * Helper method to create test files + */ + private function createTestFile(string $filename, array $translations): void + { + $content = json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + file_put_contents($this->tempDir . '/lang/' . $filename, $content); + } + + /** + * Helper method to remove directory recursively + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +} diff --git a/tests/Feature/SyncPhpTranslationsTest.php b/tests/Feature/SyncPhpTranslationsTest.php new file mode 100644 index 0000000000..e02dcc843b --- /dev/null +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -0,0 +1,544 @@ +tempDir = sys_get_temp_dir() . '/sync_php_translations_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir . '/lang', 0755, true); + + // Set the resources-core path to our temp directory + Config::set('app.resources_core_path', $this->tempDir); + + // Create fake storage for lang disk + Storage::fake('lang'); + + $this->syncTranslations = new SyncPhpTranslations(); + } + + protected function tearDown(): void + { + // Clean up temporary directory + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + + parent::tearDown(); + } + + /** + * Test copying new PHP translation files when destination doesn't exist + */ + public function testCopyNewPhpTranslationFiles() + { + // Create test PHP files in resources-core + $this->createTestPhpFile('en/auth.php', [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'nested' => [ + 'key' => 'Nested key', + ], + ]); + + $this->createTestPhpFile('en/validation.php', [ + 'required' => 'The :attribute field is required.', + 'email' => 'The :attribute must be a valid email address.', + 'nested' => [ + 'key' => 'Nested key', + ], + ]); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(2, $results['en']['files_processed']); + $this->assertEquals(2, $results['en']['files_copied']); + $this->assertEquals(0, $results['en']['files_merged']); + $this->assertEmpty($results['en']['errors']); + + // Verify files were copied correctly + $this->assertTrue(Storage::disk('lang')->exists('en/auth.php')); + $this->assertTrue(Storage::disk('lang')->exists('en/validation.php')); + + // Verify content was copied correctly + $authContent = require Storage::disk('lang')->path('en/auth.php'); + $this->assertEquals('These credentials do not match our records.', $authContent['failed']); + $this->assertEquals('Nested key', $authContent['nested']['key']); + + $validationContent = require Storage::disk('lang')->path('en/validation.php'); + $this->assertEquals('The :attribute field is required.', $validationContent['required']); + $this->assertEquals('Nested key', $validationContent['nested']['key']); + } + + /** + * Test merging new translations into existing PHP files + */ + public function testMergeNewPhpTranslations() + { + // Create existing translations in destination + $existingAuth = [ + 'failed' => 'Custom failed message.', + 'password' => 'The provided password is incorrect.', + 'nested' => [ + 'key' => 'Custom nested key value', + ], + ]; + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingAuth)); + + // Create resources-core with additional translations + $resourcesCoreAuth = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'new_key' => 'New translation value.', + 'nested' => [ + 'key' => 'A different nested key value', + ], + ]; + $this->createTestPhpFile('en/auth.php', $resourcesCoreAuth); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(1, $results['en']['files_processed']); + $this->assertEquals(0, $results['en']['files_copied']); + $this->assertEquals(1, $results['en']['files_merged']); + $this->assertEmpty($results['en']['errors']); + + // Verify merged content + $mergedContent = require Storage::disk('lang')->path('en/auth.php'); + $this->assertEquals('Custom failed message.', $mergedContent['failed']); // Preserved custom value + $this->assertEquals('Too many login attempts. Please try again in :seconds seconds.', $mergedContent['throttle']); // New from resources-core + $this->assertEquals('New translation value.', $mergedContent['new_key']); // New from resources-core + // Does not overwrite custom nested key value + $this->assertEquals('Custom nested key value', $mergedContent['nested']['key']); // New from resources-core + } + + /** + * Test no changes when all translations already exist + */ + public function testNoChangesWhenAllPhpTranslationsExist() + { + // Create existing translations in destination + $existingTranslations = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + ]; + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); + + // Create resources-core with same translations + $this->createTestPhpFile('en/auth.php', $existingTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(1, $results['en']['files_processed']); + $this->assertEquals(0, $results['en']['files_copied']); + $this->assertEquals(0, $results['en']['files_merged']); + $this->assertEquals(1, $results['en']['files_no_changes']); + $this->assertEmpty($results['en']['errors']); + } + + /** + * Test handling invalid PHP in resources-core + */ + public function testInvalidPhpInResourcesCore() + { + // Create invalid PHP file in resources-core + $invalidContent = " 'These credentials do not match our records.',\n 'password' =>\n];\n"; + mkdir($this->tempDir . '/lang/en', 0755, true); + file_put_contents($this->tempDir . '/lang/en/auth.php', $invalidContent); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(1, $results['en']['files_processed']); + $this->assertEquals(1, count($results['en']['errors'])); + $this->assertStringContainsString('Invalid PHP array', $results['en']['errors'][0]); + } + + /** + * Test handling invalid PHP in destination + */ + public function testInvalidPhpInDestination() + { + // Create invalid PHP in destination + $invalidContent = " 'These credentials do not match our records.',\n 'password' =>\n];\n"; + Storage::disk('lang')->put('en/auth.php', $invalidContent); + + // Create valid PHP in resources-core + $validTranslations = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + ]; + $this->createTestPhpFile('en/auth.php', $validTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(1, $results['en']['files_processed']); + $this->assertEquals(1, count($results['en']['errors'])); + $this->assertStringContainsString('Invalid PHP array', $results['en']['errors'][0]); + } + + /** + * Test processing multiple language files + */ + public function testProcessMultiplePhpLanguages() + { + // Create multiple language files in resources-core + $this->createTestPhpFile('en/auth.php', ['failed' => 'These credentials do not match our records.']); + $this->createTestPhpFile('es/auth.php', ['failed' => 'Estas credenciales no coinciden con nuestros registros.']); + $this->createTestPhpFile('fr/auth.php', ['failed' => 'Ces identifiants ne correspondent pas à nos enregistrements.']); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertArrayHasKey('es', $results); + $this->assertArrayHasKey('fr', $results); + + $this->assertEquals(1, $results['en']['files_copied']); + $this->assertEquals(1, $results['es']['files_copied']); + $this->assertEquals(1, $results['fr']['files_copied']); + + // Verify all files were created + $this->assertTrue(Storage::disk('lang')->exists('en/auth.php')); + $this->assertTrue(Storage::disk('lang')->exists('es/auth.php')); + $this->assertTrue(Storage::disk('lang')->exists('fr/auth.php')); + } + + /** + * Test preserving existing custom translations + */ + public function testPreserveExistingCustomPhpTranslations() + { + // Create existing translations with custom values + $existingTranslations = [ + 'failed' => 'Custom failed message.', + 'password' => 'Custom password message.', + 'custom_key' => 'Custom value', + ]; + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); + + // Create resources-core with different values for existing keys and new keys + $resourcesCoreTranslations = [ + 'failed' => 'Default failed message.', + 'password' => 'Default password message.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'new_key' => 'New translation value.', + ]; + $this->createTestPhpFile('en/auth.php', $resourcesCoreTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals(1, $results['en']['files_merged']); + $this->assertEquals(2, $results['en']['details']['auth.php']['new_keys']); // throttle and new_key + + // Verify custom translations were preserved + $mergedContent = Storage::disk('lang')->get('en/auth.php'); + $this->assertStringContainsString("'failed' => 'Custom failed message.'", $mergedContent); // Preserved custom value + $this->assertStringContainsString("'custom_key' => 'Custom value'", $mergedContent); // Preserved custom key + $this->assertStringContainsString("'throttle' => 'Too many login attempts. Please try again in :seconds seconds.'", $mergedContent); // New from resources-core + $this->assertStringContainsString("'new_key' => 'New translation value.'", $mergedContent); // New from resources-core + } + + /** + * Test processing multiple PHP files in same language + */ + public function testProcessMultiplePhpFilesInSameLanguage() + { + // Create multiple PHP files in resources-core + $this->createTestPhpFile('en/auth.php', ['failed' => 'These credentials do not match our records.']); + $this->createTestPhpFile('en/validation.php', ['required' => 'The :attribute field is required.']); + $this->createTestPhpFile('en/passwords.php', ['reset' => 'Your password has been reset.']); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertArrayHasKey('en', $results); + $this->assertEquals(3, $results['en']['files_processed']); + $this->assertEquals(3, $results['en']['files_copied']); + + // Verify all files were created + $this->assertTrue(Storage::disk('lang')->exists('en/auth.php')); + $this->assertTrue(Storage::disk('lang')->exists('en/validation.php')); + $this->assertTrue(Storage::disk('lang')->exists('en/passwords.php')); + } + + /** + * Test backup creation when merging PHP translations + */ + public function testBackupCreationWhenMergingPhp() + { + // Create existing translations in destination + $existingTranslations = [ + 'failed' => 'Custom failed message.', + 'password' => 'The provided password is incorrect.', + ]; + + // Try using a flat filename to avoid nested path issues + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); + + // Create resources-core with additional translations + $resourcesCoreTranslations = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + ]; + $this->createTestPhpFile('en/auth.php', $resourcesCoreTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('merged', $results['en']['details']['auth.php']['action']); + $this->assertTrue($results['en']['details']['auth.php']['backup_created']); + + // Verify backup file was created + $backupFiles = $this->getBackupFiles('en/auth.php'); + + $this->assertCount(1, $backupFiles); + + // Verify backup content matches original + $backupContent = Storage::disk('lang')->get($backupFiles[0]); + $this->assertStringContainsString("'failed' => 'Custom failed message.'", $backupContent); + $this->assertStringContainsString("'password' => 'The provided password is incorrect.'", $backupContent); + } + + /** + * Test no backup creation when no changes are made to PHP files + */ + public function testNoBackupCreationWhenNoPhpChanges() + { + // Create existing translations in destination + $existingTranslations = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + ]; + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); + + // Create resources-core with same translations + $this->createTestPhpFile('en/auth.php', $existingTranslations); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('no_changes', $results['en']['details']['auth.php']['action']); + $this->assertFalse($results['en']['details']['auth.php']['backup_created']); + + // Verify no backup files were created + $backupFiles = $this->getBackupFiles('en/auth.php'); + $this->assertCount(0, $backupFiles); + } + + /** + * Test backup rotation for PHP files (keeping only 3 most recent backups) + */ + public function testBackupRotationForPhpFiles() + { + // Create existing translations in destination + $existingTranslations = [ + 'failed' => 'Custom failed message.', + 'password' => 'The provided password is incorrect.', + ]; + Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); + + // Run sync multiple times to create multiple backups + for ($i = 0; $i < 7; $i++) { + // Create resources-core with additional translations (different each time) + $resourcesCoreTranslations = [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + ]; + + // Add a new key each time to ensure changes are detected + $resourcesCoreTranslations['new_key_' . $i] = 'Value ' . $i; + + $this->createTestPhpFile('en/auth.php', $resourcesCoreTranslations); + + $this->syncTranslations->sync(); + + // Small delay to ensure different timestamps + usleep(1000); + } + + // Verify only 3 backup files exist + $backupFiles = $this->getBackupFiles('en/auth.php'); + $this->assertCount(5, $backupFiles); + + // Verify the backup files have different timestamps + $this->assertEquals(5, count(array_unique($backupFiles))); + } + + /** + * Test no backup creation when copying new PHP files + */ + public function testNoBackupCreationWhenCopyingPhp() + { + // Create test PHP files in resources-core + $this->createTestPhpFile('en/auth.php', [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + ]); + + // Run sync + $results = $this->syncTranslations->sync(); + + // Assert results + $this->assertEquals('copied', $results['en']['details']['auth.php']['action']); + $this->assertFalse($results['en']['details']['auth.php']['backup_created']); + + // Verify no backup files were created + $backupFiles = $this->getBackupFiles('en/auth.php'); + $this->assertCount(0, $backupFiles); + } + + /** + * Helper method to get backup files for a given file + */ + private function getBackupFiles(string $filepath): array + { + $backupFiles = []; + $files = Storage::disk('lang')->allFiles(); + + foreach ($files as $file) { + if (preg_match('/^' . preg_quote($filepath, '/') . '\.bak\.\d+(?:\.\d+)?$/', $file)) { + $backupFiles[] = $file; + } + } + + return $backupFiles; + } + + /** + * Helper method to create test PHP files + */ + private function createTestPhpFile(string $path, array $translations): void + { + $fullPath = $this->tempDir . '/lang/' . $path; + $dir = dirname($fullPath); + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $content = $this->generatePhpContent($translations); + file_put_contents($fullPath, $content); + } + + /** + * Helper method to generate PHP content + */ + private function generatePhpContent(array $translations): string + { + $content = " $value) { + $escapedKey = $this->escapePhpString($key); + $formattedValue = $this->formatPhpValue($value, 1); + $content .= " {$escapedKey} => {$formattedValue},\n"; + } + + $content .= "\n];\n"; + + return $content; + } + + /** + * Format PHP value for output (handles strings and arrays) + */ + private function formatPhpValue($value, int $indentLevel = 0): string + { + if (is_array($value)) { + return $this->formatPhpArray($value, $indentLevel); + } + + return $this->escapePhpString($value); + } + + /** + * Format PHP array for output + */ + private function formatPhpArray(array $array, int $indentLevel = 0): string + { + $indent = str_repeat(' ', $indentLevel); + $nextIndent = str_repeat(' ', $indentLevel + 1); + + $content = "[\n"; + + foreach ($array as $key => $value) { + $escapedKey = $this->escapePhpString($key); + $formattedValue = $this->formatPhpValue($value, $indentLevel + 1); + $content .= "{$nextIndent}{$escapedKey} => {$formattedValue},\n"; + } + + $content .= "{$indent}]"; + + return $content; + } + + /** + * Escape PHP string for output + */ + private function escapePhpString(string $string): string + { + return "'" . str_replace("'", "\\'", $string) . "'"; + } + + /** + * Helper method to remove directory recursively + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +}