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')
+?>
+= __('Inline frontend translations successfully re-generated for:'); ?>
+
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 @@
+
+
diff --git a/view/adminhtml/ui_component/phpro_translations_edit_form.xml b/view/adminhtml/ui_component/phpro_translations_edit_form.xml
new file mode 100644
index 0000000..d3d8a32
--- /dev/null
+++ b/view/adminhtml/ui_component/phpro_translations_edit_form.xml
@@ -0,0 +1,117 @@
+
+
diff --git a/view/adminhtml/ui_component/phpro_translations_generate_translations_form.xml b/view/adminhtml/ui_component/phpro_translations_generate_translations_form.xml
new file mode 100644
index 0000000..3ccbff2
--- /dev/null
+++ b/view/adminhtml/ui_component/phpro_translations_generate_translations_form.xml
@@ -0,0 +1,60 @@
+
+
diff --git a/view/adminhtml/ui_component/phpro_translations_listing.xml b/view/adminhtml/ui_component/phpro_translations_listing.xml
new file mode 100644
index 0000000..7897946
--- /dev/null
+++ b/view/adminhtml/ui_component/phpro_translations_listing.xml
@@ -0,0 +1,140 @@
+
+
+
+ -
+
- phpro_translations_listing.phpro_translations_listing_data_source
+
+
+
+ phpro_translations_columns
+
+ phpro_translations_listing.phpro_translations_listing_data_source
+
+
+
+
+
+
+
+
+ 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();
+?>
+
+
+