diff --git a/app/Console/Commands/Translate.php b/app/Console/Commands/Translate.php new file mode 100644 index 0000000..b99a97b --- /dev/null +++ b/app/Console/Commands/Translate.php @@ -0,0 +1,240 @@ + for reference} + {--force : Force the operation to run} + {--only= : Limit translation to a single component.}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Translates the base english language files to another language using the Google Cloud Translation API'; + + /** + * @var Collection + */ + protected $skip; + + /** + * @var Collection + */ + protected $parts; + + protected int $chunkSize = 128; + + public function __construct() + { + parent::__construct(); + + // TODO: Configure this somewhere else + $this->skip = collect([ + // Backend + 'validation.custom.attribute-name.rule-name', + + // Frontend + 'Sidebar.creator-login', + ]); + + $this->parts = collect([ + 'backend' => fn () => $this->handleBackend(), + 'frontend' => fn () => $this->handleFrontend(), + ]); + } + + /** + * Execute the console command. + */ + public function handle(): void + { + $only = $this->option('only'); + + if (is_string($only)) { + $handler = $this->parts->get($only); + + if ($handler == null) { + $this->error('Invalid component! Specify one of the following: ' . $this->parts->keys()->join(', ')); + } else { + $this->info("Processing {$only} …"); + $handler(); + } + } else { + foreach ($this->parts as $part => $handler) { + $this->info("Processing {$part} …"); + $handler(); + } + } + } + + private function handleBackend(): void + { + $files = array_diff(scandir(lang_path('en')), ['..', '.']); + $targetLanguage = $this->argument('language'); + + $existingKeys = collect(); + if (is_dir(lang_path($targetLanguage))) { + $filesTarget = array_diff(scandir(lang_path($targetLanguage)), ['..', '.']); + /** @var Collection */ + $existingKeys = collect($filesTarget) + ->mapWithKeys(fn (string $file) => [basename($file, '.php') => require lang_path($targetLanguage . '/' . $file)]) + ->dot() + ->filter(fn (mixed $value) => is_string($value)) + ->filter(fn (string $value, string $key) => $this->skip->doesntContain($key)); + } + + /** @var Collection */ + $data = collect($files) + ->mapWithKeys(fn (string $file) => [basename($file, '.php') => require lang_path('en/' . $file)]) + ->dot() + ->filter(fn (mixed $value) => is_string($value)) + ->filter(fn (string $value, string $key) => $this->skip->doesntContain($key)) + ->filter(fn (string $value, string $key) => ! $existingKeys->has($key)); + + $count = $data->count(); + if ($count == 0) { + $this->info('There is nothing to translate :)'); + + return; + } + + if (! $this->confirmTranslation($data)) { + return; + } + + /** @var Collection */ + $translated = $data + ->chunk($this->chunkSize) + ->map(fn (Collection $value) => $this->translate($value->values())) + ->flatten(1) + ->map(fn (array $result) => $result['text']) + ->keyBy(fn (string $item, int $key) => $data->keys()[$key]) + ->merge($existingKeys) + ->undot(); + + @mkdir(lang_path($targetLanguage, 0750)); + + foreach ($translated as $file => $data) { + $targetFile = lang_path($targetLanguage . '/' . $file . '.php'); + $dump = var_export($data, true); + + $contents = <<info("Successfully translated {$count} strings."); + $this->comment('Note that the content has been machine-translated. Make sure to manually review the translations.'); + } + + private function handleFrontend(): void + { + $targetLanguage = $this->argument('language'); + + $data = collect( + json_decode(file_get_contents(base_path('frontend/messages/en.json')), true) + ) + ->dot() + ->filter(fn (string $value, string $key) => $this->skip->doesntContain($key)); + + $count = $data->count(); + if ($count == 0) { + $this->info('There is nothing to translate :)'); + + return; + } + + if (! $this->confirmTranslation($data)) { + return; + } + + /** @var Collection */ + $translated = $data + ->chunk($this->chunkSize) + ->map(fn (Collection $value) => $this->translate($value->values())) + ->flatten(1) + ->map(fn (array $result) => $result['text']) + ->keyBy(fn (string $item, int $key) => $data->keys()[$key]) + ->undot(); + + $options = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE; + file_put_contents(base_path('frontend/messages/' . $targetLanguage . '.json'), $translated->toJson($options)); + + $this->info("Successfully translated {$count} strings."); + $this->comment('Note that the content has been machine-translated. Make sure to manually review the translations.'); + } + + /** + * @param Collection $data + */ + private function confirmTranslation(Collection $data): bool + { + $count = $data->count(); + $apiCalls = ceil($count / $this->chunkSize); + + if (! $this->option('force')) { + $proceed = confirm( + label: "Will process {$count} strings. Continue?", + default: true, + yes: 'Proceed', + no: 'Cancel', + hint: "Proceeding will make approx. {$apiCalls} API calls to the Google Cloud Translation API", + ); + + if (! $proceed) { + $this->error('Operation canceled by user.'); + + return false; + } + } + + return true; + } + + /** + * @param Collection $messages + */ + private function translate(Collection $messages): ?array + { + $key = config('services.google.translate.api_key'); + if (! $key) { + throw new Exception('Api key is missing! Did you specify the environment variable "GOOGLE_TRANSLATE_API_KEY" ?'); + } + + $client = new TranslateClient([ + 'key' => $key, + ]); + + return $client->translateBatch($messages->toArray(), [ + 'source' => 'en', + 'target' => $this->argument('language'), + 'format' => 'text', + ]); + } +} diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..6598e2c --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 0000000..d481411 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 0000000..fad3a7d --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..dddc947 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,194 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +];