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);
+ }
+}