diff --git a/.github/workflows/grumphp.yml b/.github/workflows/grumphp.yml new file mode 100644 index 0000000..e67f897 --- /dev/null +++ b/.github/workflows/grumphp.yml @@ -0,0 +1,28 @@ +on: [push] + +jobs: + build: + name: Running GrumPHP + runs-on: ubuntu-latest + env: + extensions: intl, mbstring + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.3' + extensions: ${{ env.extensions }} + tools: composer + - name: Get Composer Cache Directory + id: composercache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install dependencies + run: composer config "http-basic.repo.magento.com" ${{ secrets.MAGE_USER }} ${{ secrets.MAGE_PASS }} && composer install --prefer-dist + - run: ./vendor/bin/grumphp run \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb0a8e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..cecf418 --- /dev/null +++ b/.php_cs @@ -0,0 +1,46 @@ +in('./') + ->name(['*.php']); + +return Config::create() + ->setRules([ + '@PSR2' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'phpdoc_order' => true, + 'phpdoc_summary' => false, + 'blank_line_after_opening_tag' => false, + 'concat_space' => ['spacing' => 'one'], + 'array_syntax' => ['syntax' => 'short'], + // Removes @param and @return tags that don't provide any useful information; + // Set to false to ensure custom Magento API implementations do not fail. Magento uses reflection based on its docblocks to process request/responses + 'no_superfluous_phpdoc_tags' => false, + // add declare strict type to every file + 'declare_strict_types' => true, + // use native phpunit methods + 'php_unit_construct' => true, + // Enforce camel case for PHPUnit test methods + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'yoda_style' => ['equal' => true, 'identical' => true, 'less_and_greater' => true], + 'php_unit_test_case_static_method_calls' => true, + // comparisons should always be strict + 'strict_comparison' => true, + // functions should be used with $strict param set to true + 'strict_param' => true, + 'array_indentation' => true, + 'compact_nullable_typehint' => true, + 'fully_qualified_strict_types' => true, + ]) + ->setFinder($finder) + ->setRiskyAllowed(true) + ->setUsingCache(false); + diff --git a/Api/Data/TranslationInterface.php b/Api/Data/TranslationInterface.php new file mode 100644 index 0000000..172a9f4 --- /dev/null +++ b/Api/Data/TranslationInterface.php @@ -0,0 +1,83 @@ + __('Back'), + 'on_click' => sprintf("location.href = '%s';", $this->getBackUrl()), + 'class' => 'back', + 'sort_order' => 10 + ]; + } + + /** + * Get URL for back (reset) button + * + * @return string + */ + public function getBackUrl() + { + return $this->getUrl('*/*/'); + } +} diff --git a/Block/Adminhtml/Translation/Edit/DeleteButton.php b/Block/Adminhtml/Translation/Edit/DeleteButton.php new file mode 100644 index 0000000..1267834 --- /dev/null +++ b/Block/Adminhtml/Translation/Edit/DeleteButton.php @@ -0,0 +1,39 @@ +getModelId()) { + $data = [ + 'label' => __('Delete Translation'), + 'class' => 'delete', + 'on_click' => 'deleteConfirm(\'' . __( + 'Are you sure you want to do this?' + ) . '\', \'' . $this->getDeleteUrl() . '\')', + 'sort_order' => 20, + ]; + } + return $data; + } + + /** + * Get URL for delete button + * + * @return string + */ + public function getDeleteUrl() + { + return $this->getUrl('*/*/delete', ['key_id' => $this->getModelId()]); + } +} diff --git a/Block/Adminhtml/Translation/Edit/GenericButton.php b/Block/Adminhtml/Translation/Edit/GenericButton.php new file mode 100644 index 0000000..f209eae --- /dev/null +++ b/Block/Adminhtml/Translation/Edit/GenericButton.php @@ -0,0 +1,41 @@ +context = $context; + } + + /** + * Return model ID + * + * @return int|null + */ + public function getModelId() + { + return $this->context->getRequest()->getParam('key_id'); + } + + /** + * Generate url by route and parameters + * + * @param string $route + * @param array $params + * @return string + */ + public function getUrl($route = '', $params = []) + { + return $this->context->getUrlBuilder()->getUrl($route, $params); + } +} diff --git a/Block/Adminhtml/Translation/Edit/SaveAndContinueButton.php b/Block/Adminhtml/Translation/Edit/SaveAndContinueButton.php new file mode 100644 index 0000000..528e71b --- /dev/null +++ b/Block/Adminhtml/Translation/Edit/SaveAndContinueButton.php @@ -0,0 +1,27 @@ + __('Save and Continue Edit'), + 'class' => 'save', + 'data_attribute' => [ + 'mage-init' => [ + 'button' => ['event' => 'saveAndContinueEdit'], + ], + ], + 'sort_order' => 80, + ]; + } +} diff --git a/Block/Adminhtml/Translation/Edit/SaveButton.php b/Block/Adminhtml/Translation/Edit/SaveButton.php new file mode 100644 index 0000000..63eeb5a --- /dev/null +++ b/Block/Adminhtml/Translation/Edit/SaveButton.php @@ -0,0 +1,26 @@ + __('Save Translation'), + 'class' => 'save primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + 'sort_order' => 90, + ]; + } +} diff --git a/Block/Adminhtml/Translation/GenerateJson/SaveButton.php b/Block/Adminhtml/Translation/GenerateJson/SaveButton.php new file mode 100644 index 0000000..bb12336 --- /dev/null +++ b/Block/Adminhtml/Translation/GenerateJson/SaveButton.php @@ -0,0 +1,27 @@ + __('Generate translations files'), + 'class' => 'save primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + 'sort_order' => 90, + ]; + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c5fa19 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +## [1.0.0] - 2022-01-28 +### Added +- Simple backend CRUD to manage translations +- Single and multi-inline editing via the grid-overview +- Import and export via CSV files via CLI +- Import and export via default Magento import/export functionality +- Prepare new translations via data patch scripts +- (Re)generate frontend translations (JSON translation files) via CLI and backend \ No newline at end of file diff --git a/Console/Command/Export.php b/Console/Command/Export.php new file mode 100644 index 0000000..00be805 --- /dev/null +++ b/Console/Command/Export.php @@ -0,0 +1,59 @@ +exporter = $exporter; + parent::__construct(); + } + + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Export translations from database to CSV.'); + $this->addArgument(self::ARGUMENT_LOCALES, InputArgument::IS_ARRAY, 'Locales (multiple locales with a space'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): void + { + try { + $locales = $input->getArgument(self::ARGUMENT_LOCALES); + + $output->writeln( + sprintf( + 'Exporting translations to CSV file' + ) + ); + + $exportStats = $this->exporter->export($locales); + + $output->writeln('Csv file: ' . $exportStats->getFileName() . ''); + $output->writeln('Total written rows: ' . $exportStats->getTotalRows() . ''); + + $output->writeln('Done!'); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + } + } +} diff --git a/Console/Command/GenerateFrontendTranslations.php b/Console/Command/GenerateFrontendTranslations.php new file mode 100644 index 0000000..465ee15 --- /dev/null +++ b/Console/Command/GenerateFrontendTranslations.php @@ -0,0 +1,66 @@ +inlineTranslationsGenerator = $inlineTranslations; + parent::__construct(); + } + + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Re-generate the frontend translations (js-translations.json) for given store view.'); + $this->addArgument('storeId', InputArgument::OPTIONAL, 'Leave empty for all store views', 0); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): void + { + try { + $storeId = (int)$input->getArgument('storeId'); + $statsCollection = $this->generatedTranslations($storeId); + + $table = new Table($output); + $table->setHeaders(['# Translations', 'Store view information', 'Store id']); + foreach ($statsCollection as $stats) { + $table->addRow([ + $stats->getAmountGenerated(), + $stats->getStoreInformation(), + $stats->getStoreId(), + ]); + } + $output->writeln('Inline frontend translations successfully re-generated for:'); + $table->render(); + $output->writeln('Please clean full_page and block_html caches manually'); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + } + } + + private function generatedTranslations(int $storeId): InlineGenerateStatsCollection + { + if (0 === $storeId) { + return $this->inlineTranslationsGenerator->forAll(); + } + + return $this->inlineTranslationsGenerator->forStores([$storeId]); + } +} diff --git a/Console/Command/Import.php b/Console/Command/Import.php new file mode 100644 index 0000000..9ce7f3d --- /dev/null +++ b/Console/Command/Import.php @@ -0,0 +1,81 @@ +importer = $importer; + $this->cacheManager = $cacheManager; + parent::__construct(); + } + + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Import Magento translation CSVs to the database.'); + $this->addArgument(self::ARGUMENT_CSV_FILE, InputArgument::REQUIRED); + $this->addArgument(self::ARGUMENT_LOCALE, InputArgument::REQUIRED); + $this->addOption(self::OPTION_CLEAR_CACHE, null, InputOption::VALUE_NONE, 'Clear related caches'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): void + { + try { + $csvFile = $input->getArgument(self::ARGUMENT_CSV_FILE); + $locale = $input->getArgument(self::ARGUMENT_LOCALE); + + $output->writeln( + sprintf( + 'Importing CSV file %s for locale %s', + $csvFile, + $locale + ) + ); + + $importStats = $this->importer->importMagentoCsv($csvFile, $locale); + + $output->writeln('#created: ' . $importStats->getCreatedCount() . ''); + $output->writeln('#skipped: ' . $importStats->getSkippedCount() . ''); + $output->writeln('#failed: ' . $importStats->getFailedCount() . ''); + + if (false !== $input->hasOption(self::OPTION_CLEAR_CACHE)) { + $cacheTypes = ['full_page', 'block_html', 'translate']; + $this->cacheManager->clean(['full_page', 'block_html', 'translate']); + $output->writeln('Caches cleared: ' . implode(', ', $cacheTypes) . ''); + } + + $output->writeln('Done!'); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + } + } +} diff --git a/Console/Command/ImportFull.php b/Console/Command/ImportFull.php new file mode 100644 index 0000000..8742587 --- /dev/null +++ b/Console/Command/ImportFull.php @@ -0,0 +1,77 @@ +importer = $importer; + $this->cacheManager = $cacheManager; + parent::__construct(); + } + + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Import translation CSVs to the database based on exports.'); + $this->addArgument(self::ARGUMENT_CSV_FILE, InputArgument::REQUIRED); + $this->addOption(self::OPTION_CLEAR_CACHE, null, InputOption::VALUE_NONE, 'Clear related caches'); + + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): void + { + try { + $csvFile = $input->getArgument(self::ARGUMENT_CSV_FILE); + + $output->writeln( + sprintf( + 'Importing CSV file %s', + $csvFile + ) + ); + + $importStats = $this->importer->importFull($csvFile); + + $output->writeln('#created: ' . $importStats->getCreatedCount() . ''); + $output->writeln('#skipped: ' . $importStats->getSkippedCount() . ''); + $output->writeln('#failed: ' . $importStats->getFailedCount() . ''); + + if (false !== $input->hasOption(self::OPTION_CLEAR_CACHE)) { + $cacheTypes = ['full_page', 'block_html', 'translate']; + $this->cacheManager->clean(['full_page', 'block_html', 'translate']); + $output->writeln('Caches cleared: ' . implode(', ', $cacheTypes) . ''); + } + + $output->writeln('Done!'); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + } + } +} diff --git a/Console/Command/PrepareKeysCommand.php b/Console/Command/PrepareKeysCommand.php new file mode 100644 index 0000000..e0bbbda --- /dev/null +++ b/Console/Command/PrepareKeysCommand.php @@ -0,0 +1,89 @@ +optionResolverFactory = $optionResolverFactory; + $this->parser = $parser; + + parent::__construct($name); + $this->translationDataManagement = $translationDataManagement; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName('phpro:translations:prepare-keys'); + $this->setDescription('Prepare translations keys'); + parent::configure(); + } + + /** + * CLI command description + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + protected function execute(InputInterface $input, OutputInterface $output): void + { + $phraseCollector = new PhraseCollector(new Tokenizer()); + $adapters = [ + 'php' => new Php($phraseCollector), + 'html' => new Html(), + 'js' => new Js(), + 'xml' => new Xml(), + ]; + $optionResolver = $this->optionResolverFactory->create(BP, false); + foreach ($adapters as $type => $adapter) { + $this->parser->addAdapter($type, $adapter); + } + $this->parser->parse($optionResolver->getOptions()); + $phraseList = $this->parser->getPhrases(); + + foreach ($phraseList as $phrase) { + $this->translationDataManagement->prepare($phrase->getPhrase(), $phrase->getTranslation()); + } + + $output->writeln('Keys successfully created.'); + } +} diff --git a/Controller/Adminhtml/Translation.php b/Controller/Adminhtml/Translation.php new file mode 100644 index 0000000..d965701 --- /dev/null +++ b/Controller/Adminhtml/Translation.php @@ -0,0 +1,26 @@ +setActiveMenu(self::ADMIN_RESOURCE) + ->addBreadcrumb(__('Phpro'), __('Phpro')) + ->addBreadcrumb(__('Translations'), __('Translations')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Translation/Delete.php b/Controller/Adminhtml/Translation/Delete.php new file mode 100644 index 0000000..987bf15 --- /dev/null +++ b/Controller/Adminhtml/Translation/Delete.php @@ -0,0 +1,50 @@ +translationRepository = $translationRepository; + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ + $resultRedirect = $this->resultRedirectFactory->create(); + $id = (int)$this->getRequest()->getParam('key_id'); + if ($id) { + try { + $this->translationRepository->deleteById($id); + $this->messageManager->addSuccessMessage(__('You deleted the Translation.')); + return $resultRedirect->setPath('*/*/'); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $resultRedirect->setPath('*/*/edit', ['key_id' => $id]); + } + } + + $this->messageManager->addErrorMessage(__('We can\'t find a Translation to delete.')); + + return $resultRedirect->setPath('*/*/'); + } +} diff --git a/Controller/Adminhtml/Translation/DoGenerate.php b/Controller/Adminhtml/Translation/DoGenerate.php new file mode 100644 index 0000000..e6ae713 --- /dev/null +++ b/Controller/Adminhtml/Translation/DoGenerate.php @@ -0,0 +1,77 @@ +inlineTranslationsGenerator = $inlineTranslationsGenerator; + $this->cacheTypeList = $cacheTypeList; + parent::__construct($context); + } + + /** + * @inheritDoc + */ + public function execute() + { + /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ + $resultRedirect = $this->resultRedirectFactory->create(); + $data = $this->getRequest()->getPostValue(); + + if (!isset($data['storeviews'])) { + return $resultRedirect->setPath('*/*/generatejson'); + } + + $statsCollection = $this->generatedTranslations( + array_map(function ($storeId) { + return (int)$storeId; + }, $data['storeviews']) + ); + + $this->messageManager->addComplexSuccessMessage( + 'inlineGenerateSuccessMessage', + [ + 'statsItems' => $statsCollection->toArray() + ] + ); + + $this->cacheTypeList->invalidate(Type::TYPE_IDENTIFIER); + $this->cacheTypeList->invalidate(Block::TYPE_IDENTIFIER); + $this->cacheTypeList->invalidate(Translate::TYPE_IDENTIFIER); + + return $resultRedirect->setPath('*/*/generatejson'); + } + + private function generatedTranslations(array $storeIds): InlineGenerateStatsCollection + { + if (isset($storeIds[0]) && (0 === $storeIds[0])) { + return $this->inlineTranslationsGenerator->forAll(); + } + + return $this->inlineTranslationsGenerator->forStores($storeIds); + } +} diff --git a/Controller/Adminhtml/Translation/Edit.php b/Controller/Adminhtml/Translation/Edit.php new file mode 100644 index 0000000..ee489ad --- /dev/null +++ b/Controller/Adminhtml/Translation/Edit.php @@ -0,0 +1,68 @@ +resultPageFactory = $resultPageFactory; + $this->translationRepository = $translationRepository; + $this->coreRegistry = $coreRegistry; + } + + /** + * @inheridoc + */ + public function execute() + { + try { + $translationId = $this->getRequest()->getParam('key_id'); + $translationDataModel = $this->translationRepository->getById($translationId); + $this->coreRegistry->register('phpro_translations_translation', $translationDataModel); + $resultPage = $this->resultPageFactory->create(); + + $this->initPage($resultPage)->addBreadcrumb(__('Edit Translation'), __('Edit Translation')); + $resultPage->getConfig()->getTitle()->prepend(__('Translations')); + $resultPage->getConfig()->getTitle()->prepend(__('Edit Translation %1', $translationDataModel->getKeyId())); + + return $resultPage; + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Exception $e) { + $this->messageManager->addExceptionMessage($e, __('Something went wrong during loading translation.')); + } + + return $this->resultRedirectFactory->create()->setPath('*/*/'); + } +} diff --git a/Controller/Adminhtml/Translation/GenerateJson.php b/Controller/Adminhtml/Translation/GenerateJson.php new file mode 100644 index 0000000..e1c7773 --- /dev/null +++ b/Controller/Adminhtml/Translation/GenerateJson.php @@ -0,0 +1,42 @@ +resultPageFactory = $resultPageFactory; + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var Page $resultPage */ + $resultPage = $this->resultPageFactory->create(); + $this->initPage($resultPage)->addBreadcrumb( + __('Generate Frontend Translations'), + __('Generate Frontend Translations') + ); + + $resultPage->getConfig()->getTitle()->prepend(__('Translations')); + $resultPage->getConfig()->getTitle()->prepend(__('Generate Frontend Translations')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Translation/Index.php b/Controller/Adminhtml/Translation/Index.php new file mode 100644 index 0000000..5bf1f98 --- /dev/null +++ b/Controller/Adminhtml/Translation/Index.php @@ -0,0 +1,36 @@ +resultPageFactory = $resultPageFactory; + parent::__construct($context); + } + + /** + * @inheridoc + */ + public function execute() + { + $resultPage = $this->resultPageFactory->create(); + $resultPage->getConfig()->getTitle()->prepend(__("Translations")); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Translation/InlineEdit.php b/Controller/Adminhtml/Translation/InlineEdit.php new file mode 100644 index 0000000..1433bd9 --- /dev/null +++ b/Controller/Adminhtml/Translation/InlineEdit.php @@ -0,0 +1,67 @@ +jsonFactory = $jsonFactory; + $this->translationFactory = $translationFactory; + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var \Magento\Framework\Controller\Result\Json $resultJson */ + $resultJson = $this->jsonFactory->create(); + $error = false; + $messages = []; + + if ($this->getRequest()->getParam('isAjax')) { + $postItems = $this->getRequest()->getParam('items', []); + if (!count($postItems)) { + $messages[] = __('Please correct the data sent.'); + $error = true; + } else { + foreach (array_keys($postItems) as $entityId) { + $model = $this->translationFactory->create()->load($entityId); + try { + // phpcs:ignore + $model->setData(array_merge($model->getData(), $postItems[$entityId])); + $model->save(); + } catch (\Exception $e) { + $messages[] = "[Translation ID: {$entityId}] {$e->getMessage()}"; + $error = true; + } + } + } + } + + return $resultJson->setData([ + 'messages' => $messages, + 'error' => $error + ]); + } +} diff --git a/Controller/Adminhtml/Translation/NewAction.php b/Controller/Adminhtml/Translation/NewAction.php new file mode 100644 index 0000000..cefdfc7 --- /dev/null +++ b/Controller/Adminhtml/Translation/NewAction.php @@ -0,0 +1,40 @@ +resultPageFactory = $resultPageFactory; + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var \Magento\Backend\Model\View\Result\Page $resultPage */ + $resultPage = $this->resultPageFactory->create(); + $this->initPage($resultPage)->addBreadcrumb(__('New Translation'), __('New Translation')); + $resultPage->getConfig()->getTitle()->prepend(__('Translations')); + $resultPage->getConfig()->getTitle()->prepend(__('New Translation')); + + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Translation/Save.php b/Controller/Adminhtml/Translation/Save.php new file mode 100644 index 0000000..618051e --- /dev/null +++ b/Controller/Adminhtml/Translation/Save.php @@ -0,0 +1,81 @@ +dataPersistor = $dataPersistor; + $this->translationRepository = $translationRepository; + parent::__construct($context); + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ + $resultRedirect = $this->resultRedirectFactory->create(); + $data = $this->getRequest()->getPostValue(); + $translationId = $this->getRequest()->getParam('key_id'); + if (!$translationId || !$data) { + return $resultRedirect->setPath('*/*/'); + } + + try { + $translationDataModel = $this->translationRepository->getById($translationId); + + // Fix issue: for some reason the "locale" of the backend-theme is changed if field was named "locale". + $translationDataModel->setLocale($this->getRequest()->getParam('locale_field')); + $translationDataModel->setString($this->getRequest()->getParam('string')); + $translationDataModel->setTranslate($this->getRequest()->getParam('translate')); + $translationDataModel->setFrontend($this->getRequest()->getParam('frontend')); + + $model = $this->translationRepository->save($translationDataModel); + + $this->messageManager->addSuccessMessage(__('The translation was successfully updated.')); + $this->dataPersistor->clear('phpro_translations_translation'); + + if ($this->getRequest()->getParam('back')) { + return $resultRedirect->setPath('*/*/edit', ['key_id' => $model->getKeyId()]); + } + + return $resultRedirect->setPath('*/*/'); + } catch (NoSuchEntityException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $resultRedirect->setPath('*/*/'); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Exception $e) { + $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the translation.')); + } + + $this->dataPersistor->set('phpro_translations_translation', $data); + return $resultRedirect->setPath('*/*/edit', ['key_id' => $this->getRequest()->getParam('key_id')]); + } +} diff --git a/Controller/Adminhtml/Translation/SaveNew.php b/Controller/Adminhtml/Translation/SaveNew.php new file mode 100644 index 0000000..089a8ef --- /dev/null +++ b/Controller/Adminhtml/Translation/SaveNew.php @@ -0,0 +1,104 @@ +dataPersistor = $dataPersistor; + $this->translationManagement = $translationManagement; + } + + /** + * @inheridoc + */ + public function execute() + { + /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ + $resultRedirect = $this->resultRedirectFactory->create(); + $data = $this->getRequest()->getPostValue(); + if (!$data) { + return $resultRedirect->setPath('*/*/'); + } + + try { + $this->createTranslations($this->getRequest()); + $this->dataPersistor->clear('phpro_translations_translation'); + return $resultRedirect->setPath('*/*/'); + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } catch (\Exception $e) { + $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the translation.')); + } + + $this->dataPersistor->set('phpro_translations_translation', $data); + return $resultRedirect->setPath('*/*/new'); + } + + /** + * Create all translations for given locales. + * + * @param RequestInterface $request + */ + private function createTranslations(RequestInterface $request): void + { + $locales = $request->getParam('locale_field'); + $translationKey = $this->getRequest()->getParam('string'); + $translationValue = $this->getRequest()->getParam('translate'); + $frontend = $this->getRequest()->getParam('frontend'); + $successSavedLocales = []; + foreach ($locales as $locale) { + try { + $this->translationManagement->addTranslation( + $translationKey, + $translationValue, + $locale, + $frontend + ); + $successSavedLocales[] = $locale; + } catch (LocalizedException $e) { + $this->messageManager->addErrorMessage( + __('Translation save failed for locale %1: %2', $locale, $e->getMessage()) + ); + } catch (\Exception $e) { + $this->messageManager->addExceptionMessage( + $e, + __('Translation save failed for locale %1: unknown error.') + ); + } + } + + if (0 >= count($successSavedLocales)) { + return; + } + + $this->messageManager->addSuccessMessage( + __('The translation was successfully created for locale(s) %1.', implode(', ', $successSavedLocales)) + ); + } +} diff --git a/Files/Sample/translations.csv b/Files/Sample/translations.csv new file mode 100644 index 0000000..acd4390 --- /dev/null +++ b/Files/Sample/translations.csv @@ -0,0 +1,5 @@ +key,translation,locale +"First translation key","First value","en_US" +"First translation key","Eerste waarde","nl_NL" +"Second translation key","Second value","en_US" +"Third translation key","Derde waarde","nl_NL" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0837d18 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Phpro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/Model/Data/ExportStats.php b/Model/Data/ExportStats.php new file mode 100644 index 0000000..679520b --- /dev/null +++ b/Model/Data/ExportStats.php @@ -0,0 +1,38 @@ +fileName = $fileName; + $this->totalRows = $totalRows; + } + + public static function fromRawData(string $fileName, int $totalRows): ExportStats + { + return new self($fileName, $totalRows); + } + + public function getFileName(): string + { + return $this->fileName; + } + + public function getTotalRows(): int + { + return $this->totalRows; + } +} diff --git a/Model/Data/ImportStats.php b/Model/Data/ImportStats.php new file mode 100644 index 0000000..aed8823 --- /dev/null +++ b/Model/Data/ImportStats.php @@ -0,0 +1,52 @@ +created[$row] = $key; + } + + public function getCreatedCount(): int + { + return count($this->created); + } + + public function addFailed(int $row, string $reason) + { + $this->failed[$row] = $reason; + } + + public function getFailedCount(): int + { + return count($this->failed); + } + + public function addSkipped(int $row, string $reason) + { + $this->skipped[$row] = $reason; + } + + public function getSkippedCount(): int + { + return count($this->skipped); + } +} diff --git a/Model/Data/InlineGenerateStats.php b/Model/Data/InlineGenerateStats.php new file mode 100644 index 0000000..3dff6ce --- /dev/null +++ b/Model/Data/InlineGenerateStats.php @@ -0,0 +1,42 @@ +storeInformation = $storeInformation; + $this->storeId = $storeId; + $this->amountGenerated = $amountGenerated; + } + + public function getStoreInformation(): string + { + return $this->storeInformation; + } + + public function getStoreId(): int + { + return $this->storeId; + } + + public function getAmountGenerated(): int + { + return $this->amountGenerated; + } +} diff --git a/Model/Data/InlineGenerateStatsCollection.php b/Model/Data/InlineGenerateStatsCollection.php new file mode 100644 index 0000000..79d2ea6 --- /dev/null +++ b/Model/Data/InlineGenerateStatsCollection.php @@ -0,0 +1,53 @@ +statsItems = $statsItems; + } + + public function add(InlineGenerateStats $stats) + { + $this->statsItems[] = $stats; + } + + /** + * @return ArrayIterator|InlineGenerateStats[] + */ + public function getIterator() + { + return new ArrayIterator($this->statsItems); + } + + public function count(): int + { + return \count($this->statsItems); + } + + public function toArray(): array + { + $result = []; + foreach ($this->statsItems as $stats) { + $result[] = sprintf( + '%s translations for store %s', + $stats->getAmountGenerated(), + $stats->getStoreInformation() + ); + } + + return $result; + } +} diff --git a/Model/Data/Translation.php b/Model/Data/Translation.php new file mode 100644 index 0000000..cb4fc1f --- /dev/null +++ b/Model/Data/Translation.php @@ -0,0 +1,80 @@ +_get(self::KEY_ID); + } + + public function setKeyId($keyId) + { + return $this->setData(self::KEY_ID, $keyId); + } + + public function getString() + { + return $this->_get(self::STRING); + } + + public function setString($string) + { + return $this->setData(self::STRING, $string); + } + + public function getLocale() + { + return $this->_get(self::LOCALE); + } + + public function setLocale($locale) + { + return $this->setData(self::LOCALE, $locale); + } + + public function getTranslate() + { + return $this->_get(self::TRANSLATE); + } + + public function setTranslate($translate) + { + return $this->setData(self::TRANSLATE, $translate); + } + + public function getFrontend() + { + return (boolean) $this->_get(self::FRONTEND); + } + + public function setFrontend($translate) + { + return $this->setData(self::FRONTEND, $translate); + } + + /** + * Retrieve existing extension attributes object or create a new one. + * @return \Phpro\Translations\Api\Data\TranslationExtensionInterface|null + */ + public function getExtensionAttributes() + { + return $this->_getExtensionAttributes(); + } + + /** + * Set an extension attributes object. + * @param \Phpro\Translations\Api\Data\TranslationExtensionInterface $extensionAttributes + * @return $this + */ + public function setExtensionAttributes( + \Phpro\Translations\Api\Data\TranslationExtensionInterface $extensionAttributes + ) { + return $this->_setExtensionAttributes($extensionAttributes); + } +} diff --git a/Model/ExportManagement.php b/Model/ExportManagement.php new file mode 100644 index 0000000..9a3bd3e --- /dev/null +++ b/Model/ExportManagement.php @@ -0,0 +1,92 @@ +csv = $csv; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->repository = $repository; + $this->directoryList = $directoryList; + } + + public function export(?array $locales): ExportStats + { + $this->csv->setDelimiter(','); + $this->csv->setEnclosure('"'); + + if (!empty($locales)) { + $this->searchCriteriaBuilder->addFilter('locale', $locales, 'in'); + } + + $fileName = $this->getExportPath((empty($locales)) ? 'all' : implode('_', $locales)); + $result = $this->repository->getList($this->searchCriteriaBuilder->create()); + $this->csv->saveData($fileName, $this->formatResultsToCsvData($result)); + + return ExportStats::fromRawData($fileName, $result->getTotalCount()); + } + + private function formatResultsToCsvData(\Magento\Framework\Api\SearchResultsInterface $result): \Generator + { + /** @var \Phpro\Translations\Api\Data\TranslationInterface $item */ + foreach ($result->getItems() as $item) { + yield [$item->getString(), $item->getTranslate(), $item->getLocale()]; + } + } + + private function getExportPath(string $suffix): string + { + $date = new \DateTime(); + $path = sprintf( + '%s/translations', + $this->directoryList->getPath(DirectoryList::VAR_DIR) + ); + + // phpcs:disable + if (!file_exists($path)) { + mkdir($path); + } + // phpcs:enable + + return sprintf( + '%s/%s_export_%s.csv', + $path, + $date->format('Ymd_His'), + $suffix + ); + } +} diff --git a/Model/Import/Translations.php b/Model/Import/Translations.php new file mode 100644 index 0000000..f37bb45 --- /dev/null +++ b/Model/Import/Translations.php @@ -0,0 +1,228 @@ +jsonHelper = $jsonHelper; + $this->_importExportData = $importExportData; + $this->_resourceHelper = $resourceHelper; + $this->_dataSourceModel = $importData; + $this->_connection = $resource->getConnection(ResourceConnection::DEFAULT_CONNECTION); + $this->errorAggregator = $errorAggregator; + $this->localeValidator = $localeValidator; + $this->initMessageTemplates(); + } + + /** + * Row validation + * + * @param array $rowData + * @param int $rowNum + * + * @return bool + */ + public function validateRow(array $rowData, $rowNum): bool + { + if (isset($this->_validatedRows[$rowNum])) { + return !$this->getErrorAggregator()->isRowInvalid($rowNum); + } + + $translationKey = $rowData[self::HEADER_KEY] ?? ''; + $locale = $rowData[self::HEADER_LOCALE] ?? ''; + + if ('' === trim($translationKey)) { + $this->addRowError('KeyIsRequired', $rowNum); + } + + try { + $this->localeValidator->validate($locale); + } catch (\Exception $e) { + $this->addRowError('LocaleNotSupported', $rowNum); + } + + $this->_validatedRows[$rowNum] = true; + + return !$this->getErrorAggregator()->isRowInvalid($rowNum); + } + + /** + * @return string + */ + public function getEntityTypeCode() + { + return self::ENTITY_CODE; + } + + /** + * @return string + */ + public function getCreatedItemsCount() + { + return 'n/a'; + } + + /** + * @return string + */ + public function getDeletedItemsCount() + { + return 'n/a'; + } + + /** + * @return string + */ + public function getUpdatedItemsCount() + { + return sprintf( + '%s created/updated, Total: %s', + $this->countItemsUpdated, + $this->countTotal + ); + } + + /** + * Import data + * @throws \Exception + * @return bool + */ + protected function _importData(): bool + { + if (Import::BEHAVIOR_APPEND === $this->getBehavior()) { + $this->saveAndReplaceEntity(); + return true; + } + + throw new \Exception( + sprintf( + 'Behavior %s not supported. Only add/update is supported.', + $this->getBehavior() + ) + ); + } + + /** + * Save and replace entities + * @return void + */ + private function saveAndReplaceEntity(): void + { + $behavior = $this->getBehavior(); + $this->countTotal = 0; + while ($bunch = $this->_dataSourceModel->getNextBunch()) { + $entityList = []; + foreach ($bunch as $rowNum => $row) { + if (!$this->validateRow($row, $rowNum)) { + continue; + } + if ($this->getErrorAggregator()->hasToBeTerminated()) { + $this->getErrorAggregator()->addRowToSkip($rowNum); + continue; + } + $entityList[] = [ + self::COL_KEY => $row[self::HEADER_KEY], + self::COL_TRANSLATION => $row[self::HEADER_TRANSLATION], + self::COL_LOCALE => $row[self::HEADER_LOCALE], + ]; + $this->countTotal++; + } + + $this->countTotal++; + if (Import::BEHAVIOR_APPEND === $behavior) { + $this->countItemsUpdated += $this->insertOnDuplicate($entityList); + } + } + } + + /** + * Save entities + * @param array $entityList + * @return int + */ + private function insertOnDuplicate(array $entityList): int + { + if ($entityList) { + $tableName = $this->_connection->getTableName(static::TABLE); + return (int) $this->_connection->insertOnDuplicate( + $tableName, + $entityList, + [ + self::COL_TRANSLATION, + self::COL_LOCALE, + ] + ); + } + + return 0; + } + + /** + * Init error messages + */ + private function initMessageTemplates(): void + { + $this->addMessageTemplate('KeyIsRequired', __('The key cannot be empty.')); + $this->addMessageTemplate('LocaleNotSupported', __('The locale is not supported.')); + } +} diff --git a/Model/ImportManagement.php b/Model/ImportManagement.php new file mode 100644 index 0000000..61de9bc --- /dev/null +++ b/Model/ImportManagement.php @@ -0,0 +1,132 @@ +csv = $csv; + $this->localeValidator = $localeValidator; + $this->translationFactory = $translationFactory; + $this->repository = $repository; + $this->importStatsFactory = $importStatsFactory; + } + + public function importMagentoCsv(string $filePath, string $locale): ImportStats + { + $this->localeValidator->validate($locale); + + $this->csv->setDelimiter(','); + $this->csv->setEnclosure('"'); + $csvData = $this->csv->getData($filePath); + + /** @var ImportStats $importStats */ + $importStats = $this->importStatsFactory->create(); + + foreach ($csvData as $row => $data) { + $rowNumber = $row + 1; + try { + if (2 > count($data)) { + throw new \Exception('Invalid CSV row'); + } + if (empty($data[0]) || empty($data[1])) { + throw new \Exception('Empty values in CSV row'); + } + $this->createTranslations($data[0], $data[1], $locale); + $importStats->addCreated($rowNumber, $data[0]); + } catch (\Exception $e) { + $this->handleException($e, $importStats, $rowNumber); + } + } + + return $importStats; + } + + public function importFull(string $filePath): ImportStats + { + $this->csv->setDelimiter(','); + $this->csv->setEnclosure('"'); + $csvData = $this->csv->getData($filePath); + + /** @var ImportStats $importStats */ + $importStats = $this->importStatsFactory->create(); + + foreach ($csvData as $row => $data) { + $rowNumber = $row + 1; + try { + if (3 > count($data)) { + throw new \Exception('Invalid CSV row'); + } + if (empty($data[0]) || empty($data[1]) || empty($data[2])) { + throw new \Exception('Empty values in CSV row'); + } + $this->localeValidator->validate($data[2]); + $this->createTranslations($data[0], $data[1], $data[2]); + $importStats->addCreated($rowNumber, $data[0]); + } catch (\Exception $e) { + $this->handleException($e, $importStats, $rowNumber); + } + } + + return $importStats; + } + + private function createTranslations(string $translationKey, string $translationValue, string $locale) + { + /** @var Translation $translationModel */ + $translationModel = $this->translationFactory->create(); + + $translationModel->setLocale($locale); + $translationModel->setString($translationKey); + $translationModel->setTranslate($translationValue); + + $this->repository->save($translationModel); + } + + private function handleException(\Exception $e, ImportStats $importStats, int $rowNumber) + { + if ($e instanceof CouldNotSaveException && (false !== strpos(strtolower($e->getMessage()), 'unique'))) { + $importStats->addSkipped($rowNumber, 'Translation already exist.'); + return; + } + + $importStats->addFailed($rowNumber, $e->getMessage()); + } +} diff --git a/Model/InlineTranslations/FileManager.php b/Model/InlineTranslations/FileManager.php new file mode 100644 index 0000000..e9f1b40 --- /dev/null +++ b/Model/InlineTranslations/FileManager.php @@ -0,0 +1,181 @@ +serializer = $serializer; + $this->fileDriver = $driverFile; + $this->filesystem = $filesystem; + $this->directoryList = $directoryList; + $this->urlGenerator = $urlGenerator; + $this->date = $dateTime; + } + + /** + * This method will return a public URL containing the replacement url for the original js-translation.json + * located in pub/static/frontend////... folders. + * + * E.g. https://www.webshop.com/media/phpro_translations/ThemeName/default/nl_BE/1642691554translation.json + * + * @param string $themePath + * @throws LocalizedException + * @throws \Magento\Framework\Exception\FileSystemException + * @return string + */ + public function getJsTranslationUrl(string $themePath): string + { + $versionFilePath = $this->getRelativeVersionFilePath($themePath); + $reader = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + if (!$reader->isExist($versionFilePath)) { + throw new LocalizedException( + __('No %1 file found in directory %2', self::JS_DEPLOYED_VERSION_FILENAME, $versionFilePath) + ); + } + + return \sprintf( + '%s/%s/%s/%s', + rtrim($this->urlGenerator->getBaseUrl(['_type' => UrlInterface::URL_TYPE_MEDIA]), '/'), + self::JS_TRANSLATION_DIRECTORY, + $themePath, + $reader->readFile($versionFilePath) . self::JS_TRANSLATION_FILENAME_SUFFIX + ); + } + + /** + * This method saves the translations as json string in the media directory for related theme and locale. + * Also old generated translation files will be cleaned up and a new version string is created. + * + * Example save path: pub/media/phpro_translations/ThemeName/default/nl_BE/1642691554translation.json + * + * @param array $translations + * @param string $themePath + * @throws \Magento\Framework\Exception\FileSystemException + */ + public function writeJsTranslationFileContent(array $translations, string $themePath): void + { + $savePath = sprintf( + '%s/%s/%s', + $this->directoryList->getPath(DirectoryList::MEDIA), + self::JS_TRANSLATION_DIRECTORY, + $themePath + ); + + // Ensure directory exists: + $savePathExists = $this->fileDriver->isExists($savePath); + if (!$savePathExists) { + $this->fileDriver->createDirectory($savePath); + } + + // Clean-up old translation json files: + if ($savePathExists) { + $this->cleanupOldTranslationFiles($themePath, $savePath); + } + + $versionString = (string)$this->date->date()->getTimestamp(); + // Write new translation json file: + $this->fileDriver->filePutContents( + sprintf( + '%s/%s', + $savePath, + $versionString . self::JS_TRANSLATION_FILENAME_SUFFIX + ), + $this->serializer->serialize($translations) + ); + + // Save version string + $this->fileDriver->filePutContents( + sprintf( + '%s/%s', + $savePath, + self::JS_DEPLOYED_VERSION_FILENAME + ), + $versionString + ); + } + + /** + * @param string $themePath + * @param string $savePath + * @throws \Magento\Framework\Exception\FileSystemException + */ + private function cleanupOldTranslationFiles(string $themePath, string $savePath): void + { + $versionFilePath = $this->getRelativeVersionFilePath($themePath); + $reader = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + if (!$reader->isExist($versionFilePath)) { + return; + } + + $currentTranslationFile = $reader->readFile($versionFilePath) . self::JS_TRANSLATION_FILENAME_SUFFIX; + foreach ($this->fileDriver->readDirectory($savePath) as $file) { + // phpcs:disable + if ('json' !== pathinfo($file, PATHINFO_EXTENSION) || + $currentTranslationFile === pathinfo($file, PATHINFO_BASENAME)) { + continue; + } + // phpcs:enable + $this->fileDriver->deleteFile($file); + } + } + + private function getRelativeVersionFilePath(string $themeLocalePath): string + { + return sprintf( + '%s/%s/%s', + self::JS_TRANSLATION_DIRECTORY, + $themeLocalePath, + self::JS_DEPLOYED_VERSION_FILENAME + ); + } +} diff --git a/Model/InlineTranslationsGenerator.php b/Model/InlineTranslationsGenerator.php new file mode 100644 index 0000000..8f05439 --- /dev/null +++ b/Model/InlineTranslationsGenerator.php @@ -0,0 +1,111 @@ +dataProvider = $dataProvider; + $this->state = $state; + $this->translate = $translate; + $this->fileManager = $fileManager; + $this->emulation = $emulation; + $this->viewDesign = $viewDesign; + $this->systemStore = $systemStore; + } + + public function forAll(): InlineGenerateStatsCollection + { + $storeIds = []; + foreach ($this->systemStore->getStoreCollection() as $store) { + $storeIds[] = (int)$store->getId(); + } + + return $this->forStores($storeIds); + } + + public function forStores(array $storeIds): InlineGenerateStatsCollection + { + $statsCollection = new InlineGenerateStatsCollection(); + foreach ($storeIds as $storeId) { + $statsCollection->add( + $this->generate((int)$storeId) + ); + } + + return $statsCollection; + } + + private function generate(int $storeId): InlineGenerateStats + { + $translations = []; + $area = 'frontend'; + + $this->state->emulateAreaCode($area, function () use ($storeId, $area, &$translations) { + // We need the emulation start and stop for saving the translation json file to have the correct context + $this->emulation->startEnvironmentEmulation($storeId, $area, true); + $locale = $this->viewDesign->getLocale(); + $themePath = $this->viewDesign->getDesignTheme()->getThemePath(); + // set locale and load translations string: + $this->translate + ->setLocale($locale) + ->loadData($area, true); + // find all translations for the frontend files (js en html templates): + $translations = $this->dataProvider->getData($themePath); + $this->fileManager->writeJsTranslationFileContent($translations, $themePath . '/' . $locale); + $this->emulation->stopEnvironmentEmulation(); + }); + + $storeInfo = $this->systemStore->getStoreNameWithWebsite($storeId); + return new InlineGenerateStats($storeInfo, $storeId, count($translations)); + } +} diff --git a/Model/ResourceModel/Translation.php b/Model/ResourceModel/Translation.php new file mode 100644 index 0000000..e4bb6ec --- /dev/null +++ b/Model/ResourceModel/Translation.php @@ -0,0 +1,43 @@ +getConnection(); + $connection = $this->transactionManager->start($conn); + $quotedLocales = array_map(function ($t) use ($conn) { + return $conn->quote($t); + }, $locales); + + try { + $this->objectRelationProcessor->delete( + $this->transactionManager, + $connection, + $this->getMainTable(), + $conn->quoteInto(' string = ?', $translationKey) + . ' AND locale IN (' . implode(',', $quotedLocales) . ')', + [] + ); + $this->transactionManager->commit(); + } catch (\Exception $e) { + $this->transactionManager->rollBack(); + throw $e; + } + return $this; + } + /** + * Define resource model + * + * @return void + */ + protected function _construct() + { + $this->_init('translation', 'key_id'); + } +} diff --git a/Model/ResourceModel/Translation/Collection.php b/Model/ResourceModel/Translation/Collection.php new file mode 100644 index 0000000..a42690d --- /dev/null +++ b/Model/ResourceModel/Translation/Collection.php @@ -0,0 +1,22 @@ +_init( + \Phpro\Translations\Model\Translation::class, + \Phpro\Translations\Model\ResourceModel\Translation::class + ); + } +} diff --git a/Model/Source/Import/Behavior/TranslationsBasic.php b/Model/Source/Import/Behavior/TranslationsBasic.php new file mode 100644 index 0000000..53e171f --- /dev/null +++ b/Model/Source/Import/Behavior/TranslationsBasic.php @@ -0,0 +1,44 @@ + __('Add/Update'), + ]; + } + + /** + * @inheritdoc + */ + public function getCode() + { + return 'translationsbasic'; + } + + /** + * @inheritdoc + */ + public function getNotes($entityCode) + { + $messages = ['translations' => [ + Import::BEHAVIOR_APPEND => __( + "Add or update translations. Updates are based on translation key (first column of CSV)." + ), + ]]; + return isset($messages[$entityCode]) ? $messages[$entityCode] : []; + } +} diff --git a/Model/Translation.php b/Model/Translation.php new file mode 100644 index 0000000..3d9e7eb --- /dev/null +++ b/Model/Translation.php @@ -0,0 +1,56 @@ +translationDataFactory = $translationDataFactory; + $this->dataObjectHelper = $dataObjectHelper; + parent::__construct($context, $registry, $resource, $resourceCollection, $data); + } + + /** + * @return TranslationInterface + */ + public function getDataModel() + { + $data = $this->getData(); + + $translationDataObject = $this->translationDataFactory->create(); + $this->dataObjectHelper->populateWithArray( + $translationDataObject, + $data, + TranslationInterface::class + ); + + return $translationDataObject; + } +} diff --git a/Model/Translation/DataProvider.php b/Model/Translation/DataProvider.php new file mode 100644 index 0000000..853b284 --- /dev/null +++ b/Model/Translation/DataProvider.php @@ -0,0 +1,81 @@ +collection = $collectionFactory->create(); + $this->dataPersistor = $dataPersistor; + parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data); + } + + /** + * Get data + * + * @return array + */ + public function getData() + { + if (isset($this->loadedData)) { + return $this->loadedData; + } + $items = $this->collection->getItems(); + foreach ($items as $model) { + $data = $model->getData(); + if (isset($data['locale'])) { + $data['locale_field'] = $data['locale']; + unset($data['locale']); + } + $this->loadedData[$model->getId()] = $data; + } + $data = $this->dataPersistor->get('phpro_translations_translation'); + + if (!empty($data)) { + $model = $this->collection->getNewEmptyItem(); + $model->setData($data); + $this->loadedData[$model->getId()] = $model->getData(); + $this->dataPersistor->clear('phpro_translations_translation'); + } + + return $this->loadedData; + } +} diff --git a/Model/Translation/EmptyDataProvider.php b/Model/Translation/EmptyDataProvider.php new file mode 100644 index 0000000..29c23cf --- /dev/null +++ b/Model/Translation/EmptyDataProvider.php @@ -0,0 +1,26 @@ +resourceConnection = $resourceConnection; + } + + /** + * @inheritdoc + */ + public function toOptionArray() + { + $result = []; + $connection = $this->resourceConnection->getConnection(); + $bind = [':config_path' => self::XML_PATH_LOCALE]; + $select = $connection + ->select() + ->from($this->resourceConnection->getTableName('core_config_data'), 'value') + ->distinct(true) + ->where('path = :config_path'); + $rowSet = $connection->fetchAll($select, $bind); + + foreach ($rowSet as $row) { + $result[] = ['value' => $row['value'], 'label' => $row['value']]; + } + + return $result; + } +} diff --git a/Model/TranslationDataManagement.php b/Model/TranslationDataManagement.php new file mode 100644 index 0000000..26b01c4 --- /dev/null +++ b/Model/TranslationDataManagement.php @@ -0,0 +1,149 @@ +logger = $logger; + $this->localeValidator = $localeValidator; + $this->localeSource = $localeSource; + $this->translationFactory = $translationFactory; + $this->repository = $repository; + $this->throwException = $throwException; + } + + /** + * @inheritdoc + */ + public function prepare(string $translationKey, ?string $defaultTranslation = null) + { + $locales = $this->getEnabledLocales(); + $translationValue = (empty($defaultTranslation)) ? $translationKey : $defaultTranslation; + + foreach ($locales as $locale) { + $this->insertTranslation($translationKey, $translationValue, $locale); + } + } + + /** + * @inheritdoc + */ + public function create(string $translationKey, string $translationValue, array $locales = []) + { + foreach ($locales as $locale) { + $this->insertTranslation($translationKey, $translationValue, $locale); + } + } + + /** + * @inheritdoc + */ + public function delete(string $translationKey, ?array $locales = []) + { + if (empty($locales)) { + $locales = $this->getEnabledLocales(); + } + + try { + $this->repository->deleteByTranslationKeyAndLocales($translationKey, $locales); + } catch (\Exception $e) { + $this->logger->error( + sprintf( + '[PHPRO TRANSLATION] Cannot delete translation "%s" for locales "%s": %s', + $translationKey, + implode(',', $locales), + $e->getMessage() + ) + ); + if ($this->throwException) { + throw $e; + } + } + } + + private function insertTranslation(string $translationKey, string $translationValue, string $locale) + { + try { + $this->localeValidator->validate($locale); + + /** @var Translation $translationModel */ + $translationModel = $this->translationFactory->create(); + + $translationModel->setLocale($locale); + $translationModel->setString($translationKey); + $translationModel->setTranslate($translationValue); + + $this->repository->save($translationModel); + } catch (\Exception $e) { + $this->logger->error( + sprintf( + '[PHPRO TRANSLATION] Cannot insert translation "%s" for locale "%s": %s', + $translationKey, + $locale, + $e->getMessage() + ) + ); + if ($this->throwException) { + throw $e; + } + } + } + + private function getEnabledLocales(): array + { + if (!empty($this->enabledLocales)) { + return $this->enabledLocales; + } + + foreach ($this->localeSource->toOptionArray() as $locale) { + $localeCode = $locale['value']; + $this->enabledLocales[$localeCode] = $localeCode; + } + + return $this->enabledLocales; + } +} diff --git a/Model/TranslationManagement.php b/Model/TranslationManagement.php new file mode 100644 index 0000000..4a4e544 --- /dev/null +++ b/Model/TranslationManagement.php @@ -0,0 +1,52 @@ +localeValidator = $localeValidator; + $this->translationFactory = $translationFactory; + $this->repository = $repository; + } + + public function addTranslation( + string $translationKey, + string $translationValue, + string $locale, + string $frontend + ): void { + $this->localeValidator->validate($locale); + + /** @var Translation $translationModel */ + $translationModel = $this->translationFactory->create(); + + $translationModel->setLocale($locale); + $translationModel->setString($translationKey); + $translationModel->setTranslate($translationValue); + $translationModel->setFrontend($frontend); + + $this->repository->save($translationModel); + } +} diff --git a/Model/TranslationRepository.php b/Model/TranslationRepository.php new file mode 100644 index 0000000..bd61499 --- /dev/null +++ b/Model/TranslationRepository.php @@ -0,0 +1,196 @@ +resource = $resource; + $this->translationFactory = $translationFactory; + $this->dataTranslationFactory = $dataTranslationFactory; + $this->translationCollectionFactory = $translationCollectionFactory; + $this->searchResultsFactory = $searchResultsFactory; + $this->dataObjectHelper = $dataObjectHelper; + $this->dataObjectProcessor = $dataObjectProcessor; + $this->storeManager = $storeManager; + $this->collectionProcessor = $collectionProcessor; + $this->extensionAttributesJoinProcessor = $extensionAttributesJoinProcessor; + $this->extensibleDataObjectConverter = $extensibleDataObjectConverter; + } + + /** + * {@inheritdoc + */ + public function save(TranslationInterface $translation) + { + $translationData = $this->extensibleDataObjectConverter->toNestedArray( + $translation, + [], + TranslationInterface::class + ); + + $translationModel = $this->translationFactory->create()->setData($translationData); + + try { + $this->resource->save($translationModel); + } catch (\Exception $exception) { + throw new CouldNotSaveException(__( + 'Could not save the translation: %1', + $exception->getMessage() + )); + } + return $translationModel->getDataModel(); + } + + /** + * @inheritdoc + */ + public function getById($translationId) + { + $translation = $this->translationFactory->create(); + $this->resource->load($translation, $translationId); + if (!$translation->getId()) { + throw new NoSuchEntityException(__('Translation with id "%1" does not exist.', $translationId)); + } + return $translation->getDataModel(); + } + + /** + * @inheritdoc + */ + public function getList(SearchCriteriaInterface $criteria) + { + $collection = $this->translationCollectionFactory->create(); + + $this->extensionAttributesJoinProcessor->process( + $collection, + TranslationInterface::class + ); + + $this->collectionProcessor->process($criteria, $collection); + + $searchResults = $this->searchResultsFactory->create(); + $searchResults->setSearchCriteria($criteria); + + $items = []; + foreach ($collection as $model) { + $items[] = $model->getDataModel(); + } + + $searchResults->setItems($items); + $searchResults->setTotalCount($collection->getSize()); + return $searchResults; + } + + /** + * @inheritdoc + */ + public function deleteByTranslationKeyAndLocales(string $translationKey, array $locales) + { + try { + $this->resource->deleteByTranslationKeyAndLocales($translationKey, $locales); + } catch (\Exception $exception) { + throw new CouldNotDeleteException(__( + 'Could not delete the Translations: %1', + $exception->getMessage() + )); + } + return true; + } + + /** + * @inheritdoc + */ + public function deleteById($translationId) + { + try { + $translation = $this->translationFactory->create(); + $this->resource->load($translation, $translationId); + if (!$translation->getId()) { + throw new NoSuchEntityException(__('Translation with id "%1" does not exist.', $translationId)); + } + $translation->delete(); + } catch (\Exception $exception) { + throw new CouldNotDeleteException(__( + 'Could not delete the Translation: %1', + $exception->getMessage() + )); + } + return true; + } +} diff --git a/Model/Validator/LocaleValidator.php b/Model/Validator/LocaleValidator.php new file mode 100644 index 0000000..6c742f4 --- /dev/null +++ b/Model/Validator/LocaleValidator.php @@ -0,0 +1,50 @@ +localeLists = $localeLists; + } + + /** + * @param string $locale + * @throws \Exception + */ + public function validate(string $locale) + { + if (!in_array($locale, $this->getSupportedLocales(), true)) { + throw new \Exception(sprintf('Locale %s not supported by Magento', $locale)); + } + } + + private function getSupportedLocales(): array + { + if (!empty($this->locales)) { + return $this->locales; + } + + $supportedLocaleList = $this->localeLists->getOptionLocales(); + foreach ($supportedLocaleList as $supportedLocale) { + $localeCode = $supportedLocale['value']; + $this->locales[$localeCode] = $localeCode; + } + + return $this->locales; + } +} diff --git a/Plugin/UpdateMetadataFields.php b/Plugin/UpdateMetadataFields.php new file mode 100644 index 0000000..a8a8830 --- /dev/null +++ b/Plugin/UpdateMetadataFields.php @@ -0,0 +1,44 @@ +getName()) { + return [ + Translations::COL_KEY, + Translations::COL_TRANSLATION, + Translations::COL_LOCALE + ]; + } + + return $result; + } + + public function afterGetHeaders(Subject $subject, $result, UiComponentInterface $component) + { + if (self::UI_COMPONENT_NAME === $component->getName()) { + return [ + Translations::HEADER_KEY, + Translations::HEADER_TRANSLATION, + Translations::HEADER_LOCALE, + ]; + } + + return $result; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..90fbeb9 --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ +![](https://github.com/phpro/phpro-mage2-module-translations/workflows/.github/workflows/grumphp.yml/badge.svg) + +# Translation module for Magento 2 + +The `Phpro_Translations` module helps you to manage translations via the Magento backend. + +## Features +* Simple backend CRUD to manage translations +* Single and multi-inline editing via the grid-overview +* Import and export via CSV files via CLI +* Import and export via default Magento import/export functionality +* Prepare new translations via data patch scripts +* (Re)generate frontend translations (JSON translation files) via CLI and backend + +## Installation +``` +composer require phpro/mage2-module-translations +``` + +## End user documentation +[Download the end user documentation (PDF)](./resources/phpro-translation-module-EUD.pdf) + +## Usage (technical) + +### Locales +All locales must be defined in an ISO format. Locale = ISO-639 (language) + "_" + ISO-3166 (country). + +Examples of locales: en_US, nl_BE, nl_NL, fr_BE, de_DE, ... + +### Import and export + +#### Import Magento translation CSVs + +CSV structure must be (key, value): +``` +"Transkey 1","Transvalue 2" +"Transkey 2","Transvalue 2" +... +``` +Use the `phpro:translations:import` command to import a CSV file for a given locale. Duplicate records are skipped and no updates are applied. +``` +bin/magento phpro:translations:import /path/to/cs_CZ/cs_CZ.csv cs_CZ --clear-cache +``` +Output: +``` +Importing CSV file /path/to/cs_CZ/cs_CZ.csv for locale cs_CZ +#created: 193 +#skipped: 4 +#failed: 0 +Caches cleared: full_page, block_html, translate +Done! +``` + +#### Export to CSV + +Use the `phpro:translations:export` command to export database translations to a CSV file for one or more locales. Separate multiple locales with a space. The exported CSV file is written in the `var/translations` folder of Magento. +``` +bin/magento phpro:translations:export nl_BE cs_CZ +``` +Output: +``` +Exporting translations to CSV file +Csv file: /path/to/var/translations/20190531_085402_export_nl_BE_cs_CZ.csv +Total written rows: 390 +Done! +``` +CSV structure: +``` +"New Account","Nieuwe account",nl_BE +"My Wish List","Mijn verlanglijst",nl_BE +"New Account","Nový účet",cs_CZ +"My Wish List","Mé oblíbené",cs_CZ +... +``` + +#### Import CSV (including locale) +You can use the exported CSV file(s) to import on another environment. For example you can prepare new translations on a staging environment and import them later on a production environment. + +CSV structure must be (key, value, locale): +``` +"Transkey 1","Transvalue 2",nl_BE +"Transkey 2","Transvalue 2",nl_BE +"Transkey 1","Transvalue 2",fr_BE +"Transkey 2","Transvalue 2",fr_BE +... +``` +Use the `phpro:translations:import-full` command to import a CSV file. Duplicate records are skipped and no updates are applied. +``` +bin/magento phpro:translations:import-full /path/to/full_import_nl_BE_cs_CZ.csv --clear-cache +``` +Output: +``` +Importing CSV file /path/to/full_import_nl_BE_cs_CZ.csv +#created: 10 +#skipped: 380 +#failed: 0 +Caches cleared: full_page, block_html, translate +Done! +``` + +#### Import via backend +Go to System → (Data Transfer) → Import to create or update translations based on a CSV file. Please reach out our end user documentation. + +### Re-generate frontend translations +Generating/re-generating frontend translation will generate JSON file(s) which includes all the frontend/JS translations. +These files are stored in the `pub/media/phpro_translations` directory in their related theme/locale subdirectory. + +#### Via backend +- Go to "Translations -> Generate frontend translations" in the admin. Select "all store views" or select specific ones. Click the button "Generate translations files". +- Clear full page and block html caches via "System -> Cache Management". +- Also check the end user documentation + +#### Via CLI +- Use the `phpro:translations:generate-frontend-translations` command to re-generate new JSON file(s) +- Make sure you clean full_page and block_html cache manually afterwards to apply and enable the newest translation JSON file for the storefront. + +**Re-generate for single store view** + +Specify the `storeId` argument to re-generate for specific store view (locale). Use the `bin/magento store:list` command to show the store IDs. +``` +bin/magento phpro:translations:generate-frontend-translations 5 +``` + +**Re-generate for all** + +Leave the `storeId` argument empty to re-generate for all store views. +``` +bin/magento phpro:translations:generate-frontend-translations +``` + +#### During build process +We recommend to re-generate all frontend translations during your build process with `phpro:translations:generate-frontend-translations` after the `setup:upgrade --keep-generated` step and just before deactivating maintenance. + +#### Browser cache optimizations +The translations JSON files are stored in the directory `pub/media/phpro_translations` with a specific version string. +You can choose to have a these files optimally cached by browsers by configuring the Cache-Control header. A ngnix example below: +``` +location /media/phpro_translations/ { + add_header X-Frame-Options "SAMEORIGIN"; + add_header Cache-Control "public"; + expires +1y; +} +``` + +### Collect translations from code base +Use the `phpro:translations:prepare-keys` command to collect translations phrases from the code base and prepare them. This will create a translation for every available locale. + +### Add translations during development +To prepare, create or delete translations you can inject `\Phpro\Translations\Api\TranslationDataManagementInterface` as dependency into your data patch script of your module. + +#### Prepare +Add the translation key for all enabled locales of the Magento instance. If default translation is not set, the translation key will be used as default translation. + +``` +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\NonTransactionableInterface; +use Phpro\Translations\Model\TranslationDataManagement; + +class HelloWorldTranslations implements DataPatchInterface, NonTransactionableInterface +{ + private TranslationDataManagement $translationDataManagement; + + public function __construct( + TranslationDataManagement $translationDataManagement + ) { + $this->translationDataManagement = $translationDataManagement; + } + + public function apply() + { + $this->translationDataManagement->prepare('Hello world!'); + $this->translationDataManagement->prepare('Welcome %1', 'Hello %1'); + // other translation keys here... + } +} +``` +#### Create +Add a translation for given locales. +``` +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\NonTransactionableInterface; +use Phpro\Translations\Model\TranslationDataManagement; + +class HelloWorldTranslations implements DataPatchInterface, NonTransactionableInterface +{ + private TranslationDataManagement $translationDataManagement; + + public function __construct( + TranslationDataManagement $translationDataManagement + ) { + $this->translationDataManagement = $translationDataManagement; + } + + public function apply() + { + $this->translationDataManagement->create('Hello world!', 'Hallo wereld!!!', ['nl_NL', 'nl_BE']); + $this->translationDataManagement->create('Hello world!', 'Hello world!!!', ['en_US']); + // other translation keys here... + } +} +``` +#### Delete +Delete a translation for given translation key and locale(s). In case no locales are given, all enabled locales will be used for deletion. +``` +use Magento\Framework\Setup\Patch\DataPatchInterface; +use Magento\Framework\Setup\Patch\NonTransactionableInterface; +use Phpro\Translations\Model\TranslationDataManagement; + +class DeleteHelloWorldTranslations implements DataPatchInterface, NonTransactionableInterface +{ + private TranslationDataManagement $translationDataManagement; + + public function __construct( + TranslationDataManagement $translationDataManagement + ) { + $this->translationDataManagement = $translationDataManagement; + } + + public function apply() + { + $this->translationDataManagement->delete('Hello world!'); + $this->translationDataManagement->delete('Welcome %1', ['nl_BE', 'nl_NL']); + // other translation keys here... + } +} +``` +## PWA +The checkbox "frontend" could be used to mark translations that need to be exported to a PWA installation. +With a rest API call, the translations can be fetched and stored in the PWA translations files. + +### API call that can be used in build scripts +``` +curl -G -k -H "Authorization: Bearer " --data-urlencode "searchCriteria[filter_groups][0][filters][0][field]=frontend" --data-urlencode "searchCriteria[filter_groups][0][filters][0][value]=1" --data-urlencode "searchCriteria[filter_groups][1][filters][0][field]=locale" --data-urlencode "searchCriteria[filter_groups][1][filters][0][value]=" rest/V1/phpro-translations/translation/search +``` + diff --git a/Test/Unit/Model/InlineTranslations/FileManagerTest.php b/Test/Unit/Model/InlineTranslations/FileManagerTest.php new file mode 100644 index 0000000..4656681 --- /dev/null +++ b/Test/Unit/Model/InlineTranslations/FileManagerTest.php @@ -0,0 +1,168 @@ +serializer = $this->createMock(Json::class); + $this->directoryList = $this->createMock(DirectoryList::class); + $this->directoryList->method('getPath')->willReturn(self::MEDIA_DIR_ABSOLUTE); + $this->filesystem = $this->createMock(Filesystem::class); + $this->fileDriver = $this->createMock(File::class); + $this->urlGenerator = $this->createMock(UrlInterface::class); + $this->date = $this->createMock(TimezoneInterface::class); + + $this->fileManager = new FileManager( + $this->serializer, + $this->directoryList, + $this->filesystem, + $this->fileDriver, + $this->urlGenerator, + $this->date + ); + } + + public function testWriteJsTranslationFileContentWithoutBaseDirYet() + { + $themePath = 'Theme/default/nl_BE'; + $now = new \DateTime('now'); + $version = (string)$now->getTimestamp(); + $savePath = self::MEDIA_DIR_ABSOLUTE . '/phpro_translations/' . $themePath; + + $this->fileDriver + ->expects(static::once()) + ->method('isExists') + ->with(self::MEDIA_DIR_ABSOLUTE . '/phpro_translations/' . $themePath) + ->willReturn(false); + $this->fileDriver + ->expects(static::once()) + ->method('createDirectory') + ->with($savePath); + $this->date + ->method('date') + ->willReturn($now); + $this->serializer + ->method('serialize') + ->willReturn('{"keyValue":"transValue"}'); + $this->fileDriver + ->method('filePutContents') + ->withConsecutive( + [$savePath . '/' . $version . 'translation.json', '{"keyValue":"transValue"}'], + [$savePath . '/deployed_version.txt', $version], + ); + + $this->fileManager->writeJsTranslationFileContent( + ['keyValue' => 'transValue'], + 'Theme/default/nl_BE' + ); + } + + public function testWriteJsTranslationFileContentWithBaseDirAndCleanup() + { + $themePath = 'Theme/default/nl_BE'; + $now = new \DateTime('now'); + $version = (string)$now->getTimestamp(); + $savePath = self::MEDIA_DIR_ABSOLUTE . '/phpro_translations/' . $themePath; + $relativePath = 'phpro_translations/' . $themePath; + $oldVersion = '123456'; + + $this->fileDriver + ->expects(static::once()) + ->method('isExists') + ->with(self::MEDIA_DIR_ABSOLUTE . '/phpro_translations/' . $themePath) + ->willReturn(true); + + $reader = $this->createMock(Read::class); + $this->filesystem + ->method('getDirectoryRead') + ->willReturn($reader); + $reader + ->expects(static::once()) + ->method('isExist') + ->with($relativePath . '/deployed_version.txt') + ->willReturn(true); + $reader + ->expects(static::once()) + ->method('readFile') + ->with($relativePath . '/deployed_version.txt') + ->willReturn($oldVersion); + $this->fileDriver + ->expects(static::once()) + ->method('readDirectory') + ->with($savePath) + ->willReturn([ + $savePath . '/deployed_version.txt', + $savePath . '/other.file', + $savePath . '/123456translation.json', + $savePath . '/654321translation.json', + $savePath . '/001122translation.json', + ]); + $this->fileDriver + ->expects(static::exactly(2)) + ->method('deleteFile') + ->withConsecutive( + [$savePath . '/654321translation.json'], + [$savePath . '/001122translation.json'] + ); + $this->date + ->method('date') + ->willReturn($now); + $this->serializer + ->method('serialize') + ->willReturn('{"keyValue":"transValue"}'); + $this->fileDriver + ->method('filePutContents') + ->withConsecutive( + [$savePath . '/' . $version . 'translation.json', '{"keyValue":"transValue"}'], + [$savePath . '/deployed_version.txt', $version], + ); + + $this->fileManager->writeJsTranslationFileContent( + ['keyValue' => 'transValue'], + 'Theme/default/nl_BE' + ); + } +} diff --git a/Test/Unit/bootstrap.php b/Test/Unit/bootstrap.php new file mode 100644 index 0000000..c0ad674 --- /dev/null +++ b/Test/Unit/bootstrap.php @@ -0,0 +1,2 @@ +urlBuilder = $urlBuilder; + parent::__construct($context, $uiComponentFactory, $components, $data); + } + + /** + * Prepare Data Source + * + * @param array $dataSource + * @return array + */ + public function prepareDataSource(array $dataSource) + { + if (isset($dataSource['data']['items'])) { + foreach ($dataSource['data']['items'] as & $item) { + if (isset($item['key_id'])) { + $item[$this->getData('name')] = [ + 'edit' => [ + 'href' => $this->urlBuilder->getUrl( + static::URL_PATH_EDIT, + [ + 'key_id' => $item['key_id'] + ] + ), + 'label' => __('Edit') + ], + 'delete' => [ + 'href' => $this->urlBuilder->getUrl( + static::URL_PATH_DELETE, + [ + 'key_id' => $item['key_id'] + ] + ), + 'label' => __('Delete'), + 'confirm' => [ + 'title' =>__('Delete %1', $item['key_id']), + 'message' => __('Are you sure you want to delete record %1?', $item['key_id']), + ] + ] + ]; + } + } + } + + return $dataSource; + } +} diff --git a/ViewModel/JsTranslationConfig.php b/ViewModel/JsTranslationConfig.php new file mode 100644 index 0000000..c1061e4 --- /dev/null +++ b/ViewModel/JsTranslationConfig.php @@ -0,0 +1,43 @@ +fileManager = $fileManager; + $this->design = $design; + } + + public function getJsTranslationFilePath(): string + { + try { + return $this->fileManager->getJsTranslationUrl( + sprintf( + '%s/%s', + $this->design->getDesignTheme()->getThemePath(), + $this->design->getLocale() + ) + ); + } catch (\Exception $e) { + return ''; + } + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a42b6fe --- /dev/null +++ b/composer.json @@ -0,0 +1,63 @@ +{ + "name": "phpro/mage2-module-translations", + "description": "Manage translations via the Magento backend", + "type": "magento2-module", + "license": "MIT", + "authors": [ + { + "name": "PHPro NV", + "email": "info@phpro.be", + "homepage": "https://www.phpro.be/" + } + ], + "require": { + "php": "^7.3", + "magento/framework": "^102.0|^103.0", + "magento/module-backend": "^101.0|^102.0", + "magento/module-ui": "^101.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.14", + "magento/magento-coding-standard": "5", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpro/grumphp-shim": "~1.5.0", + "phpunit/phpunit": "^8.5", + "squizlabs/php_codesniffer": "~3.4" + }, + "autoload": { + "psr-4": { + "Phpro\\Translations\\": "" + }, + "files": [ + "registration.php" + ] + }, + "repositories": [ + { + "type": "composer", + "url": "https://repo.magento.com/" + } + ], + "config": { + "sort-packages": true, + "platform": { + "ext-gd": "7.3", + "ext-xsl": "7.3", + "ext-bcmath": "7.3", + "ext-pdo_mysql": "7.3", + "ext-soap": "7.3", + "ext-zip": "7.3" + }, + "allow-plugins": { + "phpro/grumphp-shim": true + } + }, + "scripts": { + "post-install-cmd": [ + "([ $COMPOSER_DEV_MODE -eq 0 ] || vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard,../../phpcompatibility/php-compatibility/PHPCompatibility)" + ], + "post-update-cmd": [ + "([ $COMPOSER_DEV_MODE -eq 0 ] || vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard,../../phpcompatibility/php-compatibility/PHPCompatibility)" + ] + } +} diff --git a/etc/acl.xml b/etc/acl.xml new file mode 100644 index 0000000..84c2be1 --- /dev/null +++ b/etc/acl.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml new file mode 100644 index 0000000..ce4717c --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,19 @@ + + + + + + + + + + \Magento\Framework\View\Element\Message\Renderer\BlockRenderer::CODE + + Phpro_Translations::messages/inlineGenerateSuccessMessage.phtml + + + + + + diff --git a/etc/adminhtml/menu.xml b/etc/adminhtml/menu.xml new file mode 100644 index 0000000..1b746be --- /dev/null +++ b/etc/adminhtml/menu.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml new file mode 100644 index 0000000..3785433 --- /dev/null +++ b/etc/adminhtml/routes.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/etc/db_schema.xml b/etc/db_schema.xml new file mode 100644 index 0000000..c42c78b --- /dev/null +++ b/etc/db_schema.xml @@ -0,0 +1,8 @@ + + + + +
+
diff --git a/etc/di.xml b/etc/di.xml new file mode 100644 index 0000000..8036dc2 --- /dev/null +++ b/etc/di.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + translation + Phpro\Translations\Model\ResourceModel\Translation\Collection + + + + + + Phpro\Translations\Model\ResourceModel\Translation\Grid\Collection + + + + + + + Phpro\Translations\Console\Command\Import + Phpro\Translations\Console\Command\ImportFull + Phpro\Translations\Console\Command\Export + Phpro\Translations\Console\Command\GenerateFrontendTranslations + Phpro\Translations\Console\Command\PrepareKeysCommand + + + + + + + Phpro_Translations + + + + diff --git a/etc/import.xml b/etc/import.xml new file mode 100644 index 0000000..ab502a4 --- /dev/null +++ b/etc/import.xml @@ -0,0 +1,6 @@ + + + diff --git a/etc/module.xml b/etc/module.xml new file mode 100644 index 0000000..6c06b1d --- /dev/null +++ b/etc/module.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/etc/webapi.xml b/etc/webapi.xml new file mode 100644 index 0000000..fbadb54 --- /dev/null +++ b/etc/webapi.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/grumphp.yml b/grumphp.yml new file mode 100644 index 0000000..71259bd --- /dev/null +++ b/grumphp.yml @@ -0,0 +1,24 @@ +grumphp: + tasks: + phpcsfixer: + config_contains_finder: true + config: .php_cs + triggered_by: [php] + phplint: + triggered_by: [php] + phpcs: + standard: [Magento2, PSR2] + ignore_patterns: + - "mage2-module-translations/vendor" + - "Test/Unit" + exclude: + - Magento2.Functions.StaticFunction + - Magento2.Exceptions.DirectThrow + - Magento2.Exceptions.ThrowCatch + - Magento2.Translation.ConstantUsage + - Magento2.NamingConvention.ReservedWords + triggered_by: [php] + phpunit: + always_execute: true + file_size: + max_size: 5M diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..32c955b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,7 @@ + + + + Test/Unit + + + diff --git a/registration.php b/registration.php new file mode 100644 index 0000000..556395e --- /dev/null +++ b/registration.php @@ -0,0 +1,6 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/phpro_translations_translation_generatejson.xml b/view/adminhtml/layout/phpro_translations_translation_generatejson.xml new file mode 100644 index 0000000..495d6e0 --- /dev/null +++ b/view/adminhtml/layout/phpro_translations_translation_generatejson.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/phpro_translations_translation_index.xml b/view/adminhtml/layout/phpro_translations_translation_index.xml new file mode 100644 index 0000000..210d554 --- /dev/null +++ b/view/adminhtml/layout/phpro_translations_translation_index.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/phpro_translations_translation_new.xml b/view/adminhtml/layout/phpro_translations_translation_new.xml new file mode 100644 index 0000000..0c7152b --- /dev/null +++ b/view/adminhtml/layout/phpro_translations_translation_new.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/view/adminhtml/templates/messages/inlineGenerateSuccessMessage.phtml b/view/adminhtml/templates/messages/inlineGenerateSuccessMessage.phtml new file mode 100644 index 0000000..efe07ac --- /dev/null +++ b/view/adminhtml/templates/messages/inlineGenerateSuccessMessage.phtml @@ -0,0 +1,11 @@ +getData('statsItems') +?> + +
    + +
  • + +
diff --git a/view/adminhtml/ui_component/phpro_translations_create_form.xml b/view/adminhtml/ui_component/phpro_translations_create_form.xml new file mode 100644 index 0000000..e62528b --- /dev/null +++ b/view/adminhtml/ui_component/phpro_translations_create_form.xml @@ -0,0 +1,114 @@ + +
+ + + phpro_translations_create_form.translation_form_data_source + + + General Information + templates/form/collapsible + + + + + +
+ + + + key_id + + + + Phpro_Translations::Translation + + + id + key_id + + + + + + true + + + + + + + + + + editSelected + phpro_translations_listing.phpro_translations_listing.phpro_translations_columns_editor + + edit + + + + + + + + + + phpro_translations_listing.phpro_translations_listing.phpro_translations_columns.ids + true + key_id + + + false + + + + + phpro_translations_listing.phpro_translations_listing.phpro_translations_columns_editor + startEdit + + ${ $.$data.rowIndex } + true + + + + + + + key_id + false + 55 + + + + + asc + + + + + + text + + + text + + true + + + + + + + text + + + text + + true + + + + + + + + select + + select + + select + + true + + + + + + + key_id + false + 107 + + + + diff --git a/view/adminhtml/web/css/source/_module.less b/view/adminhtml/web/css/source/_module.less new file mode 100644 index 0000000..3392d11 --- /dev/null +++ b/view/adminhtml/web/css/source/_module.less @@ -0,0 +1 @@ +@import "module/_general"; diff --git a/view/adminhtml/web/css/source/module/_general.less b/view/adminhtml/web/css/source/module/_general.less new file mode 100644 index 0000000..52a2c97 --- /dev/null +++ b/view/adminhtml/web/css/source/module/_general.less @@ -0,0 +1,16 @@ +/* SVG file ./globe-darkgray.svg converted to base64 */ +.admin__menu .item-translations.level-0 > a:before { + content: ''; + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTAyNCIgaGVpZ2h0PSIxMDI0IiB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0Ij4KPHRpdGxlPjwvdGl0bGU+CjxnIGlkPSJpY29tb29uLWlnbm9yZSI+CjwvZz4KPHBhdGggZmlsbD0iI2FhYTZhMCIgZD0iTTUxMS40MDcgMC4wNDdjLTE4LjM1NSAwLTM2LjY5NyAwLjk4Ny01NC45NDggMi45NTQtMTYuNzI4IDEuNzA5LTMzLjI1IDQuNTExLTQ5LjU1IDcuOTExLTMuMTU1IDAuNjYtNi4zMTEgMS4zNTYtOS40NjYgMi4wODUtOTYuMDQwIDIxLjg2Ni0xODMuNzM2IDcwLjk3LTI1Mi41NyAxNDEuNDI0LTYuNDAxIDYuNTYxLTEyLjY2NyAxMy4zMTctMTguNzk1IDIwLjI2NC0xODUuOTQyIDIxMy40NjgtMTYzLjYyNyA1MzcuMjU1IDQ5Ljg0MyA3MjMuMTk3IDIwNC4zNyAxNzguMDE2IDUxMS45OTkgMTY2LjE3MSA3MDIuMDc2LTI3LjAzMSA2LjQxNC02LjU2MSAxMi42NzgtMTMuMzE3IDE4Ljc5NS0yMC4yNjQgODIuMDU5LTkzLjQ1IDEyNy4yODEtMjEzLjU4NiAxMjcuMjA4LTMzNy45NTEgMC0yODMuMDk3LTIyOS40OTUtNTEyLjU5MS01MTIuNTkzLTUxMi41ODh6TTE2Ny42NDkgMTgwLjA1MGMyLjctMi44MDIgNS41MDItNS40ODUgOC4yNTMtOC4yMDEgMy4xNzgtMy4xMjcgNi4zMzktNi4yNzEgOS41ODUtOS4yOTUgMi44NTMtMi42NjUgNS43OTItNS4yMjggOC43MTQtNy44MjYgMy4yOTgtMi45MjIgNi41NzgtNS44NDQgOS45NjEtOC42NjMgMi45NzMtMi41MTIgNi4wMzEtNC45MjEgOS4wNzMtNy4zNDcgMy40MTctMi43MzQgNi44MzUtNS40NjggMTAuMzU0LTguMDk5IDMuMTEtMi4zNDEgNi4yNzEtNC42MTMgOS40NDktNi44MzUgMy41MzctMi41MjkgNy4wOTEtNS4xMjYgMTAuNjk2LTcuNDg0IDMuMjI5LTIuMTg3IDYuNDkzLTQuMzA2IDkuNzczLTYuNDA3IDMuNjU2LTIuMzQxIDcuMzMtNC42NDcgMTEuMDM4LTYuODM1IDMuMzM4LTEuOTkzIDYuNjk4LTMuOTUzIDEwLjA4MS01Ljg3OCAzLjc4Mi0yLjE1MyA3LjU4LTQuMjU1IDExLjM5Ny02LjMwNSAzLjQxNy0xLjgyMiA2LjgzNS0zLjYwNSAxMC4yNTItNS4zNDggMy44OTYtMS45NjUgNy44MjYtMy44NjIgMTEuNzcyLTUuNzI0IDMuNDE3LTEuNjIzIDYuOTcxLTMuMjQ2IDEwLjQ5MS00Ljc4NCA0LjAxNS0xLjcwOSA4LjA4Mi0zLjQxNyAxMi4xNDgtNS4xMjYgMy41MzctMS40NTIgNy4wNTctMi44NzEgMTAuNjI4LTQuMjM3IDQuMTM1LTEuNTcyIDguMzIxLTMuMDQxIDEyLjUwNy00LjUxMSAzLjU3MS0xLjI0NyA3LjE0Mi0yLjQ5NSAxMC43NjQtMy42NzQgNC4yNTUtMS4zNjcgOC41NDMtMi42MzEgMTIuODQ5LTMuODc5IDMuNjA1LTEuMDQyIDcuMTc2LTIuMTE5IDEwLjc5OS0zLjA5MyAxLjM4NC0wLjM1OSAyLjc2OC0wLjY0OSA0LjE1Mi0xLjAwOC01MC41NzYgNDkuMTA4LTg4LjEwOCAxMTAuMDU1LTEwOS4xOTkgMTc3LjMyMi00MC41NTQtMTAuNjk2LTc5LjgxNS0yNS44MDUtMTE3LjA3Ni00NS4wNTcgMC41NjQtMC41OTggMS4wNDItMS4xOTYgMS41MzgtMS43MDl6TTE0My4wNzkgMjA3LjU1OWM0MS4wNDQgMjIuMjM4IDg0LjU2NyAzOS41NTkgMTI5LjY2OCA1MS42MDEtMjIuMTEgNzYuODgtMzMuNzE2IDE1Ni4zOTgtMzQuNDk3IDIzNi4zOWgtMjA0Ljg2NWMzLjcyMy0xMDUuNDg1IDQyLjI5Ny0yMDYuNzYgMTA5LjY5NC0yODcuOTl6TTE0My4wNzkgODE3LjcxMmMtNjcuMzkzLTgxLjIzMy0xMDUuOTY3LTE4Mi41MDgtMTA5LjY5NC0yODcuOTloMjA0Ljg2NWMwLjc4MiA3OS45OTIgMTIuMzg1IDE1OS41MSAzNC40OTcgMjM2LjM5LTQ1LjA5OSAxMi4wNDYtODguNjIzIDI5LjM2NS0xMjkuNjY4IDUxLjYwMXpNMzg4LjEzMSA5NzQuODM4Yy0zLjQ1MS0wLjg3MS03LjAyMi0xLjk5OS0xMC41NzYtMy4wNDEtNC4zMjMtMS4yNjQtOC42NjMtMi41MjktMTIuOTM0LTMuOTEzLTMuNjA1LTEuMTYyLTcuMTU5LTIuNDA5LTEwLjcxMy0zLjY1Ni00LjIwMy0xLjQ1Mi04LjQwNi0yLjkzOS0xMi41NTgtNC41MjgtMy41NTQtMS4zNS03LjA1Ny0yLjc2OC0xMC41NzYtNC4yMDMtNC4wODQtMS43MDktOC4xNjctMy40MTctMTIuMjE3LTUuMTI2LTMuNDg2LTEuNTM4LTYuOTU0LTMuMTItMTAuNDA2LTQuNzUtMy45ODEtMS44NzktNy45NDUtMy43OTMtMTEuOTYtNS43NzUtMy40MTctMS43MDktNi44MzUtMy40MTctMTAuMjUyLTUuMjgtMy44NjItMi4wNjctNy42ODktNC4xODYtMTEuNTE2LTYuMzczLTMuNDE3LTEuODk3LTYuNjY0LTMuODQ0LTkuOTYxLTUuODI2LTMuNzU5LTIuMjU1LTcuNDUtNC41NzktMTEuMTQtNi45NTQtMy4yNDYtMi4wNjctNi40OTMtNC4xNjktOS42ODgtNi4zMjItMy42MzktMi40Ni03LjI0NS01LjAyMy0xMC44MTYtNy41ODYtMy4xMjctMi4yMzgtNi4yNTQtNC40NzctOS4zMjktNi44MzUtMy41MzctMi42NjUtNy4wMDUtNS40MTYtMTAuNDU3LTguMTg0LTMuMDI0LTIuNDA5LTYuMDMxLTQuODAxLTguOTg3LTcuMjc5LTMuNDE3LTIuODM2LTYuNzE1LTUuNzkyLTEwLjAzMC04LjczMS0yLjkwNS0yLjU4LTUuODI2LTUuMTI2LTguNjYzLTcuNzU3LTMuMjYzLTMuMDQxLTYuNDI0LTYuMjAyLTkuNjAzLTkuMzI5LTIuNzUxLTIuNzE3LTUuNTUzLTUuMzk5LTguMjUzLTguMTg0LTAuNDk2LTAuNTMtMC45NzQtMS4wNzYtMS40NjktMS41ODkgMzcuMjU5LTE5LjI2MSA3Ni41MTktMzQuMzc2IDExNy4wNzYtNDUuMDc0IDIxLjA5NSA2Ny4yNjUgNTguNjI1IDEyOC4yMSAxMDkuMTk5IDE3Ny4zMjItMS4zODQtMC4zNTktMi43ODUtMC42NDktNC4xNjktMS4wMjV6TTQ5NC4zMjMgOTg5LjcwM2MtNzMuMTEyLTEwLjM4OS0xMzcuMjAzLTg2LjY5Ni0xNzcuNTI3LTE5OS4zODEgNTguMjk5LTEzLjA1NCAxMTcuNzkxLTIwLjA1NyAxNzcuNTI3LTIwLjg5N3YyMjAuMjc3ek00OTQuMzIzIDczNS4yNTNjLTYzLjM2OSAwLjgzMy0xMjYuNDcxIDguMzU5LTE4OC4yNTcgMjIuNDUxLTIxLjQ1NC03NC4xMjctMzIuNzcyLTE1MC44MTktMzMuNjQzLTIyNy45ODNoMjIxLjl2MjA1LjUzMnpNNDk0LjMyMyA0OTUuNTQ5aC0yMjEuOWMwLjg2OS03Ny4xNjQgMTIuMTg3LTE1My44NTYgMzMuNjQzLTIyNy45ODMgNjEuNzg2IDE0LjA5MCAxMjQuODkxIDIxLjYxNiAxODguMjU3IDIyLjQ1MXYyMDUuNTMyek00OTQuMzIzIDI1NS44NDVjLTU5LjczOC0wLjgzMy0xMTkuMjI5LTcuODM2LTE3Ny41MjctMjAuODk3IDQwLjMyNC0xMTIuNjg0IDEwNC40MTUtMTg4Ljk5MiAxNzcuNTI3LTE5OS4zODF2MjIwLjI3N3pNODc5Ljc0IDIwNy41NTljNjcuMzkzIDgxLjIzMyAxMDUuOTY3IDE4Mi41MDggMTA5LjY5NCAyODcuOTloLTIwNC44NjVjLTAuNzgyLTc5Ljk5Mi0xMi4zODUtMTU5LjUxLTM0LjQ5Ny0yMzYuMzkgNDUuMDk5LTEyLjA0OCA4OC42MjMtMjkuMzY3IDEyOS42NjgtNTEuNjAxek02MzQuNTUxIDUwLjQxNWMzLjU4OCAwLjg4OCA3LjE1OSAyLjAxNiAxMC43MTMgMy4wNTggNC4zMjMgMS4yNjQgOC42NjMgMi41MjkgMTIuOTM0IDMuOTEzIDMuNjA1IDEuMTYyIDcuMTU5IDIuNDA5IDEwLjcxMyAzLjY1NiA0LjIwMyAxLjQ1MiA4LjQwNiAyLjkzOSAxMi41NTggNC41MjggMy41NTQgMS4zNSA3LjA1NyAyLjc2OCAxMC41NzYgNC4yMDMgNC4wODQgMS43MDkgOC4xNjcgMy40MTcgMTIuMjE3IDUuMTI2IDMuNDg2IDEuNTM4IDYuOTU0IDMuMTIgMTAuNDA2IDQuNzUgMy45ODEgMS44NzkgNy45NDUgMy43OTMgMTEuOTYgNS43NzUgMy40MTcgMS43MDkgNi44MzUgMy40MTcgMTAuMjUyIDUuMjggMy44NjIgMi4wNjcgNy42ODkgNC4xODYgMTEuNTE2IDYuMzczIDMuNDE3IDEuODk3IDYuNjY0IDMuODQ0IDkuOTYxIDUuODI2IDMuNzU5IDIuMjU1IDcuNDUgNC41NzkgMTEuMTQgNi45NTQgMy4yNDYgMi4wNjcgNi40OTMgNC4xNjkgOS42ODggNi4zMjIgMy42MzkgMi40NiA3LjIyOCA1LjAwNiAxMC43OTkgNy41NjkgMy4xNDQgMi4yMzggNi4yNzEgNC40OTQgOS4zNjMgNi44MzUgMy41MiAyLjY0OCA2Ljk3MSA1LjM5OSAxMC40NCA4LjE2NyAzLjAwNyAyLjM5MiA2LjAzMSA0Ljc4NCA4Ljk4NyA3LjI3OSAzLjQxNyAyLjgzNiA2LjcxNSA1Ljc5MiAxMC4wMzAgOC43MzEgMi45MDUgMi41OCA1LjgyNiA1LjEyNiA4LjY2MyA3Ljc1NyAzLjI2MyAzLjA0MSA2LjQyNCA2LjIwMiA5LjYwMyA5LjMyOSAyLjc1MSAyLjcxNyA1LjU1MyA1LjM5OSA4LjI1MyA4LjE4NCAwLjQ5NiAwLjUzIDAuOTc0IDEuMDc2IDEuNDY5IDEuNTg5LTM3LjI1OSAxOS4yNjEtNzYuNTE5IDM0LjM3Ni0xMTcuMDc2IDQ1LjA3NC0yMS4xMzYtNjcuMjczLTU4LjcxNS0xMjguMjE0LTEwOS4zMzYtMTc3LjMwNSAxLjM4NCAwLjM1OSAyLjc4NSAwLjY0OSA0LjE2OSAxLjAyNXpNNTI4LjQ5NiAzNS41NjdjNzMuMTEyIDEwLjM4OSAxMzcuMjAzIDg2LjY5NiAxNzcuNTI3IDE5OS4zODEtNTguMjk5IDEzLjA1NC0xMTcuNzkxIDIwLjA1Ny0xNzcuNTI3IDIwLjg5N3YtMjIwLjI3N3pNNTI4LjQ5NiAyOTAuMDE3YzYzLjM2OS0wLjgzMyAxMjYuNDcxLTguMzU5IDE4OC4yNTctMjIuNDUxIDIxLjQ1NCA3NC4xMjcgMzIuNzcyIDE1MC44MTkgMzMuNjQzIDIyNy45ODNoLTIyMS45di0yMDUuNTMyek01MjguNDk2IDUyOS43MjJoMjIxLjljLTAuODY5IDc3LjE2NC0xMi4xODcgMTUzLjg1Ni0zMy42NDMgMjI3Ljk4M3YwYy02MS43ODYtMTQuMDkwLTEyNC44ODgtMjEuNjE2LTE4OC4yNTctMjIuNDUxdi0yMDUuNTMyek01MjguNDk2IDk4OS43MDN2LTIyMC4yNzdjNTkuNzM4IDAuODMzIDExOS4yMjkgNy44MzYgMTc3LjUyNyAyMC44OTctNDAuMzI0IDExMi42ODQtMTA0LjQxNSAxODguOTkyLTE3Ny41MjcgMTk5LjM4MXpNODU1LjE3IDg0NS4yMjFjLTIuNyAyLjc4NS01LjQ4NSA1LjQ2OC04LjIzNiA4LjE4NC0zLjE3OCAzLjEyNy02LjM1NiA2LjI4OC05LjYyIDkuMzI5LTIuODM2IDIuNjQ4LTUuNzU4IDUuMTI2LTguNjYzIDcuNzc0LTMuMzE1IDIuOTM5LTYuNjEyIDUuODc4LTkuOTk2IDguNzE0LTIuOTkgMi40OTUtNi4wMzEgNC45MDQtOS4wNzMgNy4zMy0zLjQxNyAyLjczNC02LjgzNSA1LjQ2OC0xMC4zNTQgOC4wOTktMy4xMSAyLjM0MS02LjI3MSA0LjYxMy05LjQ0OSA2LjgzNS0zLjUzNyAyLjUyOS03LjA5MSA1LjEyNi0xMC42OTYgNy40ODQtMy4yMjkgMi4xODctNi40OTMgNC4zMDYtOS43NzMgNi4zOS0zLjY1NiAyLjM1OC03LjM0MSA0LjYzNy0xMS4wNTUgNi44MzUtMy40MTcgMS45OTktNi42ODEgMy45NDctMTAuMDQ3IDUuODc4LTMuNzgyIDIuMTUzLTcuNTg2IDQuMjU1LTExLjQxNCA2LjMwNS0zLjQxNyAxLjgyMi02LjgzNSAzLjYwNS0xMC4yNTIgNS4zNDgtMy44OTYgMS45NjUtNy44MjYgMy44NjItMTEuNzcyIDUuNzI0LTMuNDE3IDEuNjIzLTYuOTcxIDMuMjQ2LTEwLjQ5MSA0Ljc4NC00LjAxNSAxLjcwOS04LjA4MiAzLjQxNy0xMi4xNDggNS4xMjYtMy41MzcgMS40MzUtNy4wNTcgMi44NzEtMTAuNjExIDQuMjItNC4xNjkgMS41ODktOC4zNTUgMy4wNzYtMTIuNTU4IDQuNTI4LTMuNTU0IDEuMjQ3LTcuMTA4IDIuNDk1LTEwLjcxMyAzLjY1Ni00LjI3MiAxLjM4NC04LjU0MyAyLjY0OC0xMi45IDMuODk2LTMuNTcxIDEuMDU5LTcuMTU5IDIuMTE5LTEwLjc2NCAzLjA5My0xLjM4NCAwLjM1OS0yLjc2OCAwLjY0OS00LjE1MiAxLjAwOCA1MC41NzYtNDkuMTA4IDg4LjEwOC0xMTAuMDU1IDEwOS4xOTktMTc3LjMyMiA0MC41NTQgMTAuNjk2IDc5LjgxNSAyNS44MDUgMTE3LjA3NiA0NS4wNTctMC41NjQgMC42MTUtMS4wNDIgMS4yMTMtMS41MzggMS43MjZ6TTg3OS43NCA4MTcuNzEyYy00MS4wNDQtMjIuMjM4LTg0LjU2Ny0zOS41NTktMTI5LjY2OC01MS42MDEgMjIuMTEtNzYuODggMzMuNzE2LTE1Ni4zOTggMzQuNDk3LTIzNi4zOWgyMDQuODY1Yy0zLjcyMSAxMDUuNDgzLTQyLjI5NyAyMDYuNzYtMTA5LjY5NCAyODcuOTl6Ij48L3BhdGg+Cjwvc3ZnPg==") no-repeat center; + width: 25px; + height: 25px; + background-size: contain; + display: inline-block; +} + +/* SVG file ./globe-lightgray.svg converted to base64 */ +.admin__menu .item-translations.level-0 > a:hover:before, +.admin__menu .item-translations.level-0._active > a:before { + background: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTAyNCIgaGVpZ2h0PSIxMDI0IiB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0Ij4KPHRpdGxlPjwvdGl0bGU+CjxnIGlkPSJpY29tb29uLWlnbm9yZSI+CjwvZz4KPHBhdGggZmlsbD0iI2Y3ZjNlYiIgZD0iTTUxMS40MDcgMC4wNDdjLTE4LjM1NSAwLTM2LjY5NyAwLjk4Ny01NC45NDggMi45NTQtMTYuNzI4IDEuNzA5LTMzLjI1IDQuNTExLTQ5LjU1IDcuOTExLTMuMTU1IDAuNjYtNi4zMTEgMS4zNTYtOS40NjYgMi4wODUtOTYuMDQwIDIxLjg2Ni0xODMuNzM2IDcwLjk3LTI1Mi41NyAxNDEuNDI0LTYuNDAxIDYuNTYxLTEyLjY2NyAxMy4zMTctMTguNzk1IDIwLjI2NC0xODUuOTQyIDIxMy40NjgtMTYzLjYyNyA1MzcuMjU1IDQ5Ljg0MyA3MjMuMTk3IDIwNC4zNyAxNzguMDE2IDUxMS45OTkgMTY2LjE3MSA3MDIuMDc2LTI3LjAzMSA2LjQxNC02LjU2MSAxMi42NzgtMTMuMzE3IDE4Ljc5NS0yMC4yNjQgODIuMDU5LTkzLjQ1IDEyNy4yODEtMjEzLjU4NiAxMjcuMjA4LTMzNy45NTEgMC0yODMuMDk3LTIyOS40OTUtNTEyLjU5MS01MTIuNTkzLTUxMi41ODh6TTE2Ny42NDkgMTgwLjA1MGMyLjctMi44MDIgNS41MDItNS40ODUgOC4yNTMtOC4yMDEgMy4xNzgtMy4xMjcgNi4zMzktNi4yNzEgOS41ODUtOS4yOTUgMi44NTMtMi42NjUgNS43OTItNS4yMjggOC43MTQtNy44MjYgMy4yOTgtMi45MjIgNi41NzgtNS44NDQgOS45NjEtOC42NjMgMi45NzMtMi41MTIgNi4wMzEtNC45MjEgOS4wNzMtNy4zNDcgMy40MTctMi43MzQgNi44MzUtNS40NjggMTAuMzU0LTguMDk5IDMuMTEtMi4zNDEgNi4yNzEtNC42MTMgOS40NDktNi44MzUgMy41MzctMi41MjkgNy4wOTEtNS4xMjYgMTAuNjk2LTcuNDg0IDMuMjI5LTIuMTg3IDYuNDkzLTQuMzA2IDkuNzczLTYuNDA3IDMuNjU2LTIuMzQxIDcuMzMtNC42NDcgMTEuMDM4LTYuODM1IDMuMzM4LTEuOTkzIDYuNjk4LTMuOTUzIDEwLjA4MS01Ljg3OCAzLjc4Mi0yLjE1MyA3LjU4LTQuMjU1IDExLjM5Ny02LjMwNSAzLjQxNy0xLjgyMiA2LjgzNS0zLjYwNSAxMC4yNTItNS4zNDggMy44OTYtMS45NjUgNy44MjYtMy44NjIgMTEuNzcyLTUuNzI0IDMuNDE3LTEuNjIzIDYuOTcxLTMuMjQ2IDEwLjQ5MS00Ljc4NCA0LjAxNS0xLjcwOSA4LjA4Mi0zLjQxNyAxMi4xNDgtNS4xMjYgMy41MzctMS40NTIgNy4wNTctMi44NzEgMTAuNjI4LTQuMjM3IDQuMTM1LTEuNTcyIDguMzIxLTMuMDQxIDEyLjUwNy00LjUxMSAzLjU3MS0xLjI0NyA3LjE0Mi0yLjQ5NSAxMC43NjQtMy42NzQgNC4yNTUtMS4zNjcgOC41NDMtMi42MzEgMTIuODQ5LTMuODc5IDMuNjA1LTEuMDQyIDcuMTc2LTIuMTE5IDEwLjc5OS0zLjA5MyAxLjM4NC0wLjM1OSAyLjc2OC0wLjY0OSA0LjE1Mi0xLjAwOC01MC41NzYgNDkuMTA4LTg4LjEwOCAxMTAuMDU1LTEwOS4xOTkgMTc3LjMyMi00MC41NTQtMTAuNjk2LTc5LjgxNS0yNS44MDUtMTE3LjA3Ni00NS4wNTcgMC41NjQtMC41OTggMS4wNDItMS4xOTYgMS41MzgtMS43MDl6TTE0My4wNzkgMjA3LjU1OWM0MS4wNDQgMjIuMjM4IDg0LjU2NyAzOS41NTkgMTI5LjY2OCA1MS42MDEtMjIuMTEgNzYuODgtMzMuNzE2IDE1Ni4zOTgtMzQuNDk3IDIzNi4zOWgtMjA0Ljg2NWMzLjcyMy0xMDUuNDg1IDQyLjI5Ny0yMDYuNzYgMTA5LjY5NC0yODcuOTl6TTE0My4wNzkgODE3LjcxMmMtNjcuMzkzLTgxLjIzMy0xMDUuOTY3LTE4Mi41MDgtMTA5LjY5NC0yODcuOTloMjA0Ljg2NWMwLjc4MiA3OS45OTIgMTIuMzg1IDE1OS41MSAzNC40OTcgMjM2LjM5LTQ1LjA5OSAxMi4wNDYtODguNjIzIDI5LjM2NS0xMjkuNjY4IDUxLjYwMXpNMzg4LjEzMSA5NzQuODM4Yy0zLjQ1MS0wLjg3MS03LjAyMi0xLjk5OS0xMC41NzYtMy4wNDEtNC4zMjMtMS4yNjQtOC42NjMtMi41MjktMTIuOTM0LTMuOTEzLTMuNjA1LTEuMTYyLTcuMTU5LTIuNDA5LTEwLjcxMy0zLjY1Ni00LjIwMy0xLjQ1Mi04LjQwNi0yLjkzOS0xMi41NTgtNC41MjgtMy41NTQtMS4zNS03LjA1Ny0yLjc2OC0xMC41NzYtNC4yMDMtNC4wODQtMS43MDktOC4xNjctMy40MTctMTIuMjE3LTUuMTI2LTMuNDg2LTEuNTM4LTYuOTU0LTMuMTItMTAuNDA2LTQuNzUtMy45ODEtMS44NzktNy45NDUtMy43OTMtMTEuOTYtNS43NzUtMy40MTctMS43MDktNi44MzUtMy40MTctMTAuMjUyLTUuMjgtMy44NjItMi4wNjctNy42ODktNC4xODYtMTEuNTE2LTYuMzczLTMuNDE3LTEuODk3LTYuNjY0LTMuODQ0LTkuOTYxLTUuODI2LTMuNzU5LTIuMjU1LTcuNDUtNC41NzktMTEuMTQtNi45NTQtMy4yNDYtMi4wNjctNi40OTMtNC4xNjktOS42ODgtNi4zMjItMy42MzktMi40Ni03LjI0NS01LjAyMy0xMC44MTYtNy41ODYtMy4xMjctMi4yMzgtNi4yNTQtNC40NzctOS4zMjktNi44MzUtMy41MzctMi42NjUtNy4wMDUtNS40MTYtMTAuNDU3LTguMTg0LTMuMDI0LTIuNDA5LTYuMDMxLTQuODAxLTguOTg3LTcuMjc5LTMuNDE3LTIuODM2LTYuNzE1LTUuNzkyLTEwLjAzMC04LjczMS0yLjkwNS0yLjU4LTUuODI2LTUuMTI2LTguNjYzLTcuNzU3LTMuMjYzLTMuMDQxLTYuNDI0LTYuMjAyLTkuNjAzLTkuMzI5LTIuNzUxLTIuNzE3LTUuNTUzLTUuMzk5LTguMjUzLTguMTg0LTAuNDk2LTAuNTMtMC45NzQtMS4wNzYtMS40NjktMS41ODkgMzcuMjU5LTE5LjI2MSA3Ni41MTktMzQuMzc2IDExNy4wNzYtNDUuMDc0IDIxLjA5NSA2Ny4yNjUgNTguNjI1IDEyOC4yMSAxMDkuMTk5IDE3Ny4zMjItMS4zODQtMC4zNTktMi43ODUtMC42NDktNC4xNjktMS4wMjV6TTQ5NC4zMjMgOTg5LjcwM2MtNzMuMTEyLTEwLjM4OS0xMzcuMjAzLTg2LjY5Ni0xNzcuNTI3LTE5OS4zODEgNTguMjk5LTEzLjA1NCAxMTcuNzkxLTIwLjA1NyAxNzcuNTI3LTIwLjg5N3YyMjAuMjc3ek00OTQuMzIzIDczNS4yNTNjLTYzLjM2OSAwLjgzMy0xMjYuNDcxIDguMzU5LTE4OC4yNTcgMjIuNDUxLTIxLjQ1NC03NC4xMjctMzIuNzcyLTE1MC44MTktMzMuNjQzLTIyNy45ODNoMjIxLjl2MjA1LjUzMnpNNDk0LjMyMyA0OTUuNTQ5aC0yMjEuOWMwLjg2OS03Ny4xNjQgMTIuMTg3LTE1My44NTYgMzMuNjQzLTIyNy45ODMgNjEuNzg2IDE0LjA5MCAxMjQuODkxIDIxLjYxNiAxODguMjU3IDIyLjQ1MXYyMDUuNTMyek00OTQuMzIzIDI1NS44NDVjLTU5LjczOC0wLjgzMy0xMTkuMjI5LTcuODM2LTE3Ny41MjctMjAuODk3IDQwLjMyNC0xMTIuNjg0IDEwNC40MTUtMTg4Ljk5MiAxNzcuNTI3LTE5OS4zODF2MjIwLjI3N3pNODc5Ljc0IDIwNy41NTljNjcuMzkzIDgxLjIzMyAxMDUuOTY3IDE4Mi41MDggMTA5LjY5NCAyODcuOTloLTIwNC44NjVjLTAuNzgyLTc5Ljk5Mi0xMi4zODUtMTU5LjUxLTM0LjQ5Ny0yMzYuMzkgNDUuMDk5LTEyLjA0OCA4OC42MjMtMjkuMzY3IDEyOS42NjgtNTEuNjAxek02MzQuNTUxIDUwLjQxNWMzLjU4OCAwLjg4OCA3LjE1OSAyLjAxNiAxMC43MTMgMy4wNTggNC4zMjMgMS4yNjQgOC42NjMgMi41MjkgMTIuOTM0IDMuOTEzIDMuNjA1IDEuMTYyIDcuMTU5IDIuNDA5IDEwLjcxMyAzLjY1NiA0LjIwMyAxLjQ1MiA4LjQwNiAyLjkzOSAxMi41NTggNC41MjggMy41NTQgMS4zNSA3LjA1NyAyLjc2OCAxMC41NzYgNC4yMDMgNC4wODQgMS43MDkgOC4xNjcgMy40MTcgMTIuMjE3IDUuMTI2IDMuNDg2IDEuNTM4IDYuOTU0IDMuMTIgMTAuNDA2IDQuNzUgMy45ODEgMS44NzkgNy45NDUgMy43OTMgMTEuOTYgNS43NzUgMy40MTcgMS43MDkgNi44MzUgMy40MTcgMTAuMjUyIDUuMjggMy44NjIgMi4wNjcgNy42ODkgNC4xODYgMTEuNTE2IDYuMzczIDMuNDE3IDEuODk3IDYuNjY0IDMuODQ0IDkuOTYxIDUuODI2IDMuNzU5IDIuMjU1IDcuNDUgNC41NzkgMTEuMTQgNi45NTQgMy4yNDYgMi4wNjcgNi40OTMgNC4xNjkgOS42ODggNi4zMjIgMy42MzkgMi40NiA3LjIyOCA1LjAwNiAxMC43OTkgNy41NjkgMy4xNDQgMi4yMzggNi4yNzEgNC40OTQgOS4zNjMgNi44MzUgMy41MiAyLjY0OCA2Ljk3MSA1LjM5OSAxMC40NCA4LjE2NyAzLjAwNyAyLjM5MiA2LjAzMSA0Ljc4NCA4Ljk4NyA3LjI3OSAzLjQxNyAyLjgzNiA2LjcxNSA1Ljc5MiAxMC4wMzAgOC43MzEgMi45MDUgMi41OCA1LjgyNiA1LjEyNiA4LjY2MyA3Ljc1NyAzLjI2MyAzLjA0MSA2LjQyNCA2LjIwMiA5LjYwMyA5LjMyOSAyLjc1MSAyLjcxNyA1LjU1MyA1LjM5OSA4LjI1MyA4LjE4NCAwLjQ5NiAwLjUzIDAuOTc0IDEuMDc2IDEuNDY5IDEuNTg5LTM3LjI1OSAxOS4yNjEtNzYuNTE5IDM0LjM3Ni0xMTcuMDc2IDQ1LjA3NC0yMS4xMzYtNjcuMjczLTU4LjcxNS0xMjguMjE0LTEwOS4zMzYtMTc3LjMwNSAxLjM4NCAwLjM1OSAyLjc4NSAwLjY0OSA0LjE2OSAxLjAyNXpNNTI4LjQ5NiAzNS41NjdjNzMuMTEyIDEwLjM4OSAxMzcuMjAzIDg2LjY5NiAxNzcuNTI3IDE5OS4zODEtNTguMjk5IDEzLjA1NC0xMTcuNzkxIDIwLjA1Ny0xNzcuNTI3IDIwLjg5N3YtMjIwLjI3N3pNNTI4LjQ5NiAyOTAuMDE3YzYzLjM2OS0wLjgzMyAxMjYuNDcxLTguMzU5IDE4OC4yNTctMjIuNDUxIDIxLjQ1NCA3NC4xMjcgMzIuNzcyIDE1MC44MTkgMzMuNjQzIDIyNy45ODNoLTIyMS45di0yMDUuNTMyek01MjguNDk2IDUyOS43MjJoMjIxLjljLTAuODY5IDc3LjE2NC0xMi4xODcgMTUzLjg1Ni0zMy42NDMgMjI3Ljk4M3YwYy02MS43ODYtMTQuMDkwLTEyNC44ODgtMjEuNjE2LTE4OC4yNTctMjIuNDUxdi0yMDUuNTMyek01MjguNDk2IDk4OS43MDN2LTIyMC4yNzdjNTkuNzM4IDAuODMzIDExOS4yMjkgNy44MzYgMTc3LjUyNyAyMC44OTctNDAuMzI0IDExMi42ODQtMTA0LjQxNSAxODguOTkyLTE3Ny41MjcgMTk5LjM4MXpNODU1LjE3IDg0NS4yMjFjLTIuNyAyLjc4NS01LjQ4NSA1LjQ2OC04LjIzNiA4LjE4NC0zLjE3OCAzLjEyNy02LjM1NiA2LjI4OC05LjYyIDkuMzI5LTIuODM2IDIuNjQ4LTUuNzU4IDUuMTI2LTguNjYzIDcuNzc0LTMuMzE1IDIuOTM5LTYuNjEyIDUuODc4LTkuOTk2IDguNzE0LTIuOTkgMi40OTUtNi4wMzEgNC45MDQtOS4wNzMgNy4zMy0zLjQxNyAyLjczNC02LjgzNSA1LjQ2OC0xMC4zNTQgOC4wOTktMy4xMSAyLjM0MS02LjI3MSA0LjYxMy05LjQ0OSA2LjgzNS0zLjUzNyAyLjUyOS03LjA5MSA1LjEyNi0xMC42OTYgNy40ODQtMy4yMjkgMi4xODctNi40OTMgNC4zMDYtOS43NzMgNi4zOS0zLjY1NiAyLjM1OC03LjM0MSA0LjYzNy0xMS4wNTUgNi44MzUtMy40MTcgMS45OTktNi42ODEgMy45NDctMTAuMDQ3IDUuODc4LTMuNzgyIDIuMTUzLTcuNTg2IDQuMjU1LTExLjQxNCA2LjMwNS0zLjQxNyAxLjgyMi02LjgzNSAzLjYwNS0xMC4yNTIgNS4zNDgtMy44OTYgMS45NjUtNy44MjYgMy44NjItMTEuNzcyIDUuNzI0LTMuNDE3IDEuNjIzLTYuOTcxIDMuMjQ2LTEwLjQ5MSA0Ljc4NC00LjAxNSAxLjcwOS04LjA4MiAzLjQxNy0xMi4xNDggNS4xMjYtMy41MzcgMS40MzUtNy4wNTcgMi44NzEtMTAuNjExIDQuMjItNC4xNjkgMS41ODktOC4zNTUgMy4wNzYtMTIuNTU4IDQuNTI4LTMuNTU0IDEuMjQ3LTcuMTA4IDIuNDk1LTEwLjcxMyAzLjY1Ni00LjI3MiAxLjM4NC04LjU0MyAyLjY0OC0xMi45IDMuODk2LTMuNTcxIDEuMDU5LTcuMTU5IDIuMTE5LTEwLjc2NCAzLjA5My0xLjM4NCAwLjM1OS0yLjc2OCAwLjY0OS00LjE1MiAxLjAwOCA1MC41NzYtNDkuMTA4IDg4LjEwOC0xMTAuMDU1IDEwOS4xOTktMTc3LjMyMiA0MC41NTQgMTAuNjk2IDc5LjgxNSAyNS44MDUgMTE3LjA3NiA0NS4wNTctMC41NjQgMC42MTUtMS4wNDIgMS4yMTMtMS41MzggMS43MjZ6TTg3OS43NCA4MTcuNzEyYy00MS4wNDQtMjIuMjM4LTg0LjU2Ny0zOS41NTktMTI5LjY2OC01MS42MDEgMjIuMTEtNzYuODggMzMuNzE2LTE1Ni4zOTggMzQuNDk3LTIzNi4zOWgyMDQuODY1Yy0zLjcyMSAxMDUuNDgzLTQyLjI5NyAyMDYuNzYtMTA5LjY5NCAyODcuOTl6Ij48L3BhdGg+Cjwvc3ZnPg==") no-repeat center; + background-size: contain; +} diff --git a/view/adminhtml/web/css/source/module/globe-darkgray.svg b/view/adminhtml/web/css/source/module/globe-darkgray.svg new file mode 100644 index 0000000..5379a05 --- /dev/null +++ b/view/adminhtml/web/css/source/module/globe-darkgray.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/view/adminhtml/web/css/source/module/globe-lightgray.svg b/view/adminhtml/web/css/source/module/globe-lightgray.svg new file mode 100644 index 0000000..0804907 --- /dev/null +++ b/view/adminhtml/web/css/source/module/globe-lightgray.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/view/frontend/layout/default.xml b/view/frontend/layout/default.xml new file mode 100644 index 0000000..c104c9d --- /dev/null +++ b/view/frontend/layout/default.xml @@ -0,0 +1,13 @@ + + + + + + Phpro\Translations\ViewModel\JsTranslationConfig + + + + + diff --git a/view/frontend/templates/page/js/require_js_maps.phtml b/view/frontend/templates/page/js/require_js_maps.phtml new file mode 100644 index 0000000..b43eadb --- /dev/null +++ b/view/frontend/templates/page/js/require_js_maps.phtml @@ -0,0 +1,19 @@ +getViewModel(); +$jsTranslationMapPath = $viewModel->getJsTranslationFilePath(); +?> + + +