├── src ├── Support │ ├── helpers.php │ ├── DiffTranslationsCountAction.php │ ├── CalculateTotalChangedTranslationsAction.php │ ├── MetadataManager.php │ └── Utils.php ├── Read │ ├── GetJsonTranslationsAction.php │ ├── GetAllTranslationsAction.php │ ├── GetPhpTranslationsAction.php │ ├── GetTranslationsDtoAction.php │ └── ScannerService.php ├── DTOs │ └── ApiTranslationsDto.php ├── Write │ ├── WriteJsonTranslationsAction.php │ ├── WriteTranslationsAction.php │ ├── DeleteEmptyPhpTranslationsFilesAction.php │ └── WritePhpTranslationsAction.php ├── LocaleServiceProvider.php ├── Commands │ ├── SetupCommand.php │ └── SyncCommand.php └── Locale.php ├── CHANGELOG.md ├── LICENSE.md ├── composer.json └── README.md /src/Support/helpers.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 14 | } 15 | 16 | public function __invoke(string $locale): array 17 | { 18 | $jsonPath = lang_path("{$locale}.json"); 19 | 20 | if (! $this->filesystem->exists($jsonPath)) { 21 | return []; 22 | } 23 | 24 | return json_decode($this->filesystem->get($jsonPath), true); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DTOs/ApiTranslationsDto.php: -------------------------------------------------------------------------------- 1 | $jsonData 16 | * @param array $phpData 17 | */ 18 | public function __construct(string $locale, array $jsonData, array $phpData) 19 | { 20 | $this->locale = $locale; 21 | $this->jsonData = $jsonData; 22 | $this->phpData = $phpData; 23 | } 24 | 25 | public function jsonSerialize(): array 26 | { 27 | return [ 28 | $this->locale, 29 | $this->jsonData, 30 | $this->phpData, 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/DiffTranslationsCountAction.php: -------------------------------------------------------------------------------- 1 | countDifferences($newTranslationsDto->phpData, $currentTranslationsDto->phpData); 12 | $numJsonModifiedTranslations = $this->countDifferences($newTranslationsDto->jsonData, $currentTranslationsDto->jsonData); 13 | 14 | return ($numPhpModifiedTranslations + $numJsonModifiedTranslations); 15 | } 16 | 17 | private function countDifferences(array $newTranslations, array $currentTranslations): int 18 | { 19 | return collect($newTranslations) 20 | ->diffAssoc($currentTranslations) 21 | ->count(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Write/WriteJsonTranslationsAction.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 17 | } 18 | 19 | public function __invoke(string $locale, array $keyValueTranslations): void 20 | { 21 | $filePath = lang_path("{$locale}.json"); 22 | 23 | $keyValueTranslations = array_filter($keyValueTranslations); 24 | 25 | if (empty($keyValueTranslations)) { 26 | $this->filesystem->delete($filePath); 27 | 28 | return; 29 | } 30 | $fileContent = json_encode($keyValueTranslations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 31 | $this->filesystem->put($filePath, ($fileContent) ?: ''); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Write/WriteTranslationsAction.php: -------------------------------------------------------------------------------- 1 | writeJsonTranslationsAction = $writeJsonTranslationsAction; 17 | $this->writePhpTranslationsAction = $writePhpTranslationsAction; 18 | } 19 | 20 | public function __invoke(ApiTranslationsDto $apiTranslationsDto): void 21 | { 22 | ($this->writeJsonTranslationsAction)($apiTranslationsDto->locale, $apiTranslationsDto->jsonData); 23 | ($this->writePhpTranslationsAction)($apiTranslationsDto->locale, $apiTranslationsDto->phpData); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/LocaleServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('locale-laravel') 17 | ->hasCommands([ 18 | SetupCommand::class, 19 | SyncCommand::class, 20 | ]); 21 | 22 | $this->app->singleton(Locale::class, function () { 23 | return new Locale( 24 | resolve(Filesystem::class), 25 | config('services.locale.base_url', 'https://app.uselocale.com/api/v2'), 26 | config('services.locale.key') ?? '', 27 | ); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Locale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Read/GetAllTranslationsAction.php: -------------------------------------------------------------------------------- 1 | getTranslationsDtoAction = $getTranslationsDtoAction; 16 | $this->filesystem = $filesystem; 17 | } 18 | 19 | /** 20 | * @return array ApiTranslationsDto 21 | */ 22 | public function __invoke(): array 23 | { 24 | return $this->getAllLocales() 25 | ->map(fn (string $locale) => ($this->getTranslationsDtoAction)($locale)) 26 | ->toArray(); 27 | } 28 | 29 | private function getAllLocales(): Collection 30 | { 31 | $jsonLocales = collect( 32 | $this->filesystem->glob(lang_path('*.json')) 33 | )->map(fn (string $path) => $this->filesystem->name($path)); 34 | 35 | $phpLocales = collect( 36 | $this->filesystem->directories(lang_path()) 37 | )->map(fn ($path) => $this->filesystem->name($path)); 38 | 39 | return $phpLocales->merge($jsonLocales)->unique(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Support/CalculateTotalChangedTranslationsAction.php: -------------------------------------------------------------------------------- 1 | diffTranslationsCountAction = $diffTranslationsCountAction; 19 | $this->getTranslationsDtoAction = $getTranslationsDtoAction; 20 | } 21 | 22 | public function __invoke(Collection $apiTranslationsDto): int 23 | { 24 | /** @var (callable(ApiTranslationsDto): int) $calculateChangedTranslationsCallback */ 25 | $calculateChangedTranslationsCallback = function (ApiTranslationsDto $apiTranslationsDto) { 26 | $currentTranslationsDto = ($this->getTranslationsDtoAction)($apiTranslationsDto->locale); 27 | 28 | return ($this->diffTranslationsCountAction)($currentTranslationsDto, $apiTranslationsDto); 29 | }; 30 | 31 | return $apiTranslationsDto->sum($calculateChangedTranslationsCallback); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/MetadataManager.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 15 | } 16 | 17 | private function getMetaDataFilePath(?string $filePath = null): string 18 | { 19 | return lang_path('.uselocale') . ($filePath ? (DIRECTORY_SEPARATOR . $filePath) : ''); 20 | } 21 | 22 | public function getLastSyncTimestamp(): int 23 | { 24 | return ($this->filesystem->exists($this->getMetaDataFilePath('timestamp'))) 25 | ? intval($this->filesystem->get($this->getMetaDataFilePath('timestamp'))) 26 | : 0; 27 | } 28 | 29 | public function touchTimestamp(): void 30 | { 31 | $this->filesystem->ensureDirectoryExists($this->getMetaDataFilePath()); 32 | 33 | $this->filesystem->put( 34 | $this->getMetaDataFilePath('timestamp'), 35 | (string)time() 36 | ); 37 | } 38 | 39 | public function updateSnapshot(Collection $apiTranslationsDtoCollection): void 40 | { 41 | $this->filesystem->ensureDirectoryExists($this->getMetaDataFilePath()); 42 | 43 | $this->filesystem->put( 44 | $this->getMetaDataFilePath('snapshot'), 45 | json_encode($apiTranslationsDtoCollection) 46 | ); 47 | } 48 | 49 | public function getSnapshotContent(): ?string 50 | { 51 | return ($this->filesystem->exists($this->getMetaDataFilePath('snapshot'))) 52 | ? $this->filesystem->get($this->getMetaDataFilePath('snapshot')) 53 | : null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Read/GetPhpTranslationsAction.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 19 | $this->translator = $translator; 20 | } 21 | 22 | public function __invoke(string $locale): array 23 | { 24 | $localePath = lang_path($locale); 25 | 26 | if (! $this->filesystem->exists($localePath)) { 27 | return []; 28 | } 29 | 30 | /** @var (callable(\Symfony\Component\Finder\SplFileInfo): array) $transformDirectoriesToKeyCallback */ 31 | $transformDirectoriesToKeyCallback = function (SplFileInfo $file) use ($locale) { 32 | // Generate group key 33 | $group = collect([ 34 | $file->getRelativePath(), $file->getFilenameWithoutExtension(), 35 | ])->filter()->implode(DIRECTORY_SEPARATOR); 36 | 37 | // Convert array file content to dot notation 38 | $phpTranslations = array_dot([ 39 | $group => $this->translator->getLoader()->load($locale, $group), 40 | ]); 41 | 42 | return array_filter($phpTranslations); 43 | }; 44 | 45 | return collect($this->filesystem->allFiles($localePath)) 46 | ->filter(fn ($file) => $file->getExtension() === 'php') 47 | ->mapWithKeys($transformDirectoriesToKeyCallback) 48 | ->toArray(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Read/GetTranslationsDtoAction.php: -------------------------------------------------------------------------------- 1 | getPhpTranslationsAction = $getPhpTranslationsAction; 19 | $this->getJsonTranslationsAction = $getJsonTranslationsAction; 20 | $this->scannerService = $scannerService; 21 | } 22 | 23 | public function __invoke(string $locale): ApiTranslationsDto 24 | { 25 | $translations = new ApiTranslationsDto( 26 | $locale, 27 | ($this->getJsonTranslationsAction)($locale), 28 | ($this->getPhpTranslationsAction)($locale) 29 | ); 30 | 31 | $this->scannerService->scan(); 32 | 33 | $jsonFileKeys = $this->getMissingKeys(array_keys($translations->jsonData), $this->scannerService->getJsonKeys()); 34 | $phpFileKeys = $this->getMissingKeys(array_keys($translations->phpData), $this->scannerService->getPhpKeys()); 35 | 36 | $translations->jsonData = array_merge($translations->jsonData, $jsonFileKeys); 37 | $translations->phpData = array_merge($translations->phpData, $phpFileKeys); 38 | 39 | return $translations; 40 | } 41 | 42 | private function getMissingKeys(array $translatedKeys, array $keysToCheck): array 43 | { 44 | $diffKeys = array_diff($keysToCheck, $translatedKeys); 45 | 46 | return collect($diffKeys)->mapWithKeys(fn (string $key) => [$key => null])->toArray(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Commands/SetupCommand.php: -------------------------------------------------------------------------------- 1 | confirm('Starting to upload your localized files' . PHP_EOL . "After this initial setup, your localization files will be formatted without changing its content. Do you want to proceed?")) { 25 | return self::SUCCESS; 26 | } 27 | 28 | $this->info('Uploading translations...'); 29 | 30 | // Get all translations from files 31 | $translations = ($getAllTranslationsAction)(); 32 | 33 | try { 34 | // Upload translations 35 | $message = $locale->makeSetupRequest($translations); 36 | $this->info($message); 37 | 38 | // Download translations 39 | $response = $locale->makeDownloadRequest(); 40 | 41 | // Write new file translations 42 | $response->translations->each( 43 | fn (ApiTranslationsDto $apiTranslationsDto) => ($writeTranslationsAction)($apiTranslationsDto) 44 | ); 45 | 46 | // Display information messages 47 | $this->info($response->message); 48 | 49 | return self::SUCCESS; 50 | } catch (RequestException $exception) { 51 | $this->error($exception->response->json('message')); 52 | } catch (Exception $exception) { 53 | $this->error($exception->getMessage()); 54 | } 55 | 56 | return self::FAILURE; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uselocale/locale-laravel", 3 | "description": "Manage translations with Locale and smoothly synchronize them with your project using our simple package commands.", 4 | "keywords": [ 5 | "uselocale", 6 | "localization", 7 | "localisation", 8 | "laravel", 9 | "locale-laravel" 10 | ], 11 | "homepage": "https://github.com/uselocale/locale-laravel", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Creagia", 16 | "email": "info@creagia.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^7.4|^8.0", 22 | "ext-json": "*", 23 | "guzzlehttp/guzzle": "^6.0|^7.0", 24 | "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0", 25 | "illuminate/filesystem": "^7.0|^8.0|^9.0|^10.0", 26 | "spatie/laravel-package-tools": "^1.9" 27 | }, 28 | "require-dev": { 29 | "nunomaduro/collision": "^4.0|^5.0|^6.0", 30 | "nunomaduro/larastan": "^1.0.3|^2.0", 31 | "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0", 32 | "pestphp/pest": "^1.0", 33 | "pestphp/pest-plugin-laravel": "^1.1", 34 | "phpstan/extension-installer": "^1.1", 35 | "phpstan/phpstan-deprecation-rules": "^1.0", 36 | "phpstan/phpstan-phpunit": "^1.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "UseLocale\\LocaleLaravel\\": "src", 41 | "UseLocale\\LocaleLaravel\\Database\\Factories\\": "database/factories" 42 | }, 43 | "files": [ 44 | "src/Support/Utils.php", 45 | "src/Support/helpers.php" 46 | ] 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "UseLocale\\LocaleLaravel\\Tests\\": "tests" 51 | } 52 | }, 53 | "scripts": { 54 | "analyse": "vendor/bin/phpstan analyse", 55 | "test": "vendor/bin/pest", 56 | "test-coverage": "vendor/bin/pest --coverage" 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "allow-plugins": { 61 | "pestphp/pest-plugin": true, 62 | "phpstan/extension-installer": true 63 | } 64 | }, 65 | "extra": { 66 | "laravel": { 67 | "providers": [ 68 | "UseLocale\\LocaleLaravel\\LocaleServiceProvider" 69 | ] 70 | } 71 | }, 72 | "minimum-stability": "dev", 73 | "prefer-stable": true 74 | } 75 | -------------------------------------------------------------------------------- /src/Write/DeleteEmptyPhpTranslationsFilesAction.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 18 | } 19 | 20 | public function __invoke(Collection $apiTranslationsDto): void 21 | { 22 | $absolutePathPhpFilesWithTranslations = $this->getPhpFilesWithTranslationsAbsolutePaths($apiTranslationsDto); 23 | 24 | /** @var Collection $allTranslationsFiles */ 25 | $allTranslationsFiles = collect($this->filesystem->allFiles(lang_path())); 26 | 27 | $allTranslationsFiles 28 | ->filter(fn (SplFileInfo $file) => $file->getExtension() === 'php') 29 | // @phpstan-ignore-next-line 30 | ->reject(fn (SplFileInfo $file) => (Str::startsWith($file->getRealPath(), lang_path('vendor')))) 31 | // @phpstan-ignore-next-line 32 | ->reject(fn (SplFileInfo $file) => $absolutePathPhpFilesWithTranslations->contains($file->getRealPath())) 33 | ->each(fn (SplFileInfo $file) => $this->filesystem->delete($file->getRealPath())); 34 | } 35 | 36 | private function getPhpFilesWithTranslationsAbsolutePaths(Collection $apiTranslationsDto): Collection 37 | { 38 | return $apiTranslationsDto 39 | ->flatMap(function (ApiTranslationsDto $apiTranslationsDto) { 40 | return collect($apiTranslationsDto->phpData) 41 | ->filter() 42 | ->map(function (string $translation, string $key) use ($apiTranslationsDto) { 43 | $fileName = pathinfo(explode('.', $key)[0], PATHINFO_FILENAME); 44 | $relativeDirectory = pathinfo($key, PATHINFO_DIRNAME); 45 | $absoluteDirectory = lang_path($apiTranslationsDto->locale . DIRECTORY_SEPARATOR . $relativeDirectory); 46 | $absoluteDirectory = rtrim($absoluteDirectory, DIRECTORY_SEPARATOR . '.'); 47 | 48 | return $absoluteDirectory . DIRECTORY_SEPARATOR . "{$fileName}.php"; 49 | }) 50 | ->values(); 51 | }) 52 | ->unique(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Support/Utils.php: -------------------------------------------------------------------------------- 1 | $value) { 18 | if (is_array($value) && ! empty($value)) { 19 | $results = array_merge($results, array_dot($value, $prepend.$key.'.')); 20 | } else { 21 | $results[$prepend.$key] = $value; 22 | } 23 | } 24 | 25 | return $results; 26 | } 27 | 28 | /** 29 | * Convert a flatten "dot" notation array into an expanded array. 30 | * https://github.com/laravel/framework/blob/c61fd7aa262476e9f756eff84b0de43e1f4406e8/src/Illuminate/Collections/Arr.php#L130 31 | * 32 | * @param iterable $array 33 | * @return array 34 | */ 35 | function array_undot($array) 36 | { 37 | $results = []; 38 | 39 | foreach ($array as $key => $value) { 40 | array_dot_set($results, $key, $value); 41 | } 42 | 43 | return $results; 44 | } 45 | 46 | /** 47 | * Set an array item to a given value using "dot" notation. 48 | * 49 | * If no key is given to the method, the entire array will be replaced. 50 | * https://github.com/laravel/framework/blob/c61fd7aa262476e9f756eff84b0de43e1f4406e8/src/Illuminate/Collections/Arr.php#L667 51 | * 52 | * @param array $array 53 | * @param string|int|null $key 54 | * @param mixed $value 55 | * @return array 56 | */ 57 | function array_dot_set(&$array, $key, $value) 58 | { 59 | if (is_null($key)) { 60 | return $array = $value; 61 | } 62 | 63 | $keys = explode('.', $key); 64 | 65 | foreach ($keys as $i => $key) { 66 | if (count($keys) === 1) { 67 | break; 68 | } 69 | 70 | unset($keys[$i]); 71 | 72 | // If the key doesn't exist at this depth, we will just create an empty array 73 | // to hold the next value, allowing us to create the arrays to hold final 74 | // values at the correct depth. Then we'll keep digging into the array. 75 | if (! isset($array[$key]) || ! is_array($array[$key])) { 76 | $array[$key] = []; 77 | } 78 | 79 | $array = &$array[$key]; 80 | } 81 | 82 | $array[array_shift($keys)] = $value; 83 | 84 | return $array; 85 | } 86 | -------------------------------------------------------------------------------- /src/Write/WritePhpTranslationsAction.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 17 | } 18 | 19 | public function __invoke(string $locale, array $keyValueTranslations): void 20 | { 21 | collect($keyValueTranslations) 22 | ->filter() 23 | ->groupBy( 24 | // Group by translations by file path 25 | function (string $translation, string $key) { 26 | return explode('.', $key)[0]; 27 | }, 28 | true 29 | ) 30 | ->map(function (Collection $values, string $filePath) { 31 | // Generate multi-dimensional arrays 32 | return array_undot($values)[$filePath]; 33 | }) 34 | ->each(function (array $fileTranslations, string $filePath) use ($locale) { 35 | $fileName = pathinfo($filePath, PATHINFO_FILENAME); 36 | $relativeDirectory = pathinfo($filePath, PATHINFO_DIRNAME); 37 | 38 | // Ensure Directory Exists 39 | $absoluteDirectory = lang_path($locale . DIRECTORY_SEPARATOR . $relativeDirectory); 40 | $absoluteDirectory = rtrim($absoluteDirectory, DIRECTORY_SEPARATOR.'.'); 41 | $this->filesystem->ensureDirectoryExists($absoluteDirectory); 42 | 43 | // Generate file content 44 | $fileContent = "phpVarExport($fileTranslations) . ';' . PHP_EOL; 45 | 46 | // Write file 47 | $absoluteFilePath = $absoluteDirectory . DIRECTORY_SEPARATOR . "{$fileName}.php"; 48 | $this->filesystem->put($absoluteFilePath, $fileContent); 49 | }) 50 | ->map(function (array $fileTranslations, string $filePath) use ($locale) { 51 | $fileName = pathinfo($filePath, PATHINFO_FILENAME); 52 | $relativeDirectory = pathinfo($filePath, PATHINFO_DIRNAME); 53 | 54 | $absoluteDirectory = lang_path($locale . DIRECTORY_SEPARATOR . $relativeDirectory); 55 | $absoluteDirectory = rtrim($absoluteDirectory, DIRECTORY_SEPARATOR.'.'); 56 | 57 | return $absoluteDirectory . DIRECTORY_SEPARATOR . "{$fileName}.php"; 58 | }); 59 | } 60 | 61 | private function phpVarExport($expression, string $indent = ''): string 62 | { 63 | $tab = ' '; 64 | 65 | if (is_array($expression)) { 66 | $lines = []; 67 | foreach ($expression as $key => $value) { 68 | $key = var_export($key, true); 69 | $value = $this->phpVarExport($value, $indent . $tab); 70 | $lines[] = "{$indent}{$tab}{$key} => {$value}"; 71 | } 72 | 73 | return '[' . PHP_EOL . implode(',' . PHP_EOL, $lines) . PHP_EOL . $indent . ']'; 74 | } 75 | 76 | return var_export($expression, true); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Locale.php: -------------------------------------------------------------------------------- 1 | baseUrl = $baseUrl; 20 | $this->apiKey = $key; 21 | $this->metadataManager = new MetadataManager($filesystem); 22 | } 23 | 24 | /** 25 | * @param array $translations 26 | * @return string 27 | * @throws RequestException 28 | */ 29 | public function makeSetupRequest(array $translations): string 30 | { 31 | return Http::acceptJson() 32 | ->withToken($this->apiKey) 33 | ->baseUrl($this->baseUrl) 34 | ->patch('setup', ['translations' => $translations]) 35 | ->throw() 36 | ->json('message'); 37 | } 38 | 39 | /** 40 | * @param array $translations 41 | * @return array 42 | * @throws RequestException 43 | */ 44 | public function checkSyncTranslationsAction(array $translations): array 45 | { 46 | return Http::acceptJson() 47 | ->withToken($this->apiKey) 48 | ->baseUrl($this->baseUrl) 49 | ->patch('changes', [ 50 | 'translations' => $translations, 51 | 'timestamp' => $this->metadataManager->getLastSyncTimestamp(), 52 | 'snapshot' => $this->metadataManager->getSnapshotContent(), 53 | ]) 54 | ->throw() 55 | ->json(); 56 | } 57 | 58 | /** 59 | * @param array $translations 60 | * @param bool $purge 61 | * @return void 62 | * @throws RequestException 63 | */ 64 | public function makeUploadRequest(array $translations, bool $purge): void 65 | { 66 | Http::acceptJson() 67 | ->withToken($this->apiKey) 68 | ->baseUrl($this->baseUrl) 69 | ->patch('upload', [ 70 | 'purge' => $purge, 71 | 'translations' => $translations, 72 | 'timestamp' => $this->metadataManager->getLastSyncTimestamp(), 73 | ]) 74 | ->throw(); 75 | } 76 | 77 | /** 78 | * @return object 79 | * @throws RequestException 80 | */ 81 | public function makeDownloadRequest(): object 82 | { 83 | $response = Http::acceptJson() 84 | ->withToken($this->apiKey) 85 | ->baseUrl($this->baseUrl) 86 | ->get('download') 87 | ->throw(); 88 | 89 | $translations = collect($response->json('translations')) 90 | ->map(function (array $row) { 91 | return new ApiTranslationsDto( 92 | $row['locale'], 93 | $row['jsonData'], 94 | $row['phpData'] 95 | ); 96 | }); 97 | 98 | $this->metadataManager->touchTimestamp(); 99 | 100 | $this->metadataManager->updateSnapshot($translations); 101 | 102 | return (object)[ 103 | 'message' => $response->json('message'), 104 | 'translations' => $translations, 105 | ]; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Locale](https://uselocale.com): The Laravel localization tool 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/uselocale/locale-laravel.svg?style=flat-square)](https://packagist.org/packages/uselocale/locale-laravel) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/uselocale/locale-laravel/run-tests.yml?label=tests)](https://github.com/uselocale/locale-laravel/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/uselocale/locale-laravel/php-cs-fixer.yml?label=code%20style)](https://github.com/uselocale/locale-laravel/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/uselocale/locale-laravel.svg?style=flat-square)](https://packagist.org/packages/uselocale/locale-laravel) 7 | 8 | [Locale](https://uselocale.com) is the first localization platform specifically built for Laravel. Forget the old-fashioned email exchanges between teams and translators that always slow down project development, manage translations with our admin panel and smoothly synchronize the files with your project using our simple package commands. 9 | 10 |

11 | Locale screenshot 12 |

13 | 14 | ## Installation 15 | 16 | Follow the details provided on [Locale](https://uselocale.com) after creating a new project. 17 | 18 | ## Available commands 19 | 20 | ### Setup 21 | ```bash 22 | php artisan locale:setup 23 | ``` 24 | You only need to run this command once. It will upload your existing translations to Locale and prepare your local files to be synced in the future. 25 | 26 | Your local files will be reformatted but won't change their content. 27 | 28 | ### Sync 29 | ```bash 30 | php artisan locale:sync 31 | ``` 32 | 33 | Run this command to upload any new translation keys to Locale and download updates on all your target languages. 34 | 35 | If there's any conflict during the process, you'll receive a confirmation message. 36 | 37 | #### Forcing Sync to run 38 | 39 | Syncing your translation will update your local files with new translations for Locale and upload new translations to Locale. 40 | To keep you informed and in control with everything, you will be prompted for a confirmation before the command is executed. To 41 | force the command to run without a prompt, use the `--force` flag: 42 | 43 | ```bash 44 | php artisan locale:sync --force 45 | ``` 46 | 47 | #### Purge unused translations 48 | 49 | By default, nothing is deleted from Locale. If you delete translation keys from your local code and sync, translations will 50 | still be available from Locale. This is useful if you are working with multiple branches or some big new features. 51 | 52 | However, sometimes you really need to delete old and unused translation keys from Locale. To do that, use the `--purge` flag: 53 | 54 | ```bash 55 | php artisan locale:sync --purge 56 | ``` 57 | 58 | > **Warning** 59 | > All keys that are not present in the current local branch will be permanently deleted. 60 | 61 | ## Changelog 62 | 63 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 64 | 65 | ## Contributing 66 | 67 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 68 | 69 | ## Security Vulnerabilities 70 | 71 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 72 | 73 | ## Credits 74 | 75 | - [Creagia](https://creagia.com) 76 | - [All Contributors](../../contributors) 77 | 78 | ## License 79 | 80 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 81 | -------------------------------------------------------------------------------- /src/Commands/SyncCommand.php: -------------------------------------------------------------------------------- 1 | info('Checking translation changes...'); 32 | 33 | try { 34 | $translations = ($getAllTranslationsAction)(); 35 | $response = $locale->checkSyncTranslationsAction($translations); 36 | $this->line("We've found {$response['new_keys']} new keys on your local environment."); 37 | 38 | // Check conflicts 39 | if (filled($response['conflicts'])) { 40 | $this->warn("Warning, some keys have been changed on both Locale and your environment:"); 41 | foreach ($response['conflicts'] as $key) { 42 | $this->line("- {$key}"); 43 | } 44 | } 45 | 46 | $confirmationMessage = filled($response['conflicts']) 47 | ? 'Do you want to proceed and overwrite these local values? Otherwise review and resolve the conflict manually.' 48 | : 'Do you want to proceed uploading new keys and download changes?'; 49 | 50 | if (! $this->option('force') and ! $this->confirm($confirmationMessage)) { 51 | return self::SUCCESS; 52 | } 53 | 54 | // Purge operation 55 | if ($this->option('purge')) { 56 | if (filled($response['purgable'])) { 57 | $this->warn("Warning, the next keys are not present in your current branch and will be permanently deleted:"); 58 | foreach ($response['purgable'] as $key) { 59 | $this->line("- {$key}"); 60 | } 61 | $confirmationMessage = 'Do you want to proceed and delete permanently these keys?'; 62 | if (! $this->option('force') and ! $this->confirm($confirmationMessage)) { 63 | return self::SUCCESS; 64 | } 65 | } else { 66 | $this->info('No keys will be deleted'); 67 | } 68 | } 69 | 70 | // Upload source translations 71 | $this->info('Uploading translations...'); 72 | $locale->makeUploadRequest($translations, (bool)$this->option('purge')); 73 | 74 | // Download all translations 75 | $response = $locale->makeDownloadRequest(); 76 | 77 | // Count changed translations 78 | $totalChangedTranslations = ($calculateTotalChangedTranslationsAction)($response->translations); 79 | 80 | // Write new file translations 81 | $response->translations->each( 82 | fn (ApiTranslationsDto $apiTranslationsDto) => ($writeTranslationsAction)($apiTranslationsDto) 83 | ); 84 | 85 | // Delete empty PHP Translations Files 86 | $deleteEmptyPhpTranslationsFilesAction($response->translations); 87 | 88 | // Display information messages 89 | $this->info('Successfully synced'); 90 | $this->info("{$totalChangedTranslations} translations modified"); 91 | $this->line($response->message); 92 | 93 | return self::SUCCESS; 94 | } catch (RequestException $exception) { 95 | $this->error($exception->response->json('message')); 96 | } catch (Exception $exception) { 97 | $this->error($exception->getMessage()); 98 | } 99 | 100 | return self::FAILURE; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Read/ScannerService.php: -------------------------------------------------------------------------------- 1 | getTranslationFunctions(); 35 | 36 | $phpKeys = []; 37 | $jsonKeys = []; 38 | 39 | $phpKeysPattern = // See https://regex101.com/r/WEJqdL/6 40 | "[^\w|>]" . // Must not have an alphanum or _ or > before real method 41 | '(' . implode('|', $functions) . ')' . // Must start with one of the functions 42 | "\(" . // Match opening parenthesis 43 | "[\'\"]" . // Match " or ' 44 | '(' . // Start a new group to match: 45 | '[\/a-zA-Z0-9_-]+' . // Must start with group 46 | "([.](?! )[^\1)]+)+" . // Be followed by one or more items/keys 47 | ')' . // Close group 48 | "[\'\"]" . // Closing quote 49 | "[\),]"; // Close parentheses or new parameter 50 | 51 | $jsonKeysPattern = 52 | "[^\w]" . // Must not have an alphanum before real method 53 | '(' . implode('|', $functions) . ')' . // Must start with one of the functions 54 | "\(\s*" . // Match opening parenthesis 55 | "(?P['\"])" . // Match " or ' and store in {quote} 56 | "(?P(?:\\\k{quote}|(?!\k{quote}).)*)" . // Match any string that can be {quote} escaped 57 | "\k{quote}" . // Match " or ' previously matched 58 | "\s*[\),]"; // Close parentheses or new parameter 59 | 60 | // Find all PHP + Twig files in the app folder, except for storage 61 | $finder = new Finder(); 62 | $finder->in($path)->exclude('storage')->exclude('vendor')->name('*.php')->name('*.vue')->files(); 63 | 64 | /** @var SplFileInfo $file */ 65 | foreach ($finder as $file) { 66 | // Search the current file for the pattern 67 | if (preg_match_all("/$phpKeysPattern/siU", $file->getContents(), $matches)) { 68 | // Get all matches 69 | foreach ($matches[2] as $key) { 70 | $phpKeys[] = $key; 71 | } 72 | } 73 | 74 | if (preg_match_all("/$jsonKeysPattern/siU", $file->getContents(), $matches)) { 75 | foreach ($matches['string'] as $key) { 76 | if (preg_match("/(^[\/a-zA-Z0-9_-]+([.][^\1)\ ]+)+$)/siU", $key, $groupMatches)) { 77 | // group{.group}.key format, already in $phpKeys but also matched here 78 | // do nothing, it has to be treated as a group 79 | continue; 80 | } 81 | 82 | //TODO: This can probably be done in the regex, but I couldn't do it. 83 | //skip keys which contain namespacing characters, unless they also contain a 84 | //space, which makes it JSON. 85 | if (! (Str::contains($key, '::') && Str::contains($key, '.')) 86 | || Str::contains($key, ' ')) { 87 | $jsonKeys[] = $key; 88 | } 89 | } 90 | } 91 | } 92 | 93 | // Remove duplicates 94 | $this->phpKeys = array_unique($phpKeys); 95 | $this->jsonKeys = array_unique($jsonKeys); 96 | } 97 | 98 | public function getPhpKeys(): array 99 | { 100 | return $this->phpKeys; 101 | } 102 | 103 | public function getJsonKeys(): array 104 | { 105 | return $this->jsonKeys; 106 | } 107 | } 108 | --------------------------------------------------------------------------------