diff --git a/src/Services/TranslateService.php b/src/Services/TranslateService.php index 03d0b31..f041cdb 100644 --- a/src/Services/TranslateService.php +++ b/src/Services/TranslateService.php @@ -3,140 +3,347 @@ namespace Alisalehi\LaravelLangFilesTranslator\Services; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; use Stichoza\GoogleTranslate\GoogleTranslate; use Symfony\Component\Finder\SplFileInfo; +use Symfony\Component\Console\Output\OutputInterface; +use Exception; class TranslateService { - private string $translate_from; - private string $translate_to; - - //setters - public function from(string $from): TranslateService + private string $sourceLanguage; + private string $targetLanguage; + private ?OutputInterface $output = null; + private bool $forceOverwrite = false; + private bool $dryRun = false; + private int $chunkSize = 100; + private $progressCallback = null; + private array $translationStats = [ + 'files_processed' => 0, + 'keys_translated' => 0, + 'skipped_keys' => 0, + 'failed_keys' => 0, + ]; + private array $config = [ + 'preserve_parameters' => true, + 'parameter_pattern' => '/:(\w+)/', + 'placeholder_wrapper' => '{}', + 'max_retries' => 3, + 'retry_delay' => 1000, // milliseconds + ]; + + // Fluent setters with type hints + public function setFrom(string $language): self { - $this->translate_from = $from; + $this->validateLanguageCode($language); + $this->sourceLanguage = $language; return $this; } - - public function to(string $to): TranslateService + + public function setTo(string $language): self { - $this->translate_to = $to; + $this->validateLanguageCode($language); + $this->targetLanguage = $language; return $this; } - - public function translate(): void + + public function setOutput(OutputInterface $output): self { - $files = $this->getLocalLangFiles(); - - foreach ($files as $file) { - $this->filePutContent($this->getTranslatedData($file), $file); - } + $this->output = $output; + return $this; } - - private function getLocalLangFiles(): array + + public function setForce(bool $force): self { - $this->existsLocalLangDir(); - $this->existsLocalLangFiles(); - - return $this->getFiles($this->getTranslateLocalPath()); + $this->forceOverwrite = $force; + return $this; } - - private function filePutContent(string $translatedData, string $file): void + + public function setDryRun(bool $dryRun): self { - $folderPath = lang_path($this->translate_to); - $fileName = pathinfo($file, PATHINFO_FILENAME) . '.php'; - - if (!File::isDirectory($folderPath)) { - File::makeDirectory($folderPath, 0755, true); - } - - $filePath = $folderPath . DIRECTORY_SEPARATOR . $fileName; - File::put($filePath, $translatedData); + $this->dryRun = $dryRun; + return $this; } - - private function getTranslatedData(SplFileInfo $file): string + + public function setChunkSize(int $size): self { - $translatedData = var_export($this->translateLangFiles(include $file), "false"); - return $this->addPhpSyntax($translatedData); + $this->chunkSize = max(10, $size); // Ensure minimum chunk size + return $this; } - - private function setUpGoogleTranslate(): GoogleTranslate + + public function setProgressCallback(callable $callback): self + { + $this->progressCallback = $callback; + return $this; + } + + public function setConfig(array $config): self { - $google = new GoogleTranslate(); - return $google->setSource($this->translate_from) - ->setTarget($this->translate_to); + $this->config = array_merge($this->config, $config); + return $this; } - - private function translateLangFiles(array $content): array + + public function translate(): array { - $google = $this->setUpGoogleTranslate(); + $startTime = microtime(true); + + $this->validateSetup(); + $files = $this->getSourceLanguageFiles(); - if (empty($content)) + foreach ($files as $file) { + $this->processFile($file); + } + + $this->translationStats['execution_time'] = round(microtime(true) - $startTime, 2); + + return $this->translationStats; + } + + private function processFile(SplFileInfo $file): void + { + try { + $this->log("Processing file: {$file->getFilename()}"); + + $content = $this->loadFileContent($file); + $translatedContent = $this->translateContent($content); + + if (!$this->dryRun) { + $this->saveTranslation($file, $translatedContent); + } + + $this->translationStats['files_processed']++; + } catch (Exception $e) { + $this->logError("Failed to process file {$file->getFilename()}: {$e->getMessage()}"); + throw $e; + } + } + + private function translateContent(array $content): array + { + if (empty($content)) { return []; + } - return $this->translateRecursive($content, $google); + $googleTranslate = $this->createGoogleTranslateClient(); + $translated = []; + $chunks = array_chunk($content, $this->chunkSize, true); + + foreach ($chunks as $chunk) { + $translatedChunk = $this->translateChunk($chunk, $googleTranslate); + $translated = array_merge($translated, $translatedChunk); + + if ($this->progressCallback) { + call_user_func($this->progressCallback, count($chunk)); + } + } + + return $translated; } - - private function translateRecursive($content, $google) : array + + private function translateChunk(array $chunk, GoogleTranslate $translator): array { - $trans_data = []; + $translatedChunk = []; - foreach ($content as $key => $value) { + foreach ($chunk as $key => $value) { if (is_array($value)) { - $trans_data[$key] = $this->translateRecursive($value, $google); + $translatedChunk[$key] = $this->translateContent($value); continue; } - $hasProps = str_contains($value, ':'); - $modifiedValue = $hasProps - ? preg_replace_callback( - '/(:\w+)/', - fn($match) => '{' . $match[0] . '}', - $value - ) - : $value; + try { + $processedValue = $this->preProcessValue($value); + $translatedValue = $this->retryTranslation( + fn() => $translator->translate($processedValue), + $this->config['max_retries'], + $this->config['retry_delay'] + ); + $translatedChunk[$key] = $this->postProcessValue($translatedValue, $value); + + $this->translationStats['keys_translated']++; + } catch (Exception $e) { + $this->logError("Failed to translate key '$key': {$e->getMessage()}"); + $translatedChunk[$key] = $value; // Keep original value + $this->translationStats['failed_keys']++; + } + } + + return $translatedChunk; + } + + private function preProcessValue(string $value): string + { + if (!$this->config['preserve_parameters']) { + return $value; + } + + // Replace :param with {param} to protect during translation + return preg_replace_callback( + $this->config['parameter_pattern'], + fn($matches) => $this->wrapPlaceholder($matches[1]), + $value + ); + } + + private function postProcessValue(string $translated, string $original): string + { + if (!$this->config['preserve_parameters']) { + return $translated; + } + + // Restore original parameters + $params = []; + preg_match_all($this->config['parameter_pattern'], $original, $params); + + foreach ($params[1] ?? [] as $param) { + $placeholder = $this->wrapPlaceholder($param); + $translated = str_replace($placeholder, ":$param", $translated); + } + + return $translated; + } - $translatedValue = $google->translate($modifiedValue); + private function wrapPlaceholder(string $param): string + { + [$open, $close] = str_split($this->config['placeholder_wrapper']); + return $open . $param . $close; + } + + private function retryTranslation(callable $callback, int $maxRetries, int $delayMs) + { + $attempts = 0; + $lastException = null; - $trans_data[$key] = $hasProps - ? str_replace(['{', '}'], '', $translatedValue) - : $translatedValue; + while ($attempts < $maxRetries) { + try { + return $callback(); + } catch (Exception $e) { + $lastException = $e; + $attempts++; + $this->log("Retry attempt $attempts/$maxRetries after {$delayMs}ms"); + usleep($delayMs * 1000); + } } + + throw $lastException ?? new Exception('Translation failed'); + } + + private function saveTranslation(SplFileInfo $sourceFile, array $content): void + { + $targetPath = $this->getTargetFilePath($sourceFile); - return $trans_data; + if (File::exists($targetPath) && !$this->forceOverwrite) { + $this->log("Skipping existing file: {$targetPath} (use --force to overwrite)"); + return; + } + + $this->ensureDirectoryExists(dirname($targetPath)); + $phpContent = $this->generatePhpFileContent($content); + + File::put($targetPath, $phpContent); + $this->log("Saved translation to: {$targetPath}"); } - - private function addPhpSyntax(string $translatedData): string + + private function generatePhpFileContent(array $data): string + { + $export = var_export($data, true); + + // Clean up array formatting + $export = preg_replace('/array \(/', '[', $export); + $export = preg_replace('/\)$/', ']', $export); + $export = preg_replace('/\n\s*/', ' ', $export); + + return "getPathname(); + } catch (Exception $e) { + throw new Exception("Failed to load file {$file->getFilename()}: {$e->getMessage()}"); + } } - - // Exceptions - private function existsLocalLangDir(): void + + private function getSourceLanguageFiles(): array { - $path = $this->getTranslateLocalPath(); + $sourcePath = $this->getSourceLanguagePath(); - throw_if( - !File::isDirectory($path), - ("lang folder $this->translate_from not Exist !" . PHP_EOL . ' Have you run `php artisan lang:publish` command before?') - ); + $this->validateSourceDirectory($sourcePath); + + $files = File::files($sourcePath); + + if (empty($files)) { + throw new Exception("No language files found in {$sourcePath}"); + } + + return $files; } - - private function existsLocalLangFiles(): void + + private function getTargetFilePath(SplFileInfo $sourceFile): string + { + $fileName = $sourceFile->getFilename(); + return lang_path($this->targetLanguage . DIRECTORY_SEPARATOR . $fileName); + } + + private function getSourceLanguagePath(): string + { + return lang_path($this->sourceLanguage); + } + + private function ensureDirectoryExists(string $path): void + { + if (!File::isDirectory($path)) { + File::makeDirectory($path, 0755, true); + } + } + + private function createGoogleTranslateClient(): GoogleTranslate + { + $translator = new GoogleTranslate(); + return $translator + ->setSource($this->sourceLanguage) + ->setTarget($this->targetLanguage); + } + + private function validateSetup(): void { - $files = $this->getFiles($this->getTranslateLocalPath()); + if (empty($this->sourceLanguage) || empty($this->targetLanguage)) { + throw new Exception('Source and target languages must be set'); + } - throw_if(empty($files), ("lang files in '$this->translate_from' folder not found !")); + if ($this->sourceLanguage === $this->targetLanguage) { + throw new Exception('Source and target languages cannot be the same'); + } + } + + private function validateSourceDirectory(string $path): void + { + if (!File::isDirectory($path)) { + throw new Exception("Source language directory not found: {$path}\n" . + "Have you run `php artisan lang:publish` command?"); + } + } + + private function validateLanguageCode(string $code): void + { + if (!preg_match('/^[a-z]{2}(_[A-Z]{2})?$/', $code)) { + throw new Exception("Invalid language code format: {$code}"); + } } - - //helpers - private function getFiles(string $path = null): array + + private function log(string $message): void { - return File::files($path); + if ($this->output) { + $this->output->writeln("{$message}"); + } + Log::info($message); } - - private function getTranslateLocalPath(): string + + private function logError(string $message): void { - return lang_path(DIRECTORY_SEPARATOR . $this->translate_from); + if ($this->output) { + $this->output->writeln("{$message}"); + } + Log::error($message); } }