diff --git a/app/sprinkles/core/src/Bakery/LocaleFixKeysCommand.php b/app/sprinkles/core/src/Bakery/LocaleFixKeysCommand.php new file mode 100644 index 000000000..cb9fcc209 --- /dev/null +++ b/app/sprinkles/core/src/Bakery/LocaleFixKeysCommand.php @@ -0,0 +1,243 @@ +setName('locale:fix-keys') + ->setHelp("This command generates missing keys for locale translation files. E.g. running 'locale:fix-keys -b en_US -f es_ES' will compare all es_ES and en_US locale files and populate es_ES with any missing keys from en_US.") + ->addOption('base', 'b', InputOption::VALUE_REQUIRED, 'The base locale used to generate values for any keys that are fixed. ', 'en_US') + ->addOption('locale', 'l', InputOption::VALUE_REQUIRED, 'One or more specific locales to fix. E.g. "fr_FR,es_ES" ', null) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not display confirmation.') + ->setDescription('Fix locale missing files and key values'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Fixing Locale Keys'); + + // The "base" locale to compare other locales against. Defaults to en_US if not set. + $baseLocale = $input->getOption('base'); + $baseLocaleFileNames = $this->getFilenames($baseLocale); + + // Option -c. Set to compare one or more specific locales. + $locales = $input->getOption('locale'); + + // Get locales to fix + $localesToFix = $this->getLocales($baseLocale, $locales); + + $this->io->note('Locales to be fixed: |' . implode('|', $localesToFix) . '|'); + + if (!$input->getOption('force') && !$this->io->confirm("All translation files for the locales above will be populated using key|values from | $baseLocale |. Continue?", false)) { + exit; + } + + $fixed = []; + + $progressBar = new ProgressBar($output); + $progressBar->start(count($localesToFix)); + + foreach ($localesToFix as $locale) { + $fixed[$locale] = $this->fixFiles($baseLocale, $locale, $baseLocaleFileNames); + $progressBar->advance(); + } + + $this->io->newLine(2); + + $filesFixed = $this->getListValues($fixed); + if (empty($filesFixed)) { + $this->io->success('No file need fixing'); + } else { + $this->io->section('Files fixed'); + $this->io->listing($filesFixed); + $this->io->success('Files fixed successfully'); + } + } + + /** + * Build a list of files that were fixed. + * + * @param array $array File paths and missing keys. + * + * @return array A list of fixed files + */ + protected function getListValues(array $array): array + { + $fixed = []; + + foreach ($array as $key => $value) { + if (is_array($value)) { + //We need to loop through it. + $fixed = array_merge($fixed, $this->getListValues($value)); + } elseif ($value != '0') { + //It is not an array and not '0', so add it to the list. + $fixed[] = $value; + } + } + + return $fixed; + } + + /** + * Iterate over sprinkle locale files and find the difference for two locales. + * + * @param string $baseLocale Locale being compared against. + * @param string $altLocale Locale to find missing keys for. + * @param array $filenames Sprinkle locale files that will be compared. + * + * @return array Filepaths that were fixed. + */ + protected function fixFiles(string $baseLocale, string $altLocale, array $filenames): array + { + $fixed = []; + + foreach ($filenames as $sprinklePath => $files) { + foreach ($files as $key => $file) { + $base = $this->parseFile("$sprinklePath/locale/$baseLocale/$file"); + $alt = $this->parseFile("$sprinklePath/locale/$altLocale/$file"); + $filePath = "$sprinklePath/locale/$altLocale/$file"; + $diff = $this->getDifference($base, $alt); + $missing = $this->arrayFlatten($diff); + + // The files with missing keys. + if (!empty($missing)) { + $fixed[] = $this->fix($base, $alt, $filePath, $missing); + } + } + } + + return $fixed; + } + + /** + * Fixes locale files by adding missing keys. + * + * @param array $base + * @param array $alt + * @param string $filePath The path of fixed file. + * @param array $missing + * + * @return string The path of the fixed file + */ + protected function fix(array $base, array $alt, string $filePath, array $missing): string + { + //If the directory does not exist we need to create it recursively. + if (!file_exists(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } + + // Build the respository and then merge in each locale file. + // Any keys not in the $alt locale will be the original left from the $base locales value. + $repository = new Repository(); + $repository->mergeItems(null, $base); + $repository->mergeItems(null, $alt); + + foreach ($missing as $key => $value) { + if (!$repository->has($key)) { + if (strpos($key, '@TRANSLATION') !== false) { + $val = $repository->get(str_replace('.@TRANSLATION', '', $key)); + $repository->set($key, $val); + } else { + $repository->set($key, $value); + } + } + } + + // Check if this is an existing locale file with docblock. + $temp = file_get_contents($filePath); + + if (strpos($temp, '@author') !== false || strpos($temp, '/**') !== false) { + // Save existing docblock temporarily. + $start = strpos($temp, '/**'); + $end = strpos(substr($temp, $start), '*/'); + $docblock = file_get_contents($filePath, null, null, $start, $end + 2); + + passthru("echo \ $filePath"); + + // We have to add the comment header prior to docblock or php-cs-fixer will overwrite it. + $this->fixFileWithPhpCs($filePath); + + // Append the docblock after the header comment. + file_put_contents($filePath, $docblock, FILE_APPEND); + passthru("echo '\r\n' >> $filePath"); + } else { + passthru("echo \ $filePath"); + } + + file_put_contents($filePath, var_export($repository->all(), true), FILE_APPEND); + + passthru("echo \; >> $filePath"); + + // Final check with php-cs-fixer + $this->fixFileWithPhpCs($filePath); + + // Insert 'return' into the file. + file_put_contents($filePath, preg_replace('/\[/', 'return [', file_get_contents($filePath), 1)); + + return $filePath; + } + + /** + * Fix a file using php-cs-fixer. + * + * @param string $file path of file to fix + */ + public function fixFileWithPhpCs(string $file): void + { + // Fix the file with php-cs-fixer + passthru("php ./app/vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix $file --quiet --using-cache no --config ./.php_cs"); + } + + /** + * {@inheritdoc} + */ + protected function getLocales(string $baseLocale, ?string $localesToCheck): array + { + $configuredLocales = array_diff(array_keys($this->ci->config['site']['locales']['available']), [$baseLocale]); + + // If set, use the locale(s) from the -f option. + if ($localesToCheck) { + $locales = explode(',', $localesToCheck); + foreach ($locales as $key => $value) { + if (!in_array($value, $configuredLocales)) { + $this->io->warning("The |$value| locale was not found in your current configuration. Proceeding may results in a large number of files being created. Are you sure you want to continue?"); + if (!$this->io->confirm('Continue?', false)) { + exit; + } + } + } + + return $locales; + } else { + return $configuredLocales; + } + } +} diff --git a/app/sprinkles/core/src/Bakery/LocaleInfoCommand.php b/app/sprinkles/core/src/Bakery/LocaleInfoCommand.php new file mode 100644 index 000000000..c9991d493 --- /dev/null +++ b/app/sprinkles/core/src/Bakery/LocaleInfoCommand.php @@ -0,0 +1,69 @@ +setName('locale:info') + ->setHelp('This command list all available locale as well as the defaut locale.') + ->setDescription('Informations about available locales'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Available locales'); + + /** @var \UserFrosting\Sprinkle\Core\I18n\SiteLocale */ + $localeService = $this->ci->locale; + + // Get available locales + /** @var \UserFrosting\I18n\Locale[] $available */ + $available = $localeService->getAvailable(); + + // Prepare table headers and lines array + $table = new Table($output); + $table->setHeaders(['Identifier', 'Name', 'Regional', 'Parents', 'Default']); + + foreach ($available as $locale) { + $table->addRow([ + $locale->getIdentifier(), + $locale->getName(), + $locale->getRegionalName(), + implode(', ', $locale->getDependentLocalesIdentifier()), + ($locale->getIdentifier() === $localeService->getDefaultLocale()) ? 'Yes' : '', + ]); + } + + $table->render(); + + // Everything went fine, return 0 exit code + return 0; + } +} diff --git a/app/sprinkles/core/src/Bakery/LocaleMissingKeysCommand.php b/app/sprinkles/core/src/Bakery/LocaleMissingKeysCommand.php new file mode 100644 index 000000000..ebfb1534a --- /dev/null +++ b/app/sprinkles/core/src/Bakery/LocaleMissingKeysCommand.php @@ -0,0 +1,252 @@ +setName('locale:missing-keys') + ->setHelp("This command provides a summary of missing keys for locale translation files. E.g. running 'locale:missing-keys -b en_US -c es_ES' will compare all es_ES and en_US locale files and generate a table listing the filepath and missing keys found from the `-c` locale.") + ->addOption('base', 'b', InputOption::VALUE_REQUIRED, 'The base locale to compare against.', 'en_US') + ->addOption('check', 'c', InputOption::VALUE_REQUIRED, 'One or more specific locales to check. E.g. "fr_FR,es_ES"', null) + ->setDescription('Generate a table of missing locale keys.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Missing Locale Keys'); + + // The "base" locale to compare other locales against. Defaults to en_US if not set. + $baseLocale = $input->getOption('base'); + + // Option -c. Set to compare one or more specific locales. + $localesToCheck = $input->getOption('check'); + + // Get locales to check + $locales = $this->getLocales($baseLocale, $localesToCheck); + + $this->io->writeln('Locales to check: |' . implode('|', $locales) . '|'); + $this->io->section("Searching for missing keys using $baseLocale for comparison."); + + $difference = []; + + foreach ($locales as $locale) { + + // Make sure locale exist + if (!in_array($locale, array_keys($this->ci->config['site']['locales']['available']))) { + $this->io->warning("Locale '$locale' is not available in config."); + } else { + $difference = array_merge($difference, $this->compareFiles($baseLocale, $locale)); + } + } + + // Build the table. + if (!empty($difference)) { + $this->newTable($output); + $this->table->setHeaders(['File path', 'Missing key']); + $this->buildTable($difference); + $this->table->render(); + + $this->io->writeln('Missing keys found successfully'); + } else { + $this->io->writeln('No missing keys found!'); + } + } + + /** + * Flattens a nested array into dot syntax. + * + * @param array $array The array to flatten. + * @param string $prefix (Default '') + * + * @return array Keys with missing values. + */ + protected function arrayFlatten(array $array, string $prefix = ''): array + { + $result = []; + foreach ($array as $key => $value) { + if (is_array($value)) { + $result = $result + $this->arrayFlatten($value, $prefix . $key . '.'); + } else { + $result[$prefix . $key] = $value; + } + } + + return $result; + } + + /** + * Populate table with file paths and missing keys. + * + * @param array $array File paths and missing keys. + */ + protected function buildTable(array $array): void + { + foreach ($array as $file => $missing) { + foreach ($missing as $key => $value) { + $this->table->addRow([$file, $key]); + } + } + } + + /** + * Iterate over sprinkle locale files and find the difference for two locales. + * + * @param string $baseLocale Locale being compared against. + * @param string $altLocale Locale to find missing keys for. + * + * @return array The keys in $baseLocale that do not exist in $altLocale. + */ + protected function compareFiles(string $baseLocale, string $altLocale): array + { + // Get all file for base locale + $files = $this->ci->locator->listResources("locale://$baseLocale", true); + + // Return value + $difference = []; + + foreach ($files as $basefile) { + + // Get alt locale path + // Stream Path is used as security, in case a sprinkle would be called the same as a locale + $streamPath = $basefile->getStream()->getPath(); + $altPath = str_replace("$streamPath/$baseLocale/", "$streamPath/$altLocale/", $basefile->getPath()); + + $base = $this->parseFile($basefile); + $alt = $this->parseFile($altPath); + $diff = $this->getDifference($base, $alt); + + $difference[$altPath] = $this->arrayFlatten($diff); + } + + return array_filter($difference); + } + + /** + * Find the missing keys between two arrays. + * + * @param array $array1 + * @param array $array2 + * + * @return array + */ + protected function getDifference(array $array1, array $array2): array + { + $difference = []; + + foreach ($array1 as $key => $value) { + if (is_array($value)) { + if (!isset($array2[$key])) { + $difference[$key] = $value; + } else { + if (is_array($array2[$key])) { + $difference[$key] = $this->getDifference($value, $array2[$key]); + } else { + // If the second array returns a string for a key while + // the first is an array, the whole first array is considered missing + $difference[$key] = $value; + } + } + } elseif (!isset($array2[$key])) { + $difference[$key] = $value; + } + } + + return $difference; + } + + /** + * @param string $baseLocale The "base" locale to compare to + * @param string|null $localesToCheck Comma delimited list of locales to check + * + * @return array Locales to check. + */ + protected function getLocales(string $baseLocale, ?string $localesToCheck): array + { + // If set, use the locale from the -c option. + if ($localesToCheck) { + return explode(',', $localesToCheck); + } else { + //Need to filter the base locale to prevent false positive. + return array_diff(array_keys($this->ci->config['site']['locales']['available']), [$baseLocale]); + } + } + + /** + * Set up new table with Bakery formatting. + * + * @param OutputInterface $output + */ + protected function newTable(OutputInterface $output): void + { + $tableStyle = new TableStyle(); + $tableStyle->setVerticalBorderChars(' ') + ->setDefaultCrossingChar(' ') + ->setCellHeaderFormat('%s'); + + $this->table = new Table($output); + $this->table->setStyle($tableStyle); + } + + /** + * Access file contents through inclusion. + * + * @param string $path The path of file to be included. + * + * @return array The array returned in the included locale file + */ + protected function parseFile(string $path): array + { + // Return empty array if file not found + if (!file_exists($path)) { + return []; + } + + $content = include "$path"; + + // Consider not found file returns an empty array + if ($content === false || !is_array($content)) { + return []; + } + + return $content; + } +} diff --git a/app/sprinkles/core/src/Bakery/LocaleMissingValuesCommand.php b/app/sprinkles/core/src/Bakery/LocaleMissingValuesCommand.php new file mode 100644 index 000000000..27d64d699 --- /dev/null +++ b/app/sprinkles/core/src/Bakery/LocaleMissingValuesCommand.php @@ -0,0 +1,281 @@ +setName('locale:missing-values') + ->setHelp("This command provides a summary of missing values for locale translation files. Missing keys are found by searching for empty and/or duplicate values. Either option can be turned off - see options for this command. E.g. running 'locale:missing-values -b en_US -c es_ES' will compare all es_ES and en_US locale files and find any values that are identical between the two locales, as well as searching all es_ES locale files for empty ('') values. This can be helpful to list all values in a specific languages that are present, but might need translation. For example, listing all English strings found in the French locale.") + ->addOption('base', 'b', InputOption::VALUE_REQUIRED, 'The base locale used for comparison and translation preview.', 'en_US') + ->addOption('check', 'c', InputOption::VALUE_REQUIRED, 'One or more specific locales to check. E.g. "fr_FR,es_ES"', null) + ->addOption('length', 'l', InputOption::VALUE_REQUIRED, 'Set the length for preview column text.', 50) + ->addOption('empty', 'e', InputOption::VALUE_NONE, 'Setting this will skip check for empty strings.') + ->addOption('duplicates', 'd', InputOption::VALUE_NONE, 'Setting this will skip comparison check.') + ->setDescription('Generate a table of keys with missing values.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Missing Locale Values'); + + // Option -c. The locales to be checked. + $localesToCheck = $input->getOption('check'); + + // The locale for the 'preview' column. Defaults to en_US if not set. + $baseLocale = $input->getOption('base'); + $baseLocaleFileNames = $this->getFilenames($baseLocale); + + // Get `length` option + $length = $input->getOption('length'); + + // Set translator to base locale + $this->setTranslation($baseLocale); + + // Get locales and files for said locales + $locales = $this->getLocales($baseLocale, $localesToCheck); + $files = $this->getFilePaths($locales); + + $this->io->writeln(['Locales to check: |' . implode('|', $locales) . '|']); + + // Proccess empty + if ($input->getOption('empty') === false) { + $this->io->section('Searching for empty values.'); + + $missing[] = $this->searchFilesForNull($files); + + if (!empty($missing[0])) { + $this->newTable($output, $length); + + $this->table->setHeaders([ + ['File path', 'Key', 'Translation preview'], + ]); + + // Build the table. + $this->buildTable($missing); + + $this->table->render(); + $this->io->newline(2); + } else { + $this->io->writeln('No empty values found!'); + } + } + + if ($input->getOption('duplicates') === false) { + $this->io->section('Searching for duplicate values.'); + + foreach ($locales as $locale) { + $duplicates[] = $this->compareFiles($baseLocale, $locale, $baseLocaleFileNames); + } + + if (!empty($duplicates[0])) { + $this->newTable($output, $length); + + $this->table->setHeaders([ + ['File path', 'Key', 'Translation preview'], + ]); + + $this->newTable($output, $length); + $this->table->setHeaders([ + ['File path', 'Key', 'Duplicate value'], + ]); + $this->buildTable($duplicates); + $this->table->render(); + } else { + $this->io->writeln('No empty values found!'); + } + } + } + + /** + * Intersect two arrays with considertaion of both keys and values. + * + * @param array $primary_array + * @param array $secondary_array + * + * @return array Matching keys and values that are found in both arrays. + */ + protected function arrayIntersect(array $primary_array, array $secondary_array): array + { + if (!empty($primary_array)) { + foreach ($primary_array as $key => $value) { + if (!isset($secondary_array[$key])) { + unset($primary_array[$key]); + } else { + if (serialize($secondary_array[$key]) != serialize($value)) { + unset($primary_array[$key]); + } + } + } + // We only want empty values. + return array_filter($primary_array, function ($key) { + return strpos($key, '@') === false; + }, ARRAY_FILTER_USE_KEY); + } else { + return []; + } + } + + /** + * Populate a table with data. + * + * @param array $array File paths and missing keys. + * @param int $level Nested array depth. + */ + protected function buildTable(array $array, int $level = 1): void + { + foreach ($array as $key => $value) { + //Level 2 has the filepath. + if ($level == 2) { + // Make path easier to read by removing anything before 'sprinkles' + $this->path = strstr($key, 'sprinkles'); + } + if (is_array($value)) { + //We need to loop through it. + $this->buildTable($value, ($level + 1)); + } else { + $this->table->addRow([$this->path, $key, $this->translator->translate($key)]); + } + } + } + + /** + * {@inheritdoc} + */ + protected function compareFiles(string $baseLocale, string $altLocale, array $filenames): array + { + foreach ($filenames as $sprinklePath => $files) { + foreach ($files as $key => $file) { + $base = $this->arrayFlatten($this->parseFile("$sprinklePath/locale/$baseLocale/$file")); + $alt = $this->arrayFlatten($this->parseFile("$sprinklePath/locale/$altLocale/$file")); + + $missing[$sprinklePath . '/locale' . '/' . $altLocale . '/' . $file] = $this->arrayIntersect($base, $alt); + } + } + + return array_filter($missing); + } + + /** + * Find keys with missing values. + * Collapses keys into array dot syntax. + * Missing values are identified using the same rules as the empty() method. + * + * @see https://www.php.net/manual/en/function.empty.php#refsect1-function.empty-returnvalues + * + * @param array $array Locale translation file. + * @param string $prefix + * + * @return array Keys with missing values. + */ + protected function findMissing(array $array, string $prefix = ''): array + { + $result = []; + foreach ($array as $key => $value) { + if (is_array($value)) { + $result = $result + $this->findMissing($value, $prefix . $key . '.'); + } else { + $result[$prefix . $key] = $value; + } + } + + // We only want empty values. + return array_filter($result, function ($val, $key) { + return empty($val) && strpos($key, '@') === false; + }, ARRAY_FILTER_USE_BOTH); + } + + /** + * Get a list of locale file paths. + * + * @param array $locale Array of locale(s) to get files for. + * + * @return array + */ + protected function getFilePaths(array $locale): array + { + // Set up a locator class + $locator = $this->ci->locator; + $builder = new LocalePathBuilder($locator, 'locale://', $locale); + $loader = new ArrayFileLoader($builder->buildPaths()); + + // Get nested array [0]. + return array_values((array) $loader)[0]; + } + + /** + * {@inheritdoc} + */ + protected function newTable(OutputInterface $output, int $length): void + { + parent::newTable($output); + $this->table->setColumnMaxWidth(2, $length); + } + + /** + * Search through locale files and find empty values. + * + * @param array $files File paths to search. + * + * @return array + */ + protected function searchFilesForNull(array $files): array + { + foreach ($files as $file) { + $missing[$file] = $this->findMissing($this->parseFile($file)); + + if (empty($missing[$file])) { + unset($missing[$file]); + } + } + + return $missing; + } + + /** + * Sets up translator for a specific locale. + * + * @param string $locale Locale to be used for translation. + */ + protected function setTranslation(string $locale): void + { + // Setup the translator. Set with -b or defaults to en_US + $locator = $this->ci->locator; + $builder = new LocalePathBuilder($locator, 'locale://', [$locale]); + $loader = new ArrayFileLoader($builder->buildPaths()); + $this->translator = new MessageTranslator($loader->load()); + } +} diff --git a/app/sprinkles/core/src/Util/EnvironmentInfo.php b/app/sprinkles/core/src/Util/EnvironmentInfo.php index ae37080ed..8af658dd4 100644 --- a/app/sprinkles/core/src/Util/EnvironmentInfo.php +++ b/app/sprinkles/core/src/Util/EnvironmentInfo.php @@ -22,7 +22,7 @@ class EnvironmentInfo { /** - * @var \Interop\Container\ContainerInterface The DI container for your application. + * @var \Psr\Container\ContainerInterface The DI container for your application. */ public static $ci; diff --git a/app/sprinkles/core/tests/Integration/Bakery/LocaleInfoCommandTest.php b/app/sprinkles/core/tests/Integration/Bakery/LocaleInfoCommandTest.php new file mode 100644 index 000000000..bbeee563b --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/LocaleInfoCommandTest.php @@ -0,0 +1,67 @@ +runCommand(); + $this->assertSame(0, $result->getStatusCode()); + + $output = $result->getDisplay(); + $this->assertNotContains('Français', $output); + $this->assertContains('English', $output); + } + + /** + * @param string[] $input + */ + protected function runCommand(array $input = []): CommandTester + { + // Force config to only three locales + $this->ci->config->set('site.locales.available', [ + 'en_US' => true, + 'es_ES' => true, + 'fr_FR' => false, + ]); + + // Create the app, create the command and add the command to the app + $app = new Application(); + $command = new LocaleInfoCommand(); + $command->setContainer($this->ci); + $app->add($command); + + // Add the command to the input to create the execute argument + $execute = array_merge([ + 'command' => $command->getName(), + ], $input); + + // Execute command tester + $commandTester = new CommandTester($command); + $commandTester->execute($execute); + + return $commandTester; + } +} diff --git a/app/sprinkles/core/tests/Integration/Bakery/LocaleMissingKeysCommandTest.php b/app/sprinkles/core/tests/Integration/Bakery/LocaleMissingKeysCommandTest.php new file mode 100644 index 000000000..c6d7833fd --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/LocaleMissingKeysCommandTest.php @@ -0,0 +1,110 @@ +runCommand(); + + $output = $result->getDisplay(); + $this->assertContains('Locales to check: |es_ES|fr_FR|', $output); + $this->assertContains('Missing keys found successfully', $output); + } + + /** + * @depends testCommand + */ + public function testCommandWithCheckArgument() + { + $result = $this->runCommand([ + '--check' => 'fr_FR', + ]); + + $output = $result->getDisplay(); + $this->assertContains('Locales to check: |fr_FR|', $output); + $this->assertContains('app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/foo/bar.php', $output); + $this->assertContains('FOO.BAR', $output); + $this->assertContains('Missing keys found successfully', $output); + } + + /** + * @depends testCommandWithCheckArgument + */ + public function testCommandWithCheckArgumentNoMissingKeys() + { + $result = $this->runCommand([ + '--check' => 'es_ES', + ]); + + $output = $result->getDisplay(); + $this->assertContains('Locales to check: |es_ES|', $output); + $this->assertContains('No missing keys found!', $output); + } + + /** + * @param array $input + */ + protected function runCommand($input = []) + { + // Replace default locale locator stream with the test data + $this->ci->locator->removeStream('locale'); + $this->ci->locator->registerStream('locale', '', 'tests/Integration/Bakery/data/locale'); + + // Force config to only three locales + $this->ci->config->set('site.locales.available', [ + 'en_US' => 'English', + 'es_ES' => 'Español', + 'fr_FR' => 'Français', + ]); + + // Create the app, create the command and add the command to the app + $app = new Application(); + $command = new LocaleMissingKeysCommand(); + $command->setContainer($this->ci); + $app->add($command); + + // Add the command to the input to create the execute argument + $execute = array_merge([ + 'command' => $command->getName(), + ], $input); + + // Execute command tester + $commandTester = new CommandTester($command); + $commandTester->execute($execute); + + return $commandTester; + } +} diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/errors.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/errors.php new file mode 100644 index 000000000..e48135fc0 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/errors.php @@ -0,0 +1,40 @@ + [ + '@TRANSLATION' => 'Error', + + '400' => [ + 'TITLE' => 'Error 400: Bad Request', + 'DESCRIPTION' => "It's probably not your fault.", + ], + + '404' => [ + 'TITLE' => 'Error 404: Not Found', + 'DESCRIPTION' => "We can't seem to find what you're looking for.", + 'DETAIL' => 'We tried to find your page...', + 'EXPLAIN' => 'We could not find the page you were looking for.', + 'RETURN' => 'Either way, click here to return to the front page.', + ], + + 'CONFIG' => [ + 'TITLE' => 'UserFrosting Configuration Issue!', + 'DESCRIPTION' => 'Some UserFrosting configuration requirements have not been met.', + 'DETAIL' => "Something's not right here.", + 'RETURN' => 'Please fix the following errors, then reload.', + ], + + 'DESCRIPTION' => "We've sensed a great disturbance in the Force.", + 'DETAIL' => "Here's what we got:", + + 'ENCOUNTERED' => "Uhhh...something happened. We don't know what.", + + 'MAIL' => 'Fatal error attempting mail, contact your server administrator. If you are the admin, please check the UserFrosting log.', + + 'RETURN' => 'Click here to return to the front page.', + + 'SERVER' => "Oops, looks like our server might have goofed. If you're an admin, please check the PHP or UserFrosting logs.", + + 'TITLE' => 'Disturbance in the Force', + ], +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/foo/bar.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/foo/bar.php new file mode 100644 index 000000000..ac9d9fa11 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/foo/bar.php @@ -0,0 +1,7 @@ + [ + 'BAR' => 'Disturbance in the Force', + ], +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/messages.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/messages.php new file mode 100644 index 000000000..3fd6d2cd6 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/en_US/messages.php @@ -0,0 +1,109 @@ + 1, + + 'ABOUT' => 'About', + + 'CAPTCHA' => [ + '@TRANSLATION' => 'Captcha', + 'FAIL' => 'You did not enter the captcha code correctly.', + 'SPECIFY' => 'Enter the captcha', + 'VERIFY' => 'Verify the captcha', + ], + + 'CSRF_MISSING' => 'Missing CSRF token. Try refreshing the page and then submitting again?', + + 'DB_INVALID' => 'Cannot connect to the database. If you are an administrator, please check your error log.', + 'DESCRIPTION' => 'Description', + 'DOWNLOAD' => [ + '@TRANSLATION' => 'Download', + 'CSV' => 'Download CSV', + ], + + 'EMAIL' => [ + '@TRANSLATION' => 'Email', + 'YOUR' => 'Your email address', + ], + + 'HOME' => 'Home', + + 'LEGAL' => [ + '@TRANSLATION' => 'Legal Policy', + 'DESCRIPTION' => 'Our legal policy applies to your usage of this website and our services.', + ], + + 'LOCALE' => [ + '@TRANSLATION' => 'Locale', + ], + + 'NAME' => 'Name', + 'NAVIGATION' => 'Navigation', + 'NO_RESULTS' => "Sorry, we've got nothing here.", + + 'PAGINATION' => [ + 'GOTO' => 'Jump to Page', + 'SHOW' => 'Show', + + // Paginator + // possible variables: {size}, {page}, {totalPages}, {filteredPages}, {startRow}, {endRow}, {filteredRows} and {totalRows} + // also {page:input} & {startRow:input} will add a modifiable input in place of the value + 'OUTPUT' => '{startRow} to {endRow} of {filteredRows} ({totalRows})', + 'NEXT' => 'Next page', + 'PREVIOUS' => 'Previous page', + 'FIRST' => 'First page', + 'LAST' => 'Last page', + ], + 'PRIVACY' => [ + '@TRANSLATION' => 'Privacy Policy', + 'DESCRIPTION' => 'Our privacy policy outlines what kind of information we collect from you and how we will use it.', + ], + + 'SLUG' => 'Slug', + 'SLUG_CONDITION' => 'Slug/Conditions', + 'SLUG_IN_USE' => 'A {{slug}} slug already exists', + 'STATUS' => 'Status', + 'SUGGEST' => 'Suggest', + + 'UNKNOWN' => 'Unknown', + + // Actions words + 'ACTIONS' => 'Actions', + 'ACTIVATE' => 'Activate', + 'ACTIVE' => 'Active', + 'ADD' => 'Add', + 'CANCEL' => 'Cancel', + 'CONFIRM' => 'Confirm', + 'CREATE' => 'Create', + 'DELETE' => 'Delete', + 'DELETE_CONFIRM' => 'Are you sure you want to delete this?', + 'DELETE_CONFIRM_YES' => 'Yes, delete', + 'DELETE_CONFIRM_NAMED' => 'Are you sure you want to delete {{name}}?', + 'DELETE_CONFIRM_YES_NAMED' => 'Yes, delete {{name}}', + 'DELETE_CANNOT_UNDONE' => 'This action cannot be undone.', + 'DELETE_NAMED' => 'Delete {{name}}', + 'DENY' => 'Deny', + 'DISABLE' => 'Disable', + 'DISABLED' => 'Disabled', + 'EDIT' => 'Edit', + 'ENABLE' => 'Enable', + 'ENABLED' => 'Enabled', + 'OVERRIDE' => 'Override', + 'RESET' => 'Reset', + 'SAVE' => 'Save', + 'SEARCH' => 'Search', + 'SORT' => 'Sort', + 'SUBMIT' => 'Submit', + 'PRINT' => 'Print', + 'REMOVE' => 'Remove', + 'UNACTIVATED' => 'Unactivated', + 'UPDATE' => 'Update', + 'YES' => 'Yes', + 'NO' => 'No', + 'OPTIONAL' => 'Optional', + + // Misc. + 'BUILT_WITH_UF' => 'Built with UserFrosting', + 'ADMINLTE_THEME_BY' => 'Theme by Almsaeed Studio. All rights reserved', + 'WELCOME_TO' => 'Welcome to {{title}}!', +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/errors.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/errors.php new file mode 100644 index 000000000..e48135fc0 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/errors.php @@ -0,0 +1,40 @@ + [ + '@TRANSLATION' => 'Error', + + '400' => [ + 'TITLE' => 'Error 400: Bad Request', + 'DESCRIPTION' => "It's probably not your fault.", + ], + + '404' => [ + 'TITLE' => 'Error 404: Not Found', + 'DESCRIPTION' => "We can't seem to find what you're looking for.", + 'DETAIL' => 'We tried to find your page...', + 'EXPLAIN' => 'We could not find the page you were looking for.', + 'RETURN' => 'Either way, click here to return to the front page.', + ], + + 'CONFIG' => [ + 'TITLE' => 'UserFrosting Configuration Issue!', + 'DESCRIPTION' => 'Some UserFrosting configuration requirements have not been met.', + 'DETAIL' => "Something's not right here.", + 'RETURN' => 'Please fix the following errors, then reload.', + ], + + 'DESCRIPTION' => "We've sensed a great disturbance in the Force.", + 'DETAIL' => "Here's what we got:", + + 'ENCOUNTERED' => "Uhhh...something happened. We don't know what.", + + 'MAIL' => 'Fatal error attempting mail, contact your server administrator. If you are the admin, please check the UserFrosting log.', + + 'RETURN' => 'Click here to return to the front page.', + + 'SERVER' => "Oops, looks like our server might have goofed. If you're an admin, please check the PHP or UserFrosting logs.", + + 'TITLE' => 'Disturbance in the Force', + ], +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/foo/bar.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/foo/bar.php new file mode 100644 index 000000000..ac9d9fa11 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/foo/bar.php @@ -0,0 +1,7 @@ + [ + 'BAR' => 'Disturbance in the Force', + ], +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/messages.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/messages.php new file mode 100644 index 000000000..3fd6d2cd6 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/es_ES/messages.php @@ -0,0 +1,109 @@ + 1, + + 'ABOUT' => 'About', + + 'CAPTCHA' => [ + '@TRANSLATION' => 'Captcha', + 'FAIL' => 'You did not enter the captcha code correctly.', + 'SPECIFY' => 'Enter the captcha', + 'VERIFY' => 'Verify the captcha', + ], + + 'CSRF_MISSING' => 'Missing CSRF token. Try refreshing the page and then submitting again?', + + 'DB_INVALID' => 'Cannot connect to the database. If you are an administrator, please check your error log.', + 'DESCRIPTION' => 'Description', + 'DOWNLOAD' => [ + '@TRANSLATION' => 'Download', + 'CSV' => 'Download CSV', + ], + + 'EMAIL' => [ + '@TRANSLATION' => 'Email', + 'YOUR' => 'Your email address', + ], + + 'HOME' => 'Home', + + 'LEGAL' => [ + '@TRANSLATION' => 'Legal Policy', + 'DESCRIPTION' => 'Our legal policy applies to your usage of this website and our services.', + ], + + 'LOCALE' => [ + '@TRANSLATION' => 'Locale', + ], + + 'NAME' => 'Name', + 'NAVIGATION' => 'Navigation', + 'NO_RESULTS' => "Sorry, we've got nothing here.", + + 'PAGINATION' => [ + 'GOTO' => 'Jump to Page', + 'SHOW' => 'Show', + + // Paginator + // possible variables: {size}, {page}, {totalPages}, {filteredPages}, {startRow}, {endRow}, {filteredRows} and {totalRows} + // also {page:input} & {startRow:input} will add a modifiable input in place of the value + 'OUTPUT' => '{startRow} to {endRow} of {filteredRows} ({totalRows})', + 'NEXT' => 'Next page', + 'PREVIOUS' => 'Previous page', + 'FIRST' => 'First page', + 'LAST' => 'Last page', + ], + 'PRIVACY' => [ + '@TRANSLATION' => 'Privacy Policy', + 'DESCRIPTION' => 'Our privacy policy outlines what kind of information we collect from you and how we will use it.', + ], + + 'SLUG' => 'Slug', + 'SLUG_CONDITION' => 'Slug/Conditions', + 'SLUG_IN_USE' => 'A {{slug}} slug already exists', + 'STATUS' => 'Status', + 'SUGGEST' => 'Suggest', + + 'UNKNOWN' => 'Unknown', + + // Actions words + 'ACTIONS' => 'Actions', + 'ACTIVATE' => 'Activate', + 'ACTIVE' => 'Active', + 'ADD' => 'Add', + 'CANCEL' => 'Cancel', + 'CONFIRM' => 'Confirm', + 'CREATE' => 'Create', + 'DELETE' => 'Delete', + 'DELETE_CONFIRM' => 'Are you sure you want to delete this?', + 'DELETE_CONFIRM_YES' => 'Yes, delete', + 'DELETE_CONFIRM_NAMED' => 'Are you sure you want to delete {{name}}?', + 'DELETE_CONFIRM_YES_NAMED' => 'Yes, delete {{name}}', + 'DELETE_CANNOT_UNDONE' => 'This action cannot be undone.', + 'DELETE_NAMED' => 'Delete {{name}}', + 'DENY' => 'Deny', + 'DISABLE' => 'Disable', + 'DISABLED' => 'Disabled', + 'EDIT' => 'Edit', + 'ENABLE' => 'Enable', + 'ENABLED' => 'Enabled', + 'OVERRIDE' => 'Override', + 'RESET' => 'Reset', + 'SAVE' => 'Save', + 'SEARCH' => 'Search', + 'SORT' => 'Sort', + 'SUBMIT' => 'Submit', + 'PRINT' => 'Print', + 'REMOVE' => 'Remove', + 'UNACTIVATED' => 'Unactivated', + 'UPDATE' => 'Update', + 'YES' => 'Yes', + 'NO' => 'No', + 'OPTIONAL' => 'Optional', + + // Misc. + 'BUILT_WITH_UF' => 'Built with UserFrosting', + 'ADMINLTE_THEME_BY' => 'Theme by Almsaeed Studio. All rights reserved', + 'WELCOME_TO' => 'Welcome to {{title}}!', +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/errors.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/errors.php new file mode 100644 index 000000000..a7275ef8c --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/errors.php @@ -0,0 +1,40 @@ + [ + '@TRANSLATION' => 'Erreur', + + '400' => [ + 'TITLE' => 'Erreur 400: Mauvaise requête', + 'DESCRIPTION' => "Ce n'est probablement pas de votre faute.", + ], + + '404' => [ + 'TITLE' => 'Erreur 404: Page introuvable', + 'DESCRIPTION' => 'Nous ne pouvons trouver ce que vous cherchez.', + 'DETAIL' => 'Nous avons tout tenté...', + 'EXPLAIN' => 'Nous ne pouvons trouver la page que vous cherchez.', + 'RETURN' => 'Cliquez ici pour retourner à la page d\'accueil.', + ], + + 'CONFIG' => [ + 'TITLE' => 'Problème de configuration UserFrosting!', + 'DESCRIPTION' => "Les exigences de configuration de UserFrosting n'ont pas été satisfaites.", + 'DETAIL' => 'Quelque chose cloche ici...', + 'RETURN' => 'Corrigez les erreurs suivantes, ensuite recharger la page.', + ], + + 'DESCRIPTION' => 'Nous avons ressenti un grand bouleversement de la Force.', + 'DETAIL' => 'Voici les détails :', + + 'ENCOUNTERED' => "D'oh! Quelque chose s'est produit. Aucune idée c'est quoi.", + + 'MAIL' => "Erreur fatale lors de l'envoie du courriel. Contactez votre administrateur. Si vous être administrateur, consultez les logs.", + + 'RETURN' => 'Cliquez ici pour retourner à la page d\'accueil.', + + 'SERVER' => "Oops, il semblerait que le serveur a gaffé. Si vous êtes administrateur, s-v-p vérifier les logs d'erreurs PHP ou ceux de UserFrosting.", + + 'TITLE' => 'Bouleversement de la Force', + ], +]; diff --git a/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/messages.php b/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/messages.php new file mode 100644 index 000000000..56c410c29 --- /dev/null +++ b/app/sprinkles/core/tests/Integration/Bakery/data/locale/fr_FR/messages.php @@ -0,0 +1,94 @@ + 2, + + 'ABOUT' => 'À propos', + + 'CAPTCHA' => [ + '@TRANSLATE' => 'Captcha', + 'VERIFY' => 'Vérification du captcha', + 'SPECIFY' => 'Entrer la valeur du captcha', + 'FAIL' => "La valeur du captcha n'a pas été entrée correctement.", + ], + + 'CSRF_MISSING' => 'Jeton CSRF manquant. Essayez de rafraîchir la page et de soumettre de nouveau?', + + 'DB_INVALID' => "Impossible de se connecter à la base de données. Si vous êtes un administrateur, vérifiez votre journal d'erreurs.", + 'DESCRIPTION' => 'Description', + 'DOWNLOAD' => [ + '@TRANSLATION' => 'Télécharger', + 'CSV' => 'Télécharger CSV', + ], + + 'EMAIL' => [ + '@TRANSLATION' => 'Email', + 'YOUR' => 'Votre adresse email', + ], + + 'HOME' => 'Accueil', + + 'LEGAL' => 'Politique légale', + + 'LOCALE' => [ + '@TRANSLATION' => 'Langue', + ], + + 'NAME' => 'Nom', + 'NAVIGATION' => 'Menu principal', + 'NO_RESULTS' => 'Aucun résultat trouvé.', + + 'PAGINATION' => [ + 'GOTO' => 'Aller à la page', + 'SHOW' => 'Afficher', + 'OUTPUT' => '{startRow} à {endRow} de {filteredRows} ({totalRows})', + ], + 'PRIVACY' => 'Politique de confidentialité', + + 'SLUG' => 'Jeton', + 'SLUG_CONDITION' => 'Jeton/Conditions', + 'SLUG_IN_USE' => 'Un jeton {{slug}} existe déjà', + 'STATUS' => 'Statut', + 'SUGGEST' => 'Suggérer', + + 'UNKNOWN' => 'Inconnu', + + // Actions words + 'ACTIONS' => 'Actions', + 'ACTIVATE' => 'Autoriser', + 'ACTIVE' => 'Activé', + 'ADD' => 'Ajouter', + 'CANCEL' => 'Annuler', + 'CONFIRM' => 'Confirmer', + 'CREATE' => 'Créer', + 'DELETE' => 'Supprimer', + 'DELETE_CONFIRM' => 'Êtes-vous sûr de vouloir supprimer ceci?', + 'DELETE_CONFIRM_YES' => 'Oui, supprimer', + 'DELETE_CONFIRM_NAMED' => 'Êtes-vous sûr de vouloir supprimer {{name}}?', + 'DELETE_CONFIRM_YES_NAMED' => 'Oui, supprimer {{name}}', + 'DELETE_CANNOT_UNDONE' => 'Cette action ne peut être annulée.', //This action cannot be undone + 'DELETE_NAMED' => 'Supprimer {{name}}', + 'DENY' => 'Refuser', + 'DISABLE' => 'Désactiver', + 'DISABLED' => 'Désactivé', + 'EDIT' => 'Modifier', + 'ENABLE' => 'Activer', + 'ENABLED' => 'Activé', + 'OVERRIDE' => 'Forcer', + 'RESET' => 'Réinitialiser', + 'SAVE' => 'Sauvegarder', + 'SEARCH' => 'Rechercher', + 'SORT' => 'Trier', + 'SUBMIT' => 'Envoyer', + 'PRINT' => 'Imprimer', + 'REMOVE' => 'Supprimer', + 'UNACTIVATED' => 'Non activé', + 'UPDATE' => 'Mettre à jour', + 'YES' => 'Oui', + 'NO' => 'Non', + 'OPTIONAL' => 'Facultatif', + + // Misc. + 'BUILT_WITH_UF' => 'Créé avec UserFrosting', + 'ADMINLTE_THEME_BY' => 'Thème par Almsaeed Studio. Tous droits réservés', +]; diff --git a/app/system/Bakery/Bakery.php b/app/system/Bakery/Bakery.php index 1d8fe1a48..45a48136a 100644 --- a/app/system/Bakery/Bakery.php +++ b/app/system/Bakery/Bakery.php @@ -31,7 +31,7 @@ class Bakery protected $app; /** - * @var \Interop\Container\ContainerInterface The global container object, which holds all your services. + * @var \Psr\Container\ContainerInterface The global container object, which holds all your services. */ protected $ci; diff --git a/app/system/Bakery/BaseCommand.php b/app/system/Bakery/BaseCommand.php index 54dc198cc..e91fe136a 100644 --- a/app/system/Bakery/BaseCommand.php +++ b/app/system/Bakery/BaseCommand.php @@ -24,13 +24,14 @@ abstract class BaseCommand extends Command { /** - * @var \Symfony\Component\Console\Style\SymfonyStyle - * See http://symfony.com/doc/current/console/style.html + * @var \Symfony\Component\Console\Style\SymfonyStyle + * + * @see http://symfony.com/doc/current/console/style.html */ protected $io; /** - * @var ContainerInterface The global container object, which holds all of UserFrosting services. + * @var ContainerInterface The global container object, which holds all of UserFrosting services. */ protected $ci; @@ -47,7 +48,7 @@ protected function initialize(InputInterface $input, OutputInterface $output) * * @param ContainerInterface $ci */ - public function setContainer(ContainerInterface $ci) + public function setContainer(ContainerInterface $ci): void { $this->ci = $ci; } @@ -57,12 +58,12 @@ public function setContainer(ContainerInterface $ci) * * @return bool True/False if the app is in production mode */ - protected function isProduction() + protected function isProduction(): bool { - // N.B.: Need to touch the config service first to load dotenv values + // Need to touch the config service first to load dotenv values $config = $this->ci->config; $mode = getenv('UF_MODE') ?: ''; - return $mode == 'production'; + return $mode === 'production'; } } diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index f3d82f1c5..f9a88658e 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -24,7 +24,7 @@ class TestCase extends BaseTestCase /** * The global container object, which holds all your services. * - * @var \Interop\Container\ContainerInterface + * @var \Psr\Container\ContainerInterface */ protected $ci;