From 7615d7a20f7d7e4c4669c76e178fcca9ba15b59b Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 14:14:26 +0200 Subject: [PATCH 1/6] Translations - inital commit --- Api/Data/NodeTranslationInterface.php | 80 +++++++++ Api/NodeTranslationRepositoryInterface.php | 85 ++++++++++ Block/Adminhtml/Edit/Tab/Nodes.php | 90 +++++++++- Block/Menu.php | 51 +++++- .../ImportExport/Processor/ExtendedFields.php | 9 +- Model/ImportExport/Processor/Store.php | 19 ++- Model/NodeTranslation.php | 127 +++++++++++++++ Model/NodeTranslationRepository.php | 154 ++++++++++++++++++ Model/ResourceModel/NodeTranslation.php | 18 ++ .../NodeTranslation/Collection.php | 19 +++ Plugin/Model/Menu/Node/AfterSave.php | 83 ++++++++++ Service/Menu/SaveRequestProcessor.php | 5 + etc/db_schema.xml | 23 +++ etc/di.xml | 7 + view/adminhtml/templates/menu/nodes.phtml | 8 +- view/adminhtml/web/vue/menu-type.vue | 94 +++++++++++ 16 files changed, 865 insertions(+), 7 deletions(-) create mode 100644 Api/Data/NodeTranslationInterface.php create mode 100644 Api/NodeTranslationRepositoryInterface.php create mode 100644 Model/NodeTranslation.php create mode 100644 Model/NodeTranslationRepository.php create mode 100644 Model/ResourceModel/NodeTranslation.php create mode 100644 Model/ResourceModel/NodeTranslation/Collection.php create mode 100644 Plugin/Model/Menu/Node/AfterSave.php diff --git a/Api/Data/NodeTranslationInterface.php b/Api/Data/NodeTranslationInterface.php new file mode 100644 index 00000000..62a096cc --- /dev/null +++ b/Api/Data/NodeTranslationInterface.php @@ -0,0 +1,80 @@ +imageFile = $imageFile; $this->vueProvider = $vueProvider; $this->customerGroupsProvider = $customerGroupsProvider; + $this->systemStore = $systemStore; + $this->nodeTranslationRepository = $nodeTranslationRepository; } public function renderNodes() @@ -175,7 +191,43 @@ private function renderNodeList($level, $parent, $data) } $nodes = $data[$level][$parent]; $menu = []; + + // Create store view lookup array + $storeViewLabels = []; + $websites = $this->systemStore->getWebsiteCollection(); + $websiteNames = []; + + // Create website name lookup array + foreach ($websites as $website) { + $websiteNames[$website->getId()] = $website->getName(); + } + + foreach ($this->systemStore->getStoreCollection() as $store) { + if ($store->isActive()) { + $websiteName = isset($websiteNames[$store->getWebsiteId()]) + ? $websiteNames[$store->getWebsiteId()] + : ''; + $storeViewLabels[$store->getId()] = [ + 'value' => $store->getId(), + 'label' => sprintf('%s -> %s', $websiteName, $store->getName()) + ]; + } + } + foreach ($nodes as $node) { + $translations = $this->nodeTranslationRepository->getByNodeId($node->getId()); + $translationData = []; + foreach ($translations as $translation) { + $storeId = $translation->getStoreId(); + if (isset($storeViewLabels[$storeId])) { + $translationData[] = [ + 'store_id' => $storeViewLabels[$storeId]['value'], + 'value' => $translation->getTitle(), + 'label' => $storeViewLabels[$storeId]['label'] + ]; + } + } + $menu[] = [ 'is_active' => $node->getIsActive(), 'is_stored' => true, @@ -194,7 +246,8 @@ private function renderNodeList($level, $parent, $data) 'image_height' => $node->getImageHeight(), 'columns' => $this->renderNodeList($level + 1, $node->getId(), $data) ?: [], 'selected_item_id' => $node->getSelectedItemId(), - 'customer_groups' => $node->getCustomerGroups() + 'customer_groups' => $node->getCustomerGroups(), + 'translations' => $translationData ]; } return $menu; @@ -222,4 +275,39 @@ public function getCustomerGroups() { return $this->customerGroupsProvider->getAll(); } + + /** + * Get store views for translations + * + * @return array + */ + public function getStoreViews() + { + $storeViews = []; + $stores = $this->systemStore->getStoreCollection(); + $websites = $this->systemStore->getWebsiteCollection(); + $websiteNames = []; + + // Create website name lookup array + foreach ($websites as $website) { + $websiteNames[$website->getId()] = $website->getName(); + } + + foreach ($stores as $store) { + if (!$store->isActive()) { + continue; + } + + $websiteName = isset($websiteNames[$store->getWebsiteId()]) + ? $websiteNames[$store->getWebsiteId()] + : ''; + + $storeViews[] = [ + 'value' => $store->getId(), + 'label' => sprintf('%s -> %s', $websiteName, $store->getName()) + ]; + } + + return $storeViews; + } } diff --git a/Block/Menu.php b/Block/Menu.php index dd076269..2c0c1b50 100644 --- a/Block/Menu.php +++ b/Block/Menu.php @@ -15,6 +15,8 @@ use Snowdog\Menu\Model\NodeTypeProvider; use Snowdog\Menu\Model\TemplateResolver; use Magento\Store\Model\Store; +use Snowdog\Menu\Api\NodeTranslationRepositoryInterface; +use Magento\Store\Model\StoreManagerInterface; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -40,9 +42,19 @@ class Menu extends Template implements DataObject\IdentityInterface */ private $nodeTypeProvider; - private $nodes; + /** + * @var NodeTranslationRepositoryInterface + */ + private $nodeTranslationRepository; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + private $nodes; private $menu = null; + private $nodeTranslations = []; /** * @var EventManager @@ -97,6 +109,8 @@ public function __construct( ImageFile $imageFile, Escaper $escaper, Context $httpContext, + NodeTranslationRepositoryInterface $nodeTranslationRepository, + StoreManagerInterface $storeManager, array $data = [] ) { parent::__construct($context, $data); @@ -110,6 +124,8 @@ public function __construct( $this->setTemplate($this->getMenuTemplate($this->_template)); $this->submenuTemplate = $this->getSubmenuTemplate(); $this->httpContext = $httpContext; + $this->nodeTranslationRepository = $nodeTranslationRepository; + $this->storeManager = $storeManager; } /** @@ -392,7 +408,7 @@ private function getMenuNodeBlock($node) $level = $node->getLevel(); $isRoot = 0 == $level; $nodeBlock->setId($node->getNodeId()) - ->setTitle($node->getTitle()) + ->setTitle($this->getNodeTitle($node)) ->setLevel($level) ->setIsRoot($isRoot) ->setIsParent((bool) $node->getIsParent()) @@ -447,6 +463,8 @@ private function fetchData() $customerGroupEnabled = $this->_scopeConfig->getValue(self::XML_SNOWMENU_GENERAL_CUSTOMER_GROUPS); $result = []; $types = []; + $nodeIds = []; + foreach ($nodes as $node) { if (!$node->getIsActive()) { continue; @@ -455,6 +473,7 @@ private function fetchData() continue; } + $nodeIds[] = $node->getId(); $level = $node->getLevel(); $parent = $node->getParentId() ?: 0; if (!isset($result[$level])) { @@ -470,6 +489,16 @@ private function fetchData() } $types[$type][] = $node; } + + // Load all translations for the current store in a single query + if (!empty($nodeIds)) { + $storeId = (int)$this->storeManager->getStore()->getId(); + $collection = $this->nodeTranslationRepository->getByNodeIds($nodeIds, $storeId); + foreach ($collection as $translation) { + $this->nodeTranslations[$translation->getNodeId()] = $translation; + } + } + $this->nodes = $result; foreach ($types as $type => $nodes) { @@ -513,4 +542,22 @@ public function getCustomerGroupId() { return $this->httpContext->getValue(\Magento\Customer\Model\Context::CONTEXT_GROUP); } + + /** + * Get translated node title based on current store view + * + * @param NodeInterface $node + * @return string + */ + private function getNodeTitle(NodeInterface $node): string + { + $nodeId = $node->getId(); + if (isset($this->nodeTranslations[$nodeId])) { + $title = $this->nodeTranslations[$nodeId]->getTitle(); + if ($title) { + return $title; + } + } + return $node->getTitle(); + } } diff --git a/Model/ImportExport/Processor/ExtendedFields.php b/Model/ImportExport/Processor/ExtendedFields.php index 2d97e1a8..27c20ebb 100644 --- a/Model/ImportExport/Processor/ExtendedFields.php +++ b/Model/ImportExport/Processor/ExtendedFields.php @@ -9,8 +9,13 @@ */ class ExtendedFields { - const STORES = 'stores'; const NODES = 'nodes'; + const STORES = 'stores'; + const TRANSLATIONS = 'translations'; - const FIELDS = [self::STORES, self::NODES]; + const FIELDS = [ + self::NODES, + self::STORES, + self::TRANSLATIONS + ]; } diff --git a/Model/ImportExport/Processor/Store.php b/Model/ImportExport/Processor/Store.php index 9d94b59d..1158274c 100644 --- a/Model/ImportExport/Processor/Store.php +++ b/Model/ImportExport/Processor/Store.php @@ -7,6 +7,7 @@ use Magento\Framework\Exception\NoSuchEntityException; use Magento\Store\Api\Data\StoreInterface; use Magento\Store\Api\StoreRepositoryInterface; +use Magento\Store\Model\StoreManagerInterface; class Store { @@ -15,14 +16,20 @@ class Store */ private $storeRepository; + /** + * @var StoreManagerInterface + */ + private $storeManager; + /** * @var array */ private $cachedStores = []; - public function __construct(StoreRepositoryInterface $storeRepository) + public function __construct(StoreRepositoryInterface $storeRepository, StoreManagerInterface $storeManager) { $this->storeRepository = $storeRepository; + $this->storeManager = $storeManager; } /** @@ -45,4 +52,14 @@ public function get($storeCode): ?StoreInterface return $store; } + + public function getIdByCode(string $storeCode): ?int + { + try { + $store = $this->storeManager->getStore($storeCode); + return (int) $store->getId(); + } catch (\Exception $e) { + return null; + } + } } diff --git a/Model/NodeTranslation.php b/Model/NodeTranslation.php new file mode 100644 index 00000000..afc10c65 --- /dev/null +++ b/Model/NodeTranslation.php @@ -0,0 +1,127 @@ +_init(NodeTranslationResource::class); + } + + /** + * @inheritDoc + */ + public function getTranslationId(): ?int + { + return $this->getData(self::TRANSLATION_ID) === null + ? null + : (int)$this->getData(self::TRANSLATION_ID); + } + + /** + * @inheritDoc + */ + public function setTranslationId(int $id): NodeTranslationInterface + { + return $this->setData(self::TRANSLATION_ID, $id); + } + + /** + * @inheritDoc + */ + public function getNodeId(): int + { + return (int)$this->getData(self::NODE_ID); + } + + /** + * @inheritDoc + */ + public function setNodeId(int $nodeId): NodeTranslationInterface + { + return $this->setData(self::NODE_ID, $nodeId); + } + + /** + * @inheritDoc + */ + public function getStoreId(): int + { + return (int)$this->getData(self::STORE_ID); + } + + /** + * @inheritDoc + */ + public function setStoreId(int $storeId): NodeTranslationInterface + { + return $this->setData(self::STORE_ID, $storeId); + } + + /** + * @inheritDoc + */ + public function getTitle(): ?string + { + return $this->getData(self::TITLE); + } + + /** + * @inheritDoc + */ + public function setTitle(?string $title): NodeTranslationInterface + { + return $this->setData(self::TITLE, $title); + } + + /** + * @inheritDoc + */ + public function getCreatedAt(): string + { + return (string)$this->getData(self::CREATED_AT); + } + + /** + * @inheritDoc + */ + public function setCreatedAt(string $createdAt): NodeTranslationInterface + { + return $this->setData(self::CREATED_AT, $createdAt); + } + + /** + * @inheritDoc + */ + public function getUpdatedAt(): string + { + return (string)$this->getData(self::UPDATED_AT); + } + + /** + * @inheritDoc + */ + public function setUpdatedAt(string $updatedAt): NodeTranslationInterface + { + return $this->setData(self::UPDATED_AT, $updatedAt); + } + + public function getValue(): string + { + return (string)$this->getData('value'); + } + + public function setValue(string $value): void + { + $this->setData('value', $value); + } +} diff --git a/Model/NodeTranslationRepository.php b/Model/NodeTranslationRepository.php new file mode 100644 index 00000000..2c76d03f --- /dev/null +++ b/Model/NodeTranslationRepository.php @@ -0,0 +1,154 @@ +resource = $resource; + $this->translationFactory = $translationFactory; + $this->collectionFactory = $collectionFactory; + } + + /** + * @inheritDoc + */ + public function save(NodeTranslationInterface $translation): NodeTranslationInterface + { + try { + $this->resource->save($translation); + } catch (\Exception $e) { + throw new CouldNotSaveException(__('Could not save node translation: %1', $e->getMessage())); + } + + return $translation; + } + + /** + * @inheritDoc + */ + public function getById(int $translationId): NodeTranslationInterface + { + $translation = $this->translationFactory->create(); + $this->resource->load($translation, $translationId); + + if (!$translation->getId()) { + throw new NoSuchEntityException(__('Node translation with ID "%1" does not exist.', $translationId)); + } + + return $translation; + } + + /** + * @inheritDoc + */ + public function getByNodeAndStore(int $nodeId, int $storeId): NodeTranslationInterface + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('node_id', $nodeId); + $collection->addFieldToFilter('store_id', $storeId); + $collection->setPageSize(1); + + $translation = $collection->getFirstItem(); + + if (!$translation->getId()) { + throw new NoSuchEntityException( + __('Node translation for node ID "%1" and store ID "%2" does not exist.', $nodeId, $storeId) + ); + } + + return $translation; + } + + /** + * @inheritDoc + */ + public function getByNodeId(int $nodeId): array + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('node_id', $nodeId); + + return $collection->getItems(); + } + + /** + * @inheritDoc + */ + public function getByNodeIds(array $nodeIds, int $storeId): array + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('node_id', ['in' => $nodeIds]); + $collection->addFieldToFilter('store_id', $storeId); + + return $collection->getItems(); + } + + /** + * @inheritDoc + */ + public function delete(NodeTranslationInterface $translation): bool + { + try { + $this->resource->delete($translation); + } catch (\Exception $e) { + throw new CouldNotDeleteException(__('Could not delete node translation: %1', $e->getMessage())); + } + + return true; + } + + /** + * @inheritDoc + */ + public function deleteById(int $translationId): bool + { + return $this->delete($this->getById($translationId)); + } + + public function deleteByNodeId(int $nodeId): bool + { + try { + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter('node_id', $nodeId); + foreach ($collection as $translation) { + $this->delete($translation); + } + } catch (\Exception $e) { + throw new CouldNotDeleteException(__('Could not delete node translations: %1', $e->getMessage())); + } + return true; + } +} diff --git a/Model/ResourceModel/NodeTranslation.php b/Model/ResourceModel/NodeTranslation.php new file mode 100644 index 00000000..ba8a82fc --- /dev/null +++ b/Model/ResourceModel/NodeTranslation.php @@ -0,0 +1,18 @@ +_init('snowmenu_node_translation', 'translation_id'); + } +} diff --git a/Model/ResourceModel/NodeTranslation/Collection.php b/Model/ResourceModel/NodeTranslation/Collection.php new file mode 100644 index 00000000..c4e500b7 --- /dev/null +++ b/Model/ResourceModel/NodeTranslation/Collection.php @@ -0,0 +1,19 @@ +_init(NodeTranslation::class, NodeTranslationResource::class); + } +} diff --git a/Plugin/Model/Menu/Node/AfterSave.php b/Plugin/Model/Menu/Node/AfterSave.php new file mode 100644 index 00000000..5191a040 --- /dev/null +++ b/Plugin/Model/Menu/Node/AfterSave.php @@ -0,0 +1,83 @@ +getData('translations'); + + if (!is_array($translations)) { + return $result; + } + + $nodeId = (int)$result->getId(); + $existingTranslations = $this->nodeTranslationRepository->getByNodeId($nodeId); + $existingTranslationMap = []; + + // Create a map of existing translations by store ID for easy lookup + foreach ($existingTranslations as $translation) { + $storeId = $translation->getStoreId(); + $existingTranslationMap[$storeId] = $translation; + } + + // Process new/updated translations + foreach ($translations as $translation) { + if (!isset($translation['store_id']) || !isset($translation['value'])) { + continue; + } + + $storeId = (int)$translation['store_id']; + $newValue = $translation['value']; + + // If translation exists for this store + if (isset($existingTranslationMap[$storeId])) { + $existingTranslation = $existingTranslationMap[$storeId]; + // Only update if value has changed + if ($existingTranslation->getTitle() !== $newValue) { + $existingTranslation->setTitle($newValue); + $this->nodeTranslationRepository->save($existingTranslation); + } + // Remove from map as it's been processed + unset($existingTranslationMap[$storeId]); + } else { + // Create new translation + $nodeTranslation = $this->nodeTranslationFactory->create(); + $nodeTranslation->setNodeId($nodeId); + $nodeTranslation->setStoreId($storeId); + $nodeTranslation->setTitle($newValue); + $this->nodeTranslationRepository->save($nodeTranslation); + } + } + + // Delete translations that no longer exist + foreach ($existingTranslationMap as $translation) { + $this->nodeTranslationRepository->delete($translation); + } + + return $result; + } +} diff --git a/Service/Menu/SaveRequestProcessor.php b/Service/Menu/SaveRequestProcessor.php index 219a8072..e8984074 100644 --- a/Service/Menu/SaveRequestProcessor.php +++ b/Service/Menu/SaveRequestProcessor.php @@ -184,6 +184,11 @@ private function processNodeObject( $nodeObject->setTarget($nodeData['target']); } + // Handle translations + if (isset($nodeData['translations']) && is_array($nodeData['translations'])) { + $nodeObject->setData('translations', $nodeData['translations']); + } + $nodeTemplate = null; if (isset($nodeData['node_template']) && $nodeData['type'] != $nodeData['node_template']) { $nodeTemplate = $nodeData['node_template']; diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 21d02c1d..17a7dcb6 100644 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -60,4 +60,27 @@ + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/etc/di.xml b/etc/di.xml index bf2f437f..66a7d92e 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -7,6 +7,8 @@ + + @@ -74,4 +76,9 @@ Snowdog\Menu\Model\ImportExport\Processor\Import\Node\Validator\Proxy + + + + diff --git a/view/adminhtml/templates/menu/nodes.phtml b/view/adminhtml/templates/menu/nodes.phtml index 2d1b2723..1268cc42 100644 --- a/view/adminhtml/templates/menu/nodes.phtml +++ b/view/adminhtml/templates/menu/nodes.phtml @@ -45,6 +45,7 @@ $vueComponents = $block->getVueComponents(); "imageUploadFileId": "getImageUploadFileId() ?>", "lazyMinItemsCount": "1000", "customerGroups" : getCustomerGroups()) ?>, + "storeViews" : getStoreViews()) ?>, "translation": { "nodes" : "", "click" : "", @@ -76,7 +77,12 @@ $vueComponents = $block->getVueComponents(); "imageHeight" : "", "selectedItemId" : "", "customerGroups" : "", - "customerGroupsDescription" : "" + "customerGroupsDescription" : "", + "store" : "", + "translation" : "", + "actions" : "", + "remove" : "", + "addTranslation" : "" } } } diff --git a/view/adminhtml/web/vue/menu-type.vue b/view/adminhtml/web/vue/menu-type.vue index db3c6e38..30eedb85 100644 --- a/view/adminhtml/web/vue/menu-type.vue +++ b/view/adminhtml/web/vue/menu-type.vue @@ -107,6 +107,67 @@ /> +

+ {{ translationsLabel }} +

+ +
+ + + + + + + + + + + + + + + + + + + + +
{{ config.translation.store }}{{ config.translation.translation }}{{ config.translation.actions }}
+ + + + + +
+ +
+
+

{{ templatesLabel }}

@@ -150,16 +211,24 @@ data() { return { draft: {}, + translations: [], isNodeActiveLabel: $t('Enabled'), additionalLabel: $t('Additional type options'), noTemplatesMessage: $t('There is no custom defined templates defined in theme for this node type'), templatesLabel: $t('Templates'), + translationsLabel: $t('Translations'), templateList: { 'node': 'snowMenuNodeCustomTemplates', 'submenu': 'snowMenuSubmenuCustomTemplates', } } }, + created() { + // Initialize translations from item data if they exist + if (this.item.translations) { + this.translations = this.item.translations; + } + }, computed: { isTemplateSectionVisible() { var nodeId = this.templateList['node'], @@ -215,6 +284,31 @@ return this.config.nodeTypes[option]; } return option; + }, + updateTranslation(index) { + // Sync translations back to item + this.item.translations = [...this.translations]; + }, + addTranslation() { + this.translations.push({ + store_id: '', + value: '' + }); + this.updateTranslation(); + }, + removeTranslation(index) { + this.translations.splice(index, 1); + this.updateTranslation(); + } + }, + watch: { + 'item.translations': { + handler(newVal) { + if (newVal && Array.isArray(newVal)) { + this.translations = [...newVal]; + } + }, + immediate: true } }, template: template From 44e5e36242d42a59f7900f75d7c73d739dfe77bf Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 14:19:14 +0200 Subject: [PATCH 2/6] Export translations --- Model/ImportExport/Processor/Export/Node.php | 14 +++++- .../Processor/Export/Node/DataProcessor.php | 26 ++++++++++- .../Export/Node/TranslationProcessor.php | 46 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 Model/ImportExport/Processor/Export/Node/TranslationProcessor.php diff --git a/Model/ImportExport/Processor/Export/Node.php b/Model/ImportExport/Processor/Export/Node.php index 77b51209..97970443 100644 --- a/Model/ImportExport/Processor/Export/Node.php +++ b/Model/ImportExport/Processor/Export/Node.php @@ -10,6 +10,7 @@ use Snowdog\Menu\Api\Data\NodeInterface; use Snowdog\Menu\Api\NodeRepositoryInterface; use Snowdog\Menu\Model\ImportExport\Processor\Export\Node\Tree as NodeTree; +use Snowdog\Menu\Model\ImportExport\Processor\Export\Node\DataProcessor; class Node { @@ -40,16 +41,23 @@ class Node */ private $nodeTree; + /** + * @var DataProcessor + */ + private $dataProcessor; + public function __construct( SearchCriteriaBuilder $searchCriteriaBuilder, SortOrderBuilder $sortOrderBuilder, NodeRepositoryInterface $nodeRepository, - NodeTree $nodeTree + NodeTree $nodeTree, + DataProcessor $dataProcessor ) { $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->sortOrderBuilder = $sortOrderBuilder; $this->nodeRepository = $nodeRepository; $this->nodeTree = $nodeTree; + $this->dataProcessor = $dataProcessor; } public function getList(int $menuId): array @@ -66,6 +74,10 @@ public function getList(int $menuId): array $nodes = $this->nodeRepository->getList($searchCriteria)->getItems(); + if (!empty($nodes)) { + $this->dataProcessor->preloadTranslations($nodes); + } + return $nodes ? $this->nodeTree->get($nodes) : []; } } diff --git a/Model/ImportExport/Processor/Export/Node/DataProcessor.php b/Model/ImportExport/Processor/Export/Node/DataProcessor.php index 4089a3e0..f0ff140c 100644 --- a/Model/ImportExport/Processor/Export/Node/DataProcessor.php +++ b/Model/ImportExport/Processor/Export/Node/DataProcessor.php @@ -6,6 +6,7 @@ use Snowdog\Menu\Api\Data\NodeInterface; use Snowdog\Menu\Model\ImportExport\Processor\Export\Node\TypeContent; +use Snowdog\Menu\Model\ImportExport\Processor\ExtendedFields; class DataProcessor { @@ -13,10 +14,23 @@ class DataProcessor * @var TypeContent */ private $typeContent; + private TranslationProcessor $translationProcessor; - public function __construct(TypeContent $typeContent) - { + public function __construct( + TypeContent $typeContent, + TranslationProcessor $translationProcessor + ) { $this->typeContent = $typeContent; + $this->translationProcessor = $translationProcessor; + } + + public function preloadTranslations(array $nodes): void + { + $nodeIds = array_map( + fn($node) => (int)$node->getId(), + $nodes + ); + $this->translationProcessor->preloadTranslations($nodeIds); } public function getData(array $data): array @@ -26,6 +40,14 @@ public function getData(array $data): array $data[NodeInterface::CONTENT] ); + $translations = $this->translationProcessor->getTranslations( + (int)$data[NodeInterface::NODE_ID] + ); + + if (!empty($translations)) { + $data[ExtendedFields::TRANSLATIONS] = $translations; + } + return $data; } } diff --git a/Model/ImportExport/Processor/Export/Node/TranslationProcessor.php b/Model/ImportExport/Processor/Export/Node/TranslationProcessor.php new file mode 100644 index 00000000..a66b8a27 --- /dev/null +++ b/Model/ImportExport/Processor/Export/Node/TranslationProcessor.php @@ -0,0 +1,46 @@ +nodeTranslationRepository = $nodeTranslationRepository; + $this->storeManager = $storeManager; + } + + public function preloadTranslations(array $nodeIds): void + { + if (empty($nodeIds)) { + return; + } + + $stores = $this->storeManager->getStores(); + foreach ($stores as $store) { + $translations = $this->nodeTranslationRepository->getByNodeIds($nodeIds, (int)$store->getId()); + foreach ($translations as $translation) { + $nodeId = $translation->getNodeId(); + if ($translation->getTitle()) { + $this->translationsCache[$nodeId][$store->getCode()] = $translation->getTitle(); + } + } + } + } + + public function getTranslations(int $nodeId): array + { + return $this->translationsCache[$nodeId] ?? []; + } +} From 6c44b2d0e1b6697a3a722875bda66c02cffae4b7 Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 14:26:41 +0200 Subject: [PATCH 3/6] Import Translations --- Api/Data/NodeTranslationInterface.php | 40 +------------ Api/NodeTranslationRepositoryInterface.php | 8 +++ Model/ImportExport/Processor/Import/Node.php | 21 ++++++- .../Import/Node/TranslationProcessor.php | 57 +++++++++++++++++++ Model/NodeTranslation.php | 2 +- Model/NodeTranslationRepository.php | 53 +++++++++++++---- Model/ResourceModel/NodeTranslation.php | 12 +++- etc/di.xml | 16 ++++++ 8 files changed, 157 insertions(+), 52 deletions(-) create mode 100644 Model/ImportExport/Processor/Import/Node/TranslationProcessor.php diff --git a/Api/Data/NodeTranslationInterface.php b/Api/Data/NodeTranslationInterface.php index 62a096cc..d497167c 100644 --- a/Api/Data/NodeTranslationInterface.php +++ b/Api/Data/NodeTranslationInterface.php @@ -5,23 +5,9 @@ interface NodeTranslationInterface { - public const TRANSLATION_ID = 'translation_id'; public const NODE_ID = 'node_id'; public const STORE_ID = 'store_id'; public const TITLE = 'title'; - public const CREATED_AT = 'created_at'; - public const UPDATED_AT = 'updated_at'; - - /** - * @return int|null - */ - public function getTranslationId(): ?int; - - /** - * @param int $id - * @return NodeTranslationInterface - */ - public function setTranslationId(int $id): NodeTranslationInterface; /** * @return int @@ -51,30 +37,8 @@ public function setStoreId(int $storeId): NodeTranslationInterface; public function getTitle(): ?string; /** - * @param string|null $title - * @return NodeTranslationInterface - */ - public function setTitle(?string $title): NodeTranslationInterface; - - /** - * @return string - */ - public function getCreatedAt(): string; - - /** - * @param string $createdAt - * @return NodeTranslationInterface - */ - public function setCreatedAt(string $createdAt): NodeTranslationInterface; - - /** - * @return string - */ - public function getUpdatedAt(): string; - - /** - * @param string $updatedAt + * @param string $title * @return NodeTranslationInterface */ - public function setUpdatedAt(string $updatedAt): NodeTranslationInterface; + public function setTitle(string $title): NodeTranslationInterface; } diff --git a/Api/NodeTranslationRepositoryInterface.php b/Api/NodeTranslationRepositoryInterface.php index 12fc7448..0db7ed82 100644 --- a/Api/NodeTranslationRepositoryInterface.php +++ b/Api/NodeTranslationRepositoryInterface.php @@ -3,6 +3,8 @@ namespace Snowdog\Menu\Api; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\Api\SearchResultsInterface; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NoSuchEntityException; @@ -82,4 +84,10 @@ public function deleteById(int $translationId): bool; * @throws CouldNotDeleteException */ public function deleteByNodeId(int $nodeId): bool; + + /** + * @param SearchCriteriaInterface $searchCriteria + * @return SearchResultsInterface + */ + public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface; } diff --git a/Model/ImportExport/Processor/Import/Node.php b/Model/ImportExport/Processor/Import/Node.php index 286f4e37..df8d27bd 100644 --- a/Model/ImportExport/Processor/Import/Node.php +++ b/Model/ImportExport/Processor/Import/Node.php @@ -9,6 +9,7 @@ use Snowdog\Menu\Model\ImportExport\Processor\Import\Node\DataProcessor; use Snowdog\Menu\Model\ImportExport\Processor\Import\Node\Validator; use Snowdog\Menu\Model\ImportExport\Processor\Import\Node\Validator\TreeTrace; +use Snowdog\Menu\Model\ImportExport\Processor\Import\Node\TranslationProcessor; use Snowdog\Menu\Model\ImportExport\Processor\ExtendedFields; use Snowdog\Menu\Model\ImportExport\File\Yaml; @@ -44,13 +45,19 @@ class Node */ private $yaml; + /** + * @var TranslationProcessor + */ + private $translationProcessor; + public function __construct( NodeInterfaceFactory $nodeFactory, NodeRepositoryInterface $nodeRepository, DataProcessor $dataProcessor, Validator $validator, TreeTrace $treeTrace, - Yaml $yaml + Yaml $yaml, + TranslationProcessor $translationProcessor ) { $this->nodeFactory = $nodeFactory; $this->nodeRepository = $nodeRepository; @@ -58,6 +65,7 @@ public function __construct( $this->validator = $validator; $this->treeTrace = $treeTrace; $this->yaml = $yaml; + $this->translationProcessor = $translationProcessor; } public function createNodes( @@ -75,11 +83,20 @@ public function createNodes( $node = $this->nodeFactory->create(); $data = $this->dataProcessor->getData($nodeData, $menuId, $level, $position++, $parentId); + // Extract translations before saving node + $translations = $nodeData[ExtendedFields::TRANSLATIONS] ?? []; + unset($data[ExtendedFields::TRANSLATIONS]); + $node->setData($data); $this->nodeRepository->save($node); + // Process translations after node is saved + if (!empty($translations)) { + $this->translationProcessor->processTranslations((int)$node->getId(), $translations); + } + if (isset($nodeData[ExtendedFields::NODES])) { - $nodeId = $node->getId() ? (int) $node->getId() : null; + $nodeId = $node->getId() ? (int)$node->getId() : null; $this->createNodes($nodeData[ExtendedFields::NODES], $menuId, ($level + 1), 0, $nodeId); } } diff --git a/Model/ImportExport/Processor/Import/Node/TranslationProcessor.php b/Model/ImportExport/Processor/Import/Node/TranslationProcessor.php new file mode 100644 index 00000000..30d2dc84 --- /dev/null +++ b/Model/ImportExport/Processor/Import/Node/TranslationProcessor.php @@ -0,0 +1,57 @@ +storeManager = $storeManager; + $this->nodeTranslationRepository = $nodeTranslationRepository; + $this->nodeTranslationFactory = $nodeTranslationFactory; + $this->initializeStoreMap(); + } + + private function initializeStoreMap(): void + { + $stores = $this->storeManager->getStores(); + foreach ($stores as $store) { + $this->storeCodeToId[$store->getCode()] = (int)$store->getId(); + } + } + + public function processTranslations(int $nodeId, array $translations): void + { + if (empty($translations)) { + return; + } + + foreach ($translations as $storeCode => $title) { + if (!isset($this->storeCodeToId[$storeCode])) { + continue; // Skip if store code doesn't exist + } + + $storeId = $this->storeCodeToId[$storeCode]; + $translation = $this->nodeTranslationFactory->create(); + $translation->setNodeId($nodeId) + ->setStoreId($storeId) + ->setTitle($title); + + $this->nodeTranslationRepository->save($translation); + } + } +} diff --git a/Model/NodeTranslation.php b/Model/NodeTranslation.php index afc10c65..30fba480 100644 --- a/Model/NodeTranslation.php +++ b/Model/NodeTranslation.php @@ -78,7 +78,7 @@ public function getTitle(): ?string /** * @inheritDoc */ - public function setTitle(?string $title): NodeTranslationInterface + public function setTitle(string $title): NodeTranslationInterface { return $this->setData(self::TITLE, $title); } diff --git a/Model/NodeTranslationRepository.php b/Model/NodeTranslationRepository.php index 2c76d03f..85662a80 100644 --- a/Model/NodeTranslationRepository.php +++ b/Model/NodeTranslationRepository.php @@ -3,6 +3,10 @@ namespace Snowdog\Menu\Model; +use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; +use Magento\Framework\Api\SearchCriteriaInterface; +use Magento\Framework\Api\SearchResultsInterface; +use Magento\Framework\Api\SearchResultsInterfaceFactory; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NoSuchEntityException; @@ -30,14 +34,28 @@ class NodeTranslationRepository implements NodeTranslationRepositoryInterface */ private CollectionFactory $collectionFactory; + /** + * @var CollectionProcessorInterface + */ + private CollectionProcessorInterface $collectionProcessor; + + /** + * @var SearchResultsInterfaceFactory + */ + private SearchResultsInterfaceFactory $searchResultsFactory; + public function __construct( NodeTranslationResource $resource, NodeTranslationInterfaceFactory $translationFactory, - CollectionFactory $collectionFactory + CollectionFactory $collectionFactory, + CollectionProcessorInterface $collectionProcessor, + SearchResultsInterfaceFactory $searchResultsFactory ) { $this->resource = $resource; $this->translationFactory = $translationFactory; $this->collectionFactory = $collectionFactory; + $this->collectionProcessor = $collectionProcessor; + $this->searchResultsFactory = $searchResultsFactory; } /** @@ -47,10 +65,9 @@ public function save(NodeTranslationInterface $translation): NodeTranslationInte { try { $this->resource->save($translation); - } catch (\Exception $e) { - throw new CouldNotSaveException(__('Could not save node translation: %1', $e->getMessage())); + } catch (\Exception $exception) { + throw new CouldNotSaveException(__($exception->getMessage())); } - return $translation; } @@ -61,11 +78,9 @@ public function getById(int $translationId): NodeTranslationInterface { $translation = $this->translationFactory->create(); $this->resource->load($translation, $translationId); - if (!$translation->getId()) { - throw new NoSuchEntityException(__('Node translation with ID "%1" does not exist.', $translationId)); + throw new NoSuchEntityException(__('Node Translation with id "%1" does not exist.', $translationId)); } - return $translation; } @@ -116,6 +131,25 @@ public function getByNodeIds(array $nodeIds, int $storeId): array return $collection->getItems(); } + /** + * @inheritDoc + */ + public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + + $this->collectionProcessor->process($searchCriteria, $collection); + + /** @var SearchResultsInterface $searchResults */ + $searchResults = $this->searchResultsFactory->create(); + $searchResults->setSearchCriteria($searchCriteria); + $searchResults->setItems($collection->getItems()); + $searchResults->setTotalCount($collection->getSize()); + + return $searchResults; + } + /** * @inheritDoc */ @@ -123,10 +157,9 @@ public function delete(NodeTranslationInterface $translation): bool { try { $this->resource->delete($translation); - } catch (\Exception $e) { - throw new CouldNotDeleteException(__('Could not delete node translation: %1', $e->getMessage())); + } catch (\Exception $exception) { + throw new CouldNotDeleteException(__($exception->getMessage())); } - return true; } diff --git a/Model/ResourceModel/NodeTranslation.php b/Model/ResourceModel/NodeTranslation.php index ba8a82fc..6ee23c06 100644 --- a/Model/ResourceModel/NodeTranslation.php +++ b/Model/ResourceModel/NodeTranslation.php @@ -8,11 +8,21 @@ class NodeTranslation extends AbstractDb { + /** + * @var string + */ + public const TABLE_NAME = 'snowmenu_node_translation'; + + /** + * @var string + */ + public const ID_FIELD_NAME = 'translation_id'; + /** * @inheritDoc */ protected function _construct() { - $this->_init('snowmenu_node_translation', 'translation_id'); + $this->_init(self::TABLE_NAME, self::ID_FIELD_NAME); } } diff --git a/etc/di.xml b/etc/di.xml index 66a7d92e..0d8834dc 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -81,4 +81,20 @@ + + + + + Snowdog\Menu\Model\ImportExport\Processor\Import\Node\TranslationProcessor + + + + + + + snowmenu_node_translation + Snowdog\Menu\Model\ResourceModel\NodeTranslation + + From fa3a9688c1ff88f7dc325579e640e6edb7625da8 Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 14:29:57 +0200 Subject: [PATCH 4/6] Import Translations --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0f82d516..2a344596 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "snowdog/module-menu", + "name": "magebitcom/snowdog-module-menu", "description": "Provides powerful menu editor to replace category based menus in Magento 2", "license": "MIT", "type": "magento2-module", From bab0f4dbe0502f02ad8a4918b7712b0033a9a2a8 Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 15:34:46 +0200 Subject: [PATCH 5/6] Adding translations for import categories --- Model/MenuManagement.php | 92 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/Model/MenuManagement.php b/Model/MenuManagement.php index 2c5ae695..227f71f0 100644 --- a/Model/MenuManagement.php +++ b/Model/MenuManagement.php @@ -9,6 +9,8 @@ use Magento\Catalog\Model\Category; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; class MenuManagement implements MenuManagementInterface { @@ -17,13 +19,34 @@ class MenuManagement implements MenuManagementInterface */ private $categoryManagement; + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var CollectionFactory + */ + private $categoryCollectionFactory; + + /** + * @var array + */ + private $categoryNames = []; + /** * @param CategoryManagement $categoryManagement + * @param StoreManagerInterface $storeManager + * @param CollectionFactory $categoryCollectionFactory */ public function __construct( - CategoryManagement $categoryManagement + CategoryManagement $categoryManagement, + StoreManagerInterface $storeManager, + CollectionFactory $categoryCollectionFactory ) { $this->categoryManagement = $categoryManagement; + $this->storeManager = $storeManager; + $this->categoryCollectionFactory = $categoryCollectionFactory; } /** @@ -37,11 +60,60 @@ public function getCategoryNodeList($rootCategoryId = null, $depth = null): arra { $categoriesTree = $this->categoryManagement->getTree($rootCategoryId, $depth); $categories = $this->generateCategoriesNode($categoriesTree); + + // Preload all category translations + $this->preloadCategoryTranslations($categories); + $nodeList = $this->getCategoriesNodeList(0, 0, $categories); return $nodeList; } + /** + * Preload all category translations in one go + * + * @param array $categories + * @return void + */ + private function preloadCategoryTranslations(array $categories): void + { + $categoryIds = []; + foreach ($categories as $level) { + foreach ($level as $parentId => $nodes) { + foreach ($nodes as $node) { + $categoryIds[] = $node['entity_id']; + } + } + } + + if (empty($categoryIds)) { + return; + } + + $stores = $this->storeManager->getStores(); + + foreach ($stores as $store) { + $storeId = $store->getId(); + if ($storeId === 0) { + continue; // Skip admin store + } + + /** @var \Magento\Catalog\Model\ResourceModel\Category\Collection $collection */ + $collection = $this->categoryCollectionFactory->create(); + $collection->setStoreId($storeId) + ->addAttributeToSelect('name') + ->addFieldToFilter('entity_id', ['in' => $categoryIds]); + + foreach ($collection as $category) { + $categoryId = $category->getId(); + if (!isset($this->categoryNames[$categoryId])) { + $this->categoryNames[$categoryId] = []; + } + $this->categoryNames[$categoryId][$storeId] = $category->getName(); + } + } + } + /** * @param Category $node * @param array $data @@ -88,8 +160,23 @@ private function getCategoriesNodeList($level, $parent, array $data): array $nodes = $data[$level][$parent]; $nodeList = []; + foreach ($nodes as $node) { - $nodeId = $node['id']; + $nodeId = $node['entity_id']; + $translations = []; + + // Use preloaded translations + if (isset($this->categoryNames[$nodeId])) { + foreach ($this->categoryNames[$nodeId] as $storeId => $name) { + if ($name !== $node['name']) { + $translations[] = [ + 'store_id' => (string)$storeId, + 'value' => $name + ]; + } + } + } + $nodeList[] = [ 'is_active' => '1', 'type' => 'category', @@ -104,6 +191,7 @@ private function getCategoriesNodeList($level, $parent, array $data): array 'image_width' => null, 'image_height' => null, 'submenu_template' => null, + 'translations' => $translations, 'columns' => $this->getCategoriesNodeList($level + 1, $nodeId, $data) ?: [] ]; } From 43e9f75cf6aa71a0c752c42d654ada95e2150bf9 Mon Sep 17 00:00:00 2001 From: Emils Malovka Date: Wed, 26 Feb 2025 19:31:01 +0200 Subject: [PATCH 6/6] Works on all menus --- Block/Menu.php | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/Block/Menu.php b/Block/Menu.php index 2c0c1b50..f37aa034 100644 --- a/Block/Menu.php +++ b/Block/Menu.php @@ -408,7 +408,7 @@ private function getMenuNodeBlock($node) $level = $node->getLevel(); $isRoot = 0 == $level; $nodeBlock->setId($node->getNodeId()) - ->setTitle($this->getNodeTitle($node)) + ->setTitle($node->getTitle()) ->setLevel($level) ->setIsRoot($isRoot) ->setIsParent((bool) $node->getIsParent()) @@ -465,6 +465,18 @@ private function fetchData() $types = []; $nodeIds = []; + foreach($nodes as $node) { + $nodeIds[] = $node->getId(); + } + + if (!empty($nodeIds)) { + $storeId = (int)$this->storeManager->getStore()->getId(); + $collection = $this->nodeTranslationRepository->getByNodeIds($nodeIds, $storeId); + foreach ($collection as $translation) { + $this->nodeTranslations[$translation->getNodeId()] = $translation; + } + } + foreach ($nodes as $node) { if (!$node->getIsActive()) { continue; @@ -473,7 +485,8 @@ private function fetchData() continue; } - $nodeIds[] = $node->getId(); + $node->setTitle($this->getNodeTitle($node)); + $level = $node->getLevel(); $parent = $node->getParentId() ?: 0; if (!isset($result[$level])) { @@ -490,15 +503,6 @@ private function fetchData() $types[$type][] = $node; } - // Load all translations for the current store in a single query - if (!empty($nodeIds)) { - $storeId = (int)$this->storeManager->getStore()->getId(); - $collection = $this->nodeTranslationRepository->getByNodeIds($nodeIds, $storeId); - foreach ($collection as $translation) { - $this->nodeTranslations[$translation->getNodeId()] = $translation; - } - } - $this->nodes = $result; foreach ($types as $type => $nodes) {