From 2009a79151cc3e6bdacdcad3e81f559fe711caf4 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 26 Aug 2025 12:09:42 -0700 Subject: [PATCH 1/8] Implement new sync translations logic --- ProcessMaker/Helpers/SyncJsonTranslations.php | 118 ++++++ ProcessMaker/Helpers/SyncPhpTranslations.php | 275 ++++++++++++++ ProcessMaker/Helpers/SyncTranslationsBase.php | 149 ++++++++ ProcessMaker/Multitenancy/SwitchTenant.php | 16 +- config/app.php | 2 + config/filesystems.php | 5 + tests/Feature/SyncJsonTranslationsTest.php | 287 +++++++++++++++ tests/Feature/SyncPhpTranslationsTest.php | 338 ++++++++++++++++++ 8 files changed, 1178 insertions(+), 12 deletions(-) create mode 100644 ProcessMaker/Helpers/SyncJsonTranslations.php create mode 100644 ProcessMaker/Helpers/SyncPhpTranslations.php create mode 100644 ProcessMaker/Helpers/SyncTranslationsBase.php create mode 100644 tests/Feature/SyncJsonTranslationsTest.php create mode 100644 tests/Feature/SyncPhpTranslationsTest.php diff --git a/ProcessMaker/Helpers/SyncJsonTranslations.php b/ProcessMaker/Helpers/SyncJsonTranslations.php new file mode 100644 index 0000000000..36e48851d6 --- /dev/null +++ b/ProcessMaker/Helpers/SyncJsonTranslations.php @@ -0,0 +1,118 @@ +getLanguageCodes(); + + foreach ($languageCodes as $languageCode) { + $results[$languageCode] = $this->processLanguageFile($languageCode); + } + + 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, + '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) { + $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); + } else { + $result['error'] = 'Failed to save merged translations'; + } + } else { + $result['action'] = 'no_changes'; + $result['total_keys'] = count($mergedTranslations); + } + } catch (\Exception $e) { + $result['error'] = 'Exception occurred: ' . $e->getMessage(); + } + + return $result; + } +} diff --git a/ProcessMaker/Helpers/SyncPhpTranslations.php b/ProcessMaker/Helpers/SyncPhpTranslations.php new file mode 100644 index 0000000000..3f27371f58 --- /dev/null +++ b/ProcessMaker/Helpers/SyncPhpTranslations.php @@ -0,0 +1,275 @@ +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, + '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) { + $mergedContent = $this->generatePhpArray($mergedTranslations, $sourceContent); + if ($this->saveToDestination($destinationPath, $mergedContent)) { + $fileResult['action'] = 'merged'; + $fileResult['new_keys'] = $newKeysCount; + $fileResult['total_keys'] = count($mergedTranslations); + } 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->escapePhpString($value); + $content .= " {$escapedKey} => {$escapedValue},\n"; + } + + $content .= "\n];\n"; + + 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..ee2891451d --- /dev/null +++ b/ProcessMaker/Helpers/SyncTranslationsBase.php @@ -0,0 +1,149 @@ +langDisk = Storage::disk('lang'); + $this->resourcesCorePath = Config::get('app.resources_core_path') . '/lang'; + } + + /** + * 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 a134cfdffb..051ab3e537 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -38,6 +38,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)) { @@ -73,10 +76,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, ]; @@ -119,14 +119,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/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php new file mode 100644 index 0000000000..03b81aaba0 --- /dev/null +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -0,0 +1,287 @@ +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', + 'world' => 'World', + ]; + Storage::disk('lang')->put('en.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']); + + // Verify merged content + $mergedContent = Storage::disk('lang')->get('en.json'); + $mergedTranslations = json_decode($mergedContent, true); + $expectedMerged = [ + 'hello' => 'Hello', + 'world' => 'World', + 'welcome' => 'Welcome', + 'goodbye' => 'Goodbye', + ]; + $this->assertEquals($expectedMerged, $mergedTranslations); + } + + /** + * 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 + } + + /** + * 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..571cea810e --- /dev/null +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -0,0 +1,338 @@ +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.', + ]); + + $this->createTestPhpFile('en/validation.php', [ + 'required' => 'The :attribute field is required.', + 'email' => 'The :attribute must be a valid email address.', + ]); + + // 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 = Storage::disk('lang')->get('en/auth.php'); + $this->assertStringContainsString("'failed' => 'These credentials do not match our records.'", $authContent); + + $validationContent = Storage::disk('lang')->get('en/validation.php'); + $this->assertStringContainsString("'required' => 'The :attribute field is required.'", $validationContent); + } + + /** + * 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.', + ]; + 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.', + ]; + $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 = Storage::disk('lang')->get('en/auth.php'); + $this->assertStringContainsString("'failed' => 'Custom failed message.'", $mergedContent); // Preserved custom value + $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 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')); + } + + /** + * 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) { + $content .= " '{$key}' => '{$value}',\n"; + } + + $content .= "\n];\n"; + + return $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); + } +} From 2614adf90e13d8a3fd39d578fac2ceb759f332bb Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 3 Sep 2025 12:43:51 -0700 Subject: [PATCH 2/8] wip --- ProcessMaker/Helpers/SyncJsonTranslations.php | 8 + ProcessMaker/Helpers/SyncPhpTranslations.php | 8 + ProcessMaker/Helpers/SyncTranslationsBase.php | 65 +++++++ tests/Feature/SyncJsonTranslationsTest.php | 142 ++++++++++++++ tests/Feature/SyncPhpTranslationsTest.php | 178 ++++++++++++++++++ 5 files changed, 401 insertions(+) diff --git a/ProcessMaker/Helpers/SyncJsonTranslations.php b/ProcessMaker/Helpers/SyncJsonTranslations.php index 36e48851d6..4704271bc0 100644 --- a/ProcessMaker/Helpers/SyncJsonTranslations.php +++ b/ProcessMaker/Helpers/SyncJsonTranslations.php @@ -35,6 +35,7 @@ protected function processLanguageFile(string $languageCode): array 'action' => 'none', 'new_keys' => 0, 'total_keys' => 0, + 'backup_created' => false, 'error' => null, ]; @@ -97,11 +98,18 @@ protected function processLanguageFile(string $languageCode): array // 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'; } diff --git a/ProcessMaker/Helpers/SyncPhpTranslations.php b/ProcessMaker/Helpers/SyncPhpTranslations.php index 3f27371f58..13d03f6205 100644 --- a/ProcessMaker/Helpers/SyncPhpTranslations.php +++ b/ProcessMaker/Helpers/SyncPhpTranslations.php @@ -92,6 +92,7 @@ private function processPhpFile(string $languageCode, string $filename): array 'action' => 'none', 'new_keys' => 0, 'total_keys' => 0, + 'backup_created' => false, 'error' => null, ]; @@ -162,11 +163,18 @@ private function processPhpFile(string $languageCode, string $filename): array // 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'; diff --git a/ProcessMaker/Helpers/SyncTranslationsBase.php b/ProcessMaker/Helpers/SyncTranslationsBase.php index ee2891451d..a119310f8e 100644 --- a/ProcessMaker/Helpers/SyncTranslationsBase.php +++ b/ProcessMaker/Helpers/SyncTranslationsBase.php @@ -17,6 +17,11 @@ abstract class SyncTranslationsBase */ protected $resourcesCorePath; + /** + * Maximum number of backup files to keep + */ + protected const MAX_BACKUPS = 3; + /** * Constructor */ @@ -26,6 +31,66 @@ public function __construct() $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->files(); + 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 * diff --git a/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php index 03b81aaba0..c6bcaebc79 100644 --- a/tests/Feature/SyncJsonTranslationsTest.php +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -254,6 +254,148 @@ public function testPreserveExistingCustomTranslations() $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', + ]; + Storage::disk('lang')->put('en.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); + } + + /** + * 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 < 5; $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(3, $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 */ diff --git a/tests/Feature/SyncPhpTranslationsTest.php b/tests/Feature/SyncPhpTranslationsTest.php index 571cea810e..fb1e500b73 100644 --- a/tests/Feature/SyncPhpTranslationsTest.php +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -61,6 +61,10 @@ public function testCopyNewPhpTranslationFiles() // Run sync $results = $this->syncTranslations->sync(); + // Debug: Print sync results + echo 'Sync results: '; + var_dump($results); + // Assert results $this->assertArrayHasKey('en', $results); $this->assertEquals(2, $results['en']['files_processed']); @@ -282,6 +286,180 @@ public function testProcessMultiplePhpFilesInSameLanguage() $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)); + + // Debug: Check if the file was created + echo 'File exists after creation: ' . (Storage::disk('lang')->exists('en/auth.php') ? 'yes' : 'no') . "\n"; + echo 'File content: ' . Storage::disk('lang')->get('en/auth.php') . "\n"; + + // Debug: Check what files exist before sync + echo 'Files before sync: '; + var_dump(Storage::disk('lang')->files()); + + // 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(); + + // Debug: Check what files exist after sync + echo 'Files after sync: '; + var_dump(Storage::disk('lang')->files()); + + // Assert results + dump($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'); + + // Debug: Print all files in storage + echo 'All files in storage: '; + var_dump(Storage::disk('lang')->files()); + echo 'Backup files found: '; + var_dump($backupFiles); + + $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 < 5; $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(3, $backupFiles); + + // Verify the backup files have different timestamps + $timestamps = []; + foreach ($backupFiles as $backupFile) { + if (preg_match('/\.bak\.(\d+)$/', $backupFile, $matches)) { + $timestamps[] = (int) $matches[1]; + } + } + + $this->assertCount(3, $timestamps); + $this->assertEquals(count($timestamps), count(array_unique($timestamps))); + } + + /** + * 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')->files(); + + 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 */ From 73be3fcc1ef627ba5c9e3a1000ab42c829d423ab Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 3 Sep 2025 17:30:34 -0700 Subject: [PATCH 3/8] Fix sync translations backup --- ProcessMaker/Helpers/SyncTranslationsBase.php | 2 +- tests/Feature/SyncPhpTranslationsTest.php | 35 ++----------------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/ProcessMaker/Helpers/SyncTranslationsBase.php b/ProcessMaker/Helpers/SyncTranslationsBase.php index a119310f8e..9a3f8ae32a 100644 --- a/ProcessMaker/Helpers/SyncTranslationsBase.php +++ b/ProcessMaker/Helpers/SyncTranslationsBase.php @@ -64,7 +64,7 @@ protected function cleanupOldBackups(string $filepath): void $backups = []; // Get all backup files for this file - $files = $this->langDisk->files(); + $files = $this->langDisk->allFiles(); foreach ($files as $file) { if (preg_match('/^' . preg_quote($filepath, '/') . '\.bak\.(\d+(?:\.\d+)?)$/', $file, $matches)) { $backups[] = [ diff --git a/tests/Feature/SyncPhpTranslationsTest.php b/tests/Feature/SyncPhpTranslationsTest.php index fb1e500b73..82ac003f98 100644 --- a/tests/Feature/SyncPhpTranslationsTest.php +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -61,10 +61,6 @@ public function testCopyNewPhpTranslationFiles() // Run sync $results = $this->syncTranslations->sync(); - // Debug: Print sync results - echo 'Sync results: '; - var_dump($results); - // Assert results $this->assertArrayHasKey('en', $results); $this->assertEquals(2, $results['en']['files_processed']); @@ -300,14 +296,6 @@ public function testBackupCreationWhenMergingPhp() // Try using a flat filename to avoid nested path issues Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); - // Debug: Check if the file was created - echo 'File exists after creation: ' . (Storage::disk('lang')->exists('en/auth.php') ? 'yes' : 'no') . "\n"; - echo 'File content: ' . Storage::disk('lang')->get('en/auth.php') . "\n"; - - // Debug: Check what files exist before sync - echo 'Files before sync: '; - var_dump(Storage::disk('lang')->files()); - // Create resources-core with additional translations $resourcesCoreTranslations = [ 'failed' => 'These credentials do not match our records.', @@ -319,24 +307,13 @@ public function testBackupCreationWhenMergingPhp() // Run sync $results = $this->syncTranslations->sync(); - // Debug: Check what files exist after sync - echo 'Files after sync: '; - var_dump(Storage::disk('lang')->files()); - // Assert results - dump($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'); - // Debug: Print all files in storage - echo 'All files in storage: '; - var_dump(Storage::disk('lang')->files()); - echo 'Backup files found: '; - var_dump($backupFiles); - $this->assertCount(1, $backupFiles); // Verify backup content matches original @@ -409,15 +386,7 @@ public function testBackupRotationForPhpFiles() $this->assertCount(3, $backupFiles); // Verify the backup files have different timestamps - $timestamps = []; - foreach ($backupFiles as $backupFile) { - if (preg_match('/\.bak\.(\d+)$/', $backupFile, $matches)) { - $timestamps[] = (int) $matches[1]; - } - } - - $this->assertCount(3, $timestamps); - $this->assertEquals(count($timestamps), count(array_unique($timestamps))); + $this->assertEquals(3, count(array_unique($backupFiles))); } /** @@ -449,7 +418,7 @@ public function testNoBackupCreationWhenCopyingPhp() private function getBackupFiles(string $filepath): array { $backupFiles = []; - $files = Storage::disk('lang')->files(); + $files = Storage::disk('lang')->allFiles(); foreach ($files as $file) { if (preg_match('/^' . preg_quote($filepath, '/') . '\.bak\.\d+(?:\.\d+)?$/', $file)) { From 56c2742e29d9a5f9a7e785a8896f228ed2ce41db Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 3 Sep 2025 18:21:27 -0700 Subject: [PATCH 4/8] Handle nested translations --- ProcessMaker/Helpers/SyncPhpTranslations.php | 43 ++++++++++- tests/Feature/SyncPhpTranslationsTest.php | 77 +++++++++++++++++--- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/ProcessMaker/Helpers/SyncPhpTranslations.php b/ProcessMaker/Helpers/SyncPhpTranslations.php index 13d03f6205..3d827929bb 100644 --- a/ProcessMaker/Helpers/SyncPhpTranslations.php +++ b/ProcessMaker/Helpers/SyncPhpTranslations.php @@ -240,7 +240,7 @@ private function generatePhpArray(array $translations, string $originalContent): foreach ($translations as $key => $value) { $escapedKey = $this->escapePhpString($key); - $escapedValue = $this->escapePhpString($value); + $escapedValue = $this->formatPhpValue($value, 1); $content .= " {$escapedKey} => {$escapedValue},\n"; } @@ -249,6 +249,47 @@ private function generatePhpArray(array $translations, string $originalContent): 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 * diff --git a/tests/Feature/SyncPhpTranslationsTest.php b/tests/Feature/SyncPhpTranslationsTest.php index 82ac003f98..27f675b7b6 100644 --- a/tests/Feature/SyncPhpTranslationsTest.php +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -51,11 +51,17 @@ public function testCopyNewPhpTranslationFiles() '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 @@ -73,11 +79,13 @@ public function testCopyNewPhpTranslationFiles() $this->assertTrue(Storage::disk('lang')->exists('en/validation.php')); // Verify content was copied correctly - $authContent = Storage::disk('lang')->get('en/auth.php'); - $this->assertStringContainsString("'failed' => 'These credentials do not match our records.'", $authContent); + $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 = Storage::disk('lang')->get('en/validation.php'); - $this->assertStringContainsString("'required' => 'The :attribute field is required.'", $validationContent); + $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']); } /** @@ -89,6 +97,9 @@ public function testMergeNewPhpTranslations() $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)); @@ -98,6 +109,9 @@ public function testMergeNewPhpTranslations() '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); @@ -112,10 +126,12 @@ public function testMergeNewPhpTranslations() $this->assertEmpty($results['en']['errors']); // Verify merged content - $mergedContent = Storage::disk('lang')->get('en/auth.php'); - $this->assertStringContainsString("'failed' => 'Custom failed message.'", $mergedContent); // Preserved custom value - $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 + $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 } /** @@ -453,7 +469,9 @@ private function generatePhpContent(array $translations): string $content = " $value) { - $content .= " '{$key}' => '{$value}',\n"; + $escapedKey = $this->escapePhpString($key); + $formattedValue = $this->formatPhpValue($value, 1); + $content .= " {$escapedKey} => {$formattedValue},\n"; } $content .= "\n];\n"; @@ -461,6 +479,47 @@ private function generatePhpContent(array $translations): string 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 */ From edb69de8acc623b566b7b9c5145e91b1335b1cd6 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 3 Sep 2025 18:42:29 -0700 Subject: [PATCH 5/8] Write empty strings to other languages --- ProcessMaker/Helpers/SyncJsonTranslations.php | 79 +++++++++++++++++++ ProcessMaker/Helpers/SyncTranslationsBase.php | 2 +- tests/Feature/SyncJsonTranslationsTest.php | 47 +++++++++-- tests/Feature/SyncPhpTranslationsTest.php | 6 +- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/ProcessMaker/Helpers/SyncJsonTranslations.php b/ProcessMaker/Helpers/SyncJsonTranslations.php index 4704271bc0..c8f098af6f 100644 --- a/ProcessMaker/Helpers/SyncJsonTranslations.php +++ b/ProcessMaker/Helpers/SyncJsonTranslations.php @@ -117,10 +117,89 @@ protected function processLanguageFile(string $languageCode): array $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) { + $this->syncMissingKeysToOtherLanguages($mergedTranslations); + } } catch (\Exception $e) { $result['error'] = 'Exception occurred: ' . $e->getMessage(); } return $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 void + */ + protected function syncMissingKeysToOtherLanguages(array $enTranslations): void + { + $languageFiles = $this->getLanguageFilesFromLangDisk(); + + foreach ($languageFiles as $languageCode) { + // Skip en.json as it's the source + if ($languageCode === 'en') { + continue; + } + + $filename = $languageCode . '.json'; + + // Get existing content + $existingContent = $this->getDestinationContent($filename); + if (!$existingContent) { + continue; // Skip if file doesn't exist + } + + // Decode existing content + $existingTranslations = json_decode($existingContent, true); + if (json_last_error() !== JSON_ERROR_NONE) { + continue; // Skip if invalid JSON + } + + // Check for missing keys and add them with empty strings + $hasNewKeys = false; + $updatedTranslations = $existingTranslations; + + foreach ($enTranslations as $key => $value) { + if (!array_key_exists($key, $updatedTranslations)) { + $updatedTranslations[$key] = ''; + $hasNewKeys = true; + } + } + + // Save updated translations if there are new keys + if ($hasNewKeys) { + // Create backup before modifying the file + $this->createBackup($filename); + + $updatedContent = json_encode($updatedTranslations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if ($this->saveToDestination($filename, $updatedContent)) { + // Clean up old backups after successful save + $this->cleanupOldBackups($filename); + } + } + } + } } diff --git a/ProcessMaker/Helpers/SyncTranslationsBase.php b/ProcessMaker/Helpers/SyncTranslationsBase.php index 9a3f8ae32a..cfbaa72467 100644 --- a/ProcessMaker/Helpers/SyncTranslationsBase.php +++ b/ProcessMaker/Helpers/SyncTranslationsBase.php @@ -20,7 +20,7 @@ abstract class SyncTranslationsBase /** * Maximum number of backup files to keep */ - protected const MAX_BACKUPS = 3; + protected const MAX_BACKUPS = 5; /** * Constructor diff --git a/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php index c6bcaebc79..d6969c752c 100644 --- a/tests/Feature/SyncJsonTranslationsTest.php +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -78,10 +78,12 @@ public function testMergeNewTranslations() { // Create existing translations in destination $existingTranslations = [ - 'hello' => 'Hello', + 'hello' => 'Hello Customized', 'world' => 'World', ]; - Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + 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 = [ @@ -106,12 +108,33 @@ public function testMergeNewTranslations() $mergedContent = Storage::disk('lang')->get('en.json'); $mergedTranslations = json_decode($mergedContent, true); $expectedMerged = [ - 'hello' => 'Hello', + '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']); } /** @@ -264,7 +287,9 @@ public function testBackupCreationWhenMerging() 'hello' => 'Hello', 'world' => 'World', ]; - Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); + 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 = [ @@ -289,6 +314,16 @@ public function testBackupCreationWhenMerging() $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); } /** @@ -331,7 +366,7 @@ public function testBackupRotation() Storage::disk('lang')->put('en.json', json_encode($existingTranslations, JSON_PRETTY_PRINT)); // Run sync multiple times to create multiple backups - for ($i = 0; $i < 5; $i++) { + for ($i = 0; $i < 7; $i++) { // Create resources-core with additional translations (different each time) $resourcesCoreTranslations = [ 'hello' => 'Hello', @@ -352,7 +387,7 @@ public function testBackupRotation() // Verify only 3 backup files exist $backupFiles = $this->getBackupFiles('en.json'); - $this->assertCount(3, $backupFiles); + $this->assertCount(5, $backupFiles); } /** diff --git a/tests/Feature/SyncPhpTranslationsTest.php b/tests/Feature/SyncPhpTranslationsTest.php index 27f675b7b6..e02dcc843b 100644 --- a/tests/Feature/SyncPhpTranslationsTest.php +++ b/tests/Feature/SyncPhpTranslationsTest.php @@ -378,7 +378,7 @@ public function testBackupRotationForPhpFiles() Storage::disk('lang')->put('en/auth.php', $this->generatePhpContent($existingTranslations)); // Run sync multiple times to create multiple backups - for ($i = 0; $i < 5; $i++) { + for ($i = 0; $i < 7; $i++) { // Create resources-core with additional translations (different each time) $resourcesCoreTranslations = [ 'failed' => 'These credentials do not match our records.', @@ -399,10 +399,10 @@ public function testBackupRotationForPhpFiles() // Verify only 3 backup files exist $backupFiles = $this->getBackupFiles('en/auth.php'); - $this->assertCount(3, $backupFiles); + $this->assertCount(5, $backupFiles); // Verify the backup files have different timestamps - $this->assertEquals(3, count(array_unique($backupFiles))); + $this->assertEquals(5, count(array_unique($backupFiles))); } /** From 28149c1a9217c75651c8d72be15bc938b232de7a Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 4 Sep 2025 15:29:54 -0700 Subject: [PATCH 6/8] Update sync translations --- .../Console/Commands/SyncTranslations.php | 303 ++++++------------ ProcessMaker/Helpers/SyncJsonTranslations.php | 62 +++- tests/Feature/SyncJsonTranslationsTest.php | 19 ++ 3 files changed, 176 insertions(+), 208 deletions(-) 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 index c8f098af6f..24976e1df6 100644 --- a/ProcessMaker/Helpers/SyncJsonTranslations.php +++ b/ProcessMaker/Helpers/SyncJsonTranslations.php @@ -15,7 +15,20 @@ public function sync(): array $languageCodes = $this->getLanguageCodes(); foreach ($languageCodes as $languageCode) { - $results[$languageCode] = $this->processLanguageFile($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; @@ -120,13 +133,18 @@ protected function processLanguageFile(string $languageCode): array // If processing en.json, sync missing keys to other language files if ($languageCode === 'en' && $result['action'] !== 'none' && $result['error'] === null) { - $this->syncMissingKeysToOtherLanguages($mergedTranslations); + $otherLanguageResults = $this->syncMissingKeysToOtherLanguages($mergedTranslations); + + return [ + 'result' => $result, + 'otherLanguageResults' => $otherLanguageResults, + ]; } } catch (\Exception $e) { $result['error'] = 'Exception occurred: ' . $e->getMessage(); } - return $result; + return ['result' => $result]; } /** @@ -152,10 +170,11 @@ protected function getLanguageFilesFromLangDisk(): array * Sync missing keys from en.json to other language files * * @param array $enTranslations - * @return void + * @return array */ - protected function syncMissingKeysToOtherLanguages(array $enTranslations): void + protected function syncMissingKeysToOtherLanguages(array $enTranslations): array { + $results = []; $languageFiles = $this->getLanguageFilesFromLangDisk(); foreach ($languageFiles as $languageCode) { @@ -165,41 +184,66 @@ protected function syncMissingKeysToOtherLanguages(array $enTranslations): void } $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 - $hasNewKeys = false; + $newKeysCount = 0; $updatedTranslations = $existingTranslations; foreach ($enTranslations as $key => $value) { if (!array_key_exists($key, $updatedTranslations)) { $updatedTranslations[$key] = ''; - $hasNewKeys = true; + $newKeysCount++; } } // Save updated translations if there are new keys - if ($hasNewKeys) { + if ($newKeysCount > 0) { // Create backup before modifying the file - $this->createBackup($filename); + $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/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php index d6969c752c..60ce7639d8 100644 --- a/tests/Feature/SyncJsonTranslationsTest.php +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -104,6 +104,25 @@ public function testMergeNewTranslations() $this->assertEquals(4, $results['en']['total_keys']); $this->assertNull($results['en']['error']); + // Make sure the results show the other language files were updated + $this->assertArrayHasKey('es', $results); + $this->assertEquals('updated', $results['es']['action']); + $this->assertEquals(2, $results['es']['new_keys']); + $this->assertEquals(4, $results['es']['total_keys']); + $this->assertNull($results['es']['error']); + + $this->assertArrayHasKey('fr', $results); + $this->assertEquals('updated', $results['fr']['action']); + $this->assertEquals(2, $results['fr']['new_keys']); + $this->assertEquals(4, $results['fr']['total_keys']); + $this->assertNull($results['fr']['error']); + + $this->assertArrayHasKey('de', $results); + $this->assertEquals('updated', $results['de']['action']); + $this->assertEquals(2, $results['de']['new_keys']); + $this->assertEquals(4, $results['de']['total_keys']); + $this->assertNull($results['de']['error']); + // Verify merged content $mergedContent = Storage::disk('lang')->get('en.json'); $mergedTranslations = json_decode($mergedContent, true); From 1828f7f3957945a0ba12bc4c79636b3144810b6b Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 5 Sep 2025 11:04:40 -0700 Subject: [PATCH 7/8] Fix tests --- tests/Feature/SyncJsonTranslationsTest.php | 36 ++++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/Feature/SyncJsonTranslationsTest.php b/tests/Feature/SyncJsonTranslationsTest.php index 60ce7639d8..00ef26b2f4 100644 --- a/tests/Feature/SyncJsonTranslationsTest.php +++ b/tests/Feature/SyncJsonTranslationsTest.php @@ -104,24 +104,26 @@ public function testMergeNewTranslations() $this->assertEquals(4, $results['en']['total_keys']); $this->assertNull($results['en']['error']); - // Make sure the results show the other language files were updated - $this->assertArrayHasKey('es', $results); - $this->assertEquals('updated', $results['es']['action']); - $this->assertEquals(2, $results['es']['new_keys']); - $this->assertEquals(4, $results['es']['total_keys']); - $this->assertNull($results['es']['error']); + $otherLanguageResults = $results['en']['otherLanguageResults']; - $this->assertArrayHasKey('fr', $results); - $this->assertEquals('updated', $results['fr']['action']); - $this->assertEquals(2, $results['fr']['new_keys']); - $this->assertEquals(4, $results['fr']['total_keys']); - $this->assertNull($results['fr']['error']); - - $this->assertArrayHasKey('de', $results); - $this->assertEquals('updated', $results['de']['action']); - $this->assertEquals(2, $results['de']['new_keys']); - $this->assertEquals(4, $results['de']['total_keys']); - $this->assertNull($results['de']['error']); + // 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'); From dc75874456c33d738555e562fb73dd9eb5d3d3d7 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 5 Sep 2025 15:18:38 -0700 Subject: [PATCH 8/8] Test new language strings --- resources/lang/en.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 +}