From 73ac44b3f5b4c0f6fbc957a6eae7a848f9def8e1 Mon Sep 17 00:00:00 2001 From: myprestarocks Date: Sat, 14 Feb 2026 09:20:21 +0000 Subject: [PATCH] Initial shared URL engine package Extract URL pattern management, routing, entity lifecycle handling, and schema installation from mprfriendlyurl into a shared Composer package. Both mprfriendlyurl and mprseorevolution will consume this via configurable table prefix and pattern storage abstraction. Co-Authored-By: Claude Opus 4.6 --- composer.json | 27 + src/EntityLifecycleHandler.php | 1061 ++++++++++++ src/Schema/UrlSchemaInstaller.php | 338 ++++ src/Storage/ConfigPatternStorage.php | 82 + src/Storage/PatternStorageInterface.php | 45 + src/Storage/SqlPatternStorage.php | 133 ++ src/UrlPatternManager.php | 2005 +++++++++++++++++++++++ src/UrlRouter.php | 961 +++++++++++ 8 files changed, 4652 insertions(+) create mode 100644 composer.json create mode 100644 src/EntityLifecycleHandler.php create mode 100644 src/Schema/UrlSchemaInstaller.php create mode 100644 src/Storage/ConfigPatternStorage.php create mode 100644 src/Storage/PatternStorageInterface.php create mode 100644 src/Storage/SqlPatternStorage.php create mode 100644 src/UrlPatternManager.php create mode 100644 src/UrlRouter.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dd0906e --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "myprestarocks/prestashop-url", + "description": "Shared URL engine for PrestaShop modules - pattern management, routing, entity lifecycle", + "keywords": ["prestashop", "url", "seo", "routing", "rewrite"], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "mypresta.rocks", + "email": "info@mypresta.rocks" + } + ], + "require": { + "php": ">=7.1.3" + }, + "autoload": { + "psr-4": { + "MyPrestaRocks\\Url\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "minimum-stability": "stable" +} diff --git a/src/EntityLifecycleHandler.php b/src/EntityLifecycleHandler.php new file mode 100644 index 0000000..f3e6943 --- /dev/null +++ b/src/EntityLifecycleHandler.php @@ -0,0 +1,1061 @@ + 'old-link-rewrite'] + * @var array + */ + protected static $oldUrlCache = []; + + /** + * Constructor + * + * @param \Module $module The module instance + * @param UrlPatternManager $patternManager + * @param UrlRouter $router + */ + public function __construct(\Module $module, UrlPatternManager $patternManager, UrlRouter $router) + { + $this->module = $module; + $this->patternManager = $patternManager; + $this->router = $router; + $this->tablePrefix = $patternManager->getTablePrefix(); + } + + // ────────────────────────────────────────────────────────────── + // Old URL caching (before/after hook pattern) + // ────────────────────────────────────────────────────────────── + + /** + * Capture old URL before entity update (called from "before" hooks) + * Stores the current link_rewrite for all languages so it can be compared after save + * + * @param string $entityType + * @param int $idEntity + */ + public function captureOldUrl($entityType, $idEntity) + { + if (!$this->patternManager->isEnabled($entityType)) { + return; + } + + $idShop = (int) \Context::getContext()->shop->id; + $languages = \Language::getLanguages(true, $idShop); + + foreach ($languages as $lang) { + $idLang = (int) $lang['id_lang']; + $cacheKey = $entityType . '_' . $idEntity . '_' . $idLang; + + // Get current link_rewrite from database BEFORE PrestaShop saves + $oldLinkRewrite = $this->patternManager->getEntityLinkRewrite($entityType, $idEntity, $idLang); + + if ($oldLinkRewrite) { + self::$oldUrlCache[$cacheKey] = $oldLinkRewrite; + } + } + } + + /** + * Get cached old URL for entity (captured in "before" hook) + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return string|null + */ + protected function getCachedOldUrl($entityType, $idEntity, $idLang) + { + $cacheKey = $entityType . '_' . $idEntity . '_' . $idLang; + return self::$oldUrlCache[$cacheKey] ?? null; + } + + /** + * Clear cached old URL after processing + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + */ + protected function clearCachedOldUrl($entityType, $idEntity, $idLang) + { + $cacheKey = $entityType . '_' . $idEntity . '_' . $idLang; + unset(self::$oldUrlCache[$cacheKey]); + } + + // ────────────────────────────────────────────────────────────── + // Product hooks + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionProductAdd + * + * @param array $params + */ + public function hookActionProductAdd($params) + { + if (empty($params['id_product'])) { + return; + } + + $this->cacheEntityUrl('product', (int) $params['id_product']); + } + + /** + * Hook: actionProductUpdate + * + * @param array $params + */ + public function hookActionProductUpdate($params) + { + if (empty($params['id_product'])) { + return; + } + + $this->handleEntityUpdate('product', (int) $params['id_product']); + } + + /** + * Hook: actionProductDelete + * + * @param array $params + */ + public function hookActionProductDelete($params) + { + if (empty($params['id_product'])) { + return; + } + + $this->handleEntityDelete('product', (int) $params['id_product']); + } + + /** + * Hook: actionObjectProductUpdateAfter + * Handle product status changes (active/inactive) + * + * @param array $params + */ + public function hookActionObjectProductUpdateAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Product)) { + return; + } + + $product = $params['object']; + + if (!$product->active) { + $this->handleEntityDisabled('product', (int) $product->id); + } + } + + // ────────────────────────────────────────────────────────────── + // Category hooks + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionCategoryAdd + * + * @param array $params + */ + public function hookActionCategoryAdd($params) + { + if (empty($params['category']) || !($params['category'] instanceof \Category)) { + return; + } + + $this->cacheEntityUrl('category', (int) $params['category']->id); + } + + /** + * Hook: actionCategoryUpdate + * + * @param array $params + */ + public function hookActionCategoryUpdate($params) + { + if (empty($params['category']) || !($params['category'] instanceof \Category)) { + return; + } + + $this->handleEntityUpdate('category', (int) $params['category']->id); + } + + /** + * Hook: actionCategoryDelete + * + * @param array $params + */ + public function hookActionCategoryDelete($params) + { + if (empty($params['category']) || !($params['category'] instanceof \Category)) { + return; + } + + $this->handleEntityDelete('category', (int) $params['category']->id); + } + + /** + * Hook: actionObjectCategoryUpdateAfter + * + * @param array $params + */ + public function hookActionObjectCategoryUpdateAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Category)) { + return; + } + + $category = $params['object']; + + if (!$category->active) { + $this->handleEntityDisabled('category', (int) $category->id); + } + } + + // ────────────────────────────────────────────────────────────── + // CMS hooks (Symfony admin) + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionAfterCreateCmsPageFormHandler + * + * @param array $params + */ + public function hookActionAfterCreateCmsPageFormHandler($params) + { + if (empty($params['id'])) { + return; + } + + $this->cacheEntityUrl('cms', (int) $params['id']); + } + + /** + * Hook: actionAfterUpdateCmsPageFormHandler + * + * @param array $params + */ + public function hookActionAfterUpdateCmsPageFormHandler($params) + { + if (empty($params['id'])) { + return; + } + + $this->handleEntityUpdate('cms', (int) $params['id']); + } + + // ────────────────────────────────────────────────────────────── + // CMS hooks (legacy ObjectModel) + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionObjectCmsAddAfter + * + * @param array $params + */ + public function hookActionObjectCmsAddAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \CMS)) { + return; + } + + $this->cacheEntityUrl('cms', (int) $params['object']->id); + } + + /** + * Hook: actionObjectCmsUpdateAfter + * + * @param array $params + */ + public function hookActionObjectCmsUpdateAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \CMS)) { + return; + } + + $cms = $params['object']; + + if (!$cms->active) { + $this->handleEntityDisabled('cms', (int) $cms->id); + } + + $this->handleEntityUpdate('cms', (int) $cms->id); + } + + /** + * Hook: actionObjectCmsDeleteAfter + * + * @param array $params + */ + public function hookActionObjectCmsDeleteAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \CMS)) { + return; + } + + $this->handleEntityDelete('cms', (int) $params['object']->id); + } + + // ────────────────────────────────────────────────────────────── + // Manufacturer hooks + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionObjectManufacturerAddAfter + * + * @param array $params + */ + public function hookActionObjectManufacturerAddAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Manufacturer)) { + return; + } + + $this->cacheEntityUrl('manufacturer', (int) $params['object']->id); + } + + /** + * Hook: actionObjectManufacturerUpdateAfter + * + * @param array $params + */ + public function hookActionObjectManufacturerUpdateAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Manufacturer)) { + return; + } + + $manufacturer = $params['object']; + + if (!$manufacturer->active) { + $this->handleEntityDisabled('manufacturer', (int) $manufacturer->id); + } + + $this->handleEntityUpdate('manufacturer', (int) $manufacturer->id); + } + + /** + * Hook: actionObjectManufacturerDeleteAfter + * + * @param array $params + */ + public function hookActionObjectManufacturerDeleteAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Manufacturer)) { + return; + } + + $this->handleEntityDelete('manufacturer', (int) $params['object']->id); + } + + // ────────────────────────────────────────────────────────────── + // Supplier hooks + // ────────────────────────────────────────────────────────────── + + /** + * Hook: actionObjectSupplierAddAfter + * + * @param array $params + */ + public function hookActionObjectSupplierAddAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Supplier)) { + return; + } + + $this->cacheEntityUrl('supplier', (int) $params['object']->id); + } + + /** + * Hook: actionObjectSupplierUpdateAfter + * + * @param array $params + */ + public function hookActionObjectSupplierUpdateAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Supplier)) { + return; + } + + $supplier = $params['object']; + + if (!$supplier->active) { + $this->handleEntityDisabled('supplier', (int) $supplier->id); + } + + $this->handleEntityUpdate('supplier', (int) $supplier->id); + } + + /** + * Hook: actionObjectSupplierDeleteAfter + * + * @param array $params + */ + public function hookActionObjectSupplierDeleteAfter($params) + { + if (empty($params['object']) || !($params['object'] instanceof \Supplier)) { + return; + } + + $this->handleEntityDelete('supplier', (int) $params['object']->id); + } + + // ────────────────────────────────────────────────────────────── + // Core entity lifecycle logic + // ────────────────────────────────────────────────────────────── + + /** + * Handle entity update - check for URL changes + * Uses cached old URL from "before" hook to properly track changes + * + * @param string $entityType + * @param int $idEntity + */ + protected function handleEntityUpdate($entityType, $idEntity) + { + if (!$this->patternManager->isEnabled($entityType)) { + return; + } + + $idShop = (int) \Context::getContext()->shop->id; + $languages = \Language::getLanguages(true, $idShop); + + foreach ($languages as $lang) { + $idLang = (int) $lang['id_lang']; + + // Get old link_rewrite from cache (captured in "before" hook) + $oldLinkRewrite = $this->getCachedOldUrl($entityType, $idEntity, $idLang); + + // Check if there's a locked override + $override = $this->patternManager->getOverride($entityType, $idEntity, $idLang); + if ($override && !empty($override['locked'])) { + $this->clearCachedOldUrl($entityType, $idEntity, $idLang); + continue; + } + + // Get the new link_rewrite (what PrestaShop just saved) + $newLinkRewrite = $this->patternManager->getEntityLinkRewrite($entityType, $idEntity, $idLang); + + // Record URL change for redirects if link_rewrite actually changed + if ($oldLinkRewrite && $newLinkRewrite && $oldLinkRewrite !== $newLinkRewrite) { + $this->patternManager->recordUrlChange( + $entityType, + $idEntity, + $idLang, + $oldLinkRewrite, + $newLinkRewrite, + 'entity_update' + ); + } + + // Clear the cache for this entity + $this->clearCachedOldUrl($entityType, $idEntity, $idLang); + + // Invalidate URL cache + $this->patternManager->invalidateCache($entityType, $idEntity, $idLang); + } + } + + /** + * Handle entity deletion - record for redirects + * + * @param string $entityType + * @param int $idEntity + */ + protected function handleEntityDelete($entityType, $idEntity) + { + $idShop = (int) \Context::getContext()->shop->id; + $languages = \Language::getLanguages(true, $idShop); + + // Get redirect behavior from per-entity pattern settings + $pattern = $this->patternManager->getPattern($entityType, $idShop); + $behavior = $pattern['redirect_on_delete'] ?? 'parent'; + + // If behavior is 'none', don't create redirects + if ($behavior === 'none') { + foreach ($languages as $lang) { + $this->patternManager->invalidateCache($entityType, $idEntity, (int) $lang['id_lang']); + } + return; + } + + // Map behavior to redirect type + $redirectType = ($behavior === '410') ? '410' : '301'; + + foreach ($languages as $lang) { + $idLang = (int) $lang['id_lang']; + + // Get current URL before deletion + $oldUrl = $this->patternManager->getCurrentUrl($entityType, $idEntity, $idLang); + + if ($oldUrl) { + // Get fallback URL based on entity type and behavior + $fallbackUrl = null; + if ($behavior === 'parent') { + $fallbackUrl = $this->getEntityFallbackUrl($entityType, $idEntity, $idLang, $pattern); + } elseif ($behavior === 'homepage') { + $fallbackUrl = \Context::getContext()->link->getPageLink('index', true, $idLang); + } + + // Record deletion with configured behavior + $this->patternManager->recordUrlChange( + $entityType, + $idEntity, + $idLang, + $oldUrl, + $fallbackUrl, + 'entity_delete', + $redirectType + ); + } + + // Remove from cache + $this->patternManager->invalidateCache($entityType, $idEntity, $idLang); + } + } + + /** + * Handle entity disabled - record for redirects + * + * @param string $entityType + * @param int $idEntity + */ + protected function handleEntityDisabled($entityType, $idEntity) + { + $idShop = (int) \Context::getContext()->shop->id; + + // Get redirect behavior from per-entity pattern settings + $pattern = $this->patternManager->getPattern($entityType, $idShop); + $behavior = $pattern['redirect_on_disable'] ?? 'parent'; + + // If behavior is 'none', don't create redirects for disabled entities + if ($behavior === 'none') { + return; + } + + $languages = \Language::getLanguages(true, $idShop); + + // Map behavior to redirect type + $redirectType = ($behavior === '410') ? '410' : '301'; + + foreach ($languages as $lang) { + $idLang = (int) $lang['id_lang']; + + // Get current URL + $oldUrl = $this->patternManager->getCurrentUrl($entityType, $idEntity, $idLang); + + if ($oldUrl) { + // Get fallback URL based on entity type and behavior + $fallbackUrl = null; + if ($behavior === 'parent') { + $fallbackUrl = $this->getEntityFallbackUrl($entityType, $idEntity, $idLang, $pattern); + } elseif ($behavior === 'homepage') { + $fallbackUrl = \Context::getContext()->link->getPageLink('index', true, $idLang); + } + + // Record disabled state with configured behavior + $this->patternManager->recordUrlChange( + $entityType, + $idEntity, + $idLang, + $oldUrl, + $fallbackUrl, + 'entity_disabled', + $redirectType + ); + } + } + } + + // ────────────────────────────────────────────────────────────── + // Fallback URL resolution + // ────────────────────────────────────────────────────────────── + + /** + * Get fallback URL for an entity based on its type + * - Product: redirect to default category, topmost category, or adjacent product + * - Category: redirect to parent category + * - CMS: redirect to CMS category + * - Manufacturer/Supplier: redirect to their listing page + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param array|null $pattern Pattern settings with redirect target info + * @return string|null + */ + protected function getEntityFallbackUrl($entityType, $idEntity, $idLang, $pattern = null) + { + $context = \Context::getContext(); + + switch ($entityType) { + case 'product': + return $this->getProductFallbackUrl($idEntity, $idLang, $pattern); + + case 'category': + return $this->getCategoryParentUrl($idEntity, $idLang); + + case 'cms': + return $this->getCmsCategoryUrl($idEntity, $idLang); + + case 'manufacturer': + return $context->link->getPageLink('manufacturer', true, $idLang); + + case 'supplier': + return $context->link->getPageLink('supplier', true, $idLang); + + default: + return null; + } + } + + /** + * Get product's fallback URL based on redirect target configuration + * + * @param int $idProduct + * @param int|null $idLang + * @param array|null $pattern Pattern settings with product_redirect_target + * @return string|null + */ + protected function getProductFallbackUrl($idProduct, $idLang = null, $pattern = null) + { + $context = \Context::getContext(); + $idLang = $idLang ?: $context->language->id; + $db = \Db::getInstance(); + + // Get redirect target from pattern settings + $target = $pattern['product_redirect_target'] ?? 'default_category'; + + // Get product's default category and position info + $productData = $db->getRow(' + SELECT p.id_category_default, p.id_product, cp.position + FROM `' . _DB_PREFIX_ . 'product` p + LEFT JOIN `' . _DB_PREFIX_ . 'category_product` cp + ON cp.id_product = p.id_product AND cp.id_category = p.id_category_default + WHERE p.id_product = ' . (int) $idProduct + ); + + if (!$productData) { + return null; + } + + $idDefaultCategory = (int) $productData['id_category_default']; + + switch ($target) { + case 'default_category': + if ($idDefaultCategory <= 2) { + return null; + } + return $context->link->getCategoryLink($idDefaultCategory, null, $idLang); + + case 'topmost_category': + $topmostCategory = $this->getTopmostCategory($idDefaultCategory); + if (!$topmostCategory || $topmostCategory <= 2) { + return null; + } + return $context->link->getCategoryLink($topmostCategory, null, $idLang); + + case 'parent_category': + $idParent = (int) $db->getValue(' + SELECT id_parent FROM `' . _DB_PREFIX_ . 'category` + WHERE id_category = ' . (int) $idDefaultCategory + ); + if (!$idParent || $idParent <= 2) { + return $context->link->getCategoryLink($idDefaultCategory, null, $idLang); + } + return $context->link->getCategoryLink($idParent, null, $idLang); + + case 'next_product': + return $this->getAdjacentProductUrl($idProduct, $idDefaultCategory, $idLang, 'next'); + + case 'previous_product': + return $this->getAdjacentProductUrl($idProduct, $idDefaultCategory, $idLang, 'previous'); + + default: + if ($idDefaultCategory <= 2) { + return null; + } + return $context->link->getCategoryLink($idDefaultCategory, null, $idLang); + } + } + + /** + * Get the topmost (root-level) category in the hierarchy + * + * @param int $idCategory + * @return int|null + */ + protected function getTopmostCategory($idCategory) + { + $db = \Db::getInstance(); + $currentId = (int) $idCategory; + $topmostId = $currentId; + + while ($currentId > 2) { + $parentId = (int) $db->getValue(' + SELECT id_parent FROM `' . _DB_PREFIX_ . 'category` + WHERE id_category = ' . $currentId + ); + + if (!$parentId || $parentId <= 2) { + return $currentId; + } + + $topmostId = $currentId; + $currentId = $parentId; + } + + return $topmostId > 2 ? $topmostId : null; + } + + /** + * Get URL of adjacent product (next or previous) in the same category + * + * @param int $idProduct + * @param int $idCategory + * @param int $idLang + * @param string $direction 'next' or 'previous' + * @return string|null + */ + protected function getAdjacentProductUrl($idProduct, $idCategory, $idLang, $direction = 'next') + { + $db = \Db::getInstance(); + $context = \Context::getContext(); + $idShop = (int) $context->shop->id; + + // Get current product's position + $currentPosition = (int) $db->getValue(' + SELECT position FROM `' . _DB_PREFIX_ . 'category_product` + WHERE id_product = ' . (int) $idProduct . ' + AND id_category = ' . (int) $idCategory + ); + + // Find adjacent product + if ($direction === 'next') { + $adjacentId = (int) $db->getValue(' + SELECT cp.id_product + FROM `' . _DB_PREFIX_ . 'category_product` cp + INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps + ON ps.id_product = cp.id_product AND ps.id_shop = ' . $idShop . ' + WHERE cp.id_category = ' . (int) $idCategory . ' + AND cp.position > ' . $currentPosition . ' + AND ps.active = 1 + ORDER BY cp.position ASC + LIMIT 1 + '); + } else { + $adjacentId = (int) $db->getValue(' + SELECT cp.id_product + FROM `' . _DB_PREFIX_ . 'category_product` cp + INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps + ON ps.id_product = cp.id_product AND ps.id_shop = ' . $idShop . ' + WHERE cp.id_category = ' . (int) $idCategory . ' + AND cp.position < ' . $currentPosition . ' + AND ps.active = 1 + ORDER BY cp.position DESC + LIMIT 1 + '); + } + + if ($adjacentId) { + return $context->link->getProductLink($adjacentId, null, null, null, $idLang); + } + + // If no adjacent product found, fall back to category + return $context->link->getCategoryLink($idCategory, null, $idLang); + } + + /** + * Get category's parent URL for redirect fallback + * + * @param int $idCategory + * @param int|null $idLang + * @return string|null + */ + protected function getCategoryParentUrl($idCategory, $idLang = null) + { + $idParent = (int) \Db::getInstance()->getValue(' + SELECT id_parent FROM `' . _DB_PREFIX_ . 'category` + WHERE id_category = ' . (int) $idCategory + ); + + if (!$idParent || $idParent <= 2) { + return null; + } + + $context = \Context::getContext(); + $idLang = $idLang ?: $context->language->id; + + return $context->link->getCategoryLink($idParent, null, $idLang); + } + + /** + * Get CMS page's category URL for redirect fallback + * + * @param int $idCms + * @param int|null $idLang + * @return string|null + */ + protected function getCmsCategoryUrl($idCms, $idLang = null) + { + $idCmsCategory = (int) \Db::getInstance()->getValue(' + SELECT id_cms_category FROM `' . _DB_PREFIX_ . 'cms` + WHERE id_cms = ' . (int) $idCms + ); + + if (!$idCmsCategory || $idCmsCategory <= 1) { + return null; + } + + $context = \Context::getContext(); + $idLang = $idLang ?: $context->language->id; + + return $context->link->getCMSCategoryLink($idCmsCategory, null, $idLang); + } + + // ────────────────────────────────────────────────────────────── + // URL caching & redirect helpers + // ────────────────────────────────────────────────────────────── + + /** + * Cache entity URL (for new entities — updates link_rewrite based on pattern) + * + * @param string $entityType + * @param int $idEntity + */ + protected function cacheEntityUrl($entityType, $idEntity) + { + if (!$this->patternManager->isEnabled($entityType)) { + return; + } + + // Update native link_rewrite fields based on pattern + $this->patternManager->updateEntityLinkRewrite($entityType, $idEntity); + } + + /** + * Check if an entity has a redirect override and perform the redirect if so + * + * @param string $entityType Entity type (product, category, cms, manufacturer, supplier) + * @param int $idEntity Entity ID + * @param int $idLang Language ID + * @param int $idShop Shop ID + * @return bool True if redirect was performed (exit called), false if no override + */ + public function checkOverrideRedirect($entityType, $idEntity, $idLang, $idShop) + { + $sql = 'SELECT target_entity_type, target_id_entity, redirect_type, target_custom_url' + . ' FROM `' . _DB_PREFIX_ . pSQL($this->tablePrefix) . 'url_override`' + . ' WHERE entity_type = "' . pSQL($entityType) . '"' + . ' AND id_entity = ' . (int) $idEntity + . ' AND id_lang = ' . (int) $idLang + . ' AND id_shop = ' . (int) $idShop + . ' AND (target_entity_type IS NOT NULL OR target_custom_url IS NOT NULL)'; + $result = \Db::getInstance()->getRow($sql); + if (!$result) { + return false; + } + + if (!empty($result['target_entity_type']) && !empty($result['target_id_entity'])) { + $ctx = \Context::getContext(); + $link = $ctx->link; + $targetUrl = null; + + try { + switch ($result['target_entity_type']) { + case 'product': + $targetUrl = $link->getProductLink((int) $result['target_id_entity'], null, null, null, $idLang); + break; + case 'category': + $targetUrl = $link->getCategoryLink((int) $result['target_id_entity'], null, $idLang); + break; + case 'cms': + $cmsObj = new \CMS((int) $result['target_id_entity'], $idLang); + $targetUrl = $link->getCMSLink($cmsObj, null, null, $idLang); + break; + case 'manufacturer': + $targetUrl = $link->getManufacturerLink((int) $result['target_id_entity'], null, $idLang); + break; + case 'supplier': + $targetUrl = $link->getSupplierLink((int) $result['target_id_entity'], null, $idLang); + break; + } + } catch (\Exception $e) { + return false; + } + + if ($targetUrl) { + $baseUrl = $ctx->shop->getBaseURL(true); + if (strpos($targetUrl, $baseUrl) === 0) { + $targetUrl = ltrim(substr($targetUrl, strlen($baseUrl)), '/'); + } else { + $targetUrl = ltrim(parse_url($targetUrl, PHP_URL_PATH), '/'); + } + $this->performRedirect($targetUrl, $result['redirect_type'] ?: '301'); + return true; + } + } + + if (!empty($result['target_custom_url'])) { + $this->performRedirect($result['target_custom_url'], $result['redirect_type'] ?: '301'); + return true; + } + + return false; + } + + /** + * Perform HTTP redirect + * + * @param string $url Relative or absolute URL + * @param string $type Redirect type: 301, 302, 307, 410 + */ + public function performRedirect($url, $type = '301') + { + $statusCodes = [ + '301' => 'HTTP/1.1 301 Moved Permanently', + '302' => 'HTTP/1.1 302 Found', + '307' => 'HTTP/1.1 307 Temporary Redirect', + '410' => 'HTTP/1.1 410 Gone', + ]; + + $status = $statusCodes[$type] ?? $statusCodes['301']; + + // Build full URL + $baseUrl = \Context::getContext()->shop->getBaseURL(true); + $fullUrl = $baseUrl . ltrim($url, '/'); + + header($status); + + if ($type !== '410') { + header('Location: ' . $fullUrl); + } + + exit; + } + + /** + * Set request parameters for resolved entity + * + * @param array $resolved + */ + public function setRequestParameters(array $resolved) + { + switch ($resolved['entity_type']) { + case 'product': + $_GET['id_product'] = $resolved['id_entity']; + $_GET['controller'] = 'product'; + if (!empty($resolved['id_product_attribute'])) { + $_GET['id_product_attribute'] = $resolved['id_product_attribute']; + } + break; + + case 'category': + $_GET['id_category'] = $resolved['id_entity']; + $_GET['controller'] = 'category'; + break; + + case 'cms': + $_GET['id_cms'] = $resolved['id_entity']; + $_GET['controller'] = 'cms'; + break; + + case 'cms_category': + $_GET['id_cms_category'] = $resolved['id_entity']; + $_GET['controller'] = 'cms'; + break; + + case 'manufacturer': + $_GET['id_manufacturer'] = $resolved['id_entity']; + $_GET['controller'] = 'manufacturer'; + break; + + case 'supplier': + $_GET['id_supplier'] = $resolved['id_entity']; + $_GET['controller'] = 'supplier'; + break; + } + + // Set language + if (!empty($resolved['id_lang'])) { + $_GET['id_lang'] = $resolved['id_lang']; + } + } + + // ────────────────────────────────────────────────────────────── + // Hook registration helper + // ────────────────────────────────────────────────────────────── + + /** + * Get list of hooks this handler needs for entity lifecycle tracking + * Does NOT include dispatcher/routing hooks — those stay module-specific + * + * @return array + */ + public static function getRequiredHooks() + { + return [ + // Product hooks + 'actionProductAdd', + 'actionProductUpdate', + 'actionProductDelete', + 'actionObjectProductUpdateAfter', + + // Category hooks + 'actionCategoryAdd', + 'actionCategoryUpdate', + 'actionCategoryDelete', + 'actionObjectCategoryUpdateAfter', + + // CMS hooks (legacy) + 'actionObjectCmsAddAfter', + 'actionObjectCmsUpdateAfter', + 'actionObjectCmsDeleteAfter', + + // CMS hooks (Symfony) + 'actionAfterCreateCmsPageFormHandler', + 'actionAfterUpdateCmsPageFormHandler', + + // Manufacturer hooks + 'actionObjectManufacturerAddAfter', + 'actionObjectManufacturerUpdateAfter', + 'actionObjectManufacturerDeleteAfter', + + // Supplier hooks + 'actionObjectSupplierAddAfter', + 'actionObjectSupplierUpdateAfter', + 'actionObjectSupplierDeleteAfter', + ]; + } +} diff --git a/src/Schema/UrlSchemaInstaller.php b/src/Schema/UrlSchemaInstaller.php new file mode 100644 index 0000000..6b49824 --- /dev/null +++ b/src/Schema/UrlSchemaInstaller.php @@ -0,0 +1,338 @@ +tablePrefix = $tablePrefix; + } + + /** + * Install all URL tables + * + * @return bool + */ + public function install() + { + $engine = _MYSQL_ENGINE_; + + return $this->createUrlCacheTable($engine) + && $this->createUrlHistoryTable($engine) + && $this->createUrlOverrideTable($engine) + && $this->createUrlPatternTable($engine); + } + + /** + * Uninstall all URL tables + * + * @return bool + */ + public function uninstall() + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + + return \Db::getInstance()->execute('DROP TABLE IF EXISTS `' . $prefix . 'url_cache`') + && \Db::getInstance()->execute('DROP TABLE IF EXISTS `' . $prefix . 'url_history`') + && \Db::getInstance()->execute('DROP TABLE IF EXISTS `' . $prefix . 'url_override`') + && \Db::getInstance()->execute('DROP TABLE IF EXISTS `' . $prefix . 'url_pattern`'); + } + + /** + * Create url_cache table + * + * @param string $engine MySQL engine + * @return bool + */ + protected function createUrlCacheTable($engine) + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + + return \Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `' . $prefix . 'url_cache` ( + `id_cache` INT AUTO_INCREMENT PRIMARY KEY, + `entity_type` VARCHAR(50) NOT NULL, + `id_entity` INT NOT NULL, + `id_lang` INT NOT NULL, + `id_shop` INT NOT NULL, + `url` VARCHAR(500) NOT NULL, + `url_hash` CHAR(32) NOT NULL, + `full_url` VARCHAR(600) DEFAULT NULL, + `source` ENUM(\'pattern\', \'override\') DEFAULT \'pattern\', + `pattern_version` INT DEFAULT 1, + `date_add` DATETIME NOT NULL, + `date_upd` DATETIME NOT NULL, + UNIQUE KEY `unique_entity` (`entity_type`, `id_entity`, `id_lang`, `id_shop`), + INDEX `idx_url_hash` (`url_hash`), + INDEX `idx_shop_lang` (`id_shop`, `id_lang`) + ) ENGINE=' . $engine . ' DEFAULT CHARSET=utf8mb4 + '); + } + + /** + * Create url_history table + * + * @param string $engine MySQL engine + * @return bool + */ + protected function createUrlHistoryTable($engine) + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + + return \Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `' . $prefix . 'url_history` ( + `id_history` INT AUTO_INCREMENT PRIMARY KEY, + `entity_type` VARCHAR(50) NOT NULL, + `id_entity` INT NOT NULL, + `id_lang` INT NOT NULL, + `id_shop` INT NOT NULL, + `old_url` VARCHAR(500) NOT NULL, + `old_url_hash` CHAR(32) NOT NULL, + `new_url` VARCHAR(500) DEFAULT NULL, + `redirect_type` ENUM(\'301\', \'302\', \'307\', \'410\') DEFAULT \'301\', + `redirect_enabled` TINYINT(1) DEFAULT 1, + `change_reason` ENUM(\'pattern_change\', \'manual_edit\', \'entity_update\', \'entity_delete\', \'bulk_regenerate\', \'entity_disabled\') DEFAULT \'entity_update\', + `hits` INT DEFAULT 0, + `last_hit` DATETIME DEFAULT NULL, + `date_add` DATETIME NOT NULL, + INDEX `idx_old_url_hash` (`old_url_hash`), + INDEX `idx_entity` (`entity_type`, `id_entity`, `id_lang`), + INDEX `idx_shop_enabled` (`id_shop`, `redirect_enabled`) + ) ENGINE=' . $engine . ' DEFAULT CHARSET=utf8mb4 + '); + } + + /** + * Create url_override table (full version with redirect target fields) + * + * @param string $engine MySQL engine + * @return bool + */ + protected function createUrlOverrideTable($engine) + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + + return \Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `' . $prefix . 'url_override` ( + `id_override` INT AUTO_INCREMENT PRIMARY KEY, + `entity_type` VARCHAR(50) NOT NULL, + `id_entity` INT NOT NULL, + `id_lang` INT NOT NULL, + `id_shop` INT NOT NULL, + `id_override_group` INT UNSIGNED NOT NULL DEFAULT 0, + `custom_url` VARCHAR(500) NOT NULL DEFAULT \'\', + `custom_url_hash` CHAR(32) NOT NULL DEFAULT \'\', + `target_entity_type` VARCHAR(50) DEFAULT NULL, + `target_id_entity` INT DEFAULT 0, + `redirect_type` ENUM(\'301\', \'302\', \'307\') DEFAULT \'301\', + `target_custom_url` VARCHAR(500) DEFAULT NULL, + `locked` TINYINT(1) DEFAULT 1, + `date_add` DATETIME NOT NULL, + `date_upd` DATETIME NOT NULL, + UNIQUE KEY `unique_entity` (`entity_type`, `id_entity`, `id_lang`, `id_shop`), + UNIQUE KEY `unique_url` (`custom_url_hash`, `id_lang`, `id_shop`), + INDEX `idx_shop_lang` (`id_shop`, `id_lang`), + INDEX `idx_override_group` (`id_override_group`) + ) ENGINE=' . $engine . ' DEFAULT CHARSET=utf8mb4 + '); + } + + /** + * Create url_pattern table (for SqlPatternStorage) + * + * @param string $engine MySQL engine + * @return bool + */ + protected function createUrlPatternTable($engine) + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + + return \Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `' . $prefix . 'url_pattern` ( + `id_pattern` INT AUTO_INCREMENT PRIMARY KEY, + `entity_type` VARCHAR(50) NOT NULL, + `id_shop` INT NOT NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 0, + `pattern` VARCHAR(500) NOT NULL, + `suffix` VARCHAR(10) DEFAULT \'\', + `include_parent_categories` TINYINT(1) DEFAULT 0, + `category_separator` VARCHAR(10) DEFAULT \'/\', + `max_category_depth` INT DEFAULT 0, + `include_cms_category` TINYINT(1) DEFAULT 0, + `lowercase` TINYINT(1) DEFAULT 1, + `replace_spaces` VARCHAR(10) DEFAULT \'-\', + `remove_accents` TINYINT(1) DEFAULT 1, + `remove_special_chars` TINYINT(1) DEFAULT 1, + `collision_suffix` ENUM(\'id\', \'increment\', \'sku\', \'ean13\') DEFAULT \'id\', + `cache_enabled` TINYINT(1) DEFAULT 1, + `cache_ttl` INT DEFAULT 86400, + `redirect_on_delete` VARCHAR(20) DEFAULT \'parent\', + `redirect_on_disable` VARCHAR(20) DEFAULT \'parent\', + `redirect_on_url_change` VARCHAR(20) DEFAULT \'301\', + `product_redirect_target` VARCHAR(50) DEFAULT \'default_category\', + `disable_attribute_urls` TINYINT(1) DEFAULT 0, + `attribute_url_style` VARCHAR(50) DEFAULT \'group-value\', + `date_add` DATETIME NOT NULL, + `date_upd` DATETIME NOT NULL, + UNIQUE KEY `unique_type_shop` (`entity_type`, `id_shop`), + INDEX `idx_enabled` (`enabled`) + ) ENGINE=' . $engine . ' DEFAULT CHARSET=utf8mb4 + '); + } + + /** + * Migrate mprseorevolution's url_override table to add missing columns + * Safe to call multiple times — uses IF NOT EXISTS / column existence checks + * + * @return bool + */ + public function migrateOverrideTable() + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + $db = \Db::getInstance(); + + $columns = $this->getTableColumns($prefix . 'url_override'); + + $success = true; + + if (!in_array('id_override_group', $columns)) { + $success = $success && $db->execute(' + ALTER TABLE `' . $prefix . 'url_override` + ADD COLUMN `id_override_group` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `id_shop`, + ADD INDEX `idx_override_group` (`id_override_group`) + '); + } + + if (!in_array('target_entity_type', $columns)) { + $success = $success && $db->execute(' + ALTER TABLE `' . $prefix . 'url_override` + ADD COLUMN `target_entity_type` VARCHAR(50) DEFAULT NULL AFTER `locked` + '); + } + + if (!in_array('target_id_entity', $columns)) { + $success = $success && $db->execute(' + ALTER TABLE `' . $prefix . 'url_override` + ADD COLUMN `target_id_entity` INT DEFAULT 0 AFTER `target_entity_type` + '); + } + + if (!in_array('redirect_type', $columns)) { + $success = $success && $db->execute(' + ALTER TABLE `' . $prefix . 'url_override` + ADD COLUMN `redirect_type` ENUM(\'301\', \'302\', \'307\') DEFAULT \'301\' AFTER `target_id_entity` + '); + } + + if (!in_array('target_custom_url', $columns)) { + $success = $success && $db->execute(' + ALTER TABLE `' . $prefix . 'url_override` + ADD COLUMN `target_custom_url` VARCHAR(500) DEFAULT NULL AFTER `redirect_type` + '); + } + + return $success; + } + + /** + * Migrate url_history table to add entity_disabled change_reason + * + * @return bool + */ + public function migrateHistoryTable() + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + $db = \Db::getInstance(); + + // Check if entity_disabled is already in the ENUM + $columnInfo = $db->getRow(' + SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = "' . pSQL($prefix) . 'url_history" + AND COLUMN_NAME = "change_reason" + '); + + if ($columnInfo && strpos($columnInfo['COLUMN_TYPE'], 'entity_disabled') === false) { + return $db->execute(' + ALTER TABLE `' . $prefix . 'url_history` + MODIFY COLUMN `change_reason` + ENUM(\'pattern_change\', \'manual_edit\', \'entity_update\', \'entity_delete\', \'bulk_regenerate\', \'entity_disabled\') + DEFAULT \'entity_update\' + '); + } + + return true; + } + + /** + * Run all migrations (safe to call multiple times) + * + * @return bool + */ + public function migrate() + { + return $this->migrateOverrideTable() + && $this->migrateHistoryTable(); + } + + /** + * Get column names for a table + * + * @param string $tableName Full table name (with ps_ prefix) + * @return array + */ + protected function getTableColumns($tableName) + { + $columns = []; + + $rows = \Db::getInstance()->executeS(' + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = "' . pSQL($tableName) . '" + '); + + foreach ($rows ?: [] as $row) { + $columns[] = $row['COLUMN_NAME']; + } + + return $columns; + } + + /** + * Check if a specific URL table exists + * + * @param string $tableSuffix e.g., 'url_cache', 'url_history' + * @return bool + */ + public function tableExists($tableSuffix) + { + $prefix = _DB_PREFIX_ . pSQL($this->tablePrefix); + $tableName = $prefix . pSQL($tableSuffix); + + $result = \Db::getInstance()->getValue(' + SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = "' . pSQL($tableName) . '" + '); + + return (int) $result > 0; + } +} diff --git a/src/Storage/ConfigPatternStorage.php b/src/Storage/ConfigPatternStorage.php new file mode 100644 index 0000000..2aaf7c3 --- /dev/null +++ b/src/Storage/ConfigPatternStorage.php @@ -0,0 +1,82 @@ +moduleName = $moduleName; + $this->defaults = $defaults; + } + + /** + * {@inheritdoc} + */ + public function getPattern($entityType, $idShop) + { + $configKey = 'pattern.' . $entityType; + $stored = ConfigTable::get($this->moduleName, $configKey, $idShop); + + if ($stored) { + $pattern = json_decode($stored, true); + if (is_array($pattern)) { + return $pattern; + } + } + + return $this->defaults[$entityType] ?? null; + } + + /** + * {@inheritdoc} + */ + public function setPattern($entityType, $idShop, array $data) + { + $configKey = 'pattern.' . $entityType; + + return ConfigTable::set($this->moduleName, $configKey, json_encode($data), $idShop); + } + + /** + * {@inheritdoc} + */ + public function getAllPatterns($idShop) + { + $entityTypes = ['product', 'category', 'cms', 'cms_category', 'manufacturer', 'supplier']; + $patterns = []; + + foreach ($entityTypes as $type) { + $pattern = $this->getPattern($type, $idShop); + if ($pattern) { + $patterns[$type] = $pattern; + } + } + + return $patterns; + } +} diff --git a/src/Storage/PatternStorageInterface.php b/src/Storage/PatternStorageInterface.php new file mode 100644 index 0000000..18ae79c --- /dev/null +++ b/src/Storage/PatternStorageInterface.php @@ -0,0 +1,45 @@ +tablePrefix = $tablePrefix; + $this->defaults = $defaults; + } + + /** + * {@inheritdoc} + */ + public function getPattern($entityType, $idShop) + { + $row = \Db::getInstance()->getRow(' + SELECT * FROM `' . _DB_PREFIX_ . pSQL($this->tablePrefix) . 'url_pattern` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_shop = ' . (int) $idShop + ); + + if ($row) { + // Convert DB row to config array (same keys as ConfigPatternStorage) + $pattern = []; + foreach (self::$columns as $col) { + if (array_key_exists($col, $row)) { + $pattern[$col] = $row[$col]; + } + } + + return $pattern; + } + + return $this->defaults[$entityType] ?? null; + } + + /** + * {@inheritdoc} + */ + public function setPattern($entityType, $idShop, array $data) + { + $existing = \Db::getInstance()->getRow(' + SELECT id_pattern FROM `' . _DB_PREFIX_ . pSQL($this->tablePrefix) . 'url_pattern` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_shop = ' . (int) $idShop + ); + + $dbData = []; + foreach (self::$columns as $col) { + if (array_key_exists($col, $data)) { + $dbData[$col] = pSQL($data[$col]); + } + } + $dbData['date_upd'] = date('Y-m-d H:i:s'); + + if ($existing) { + return \Db::getInstance()->update( + pSQL($this->tablePrefix) . 'url_pattern', + $dbData, + 'id_pattern = ' . (int) $existing['id_pattern'] + ); + } + + $dbData['entity_type'] = pSQL($entityType); + $dbData['id_shop'] = (int) $idShop; + $dbData['date_add'] = date('Y-m-d H:i:s'); + + return \Db::getInstance()->insert( + pSQL($this->tablePrefix) . 'url_pattern', + $dbData + ); + } + + /** + * {@inheritdoc} + */ + public function getAllPatterns($idShop) + { + $rows = \Db::getInstance()->executeS(' + SELECT * FROM `' . _DB_PREFIX_ . pSQL($this->tablePrefix) . 'url_pattern` + WHERE id_shop = ' . (int) $idShop + ); + + $patterns = []; + foreach ($rows ?: [] as $row) { + $pattern = []; + foreach (self::$columns as $col) { + if (array_key_exists($col, $row)) { + $pattern[$col] = $row[$col]; + } + } + $patterns[$row['entity_type']] = $pattern; + } + + return $patterns; + } +} diff --git a/src/UrlPatternManager.php b/src/UrlPatternManager.php new file mode 100644 index 0000000..829a38c --- /dev/null +++ b/src/UrlPatternManager.php @@ -0,0 +1,2005 @@ + [ + '{id}' => 'Product ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Product name (alias for rewrite)', + '{reference}' => 'Product reference/SKU', + '{ean13}' => 'EAN13 barcode', + '{upc}' => 'UPC code', + '{isbn}' => 'ISBN number', + '{mpn}' => 'Manufacturer Part Number', + '{category}' => 'Default category name', + '{category_id}' => 'Default category ID', + '{categories}' => 'Full category path (parent/child/subchild)', + '{brand}' => 'Manufacturer/Brand name', + '{brand_id}' => 'Manufacturer ID', + '{supplier}' => 'Supplier name', + '{supplier_id}' => 'Supplier ID', + '{attributes}' => 'Combination attributes (color-size)', + '{attribute:color}' => 'Specific attribute by name', + '{attribute:size}' => 'Specific attribute by name', + '{id_product_attribute}' => 'Combination ID', + '{price}' => 'Product price (formatted)', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + '{tags}' => 'Product tags (hyphen-separated)', + ], + 'category' => [ + '{id}' => 'Category ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Category name (alias for rewrite)', + '{parent}' => 'Parent category name', + '{parent_id}' => 'Parent category ID', + '{parents}' => 'Full parent path', + '{level_depth}' => 'Category depth level', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + ], + 'cms' => [ + '{id}' => 'CMS page ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Page name (alias for rewrite)', + '{category}' => 'CMS category name (direct parent)', + '{categories}' => 'Full CMS category path (when enabled)', + '{category_id}' => 'CMS category ID', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + ], + 'cms_category' => [ + '{id}' => 'CMS Category ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Category name (alias for rewrite)', + '{parent}' => 'Parent category name', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + ], + 'manufacturer' => [ + '{id}' => 'Manufacturer ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Manufacturer name (alias for rewrite)', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + ], + 'supplier' => [ + '{id}' => 'Supplier ID', + '{rewrite}' => 'Current link_rewrite (recommended)', + '{name}' => 'Supplier name (alias for rewrite)', + '{meta_title}' => 'Meta title', + '{meta_keywords}' => 'Meta keywords', + ], + ]; + + /** @var array Default patterns per entity type */ + protected static $defaultPatterns = [ + 'product' => '{rewrite}', + 'category' => '{rewrite}', + 'cms' => '{rewrite}', + 'cms_category' => '{rewrite}', + 'manufacturer' => '{rewrite}', + 'supplier' => '{rewrite}', + ]; + + /** + * Constructor + * + * @param PatternStorageInterface $storage Pattern config storage backend + * @param string $tablePrefix Table prefix for URL tables (e.g., 'mprfurl_' or 'mprseo_') + * @param int|null $idShop + * @param int|null $idLang + */ + public function __construct(PatternStorageInterface $storage, $tablePrefix, $idShop = null, $idLang = null) + { + $this->storage = $storage; + $this->tablePrefix = $tablePrefix; + $this->idShop = $idShop ?: (int) \Context::getContext()->shop->id; + $this->idLang = $idLang ?: (int) \Context::getContext()->language->id; + } + + /** + * Get the table prefix + * + * @return string + */ + public function getTablePrefix() + { + return $this->tablePrefix; + } + + /** + * Get available placeholders for an entity type + * + * @param string $entityType + * @return array + */ + public static function getAvailablePlaceholders($entityType) + { + return self::$availablePlaceholders[$entityType] ?? []; + } + + /** + * Get all entity types that support URL patterns + * + * @return array + */ + public static function getSupportedEntityTypes() + { + return array_keys(self::$availablePlaceholders); + } + + /** + * Get the URL pattern configuration for an entity type + * + * @param string $entityType + * @param int|null $idShop + * @return array + */ + public function getPattern($entityType, $idShop = null) + { + $idShop = $idShop ?: $this->idShop; + $cacheKey = $this->tablePrefix . $entityType . '_' . $idShop; + + if (isset(self::$patterns[$cacheKey])) { + return self::$patterns[$cacheKey]; + } + + $stored = $this->storage->getPattern($entityType, $idShop); + + if ($stored && is_array($stored)) { + $pattern = array_merge($this->getDefaultPatternConfig($entityType), $stored); + $pattern['entity_type'] = $entityType; + $pattern['id_shop'] = $idShop; + self::$patterns[$cacheKey] = $pattern; + + return $pattern; + } + + // Return default pattern structure + $pattern = $this->getDefaultPatternConfig($entityType); + $pattern['entity_type'] = $entityType; + $pattern['id_shop'] = $idShop; + + self::$patterns[$cacheKey] = $pattern; + + return $pattern; + } + + /** + * Get default pattern configuration + * + * @param string $entityType + * @return array + */ + protected function getDefaultPatternConfig($entityType) + { + return [ + 'enabled' => 1, + 'pattern' => self::$defaultPatterns[$entityType] ?? '{rewrite}', + 'suffix' => '', + 'include_parent_categories' => 0, + 'category_separator' => '/', + 'max_category_depth' => 0, + 'lowercase' => 1, + 'replace_spaces' => '-', + 'remove_accents' => 1, + 'remove_special_chars' => 1, + 'collision_suffix' => 'id', + 'cache_enabled' => 1, + 'cache_ttl' => 86400, + 'redirect_on_delete' => 'parent', + 'redirect_on_disable' => 'parent', + 'redirect_on_url_change' => '301', + 'product_redirect_target' => 'default_category', + 'include_cms_category' => 0, + 'disable_attribute_urls' => 0, + 'attribute_url_style' => 'none', + ]; + } + + /** + * Check if custom URL patterns are enabled for an entity type + * + * @param string $entityType + * @param int|null $idShop + * @return bool + */ + public function isEnabled($entityType, $idShop = null) + { + $pattern = $this->getPattern($entityType, $idShop); + + return !empty($pattern['enabled']); + } + + /** + * Enable or disable custom URL patterns for an entity type + * + * @param string $entityType + * @param int $idShop + * @param bool $enabled + * @return bool + */ + public function setEnabled($entityType, $idShop, $enabled) + { + $pattern = $this->getPattern($entityType, $idShop); + $pattern['enabled'] = (int) $enabled; + + // Clear cache + $cacheKey = $this->tablePrefix . $entityType . '_' . $idShop; + unset(self::$patterns[$cacheKey]); + + $configData = $pattern; + unset($configData['entity_type'], $configData['id_shop']); + + return $this->storage->setPattern($entityType, $idShop, $configData); + } + + /** + * Save URL pattern configuration + * + * @param string $entityType + * @param int $idShop + * @param array $data + * @return bool + */ + public function savePattern($entityType, $idShop, array $data) + { + $configData = [ + 'enabled' => isset($data['enabled']) ? (int) $data['enabled'] : 1, + 'pattern' => $data['pattern'] ?? self::$defaultPatterns[$entityType] ?? '{name}', + 'suffix' => $data['suffix'] ?? '', + 'include_parent_categories' => isset($data['include_parent_categories']) ? (int) $data['include_parent_categories'] : 0, + 'category_separator' => $data['category_separator'] ?? '/', + 'max_category_depth' => isset($data['max_category_depth']) ? (int) $data['max_category_depth'] : 0, + 'lowercase' => isset($data['lowercase']) ? (int) $data['lowercase'] : 1, + 'replace_spaces' => $data['replace_spaces'] ?? '-', + 'remove_accents' => isset($data['remove_accents']) ? (int) $data['remove_accents'] : 1, + 'remove_special_chars' => isset($data['remove_special_chars']) ? (int) $data['remove_special_chars'] : 1, + 'collision_suffix' => $data['collision_suffix'] ?? 'id', + 'cache_enabled' => isset($data['cache_enabled']) ? (int) $data['cache_enabled'] : 1, + 'cache_ttl' => isset($data['cache_ttl']) ? (int) $data['cache_ttl'] : 86400, + 'redirect_on_delete' => $data['redirect_on_delete'] ?? 'parent', + 'redirect_on_disable' => $data['redirect_on_disable'] ?? 'parent', + 'redirect_on_url_change' => $data['redirect_on_url_change'] ?? '301', + 'product_redirect_target' => $data['product_redirect_target'] ?? 'default_category', + 'include_cms_category' => isset($data['include_cms_category']) ? (int) $data['include_cms_category'] : 0, + ]; + + if ($entityType === 'product') { + $configData['disable_attribute_urls'] = isset($data['disable_attribute_urls']) ? (int) $data['disable_attribute_urls'] : 0; + $configData['attribute_url_style'] = $data['attribute_url_style'] ?? 'none'; + } + + // Clear cache + $cacheKey = $this->tablePrefix . $entityType . '_' . $idShop; + unset(self::$patterns[$cacheKey]); + + return $this->storage->setPattern($entityType, $idShop, $configData); + } + + /** + * Convert module pattern syntax to PrestaShop route syntax + * + * @param string $entityType + * @param string $pattern + * @param string $suffix + * @return string + */ + public function convertPatternToRoute($entityType, $pattern, $suffix = '') + { + $mappings = [ + '{name}' => '{rewrite}', + '{rewrite}' => '{rewrite}', + '{category}' => '{category:/}', + '{categories}' => '{categories:/}', + '{brand}' => '{manufacturer}', + '{manufacturer}' => '{manufacturer}', + '{supplier}' => '{supplier}', + '{reference}' => '{reference}', + '{ean13}' => '{-:ean13}', + '{upc}' => '{-:upc}', + '{isbn}' => '{-:isbn}', + '{mpn}' => '{-:mpn}', + '{id}' => '{id}', + '{price}' => '{price}', + '{meta_title}' => '{meta_title}', + '{meta_keywords}' => '{meta_keywords}', + '{tags}' => '{tags}', + '{parent}' => '{parent:/}', + '{parents}' => '{parents:/}', + ]; + + $route = $pattern; + + foreach ($mappings as $moduleKey => $psKey) { + $route = str_replace($moduleKey, $psKey, $route); + } + + $route = preg_replace('/\{attribute:[^}]+\}/', '', $route); + $route = preg_replace('/\{feature:[^}]+\}/', '', $route); + $route = preg_replace('#/+#', '/', $route); + $route = trim($route, '/'); + + if ($suffix) { + $route .= $suffix; + } + + return $route; + } + + /** + * Generate URL for an entity based on pattern + * + * @param string $entityType + * @param int $idEntity + * @param int|null $idLang + * @param int|null $idProductAttribute + * @return string|null + */ + public function generateUrl($entityType, $idEntity, $idLang = null, $idProductAttribute = null) + { + $idLang = $idLang ?: $this->idLang; + + // Check for custom override first + $override = $this->getOverride($entityType, $idEntity, $idLang); + if ($override) { + return $this->applyUrlFormatting($override['custom_url'], $entityType); + } + + // Check cache + $cached = $this->getCachedUrl($entityType, $idEntity, $idLang); + if ($cached) { + return $cached['url']; + } + + // Get pattern + $patternConfig = $this->getPattern($entityType); + if (!$patternConfig['enabled']) { + return null; + } + + // Get entity data + $entityData = $this->getEntityData($entityType, $idEntity, $idLang, $idProductAttribute); + if (!$entityData) { + return null; + } + + // Replace placeholders + $url = $this->replacePlaceholders($patternConfig['pattern'], $entityData, $patternConfig); + + // Apply formatting + $url = $this->applyUrlFormatting($url, $entityType, $patternConfig); + + // Handle collisions + $url = $this->handleCollision($url, $entityType, $idEntity, $idLang, $patternConfig); + + // Cache the result + if ($patternConfig['cache_enabled']) { + $this->cacheUrl($entityType, $idEntity, $idLang, $url, 'pattern'); + } + + return $url; + } + + /** + * Get entity data for placeholder replacement + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param int|null $idProductAttribute + * @return array|null + */ + protected function getEntityData($entityType, $idEntity, $idLang, $idProductAttribute = null) + { + switch ($entityType) { + case 'product': + return $this->getProductData($idEntity, $idLang, $idProductAttribute); + case 'category': + return $this->getCategoryData($idEntity, $idLang); + case 'cms': + return $this->getCmsData($idEntity, $idLang); + case 'cms_category': + return $this->getCmsCategoryData($idEntity, $idLang); + case 'manufacturer': + return $this->getManufacturerData($idEntity, $idLang); + case 'supplier': + return $this->getSupplierData($idEntity, $idLang); + default: + return null; + } + } + + /** + * Get product data for URL generation + * + * @param int $idProduct + * @param int $idLang + * @param int|null $idProductAttribute + * @return array|null + */ + protected function getProductData($idProduct, $idLang, $idProductAttribute = null) + { + $sql = ' + SELECT + p.id_product, + p.reference, + p.ean13, + p.upc, + p.isbn, + p.mpn, + p.id_category_default, + p.id_manufacturer, + p.id_supplier, + p.price, + pl.name, + pl.link_rewrite, + pl.meta_title, + pl.meta_keywords, + cl.name as category_name, + cl.link_rewrite as category_rewrite, + m.name as manufacturer_name, + s.name as supplier_name + FROM `' . _DB_PREFIX_ . 'product` p + LEFT JOIN `' . _DB_PREFIX_ . 'product_lang` pl + ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $this->idShop . ' + LEFT JOIN `' . _DB_PREFIX_ . 'category_lang` cl + ON cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $this->idShop . ' + LEFT JOIN `' . _DB_PREFIX_ . 'manufacturer` m + ON m.id_manufacturer = p.id_manufacturer + LEFT JOIN `' . _DB_PREFIX_ . 'supplier` s + ON s.id_supplier = p.id_supplier + WHERE p.id_product = ' . (int) $idProduct; + + $product = \Db::getInstance()->getRow($sql); + + if (!$product) { + return null; + } + + $data = [ + 'id' => $product['id_product'], + 'rewrite' => $product['link_rewrite'], + 'name' => $product['link_rewrite'], + 'reference' => $product['reference'], + 'ean13' => $product['ean13'], + 'upc' => $product['upc'], + 'isbn' => $product['isbn'], + 'mpn' => $product['mpn'], + 'category' => $product['category_rewrite'], + 'category_id' => $product['id_category_default'], + 'brand' => \Tools::str2url($product['manufacturer_name'] ?: ''), + 'brand_id' => $product['id_manufacturer'], + 'supplier' => \Tools::str2url($product['supplier_name'] ?: ''), + 'supplier_id' => $product['id_supplier'], + 'price' => number_format((float) $product['price'], 2, '-', ''), + 'meta_title' => \Tools::str2url($product['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($product['meta_keywords'] ?: ''), + 'tags' => $this->getProductTags($idProduct, $idLang), + ]; + + $data['categories'] = $this->getCategoryPath($product['id_category_default'], $idLang); + + if ($idProductAttribute) { + $data['id_product_attribute'] = $idProductAttribute; + $data['attributes'] = $this->getProductAttributeString($idProduct, $idProductAttribute, $idLang); + + $attributes = $this->getProductAttributes($idProduct, $idProductAttribute, $idLang); + foreach ($attributes as $attrName => $attrValue) { + $data['attribute:' . strtolower($attrName)] = $attrValue; + } + } + + return $data; + } + + /** + * Get product tags as hyphen-separated string + * + * @param int $idProduct + * @param int $idLang + * @return string + */ + protected function getProductTags($idProduct, $idLang) + { + $tags = \Db::getInstance()->executeS(' + SELECT t.name + FROM `' . _DB_PREFIX_ . 'product_tag` pt + JOIN `' . _DB_PREFIX_ . 'tag` t ON t.id_tag = pt.id_tag + WHERE pt.id_product = ' . (int) $idProduct . ' + AND t.id_lang = ' . (int) $idLang . ' + ORDER BY t.name ASC + '); + + if (!$tags) { + return ''; + } + + return implode('-', array_map(function ($t) { + return \Tools::str2url($t['name']); + }, $tags)); + } + + /** + * Get category path for URL + * + * @param int $idCategory + * @param int $idLang + * @return string + */ + protected function getCategoryPath($idCategory, $idLang) + { + $patternConfig = $this->getPattern('product'); + $maxDepth = (int) ($patternConfig['max_category_depth'] ?? 0); + $separator = $patternConfig['category_separator'] ?? '/'; + + $categories = []; + $homeCategory = (int) \Configuration::get('PS_HOME_CATEGORY'); + $rootCategory = (int) \Configuration::get('PS_ROOT_CATEGORY'); + + $category = new \Category($idCategory, $idLang); + + while ($category->id && $category->id != $homeCategory && $category->id != $rootCategory) { + $categories[] = $category->link_rewrite; + $category = new \Category($category->id_parent, $idLang); + + if ($maxDepth > 0 && count($categories) >= $maxDepth) { + break; + } + } + + $categories = array_reverse($categories); + + return implode($separator, $categories); + } + + /** + * Get product attribute string (e.g., "red-large") + * + * @param int $idProduct + * @param int $idProductAttribute + * @param int $idLang + * @return string + */ + protected function getProductAttributeString($idProduct, $idProductAttribute, $idLang) + { + $attributes = $this->getProductAttributes($idProduct, $idProductAttribute, $idLang); + + return implode('-', array_map(function ($value) { + return \Tools::str2url($value); + }, array_values($attributes))); + } + + /** + * Get product attributes as array + * + * @param int $idProduct + * @param int $idProductAttribute + * @param int $idLang + * @return array [attribute_name => attribute_value] + */ + protected function getProductAttributes($idProduct, $idProductAttribute, $idLang) + { + $sql = ' + SELECT agl.name as group_name, al.name as attribute_name + FROM `' . _DB_PREFIX_ . 'product_attribute_combination` pac + INNER JOIN `' . _DB_PREFIX_ . 'attribute` a ON a.id_attribute = pac.id_attribute + INNER JOIN `' . _DB_PREFIX_ . 'attribute_lang` al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . ' + INNER JOIN `' . _DB_PREFIX_ . 'attribute_group_lang` agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . ' + WHERE pac.id_product_attribute = ' . (int) $idProductAttribute . ' + ORDER BY agl.name ASC + '; + + $result = \Db::getInstance()->executeS($sql); + $attributes = []; + + foreach ($result as $row) { + $attributes[$row['group_name']] = $row['attribute_name']; + } + + return $attributes; + } + + /** + * Get category data for URL generation + * + * @param int $idCategory + * @param int $idLang + * @return array|null + */ + protected function getCategoryData($idCategory, $idLang) + { + $sql = ' + SELECT + c.id_category, + c.id_parent, + c.level_depth, + cl.name, + cl.link_rewrite, + cl.meta_title, + cl.meta_keywords, + pcl.link_rewrite as parent_rewrite, + pcl.name as parent_name + FROM `' . _DB_PREFIX_ . 'category` c + LEFT JOIN `' . _DB_PREFIX_ . 'category_lang` cl + ON cl.id_category = c.id_category AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $this->idShop . ' + LEFT JOIN `' . _DB_PREFIX_ . 'category_lang` pcl + ON pcl.id_category = c.id_parent AND pcl.id_lang = ' . (int) $idLang . ' AND pcl.id_shop = ' . (int) $this->idShop . ' + WHERE c.id_category = ' . (int) $idCategory; + + $category = \Db::getInstance()->getRow($sql); + + if (!$category) { + return null; + } + + return [ + 'id' => $category['id_category'], + 'rewrite' => $category['link_rewrite'], + 'name' => $category['link_rewrite'], + 'parent' => $category['parent_rewrite'], + 'parent_id' => $category['id_parent'], + 'parents' => $this->getParentCategoryPath($idCategory, $idLang), + 'level_depth' => $category['level_depth'], + 'meta_title' => \Tools::str2url($category['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($category['meta_keywords'] ?: ''), + ]; + } + + /** + * Get parent category path + * + * @param int $idCategory + * @param int $idLang + * @return string + */ + protected function getParentCategoryPath($idCategory, $idLang) + { + $patternConfig = $this->getPattern('category'); + $maxDepth = (int) ($patternConfig['max_category_depth'] ?? 0); + $separator = $patternConfig['category_separator'] ?? '/'; + + $homeCategory = (int) \Configuration::get('PS_HOME_CATEGORY'); + $rootCategory = (int) \Configuration::get('PS_ROOT_CATEGORY'); + + $category = new \Category($idCategory, $idLang); + $categories = []; + + $category = new \Category($category->id_parent, $idLang); + + while ($category->id && $category->id != $homeCategory && $category->id != $rootCategory) { + $categories[] = $category->link_rewrite; + $category = new \Category($category->id_parent, $idLang); + + if ($maxDepth > 0 && count($categories) >= $maxDepth) { + break; + } + } + + return implode($separator, array_reverse($categories)); + } + + /** + * Get CMS page data + * + * @param int $idCms + * @param int $idLang + * @return array|null + */ + protected function getCmsData($idCms, $idLang) + { + $sql = ' + SELECT + c.id_cms, + c.id_cms_category, + cl.link_rewrite, + cl.meta_title, + cl.meta_keywords, + ccl.link_rewrite as category_rewrite, + ccl.name as category_name + FROM `' . _DB_PREFIX_ . 'cms` c + LEFT JOIN `' . _DB_PREFIX_ . 'cms_lang` cl + ON cl.id_cms = c.id_cms AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $this->idShop . ' + LEFT JOIN `' . _DB_PREFIX_ . 'cms_category_lang` ccl + ON ccl.id_cms_category = c.id_cms_category AND ccl.id_lang = ' . (int) $idLang . ' AND ccl.id_shop = ' . (int) $this->idShop . ' + WHERE c.id_cms = ' . (int) $idCms; + + $cms = \Db::getInstance()->getRow($sql); + + if (!$cms) { + return null; + } + + $data = [ + 'id' => $cms['id_cms'], + 'rewrite' => $cms['link_rewrite'], + 'name' => $cms['link_rewrite'], + 'category' => $cms['category_rewrite'], + 'category_id' => $cms['id_cms_category'], + 'meta_title' => \Tools::str2url($cms['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($cms['meta_keywords'] ?: ''), + ]; + + $patternConfig = $this->getPattern('cms'); + if (!empty($patternConfig['include_cms_category']) && $cms['id_cms_category'] > 1) { + $data['categories'] = $this->getCmsCategoryPath($cms['id_cms_category'], $idLang); + } + + return $data; + } + + /** + * Get CMS category path + * + * @param int $idCmsCategory + * @param int $idLang + * @param string $separator + * @return string + */ + protected function getCmsCategoryPath($idCmsCategory, $idLang, $separator = '/') + { + $categories = []; + $currentId = (int) $idCmsCategory; + + $maxDepth = 10; + while ($currentId > 1 && $maxDepth > 0) { + $category = \Db::getInstance()->getRow(' + SELECT cc.id_parent, ccl.link_rewrite + FROM `' . _DB_PREFIX_ . 'cms_category` cc + LEFT JOIN `' . _DB_PREFIX_ . 'cms_category_lang` ccl + ON ccl.id_cms_category = cc.id_cms_category + AND ccl.id_lang = ' . (int) $idLang . ' + AND ccl.id_shop = ' . (int) $this->idShop . ' + WHERE cc.id_cms_category = ' . $currentId + ); + + if (!$category || empty($category['link_rewrite'])) { + break; + } + + $categories[] = $category['link_rewrite']; + $currentId = (int) $category['id_parent']; + $maxDepth--; + } + + return implode($separator, array_reverse($categories)); + } + + /** + * Get CMS category data + * + * @param int $idCmsCategory + * @param int $idLang + * @return array|null + */ + protected function getCmsCategoryData($idCmsCategory, $idLang) + { + $sql = ' + SELECT + cc.id_cms_category, + cc.id_parent, + ccl.link_rewrite, + ccl.name, + ccl.meta_title, + ccl.meta_keywords, + pccl.link_rewrite as parent_rewrite + FROM `' . _DB_PREFIX_ . 'cms_category` cc + LEFT JOIN `' . _DB_PREFIX_ . 'cms_category_lang` ccl + ON ccl.id_cms_category = cc.id_cms_category AND ccl.id_lang = ' . (int) $idLang . ' AND ccl.id_shop = ' . (int) $this->idShop . ' + LEFT JOIN `' . _DB_PREFIX_ . 'cms_category_lang` pccl + ON pccl.id_cms_category = cc.id_parent AND pccl.id_lang = ' . (int) $idLang . ' AND pccl.id_shop = ' . (int) $this->idShop . ' + WHERE cc.id_cms_category = ' . (int) $idCmsCategory; + + $category = \Db::getInstance()->getRow($sql); + + if (!$category) { + return null; + } + + return [ + 'id' => $category['id_cms_category'], + 'rewrite' => $category['link_rewrite'], + 'name' => $category['link_rewrite'], + 'parent' => $category['parent_rewrite'], + 'meta_title' => \Tools::str2url($category['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($category['meta_keywords'] ?: ''), + ]; + } + + /** + * Get manufacturer data + * + * @param int $idManufacturer + * @param int $idLang + * @return array|null + */ + protected function getManufacturerData($idManufacturer, $idLang) + { + $sql = ' + SELECT + m.id_manufacturer, + m.name, + ml.meta_title, + ml.meta_keywords + FROM `' . _DB_PREFIX_ . 'manufacturer` m + LEFT JOIN `' . _DB_PREFIX_ . 'manufacturer_lang` ml + ON ml.id_manufacturer = m.id_manufacturer AND ml.id_lang = ' . (int) $idLang . ' + WHERE m.id_manufacturer = ' . (int) $idManufacturer; + + $manufacturer = \Db::getInstance()->getRow($sql); + + if (!$manufacturer) { + return null; + } + + $rewrite = \Tools::str2url($manufacturer['name']); + + return [ + 'id' => $manufacturer['id_manufacturer'], + 'rewrite' => $rewrite, + 'name' => $rewrite, + 'meta_title' => \Tools::str2url($manufacturer['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($manufacturer['meta_keywords'] ?: ''), + ]; + } + + /** + * Get supplier data + * + * @param int $idSupplier + * @param int $idLang + * @return array|null + */ + protected function getSupplierData($idSupplier, $idLang) + { + $sql = ' + SELECT + s.id_supplier, + s.name, + sl.meta_title, + sl.meta_keywords + FROM `' . _DB_PREFIX_ . 'supplier` s + LEFT JOIN `' . _DB_PREFIX_ . 'supplier_lang` sl + ON sl.id_supplier = s.id_supplier AND sl.id_lang = ' . (int) $idLang . ' + WHERE s.id_supplier = ' . (int) $idSupplier; + + $supplier = \Db::getInstance()->getRow($sql); + + if (!$supplier) { + return null; + } + + $rewrite = \Tools::str2url($supplier['name']); + + return [ + 'id' => $supplier['id_supplier'], + 'rewrite' => $rewrite, + 'name' => $rewrite, + 'meta_title' => \Tools::str2url($supplier['meta_title'] ?: ''), + 'meta_keywords' => \Tools::str2url($supplier['meta_keywords'] ?: ''), + ]; + } + + /** + * Replace placeholders in pattern with actual values + * + * @param string $pattern + * @param array $data + * @param array $config + * @return string + */ + protected function replacePlaceholders($pattern, array $data, array $config) + { + // Handle conditional attribute placeholders like {attribute:color} + $pattern = preg_replace_callback('/\{attribute:([^}]+)\}/', function ($matches) use ($data) { + $attrKey = 'attribute:' . strtolower($matches[1]); + + return $data[$attrKey] ?? ''; + }, $pattern); + + // Replace standard placeholders + foreach ($data as $key => $value) { + if (strpos($key, 'attribute:') === 0) { + continue; + } + $pattern = str_replace('{' . $key . '}', $value, $pattern); + } + + // Remove any remaining unmatched placeholders + $pattern = preg_replace('/\{[^}]+\}/', '', $pattern); + + // Clean up multiple slashes + $pattern = preg_replace('#/+#', '/', $pattern); + + // Remove leading/trailing slashes + $pattern = trim($pattern, '/'); + + return $pattern; + } + + /** + * Apply URL formatting rules + * + * @param string $url + * @param string $entityType + * @param array|null $config + * @return string + */ + protected function applyUrlFormatting($url, $entityType, array $config = null) + { + if (!$config) { + $config = $this->getPattern($entityType); + } + + if (!empty($config['lowercase'])) { + $url = strtolower($url); + } + + if (!empty($config['remove_accents'])) { + $url = \Tools::replaceAccentedChars($url); + } + + $replaceSpaces = $config['replace_spaces'] ?? '-'; + $url = str_replace(' ', $replaceSpaces, $url); + + if (!empty($config['remove_special_chars'])) { + $url = preg_replace('/[^a-zA-Z0-9\-\/]/', '', $url); + } + + $url = preg_replace('/-+/', '-', $url); + $url = str_replace(['-/', '/-'], '/', $url); + + if (!empty($config['suffix'])) { + $url .= $config['suffix']; + } + + return $url; + } + + /** + * Handle URL collision (duplicate URLs) + * + * @param string $url + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param array $config + * @return string + */ + protected function handleCollision($url, $entityType, $idEntity, $idLang, array $config) + { + $urlHash = md5($url); + $tp = $this->tablePrefix; + + // Check if URL exists for different entity + $existing = \Db::getInstance()->getRow(' + SELECT entity_type, id_entity FROM `' . _DB_PREFIX_ . $tp . 'url_cache` + WHERE url_hash = "' . pSQL($urlHash) . '" + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop . ' + AND NOT (entity_type = "' . pSQL($entityType) . '" AND id_entity = ' . (int) $idEntity . ') + '); + + if (!$existing) { + $existing = \Db::getInstance()->getRow(' + SELECT entity_type, id_entity FROM `' . _DB_PREFIX_ . $tp . 'url_override` + WHERE custom_url_hash = "' . pSQL($urlHash) . '" + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop . ' + AND NOT (entity_type = "' . pSQL($entityType) . '" AND id_entity = ' . (int) $idEntity . ') + '); + } + + if (!$existing) { + return $url; + } + + // Collision detected - add suffix + $suffix = $config['suffix'] ?? ''; + $baseUrl = $suffix ? substr($url, 0, -strlen($suffix)) : $url; + + switch ($config['collision_suffix'] ?? 'id') { + case 'id': + return $baseUrl . '-' . $idEntity . $suffix; + + case 'sku': + if ($entityType === 'product') { + $reference = \Db::getInstance()->getValue(' + SELECT reference FROM `' . _DB_PREFIX_ . 'product` + WHERE id_product = ' . (int) $idEntity + ); + if ($reference) { + return $baseUrl . '-' . \Tools::str2url($reference) . $suffix; + } + } + + return $baseUrl . '-' . $idEntity . $suffix; + + case 'ean13': + if ($entityType === 'product') { + $ean13 = \Db::getInstance()->getValue(' + SELECT ean13 FROM `' . _DB_PREFIX_ . 'product` + WHERE id_product = ' . (int) $idEntity + ); + if ($ean13) { + return $baseUrl . '-' . $ean13 . $suffix; + } + } + + return $baseUrl . '-' . $idEntity . $suffix; + + case 'increment': + default: + $counter = 2; + do { + $newUrl = $baseUrl . '-' . $counter . $suffix; + $newHash = md5($newUrl); + $counter++; + + $exists = \Db::getInstance()->getValue(' + SELECT 1 FROM `' . _DB_PREFIX_ . $tp . 'url_cache` + WHERE url_hash = "' . pSQL($newHash) . '" + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + } while ($exists && $counter < 100); + + return $newUrl; + } + } + + /** + * Get custom URL override for an entity + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return array|null + */ + public function getOverride($entityType, $idEntity, $idLang = null) + { + $idLang = $idLang ?: $this->idLang; + + return \Db::getInstance()->getRow(' + SELECT * FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_override` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + } + + /** + * Set custom URL override for an entity + * + * @param string $entityType + * @param int $idEntity + * @param string $customUrl + * @param int|null $idLang + * @param bool $locked + * @return bool + */ + public function setOverride($entityType, $idEntity, $customUrl, $idLang = null, $locked = true) + { + $idLang = $idLang ?: $this->idLang; + $now = date('Y-m-d H:i:s'); + + $currentUrl = $this->getCurrentUrl($entityType, $idEntity, $idLang); + + $customUrl = $this->applyUrlFormatting($customUrl, $entityType); + $urlHash = md5($customUrl); + + $existing = $this->getOverride($entityType, $idEntity, $idLang); + + $data = [ + 'custom_url' => pSQL($customUrl), + 'custom_url_hash' => pSQL($urlHash), + 'locked' => (int) $locked, + 'date_upd' => $now, + ]; + + $success = false; + if ($existing) { + $success = \Db::getInstance()->update( + $this->tablePrefix . 'url_override', + $data, + 'id_override = ' . (int) $existing['id_override'] + ); + } else { + $data['entity_type'] = pSQL($entityType); + $data['id_entity'] = (int) $idEntity; + $data['id_lang'] = (int) $idLang; + $data['id_shop'] = (int) $this->idShop; + $data['date_add'] = $now; + + $success = \Db::getInstance()->insert($this->tablePrefix . 'url_override', $data); + } + + if ($success && $currentUrl && $currentUrl !== $customUrl) { + $this->recordUrlChange($entityType, $idEntity, $idLang, $currentUrl, $customUrl, 'manual_edit'); + } + + if ($success) { + $this->cacheUrl($entityType, $idEntity, $idLang, $customUrl, 'override'); + } + + return $success; + } + + /** + * Remove custom URL override + * + * @param string $entityType + * @param int $idEntity + * @param int|null $idLang + * @return bool + */ + public function removeOverride($entityType, $idEntity, $idLang = null) + { + $idLang = $idLang ?: $this->idLang; + + return \Db::getInstance()->delete( + $this->tablePrefix . 'url_override', + 'entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + } + + /** + * Get cached URL + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return array|null + */ + protected function getCachedUrl($entityType, $idEntity, $idLang) + { + $patternConfig = $this->getPattern($entityType); + + if (empty($patternConfig['cache_enabled'])) { + return null; + } + + $cache = \Db::getInstance()->getRow(' + SELECT * FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_cache` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + + if (!$cache) { + return null; + } + + $ttl = (int) ($patternConfig['cache_ttl'] ?? 86400); + $cacheTime = strtotime($cache['date_upd']); + + if (time() - $cacheTime > $ttl) { + return null; + } + + return $cache; + } + + /** + * Cache a generated URL + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param string $url + * @param string $source + * @return bool + */ + public function cacheUrl($entityType, $idEntity, $idLang, $url, $source = 'pattern') + { + $now = date('Y-m-d H:i:s'); + $urlHash = md5($url); + $tp = $this->tablePrefix; + + $baseUrl = \Context::getContext()->shop->getBaseURL(true); + $langIso = \Language::getIsoById($idLang); + $fullUrl = $baseUrl . $langIso . '/' . $url; + + $existing = \Db::getInstance()->getValue(' + SELECT id_cache FROM `' . _DB_PREFIX_ . $tp . 'url_cache` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + + $data = [ + 'url' => pSQL($url), + 'url_hash' => pSQL($urlHash), + 'full_url' => pSQL($fullUrl), + 'source' => pSQL($source), + 'date_upd' => $now, + ]; + + if ($existing) { + return \Db::getInstance()->update( + $tp . 'url_cache', + $data, + 'id_cache = ' . (int) $existing + ); + } + + $data['entity_type'] = pSQL($entityType); + $data['id_entity'] = (int) $idEntity; + $data['id_lang'] = (int) $idLang; + $data['id_shop'] = (int) $this->idShop; + $data['pattern_version'] = 1; + $data['date_add'] = $now; + + return \Db::getInstance()->insert($tp . 'url_cache', $data); + } + + /** + * Get current URL for an entity (from cache or override) + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return string|null + */ + public function getCurrentUrl($entityType, $idEntity, $idLang = null) + { + $idLang = $idLang ?: $this->idLang; + + $cached = \Db::getInstance()->getValue(' + SELECT url FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_cache` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop + ); + + if ($cached) { + return $cached; + } + + $override = $this->getOverride($entityType, $idEntity, $idLang); + if ($override) { + return $override['custom_url']; + } + + return null; + } + + /** + * Record a URL change in history for redirect handling + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param string $oldUrl + * @param string|null $newUrl + * @param string $reason + * @param string $redirectType + * @return bool + */ + public function recordUrlChange($entityType, $idEntity, $idLang, $oldUrl, $newUrl = null, $reason = 'entity_update', $redirectType = '301') + { + $now = date('Y-m-d H:i:s'); + $oldUrlHash = md5($oldUrl); + + return \Db::getInstance()->insert($this->tablePrefix . 'url_history', [ + 'entity_type' => pSQL($entityType), + 'id_entity' => (int) $idEntity, + 'id_lang' => (int) $idLang, + 'id_shop' => (int) $this->idShop, + 'old_url' => pSQL($oldUrl), + 'old_url_hash' => pSQL($oldUrlHash), + 'new_url' => $newUrl ? pSQL($newUrl) : null, + 'redirect_type' => pSQL($redirectType), + 'redirect_enabled' => 1, + 'change_reason' => pSQL($reason), + 'date_add' => $now, + ]); + } + + /** + * Find redirect target for an old URL + * + * @param string $url + * @param int $idLang + * @return array|null [new_url, redirect_type] + */ + public function findRedirect($url, $idLang = null) + { + $idLang = $idLang ?: $this->idLang; + $urlHash = md5($url); + + $redirect = \Db::getInstance()->getRow(' + SELECT new_url, redirect_type, id_history, entity_type, id_entity + FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_history` + WHERE old_url_hash = "' . pSQL($urlHash) . '" + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop . ' + AND redirect_enabled = 1 + AND new_url IS NOT NULL + ORDER BY date_add DESC + '); + + if ($redirect) { + \Db::getInstance()->execute(' + UPDATE `' . _DB_PREFIX_ . $this->tablePrefix . 'url_history` + SET hits = hits + 1, last_hit = NOW() + WHERE id_history = ' . (int) $redirect['id_history'] + ); + + $fullUrl = $this->buildEntityUrlPath( + $redirect['entity_type'], + (int) $redirect['id_entity'], + $redirect['new_url'], + $idLang + ); + + return [ + 'new_url' => $fullUrl, + 'redirect_type' => $redirect['redirect_type'], + ]; + } + + return null; + } + + /** + * Build full URL path for an entity based on current pattern + * + * @param string $entityType + * @param int $idEntity + * @param string $linkRewrite + * @param int $idLang + * @return string + */ + protected function buildEntityUrlPath($entityType, $idEntity, $linkRewrite, $idLang) + { + $pattern = $this->getPattern($entityType); + + if (empty($pattern['enabled']) || $pattern['pattern'] === '{rewrite}' || $pattern['pattern'] === '{name}') { + return $linkRewrite; + } + + if ($entityType === 'product' && strpos($pattern['pattern'], '{category}') !== false) { + $categoryRewrite = $this->getProductCategoryRewrite($idEntity, $idLang); + if ($categoryRewrite) { + return $categoryRewrite . '/' . $linkRewrite; + } + } + + if ($entityType === 'category' && strpos($pattern['pattern'], '{parent}') !== false) { + $parentRewrite = $this->getCategoryParentRewrite($idEntity, $idLang); + if ($parentRewrite) { + return $parentRewrite . '/' . $linkRewrite; + } + } + + return $linkRewrite; + } + + /** + * Get product's default category link_rewrite + * + * @param int $idProduct + * @param int $idLang + * @return string|null + */ + protected function getProductCategoryRewrite($idProduct, $idLang) + { + return \Db::getInstance()->getValue(' + SELECT cl.link_rewrite + FROM `' . _DB_PREFIX_ . 'product` p + INNER JOIN `' . _DB_PREFIX_ . 'category_lang` cl + ON cl.id_category = p.id_category_default + AND cl.id_lang = ' . (int) $idLang . ' + AND cl.id_shop = ' . (int) $this->idShop . ' + WHERE p.id_product = ' . (int) $idProduct + ); + } + + /** + * Get category's parent link_rewrite + * + * @param int $idCategory + * @param int $idLang + * @return string|null + */ + protected function getCategoryParentRewrite($idCategory, $idLang) + { + return \Db::getInstance()->getValue(' + SELECT cl.link_rewrite + FROM `' . _DB_PREFIX_ . 'category` c + INNER JOIN `' . _DB_PREFIX_ . 'category_lang` cl + ON cl.id_category = c.id_parent + AND cl.id_lang = ' . (int) $idLang . ' + AND cl.id_shop = ' . (int) $this->idShop . ' + WHERE c.id_category = ' . (int) $idCategory . ' + AND c.id_parent > 2' + ); + } + + /** + * Invalidate cache for an entity + * + * @param string $entityType + * @param int $idEntity + * @param int|null $idLang Null to invalidate all languages + * @return bool + */ + public function invalidateCache($entityType, $idEntity, $idLang = null) + { + $where = 'entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_shop = ' . (int) $this->idShop; + + if ($idLang) { + $where .= ' AND id_lang = ' . (int) $idLang; + } + + return \Db::getInstance()->delete($this->tablePrefix . 'url_cache', $where); + } + + /** + * Invalidate all cache for an entity type + * + * @param string $entityType + * @return bool + */ + public function invalidateAllCache($entityType) + { + return \Db::getInstance()->delete( + $this->tablePrefix . 'url_cache', + 'entity_type = "' . pSQL($entityType) . '" AND id_shop = ' . (int) $this->idShop + ); + } + + /** + * Get URL history for an entity + * + * @param string $entityType + * @param int $idEntity + * @param int|null $idLang + * @return array + */ + public function getUrlHistory($entityType, $idEntity, $idLang = null) + { + $idLang = $idLang ?: $this->idLang; + + return \Db::getInstance()->executeS(' + SELECT * FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_history` + WHERE entity_type = "' . pSQL($entityType) . '" + AND id_entity = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang . ' + AND id_shop = ' . (int) $this->idShop . ' + ORDER BY date_add DESC + '); + } + + // ========================================================================= + // NATIVE LINK_REWRITE UPDATE METHODS + // ========================================================================= + + /** + * Update the native link_rewrite field for an entity based on pattern + * + * @param string $entityType + * @param int $idEntity + * @param int|null $idLang Null to update all languages + * @return bool + */ + public function updateEntityLinkRewrite($entityType, $idEntity, $idLang = null) + { + if (!$this->isEnabled($entityType)) { + return false; + } + + $languages = $idLang + ? [['id_lang' => $idLang]] + : \Language::getLanguages(true, $this->idShop); + + $success = true; + + foreach ($languages as $lang) { + $langId = (int) $lang['id_lang']; + + $oldLinkRewrite = $this->getEntityLinkRewrite($entityType, $idEntity, $langId); + + $entityData = $this->getEntityData($entityType, $idEntity, $langId); + if (!$entityData) { + continue; + } + + $patternConfig = $this->getPattern($entityType); + $newUrl = $this->replacePlaceholders($patternConfig['pattern'], $entityData, $patternConfig); + $newUrl = $this->applyUrlFormatting($newUrl, $entityType, $patternConfig); + + $newLinkRewrite = $this->extractLinkRewrite($newUrl, $entityType); + + $newLinkRewrite = $this->handleLinkRewriteCollision( + $newLinkRewrite, + $entityType, + $idEntity, + $langId, + $patternConfig + ); + + if ($newLinkRewrite && $newLinkRewrite !== $oldLinkRewrite) { + if ($oldLinkRewrite) { + $this->recordUrlChange( + $entityType, + $idEntity, + $langId, + $oldLinkRewrite, + $newLinkRewrite, + 'pattern_change' + ); + } + + if (!$this->setEntityLinkRewrite($entityType, $idEntity, $langId, $newLinkRewrite)) { + $success = false; + } + } + } + + return $success; + } + + /** + * Extract the link_rewrite portion from a full URL pattern + * + * @param string $url + * @param string $entityType + * @return string + */ + protected function extractLinkRewrite($url, $entityType) + { + $patternConfig = $this->getPattern($entityType); + $suffix = $patternConfig['suffix'] ?? ''; + if ($suffix && substr($url, -strlen($suffix)) === $suffix) { + $url = substr($url, 0, -strlen($suffix)); + } + + $parts = explode('/', $url); + $linkRewrite = end($parts); + + return \Tools::str2url($linkRewrite); + } + + /** + * Get current link_rewrite for an entity + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return string|null + */ + public function getEntityLinkRewrite($entityType, $idEntity, $idLang) + { + $table = $this->getLangTableForEntityType($entityType); + $idColumn = $this->getIdColumnForEntityType($entityType); + + if (!$table || !$idColumn) { + return null; + } + + $sql = 'SELECT link_rewrite FROM `' . _DB_PREFIX_ . $table . '` + WHERE ' . $idColumn . ' = ' . (int) $idEntity . ' + AND id_lang = ' . (int) $idLang; + + if (in_array($entityType, ['product', 'category', 'cms', 'cms_category'])) { + $sql .= ' AND id_shop = ' . (int) $this->idShop; + } + + return \Db::getInstance()->getValue($sql); + } + + /** + * Set link_rewrite for an entity + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param string $linkRewrite + * @return bool + */ + protected function setEntityLinkRewrite($entityType, $idEntity, $idLang, $linkRewrite) + { + $table = $this->getLangTableForEntityType($entityType); + $idColumn = $this->getIdColumnForEntityType($entityType); + + if (!$table || !$idColumn) { + return false; + } + + $where = $idColumn . ' = ' . (int) $idEntity . ' AND id_lang = ' . (int) $idLang; + + if (in_array($entityType, ['product', 'category', 'cms', 'cms_category'])) { + $where .= ' AND id_shop = ' . (int) $this->idShop; + } + + return \Db::getInstance()->update( + $table, + ['link_rewrite' => pSQL($linkRewrite)], + $where + ); + } + + /** + * Get the language table name for an entity type + * + * @param string $entityType + * @return string|null + */ + protected function getLangTableForEntityType($entityType) + { + $tables = [ + 'product' => 'product_lang', + 'category' => 'category_lang', + 'cms' => 'cms_lang', + 'cms_category' => 'cms_category_lang', + 'manufacturer' => 'manufacturer_lang', + 'supplier' => 'supplier_lang', + ]; + + return $tables[$entityType] ?? null; + } + + /** + * Get the ID column name for an entity type + * + * @param string $entityType + * @return string|null + */ + protected function getIdColumnForEntityType($entityType) + { + $columns = [ + 'product' => 'id_product', + 'category' => 'id_category', + 'cms' => 'id_cms', + 'cms_category' => 'id_cms_category', + 'manufacturer' => 'id_manufacturer', + 'supplier' => 'id_supplier', + ]; + + return $columns[$entityType] ?? null; + } + + /** + * Handle link_rewrite collision + * + * @param string $linkRewrite + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @param array $config + * @return string + */ + protected function handleLinkRewriteCollision($linkRewrite, $entityType, $idEntity, $idLang, array $config) + { + $table = $this->getLangTableForEntityType($entityType); + $idColumn = $this->getIdColumnForEntityType($entityType); + + if (!$table || !$idColumn) { + return $linkRewrite; + } + + $sql = 'SELECT ' . $idColumn . ' FROM `' . _DB_PREFIX_ . $table . '` + WHERE link_rewrite = "' . pSQL($linkRewrite) . '" + AND id_lang = ' . (int) $idLang . ' + AND ' . $idColumn . ' != ' . (int) $idEntity; + + if (in_array($entityType, ['product', 'category', 'cms', 'cms_category'])) { + $sql .= ' AND id_shop = ' . (int) $this->idShop; + } + + $existing = \Db::getInstance()->getValue($sql); + + if (!$existing) { + return $linkRewrite; + } + + switch ($config['collision_suffix'] ?? 'id') { + case 'id': + return $linkRewrite . '-' . $idEntity; + + case 'sku': + if ($entityType === 'product') { + $reference = \Db::getInstance()->getValue( + 'SELECT reference FROM `' . _DB_PREFIX_ . 'product` WHERE id_product = ' . (int) $idEntity + ); + if ($reference) { + return $linkRewrite . '-' . \Tools::str2url($reference); + } + } + + return $linkRewrite . '-' . $idEntity; + + case 'ean13': + if ($entityType === 'product') { + $ean13 = \Db::getInstance()->getValue( + 'SELECT ean13 FROM `' . _DB_PREFIX_ . 'product` WHERE id_product = ' . (int) $idEntity + ); + if ($ean13) { + return $linkRewrite . '-' . $ean13; + } + } + + return $linkRewrite . '-' . $idEntity; + + case 'increment': + default: + $counter = 2; + $baseLinkRewrite = $linkRewrite; + do { + $linkRewrite = $baseLinkRewrite . '-' . $counter; + + $sql = 'SELECT 1 FROM `' . _DB_PREFIX_ . $table . '` + WHERE link_rewrite = "' . pSQL($linkRewrite) . '" + AND id_lang = ' . (int) $idLang . ' + AND ' . $idColumn . ' != ' . (int) $idEntity; + + if (in_array($entityType, ['product', 'category', 'cms', 'cms_category'])) { + $sql .= ' AND id_shop = ' . (int) $this->idShop; + } + + $exists = \Db::getInstance()->getValue($sql); + $counter++; + } while ($exists && $counter < 100); + + return $linkRewrite; + } + } + + /** + * Regenerate link_rewrites for an entity type in batches + * + * @param string $entityType + * @param int $idShop + * @param int $offset + * @param int $batchSize + * @return array ['processed' => int, 'updated' => int] + */ + public function regenerateUrls($entityType, $idShop, $offset = 0, $batchSize = 50) + { + $db = \Db::getInstance(); + $processed = 0; + $updated = 0; + + switch ($entityType) { + case 'product': + $entities = $db->executeS(' + SELECT pl.id_product as id, pl.name, pl.link_rewrite, pl.id_lang + FROM ' . _DB_PREFIX_ . 'product_lang pl + INNER JOIN ' . _DB_PREFIX_ . 'product_shop ps + ON ps.id_product = pl.id_product AND ps.id_shop = ' . (int) $idShop . ' + WHERE pl.id_shop = ' . (int) $idShop . ' + AND ps.active = 1 + ORDER BY pl.id_product ASC, pl.id_lang ASC + LIMIT ' . (int) $offset . ', ' . (int) $batchSize + ); + break; + + case 'category': + $entities = $db->executeS(' + SELECT cl.id_category as id, cl.name, cl.link_rewrite, cl.id_lang + FROM ' . _DB_PREFIX_ . 'category_lang cl + INNER JOIN ' . _DB_PREFIX_ . 'category_shop cs + ON cs.id_category = cl.id_category AND cs.id_shop = ' . (int) $idShop . ' + INNER JOIN ' . _DB_PREFIX_ . 'category c + ON c.id_category = cl.id_category + WHERE cl.id_shop = ' . (int) $idShop . ' + AND c.active = 1 + AND cl.id_category > 2 + ORDER BY cl.id_category ASC, cl.id_lang ASC + LIMIT ' . (int) $offset . ', ' . (int) $batchSize + ); + break; + + case 'cms': + $entities = $db->executeS(' + SELECT cl.id_cms as id, cl.meta_title as name, cl.link_rewrite, cl.id_lang + FROM ' . _DB_PREFIX_ . 'cms_lang cl + INNER JOIN ' . _DB_PREFIX_ . 'cms_shop cs + ON cs.id_cms = cl.id_cms AND cs.id_shop = ' . (int) $idShop . ' + INNER JOIN ' . _DB_PREFIX_ . 'cms c + ON c.id_cms = cl.id_cms + WHERE cl.id_shop = ' . (int) $idShop . ' + AND c.active = 1 + ORDER BY cl.id_cms ASC, cl.id_lang ASC + LIMIT ' . (int) $offset . ', ' . (int) $batchSize + ); + break; + + case 'manufacturer': + $entities = $db->executeS(' + SELECT m.id_manufacturer as id, m.name, m.name as link_rewrite, 0 as id_lang + FROM ' . _DB_PREFIX_ . 'manufacturer m + INNER JOIN ' . _DB_PREFIX_ . 'manufacturer_shop ms + ON ms.id_manufacturer = m.id_manufacturer AND ms.id_shop = ' . (int) $idShop . ' + WHERE m.active = 1 + ORDER BY m.id_manufacturer ASC + LIMIT ' . (int) $offset . ', ' . (int) $batchSize + ); + break; + + case 'supplier': + $entities = $db->executeS(' + SELECT s.id_supplier as id, s.name, s.name as link_rewrite, 0 as id_lang + FROM ' . _DB_PREFIX_ . 'supplier s + INNER JOIN ' . _DB_PREFIX_ . 'supplier_shop ss + ON ss.id_supplier = s.id_supplier AND ss.id_shop = ' . (int) $idShop . ' + WHERE s.active = 1 + ORDER BY s.id_supplier ASC + LIMIT ' . (int) $offset . ', ' . (int) $batchSize + ); + break; + + default: + return ['processed' => 0, 'updated' => 0]; + } + + foreach ($entities ?: [] as $entity) { + $processed++; + + $currentRewrite = $entity['link_rewrite']; + $newRewrite = \Tools::str2url($entity['name']); + + if ($currentRewrite !== $newRewrite && !empty($newRewrite)) { + $idLang = (int) $entity['id_lang']; + + switch ($entityType) { + case 'product': + $result = $db->update( + 'product_lang', + ['link_rewrite' => pSQL($newRewrite)], + 'id_product = ' . (int) $entity['id'] . ' AND id_lang = ' . $idLang . ' AND id_shop = ' . (int) $idShop + ); + break; + + case 'category': + $result = $db->update( + 'category_lang', + ['link_rewrite' => pSQL($newRewrite)], + 'id_category = ' . (int) $entity['id'] . ' AND id_lang = ' . $idLang . ' AND id_shop = ' . (int) $idShop + ); + break; + + case 'cms': + $result = $db->update( + 'cms_lang', + ['link_rewrite' => pSQL($newRewrite)], + 'id_cms = ' . (int) $entity['id'] . ' AND id_lang = ' . $idLang . ' AND id_shop = ' . (int) $idShop + ); + break; + + default: + $result = false; + } + + if (!empty($result)) { + $updated++; + + $this->recordUrlChange( + $entityType, + (int) $entity['id'], + $idLang, + $currentRewrite, + $newRewrite, + 'bulk_regenerate' + ); + } + } + } + + return [ + 'processed' => $processed, + 'updated' => $updated, + ]; + } + + /** + * Regenerate all link_rewrites for an entity type based on current pattern + * + * @param string $entityType + * @param callable|null $progressCallback Called with (current, total) + * @return array ['success' => int, 'failed' => int, 'total' => int] + */ + public function regenerateAllLinkRewrites($entityType, callable $progressCallback = null) + { + $ids = $this->getAllEntityIds($entityType); + $total = count($ids); + $success = 0; + $failed = 0; + + foreach ($ids as $index => $idEntity) { + if ($this->updateEntityLinkRewrite($entityType, $idEntity)) { + $success++; + } else { + $failed++; + } + + if ($progressCallback) { + $progressCallback($index + 1, $total); + } + } + + return [ + 'success' => $success, + 'failed' => $failed, + 'total' => $total, + ]; + } + + /** + * Get all entity IDs for a type + * + * @param string $entityType + * @return array + */ + protected function getAllEntityIds($entityType) + { + $tables = [ + 'product' => ['table' => 'product_shop', 'id' => 'id_product', 'shop_filter' => true], + 'category' => ['table' => 'category_shop', 'id' => 'id_category', 'shop_filter' => true], + 'cms' => ['table' => 'cms_shop', 'id' => 'id_cms', 'shop_filter' => true], + 'cms_category' => ['table' => 'cms_category_shop', 'id' => 'id_cms_category', 'shop_filter' => true], + 'manufacturer' => ['table' => 'manufacturer', 'id' => 'id_manufacturer', 'shop_filter' => false], + 'supplier' => ['table' => 'supplier', 'id' => 'id_supplier', 'shop_filter' => false], + ]; + + $config = $tables[$entityType] ?? null; + if (!$config) { + return []; + } + + $sql = 'SELECT DISTINCT ' . $config['id'] . ' FROM `' . _DB_PREFIX_ . $config['table'] . '`'; + + if ($config['shop_filter']) { + $sql .= ' WHERE id_shop = ' . (int) $this->idShop; + } + + $result = \Db::getInstance()->executeS($sql); + + return array_column($result, $config['id']); + } +} diff --git a/src/UrlRouter.php b/src/UrlRouter.php new file mode 100644 index 0000000..a376508 --- /dev/null +++ b/src/UrlRouter.php @@ -0,0 +1,961 @@ +patternManager = $patternManager; + $this->tablePrefix = $tablePrefix; + $this->moduleName = $moduleName; + $this->idShop = $idShop ?: (int) \Context::getContext()->shop->id; + $this->idLang = $idLang ?: (int) \Context::getContext()->language->id; + } + + /** + * Get the pattern manager instance + * + * @return UrlPatternManager + */ + public function getPatternManager() + { + return $this->patternManager; + } + + /** + * Resolve a URL to an entity + * + * @param string $url The request URI (without domain, with or without language prefix) + * @return array|null [entity_type, id_entity, id_lang, id_product_attribute, controller] or redirect info + */ + public function resolve($url) + { + $url = $this->normalizeUrl($url); + + if (empty($url)) { + return null; + } + + // 1. Check URL cache for exact match (fastest) + $cached = $this->lookupCachedUrl($url); + if ($cached) { + return $cached; + } + + // 2. Check custom overrides + $override = $this->lookupOverride($url); + if ($override) { + return $override; + } + + // 3. Check URL history for redirects + $redirect = $this->patternManager->findRedirect($url, $this->idLang); + if ($redirect) { + return [ + 'redirect' => true, + 'redirect_url' => $redirect['new_url'], + 'redirect_type' => $redirect['redirect_type'], + ]; + } + + // 4. Try pattern matching (slowest, but handles dynamic URLs) + $matched = $this->matchPattern($url); + if ($matched) { + return $matched; + } + + return null; + } + + /** + * Normalize URL for matching + * + * @param string $url + * @return string + */ + protected function normalizeUrl($url) + { + // Remove query string + if (($pos = strpos($url, '?')) !== false) { + $url = substr($url, 0, $pos); + } + + $url = ltrim($url, '/'); + + // Remove language prefix if present + $languages = \Language::getLanguages(true, $this->idShop); + foreach ($languages as $lang) { + $prefix = $lang['iso_code'] . '/'; + if (strpos($url, $prefix) === 0) { + $url = substr($url, strlen($prefix)); + $this->idLang = (int) $lang['id_lang']; + break; + } + } + + $url = rtrim($url, '/'); + + // Remove common suffixes for matching + $suffixes = ['.html', '.htm', '.php']; + foreach ($suffixes as $suffix) { + if (substr($url, -strlen($suffix)) === $suffix) { + $url = substr($url, 0, -strlen($suffix)); + break; + } + } + + return strtolower($url); + } + + /** + * Lookup URL in cache table + * + * @param string $url + * @return array|null + */ + protected function lookupCachedUrl($url) + { + $urlVariants = [$url, $url . '.html', $url . '/']; + + foreach ($urlVariants as $variant) { + $hash = md5($variant); + + $result = \Db::getInstance()->getRow(' + SELECT entity_type, id_entity, id_lang + FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_cache` + WHERE url_hash = "' . pSQL($hash) . '" + AND id_shop = ' . (int) $this->idShop . ' + '); + + if ($result) { + return [ + 'entity_type' => $result['entity_type'], + 'id_entity' => (int) $result['id_entity'], + 'id_lang' => (int) $result['id_lang'], + 'controller' => $this->getControllerForEntityType($result['entity_type']), + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Lookup URL in overrides table + * + * @param string $url + * @return array|null + */ + protected function lookupOverride($url) + { + $urlVariants = [$url, $url . '.html', $url . '/']; + + foreach ($urlVariants as $variant) { + $hash = md5($variant); + + $result = \Db::getInstance()->getRow(' + SELECT entity_type, id_entity, id_lang, + target_entity_type, target_id_entity, redirect_type, target_custom_url + FROM `' . _DB_PREFIX_ . $this->tablePrefix . 'url_override` + WHERE custom_url_hash = "' . pSQL($hash) . '" + AND id_shop = ' . (int) $this->idShop . ' + '); + + if ($result) { + // Redirect to target entity + if (!empty($result['target_entity_type']) && !empty($result['target_id_entity'])) { + $targetUrl = $this->resolveEntityUrl( + $result['target_entity_type'], + (int) $result['target_id_entity'], + (int) $result['id_lang'] + ); + if ($targetUrl) { + return [ + 'redirect' => true, + 'redirect_url' => $targetUrl, + 'redirect_type' => $result['redirect_type'] ?: '301', + ]; + } + } + + // Redirect to custom URL + if (!empty($result['target_custom_url'])) { + return [ + 'redirect' => true, + 'redirect_url' => $result['target_custom_url'], + 'redirect_type' => $result['redirect_type'] ?: '301', + ]; + } + + // Legacy override: serve entity at custom URL + return [ + 'entity_type' => $result['entity_type'], + 'id_entity' => (int) $result['id_entity'], + 'id_lang' => (int) $result['id_lang'], + 'controller' => $this->getControllerForEntityType($result['entity_type']), + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Resolve a target entity to its front-office URL (relative path) + * + * @param string $entityType + * @param int $idEntity + * @param int $idLang + * @return string|null + */ + protected function resolveEntityUrl($entityType, $idEntity, $idLang) + { + $context = \Context::getContext(); + $link = $context->link; + + try { + switch ($entityType) { + case 'product': + $url = $link->getProductLink($idEntity, null, null, null, $idLang); + break; + case 'category': + $url = $link->getCategoryLink($idEntity, null, $idLang); + break; + case 'cms': + $cmsObj = new \CMS($idEntity, $idLang); + $url = $link->getCMSLink($cmsObj, null, null, $idLang); + break; + case 'manufacturer': + $url = $link->getManufacturerLink($idEntity, null, $idLang); + break; + case 'supplier': + $url = $link->getSupplierLink($idEntity, null, $idLang); + break; + default: + return null; + } + + $baseUrl = $context->shop->getBaseURL(true); + if (strpos($url, $baseUrl) === 0) { + return ltrim(substr($url, strlen($baseUrl)), '/'); + } + + return ltrim(parse_url($url, PHP_URL_PATH), '/'); + } catch (\Exception $e) { + return null; + } + } + + /** + * Try to match URL against patterns + * + * @param string $url + * @return array|null + */ + protected function matchPattern($url) + { + $entityTypes = UrlPatternManager::getSupportedEntityTypes(); + + foreach ($entityTypes as $entityType) { + if (!$this->patternManager->isEnabled($entityType)) { + continue; + } + + $pattern = $this->patternManager->getPattern($entityType); + $regex = $this->patternToRegex($pattern['pattern'], $entityType); + + if (preg_match($regex, $url, $matches)) { + $entity = $this->findEntityByPatternMatch($entityType, $matches); + if ($entity) { + return $entity; + } + } + } + + return null; + } + + /** + * Convert pattern to regex for matching + * + * @param string $pattern + * @param string $entityType + * @return string + */ + protected function patternToRegex($pattern, $entityType) + { + $cacheKey = $this->tablePrefix . $entityType . '_' . md5($pattern); + + if (isset(self::$routePatterns[$cacheKey])) { + return self::$routePatterns[$cacheKey]; + } + + $regex = preg_quote($pattern, '#'); + + $placeholders = [ + '\\{id\\}' => '(?P[0-9]+)', + '\\{name\\}' => '(?P[a-z0-9\-]+)', + '\\{rewrite\\}' => '(?P[a-z0-9\-]+)', + '\\{reference\\}' => '(?P[a-z0-9\-]+)', + '\\{ean13\\}' => '(?P[0-9]{13})?', + '\\{upc\\}' => '(?P[0-9]{12})?', + '\\{category\\}' => '(?P[a-z0-9\-]+)', + '\\{categories\\}' => '(?P[a-z0-9\-\/]+)', + '\\{brand\\}' => '(?P[a-z0-9\-]+)', + '\\{supplier\\}' => '(?P[a-z0-9\-]+)', + '\\{parent\\}' => '(?P[a-z0-9\-]+)', + '\\{parents\\}' => '(?P[a-z0-9\-\/]+)', + '\\{id_product_attribute\\}' => '(?P[0-9]+)?', + '\\{attributes\\}' => '(?P[a-z0-9\-]+)?', + '\\{tags\\}' => '(?P[a-z0-9\-]+)?', + '\\{meta_keywords\\}' => '(?P[a-z0-9\-]+)?', + '\\{meta_title\\}' => '(?P[a-z0-9\-]+)?', + ]; + + $regex = preg_replace('#\\\\\\{attribute\\:[^}]+\\\\\\}#', '(?P[a-z0-9\-]+)?', $regex); + + foreach ($placeholders as $placeholder => $replacement) { + $regex = preg_replace('#' . $placeholder . '#', $replacement, $regex); + } + + $regex = preg_replace('#\\\\\\{[^}]+\\\\\\}#', '[a-z0-9\-]*', $regex); + + $regex = '#^' . $regex . '$#i'; + + self::$routePatterns[$cacheKey] = $regex; + + return $regex; + } + + /** + * Find entity by pattern match data + * + * @param string $entityType + * @param array $matches + * @return array|null + */ + protected function findEntityByPatternMatch($entityType, array $matches) + { + switch ($entityType) { + case 'product': + return $this->findProductByMatch($matches); + case 'category': + return $this->findCategoryByMatch($matches); + case 'cms': + return $this->findCmsByMatch($matches); + case 'cms_category': + return $this->findCmsCategoryByMatch($matches); + case 'manufacturer': + return $this->findManufacturerByMatch($matches); + case 'supplier': + return $this->findSupplierByMatch($matches); + default: + return null; + } + } + + /** + * Find product by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findProductByMatch(array $matches) + { + if (!empty($matches['id'])) { + $idProduct = (int) $matches['id']; + if ($this->productExists($idProduct)) { + return [ + 'entity_type' => 'product', + 'id_entity' => $idProduct, + 'id_lang' => $this->idLang, + 'controller' => 'product', + 'id_product_attribute' => !empty($matches['id_product_attribute']) ? (int) $matches['id_product_attribute'] : 0, + ]; + } + } + + if (!empty($matches['name'])) { + $sql = ' + SELECT p.id_product + FROM `' . _DB_PREFIX_ . 'product` p + INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps ON ps.id_product = p.id_product AND ps.id_shop = ' . (int) $this->idShop . ' + INNER JOIN `' . _DB_PREFIX_ . 'product_lang` pl ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $this->idLang . ' AND pl.id_shop = ' . (int) $this->idShop . ' + WHERE pl.link_rewrite = "' . pSQL($matches['name']) . '" + AND ps.active = 1 + '; + + if (!empty($matches['category'])) { + $sql .= ' AND EXISTS ( + SELECT 1 FROM `' . _DB_PREFIX_ . 'category_lang` cl + WHERE cl.id_category = p.id_category_default + AND cl.id_lang = ' . (int) $this->idLang . ' + AND cl.link_rewrite = "' . pSQL($matches['category']) . '" + )'; + } + + if (!empty($matches['brand'])) { + $sql .= ' AND EXISTS ( + SELECT 1 FROM `' . _DB_PREFIX_ . 'manufacturer` m + WHERE m.id_manufacturer = p.id_manufacturer + AND LOWER(REPLACE(m.name, " ", "-")) = "' . pSQL($matches['brand']) . '" + )'; + } + + $sql .= ' LIMIT 1'; + + $idProduct = (int) \Db::getInstance()->getValue($sql); + + if ($idProduct) { + return [ + 'entity_type' => 'product', + 'id_entity' => $idProduct, + 'id_lang' => $this->idLang, + 'controller' => 'product', + 'id_product_attribute' => !empty($matches['id_product_attribute']) ? (int) $matches['id_product_attribute'] : 0, + ]; + } + } + + if (!empty($matches['ean13'])) { + $idProduct = (int) \Db::getInstance()->getValue(' + SELECT p.id_product + FROM `' . _DB_PREFIX_ . 'product` p + INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps ON ps.id_product = p.id_product AND ps.id_shop = ' . (int) $this->idShop . ' + WHERE p.ean13 = "' . pSQL($matches['ean13']) . '" + AND ps.active = 1 + LIMIT 1 + '); + + if ($idProduct) { + return [ + 'entity_type' => 'product', + 'id_entity' => $idProduct, + 'id_lang' => $this->idLang, + 'controller' => 'product', + 'id_product_attribute' => 0, + ]; + } + } + + if (!empty($matches['reference'])) { + $idProduct = (int) \Db::getInstance()->getValue(' + SELECT p.id_product + FROM `' . _DB_PREFIX_ . 'product` p + INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps ON ps.id_product = p.id_product AND ps.id_shop = ' . (int) $this->idShop . ' + WHERE LOWER(p.reference) = "' . pSQL(strtolower($matches['reference'])) . '" + AND ps.active = 1 + LIMIT 1 + '); + + if ($idProduct) { + return [ + 'entity_type' => 'product', + 'id_entity' => $idProduct, + 'id_lang' => $this->idLang, + 'controller' => 'product', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Find category by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findCategoryByMatch(array $matches) + { + if (!empty($matches['id'])) { + $idCategory = (int) $matches['id']; + if ($this->categoryExists($idCategory)) { + return [ + 'entity_type' => 'category', + 'id_entity' => $idCategory, + 'id_lang' => $this->idLang, + 'controller' => 'category', + 'id_product_attribute' => 0, + ]; + } + } + + if (!empty($matches['name'])) { + $sql = ' + SELECT c.id_category + FROM `' . _DB_PREFIX_ . 'category` c + INNER JOIN `' . _DB_PREFIX_ . 'category_shop` cs ON cs.id_category = c.id_category AND cs.id_shop = ' . (int) $this->idShop . ' + INNER JOIN `' . _DB_PREFIX_ . 'category_lang` cl ON cl.id_category = c.id_category AND cl.id_lang = ' . (int) $this->idLang . ' AND cl.id_shop = ' . (int) $this->idShop . ' + WHERE cl.link_rewrite = "' . pSQL($matches['name']) . '" + AND c.active = 1 + '; + + if (!empty($matches['parent'])) { + $sql .= ' AND EXISTS ( + SELECT 1 FROM `' . _DB_PREFIX_ . 'category_lang` pcl + WHERE pcl.id_category = c.id_parent + AND pcl.id_lang = ' . (int) $this->idLang . ' + AND pcl.link_rewrite = "' . pSQL($matches['parent']) . '" + )'; + } + + $sql .= ' LIMIT 1'; + + $idCategory = (int) \Db::getInstance()->getValue($sql); + + if ($idCategory) { + return [ + 'entity_type' => 'category', + 'id_entity' => $idCategory, + 'id_lang' => $this->idLang, + 'controller' => 'category', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Find CMS page by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findCmsByMatch(array $matches) + { + if (!empty($matches['id'])) { + $idCms = (int) $matches['id']; + if ($this->cmsExists($idCms)) { + return [ + 'entity_type' => 'cms', + 'id_entity' => $idCms, + 'id_lang' => $this->idLang, + 'controller' => 'cms', + 'id_product_attribute' => 0, + ]; + } + } + + if (!empty($matches['name'])) { + $idCms = (int) \Db::getInstance()->getValue(' + SELECT c.id_cms + FROM `' . _DB_PREFIX_ . 'cms` c + INNER JOIN `' . _DB_PREFIX_ . 'cms_shop` cs ON cs.id_cms = c.id_cms AND cs.id_shop = ' . (int) $this->idShop . ' + INNER JOIN `' . _DB_PREFIX_ . 'cms_lang` cl ON cl.id_cms = c.id_cms AND cl.id_lang = ' . (int) $this->idLang . ' AND cl.id_shop = ' . (int) $this->idShop . ' + WHERE cl.link_rewrite = "' . pSQL($matches['name']) . '" + AND c.active = 1 + LIMIT 1 + '); + + if ($idCms) { + return [ + 'entity_type' => 'cms', + 'id_entity' => $idCms, + 'id_lang' => $this->idLang, + 'controller' => 'cms', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Find CMS category by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findCmsCategoryByMatch(array $matches) + { + if (!empty($matches['id'])) { + return [ + 'entity_type' => 'cms_category', + 'id_entity' => (int) $matches['id'], + 'id_lang' => $this->idLang, + 'controller' => 'cms', + 'id_product_attribute' => 0, + ]; + } + + if (!empty($matches['name'])) { + $idCmsCategory = (int) \Db::getInstance()->getValue(' + SELECT cc.id_cms_category + FROM `' . _DB_PREFIX_ . 'cms_category` cc + INNER JOIN `' . _DB_PREFIX_ . 'cms_category_shop` ccs ON ccs.id_cms_category = cc.id_cms_category AND ccs.id_shop = ' . (int) $this->idShop . ' + INNER JOIN `' . _DB_PREFIX_ . 'cms_category_lang` ccl ON ccl.id_cms_category = cc.id_cms_category AND ccl.id_lang = ' . (int) $this->idLang . ' AND ccl.id_shop = ' . (int) $this->idShop . ' + WHERE ccl.link_rewrite = "' . pSQL($matches['name']) . '" + AND cc.active = 1 + LIMIT 1 + '); + + if ($idCmsCategory) { + return [ + 'entity_type' => 'cms_category', + 'id_entity' => $idCmsCategory, + 'id_lang' => $this->idLang, + 'controller' => 'cms', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Find manufacturer by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findManufacturerByMatch(array $matches) + { + if (!empty($matches['id'])) { + return [ + 'entity_type' => 'manufacturer', + 'id_entity' => (int) $matches['id'], + 'id_lang' => $this->idLang, + 'controller' => 'manufacturer', + 'id_product_attribute' => 0, + ]; + } + + if (!empty($matches['name'])) { + $idManufacturer = (int) \Db::getInstance()->getValue(' + SELECT m.id_manufacturer + FROM `' . _DB_PREFIX_ . 'manufacturer` m + INNER JOIN `' . _DB_PREFIX_ . 'manufacturer_shop` ms ON ms.id_manufacturer = m.id_manufacturer AND ms.id_shop = ' . (int) $this->idShop . ' + WHERE LOWER(REPLACE(REPLACE(m.name, " ", "-"), "\'", "")) = "' . pSQL($matches['name']) . '" + AND m.active = 1 + LIMIT 1 + '); + + if ($idManufacturer) { + return [ + 'entity_type' => 'manufacturer', + 'id_entity' => $idManufacturer, + 'id_lang' => $this->idLang, + 'controller' => 'manufacturer', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Find supplier by pattern match + * + * @param array $matches + * @return array|null + */ + protected function findSupplierByMatch(array $matches) + { + if (!empty($matches['id'])) { + return [ + 'entity_type' => 'supplier', + 'id_entity' => (int) $matches['id'], + 'id_lang' => $this->idLang, + 'controller' => 'supplier', + 'id_product_attribute' => 0, + ]; + } + + if (!empty($matches['name'])) { + $idSupplier = (int) \Db::getInstance()->getValue(' + SELECT s.id_supplier + FROM `' . _DB_PREFIX_ . 'supplier` s + INNER JOIN `' . _DB_PREFIX_ . 'supplier_shop` ss ON ss.id_supplier = s.id_supplier AND ss.id_shop = ' . (int) $this->idShop . ' + WHERE LOWER(REPLACE(REPLACE(s.name, " ", "-"), "\'", "")) = "' . pSQL($matches['name']) . '" + AND s.active = 1 + LIMIT 1 + '); + + if ($idSupplier) { + return [ + 'entity_type' => 'supplier', + 'id_entity' => $idSupplier, + 'id_lang' => $this->idLang, + 'controller' => 'supplier', + 'id_product_attribute' => 0, + ]; + } + } + + return null; + } + + /** + * Check if product exists + * + * @param int $idProduct + * @return bool + */ + protected function productExists($idProduct) + { + return (bool) \Db::getInstance()->getValue( + 'SELECT 1 FROM `' . _DB_PREFIX_ . 'product_shop` + WHERE id_product = ' . (int) $idProduct . ' + AND id_shop = ' . (int) $this->idShop . ' + AND active = 1' + ); + } + + /** + * Check if category exists + * + * @param int $idCategory + * @return bool + */ + protected function categoryExists($idCategory) + { + return (bool) \Db::getInstance()->getValue( + 'SELECT 1 FROM `' . _DB_PREFIX_ . 'category_shop` + WHERE id_category = ' . (int) $idCategory . ' + AND id_shop = ' . (int) $this->idShop + ); + } + + /** + * Check if CMS page exists + * + * @param int $idCms + * @return bool + */ + protected function cmsExists($idCms) + { + return (bool) \Db::getInstance()->getValue( + 'SELECT 1 FROM `' . _DB_PREFIX_ . 'cms_shop` + WHERE id_cms = ' . (int) $idCms . ' + AND id_shop = ' . (int) $this->idShop + ); + } + + /** + * Get controller name for entity type + * + * @param string $entityType + * @return string + */ + public function getControllerForEntityType($entityType) + { + $controllers = [ + 'product' => 'product', + 'category' => 'category', + 'cms' => 'cms', + 'cms_category' => 'cms', + 'manufacturer' => 'manufacturer', + 'supplier' => 'supplier', + ]; + + return $controllers[$entityType] ?? 'index'; + } + + /** + * Generate PrestaShop-compatible route definitions for moduleRoutes hook + * + * @return array + */ + public function generateModuleRoutes() + { + $routes = []; + $entityTypes = UrlPatternManager::getSupportedEntityTypes(); + $routePrefix = str_replace('_', '', $this->tablePrefix); + + foreach ($entityTypes as $entityType) { + if (!$this->patternManager->isEnabled($entityType)) { + continue; + } + + $pattern = $this->patternManager->getPattern($entityType); + $routeRule = $this->patternToRouteRule($pattern['pattern'], $pattern); + + $controller = $this->getControllerForEntityType($entityType); + + $languages = \Language::getLanguages(true, $this->idShop); + foreach ($languages as $lang) { + $routeKey = $routePrefix . '_' . $entityType . '_' . $lang['iso_code']; + + $routes[$routeKey] = [ + 'controller' => $controller, + 'rule' => $lang['iso_code'] . '/' . $routeRule, + 'keywords' => $this->getRouteKeywords($entityType), + 'params' => [ + 'fc' => 'module', + 'module' => $this->moduleName, + ], + ]; + } + } + + return $routes; + } + + /** + * Convert pattern to PrestaShop route rule + * + * @param string $pattern + * @param array $config + * @return string + */ + protected function patternToRouteRule($pattern, array $config) + { + $conversions = [ + '{id}' => '{id}', + '{name}' => '{rewrite}', + '{rewrite}' => '{rewrite}', + '{category}' => '{category}', + '{categories}' => '{categories}', + '{brand}' => '{manufacturer}', + '{supplier}' => '{supplier}', + '{ean13}' => '{ean13}', + '{reference}' => '{reference}', + '{parent}' => '{parent}', + '{parents}' => '{parents}', + '{id_product_attribute}' => '{id_product_attribute}', + '{attributes}' => '{attributes}', + ]; + + $rule = $pattern; + foreach ($conversions as $from => $to) { + $rule = str_replace($from, $to, $rule); + } + + $rule = preg_replace('/\{attribute:[^}]+\}/', '', $rule); + $rule = preg_replace('/\{[^}]+\}/', '', $rule); + + if (!empty($config['suffix'])) { + $rule .= $config['suffix']; + } + + $rule = preg_replace('#/+#', '/', $rule); + $rule = trim($rule, '/'); + + return $rule; + } + + /** + * Get route keywords for entity type + * + * @param string $entityType + * @return array + */ + public function getRouteKeywords($entityType) + { + $baseKeywords = [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_' . $entityType], + 'rewrite' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*', 'param' => 'rewrite'], + ]; + + switch ($entityType) { + case 'product': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_product'], + 'category' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'categories' => ['regexp' => '[_a-zA-Z0-9\pL\pS\/-]*'], + 'ean13' => ['regexp' => '[0-9]*'], + 'reference' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'manufacturer' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'supplier' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'id_product_attribute' => ['regexp' => '[0-9]*', 'param' => 'id_product_attribute'], + 'attributes' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'price' => ['regexp' => '[0-9\-\.]*'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'tags' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + case 'category': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_category'], + 'parent' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'parents' => ['regexp' => '[_a-zA-Z0-9\pL\pS\/-]*'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + case 'cms': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_cms'], + 'category' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'categories' => ['regexp' => '[_a-zA-Z0-9\pL\pS\/-]*'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + case 'cms_category': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_cms_category'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + case 'manufacturer': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_manufacturer'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + case 'supplier': + return array_merge($baseKeywords, [ + 'id' => ['regexp' => '[0-9]+', 'param' => 'id_supplier'], + 'meta_title' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + 'meta_keywords' => ['regexp' => '[_a-zA-Z0-9\pL\pS-]*'], + ]); + + default: + return $baseKeywords; + } + } +}