diff --git a/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php new file mode 100644 index 0000000..dbd519f --- /dev/null +++ b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php @@ -0,0 +1,53 @@ +addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product'); + $this->addArgument('uid', InputArgument::REQUIRED, 'Record uid'); + $this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true); + $paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class) + ->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields); + if ($paths === []) { + $output->writeln('nothing to purge (record not rendered anywhere)'); + } elseif ($paths === [SsrPurgeTargetResolver::ALL]) { + $output->writeln('* (everything)'); + } else { + $output->writeln(count($paths) . ' path(s):'); + foreach ($paths as $path) { + $output->writeln(' ' . $path); + } + } + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php index 49d5a9b..008417b 100644 --- a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php +++ b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php @@ -6,6 +6,7 @@ namespace Evomedien\Vitec\Hooks; use TYPO3\CMS\Core\Core\Environment; use Evomedien\Vitec\Service\SsrPurgeService; +use Evomedien\Vitec\Service\SsrPurgeTargetResolver; use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -19,10 +20,14 @@ use TYPO3\CMS\Core\Database\ConnectionPool; * - pages: navigation-relevant field changed (title, nav_title, slug, * hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page), * otherwise just that page's path. - * - tt_content: the hosting page's path. - * - tx_news_* and tx_vitec_*: '*' (news feed the megamenu spotlight, - * domain models render on many pages). - * - delete/move/undelete commands: pages -> '*', records -> hosting page. + * - tt_content: the hosting page's path; the megamenu element -> '*'. + * - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that + * show the record (SsrPurgeTargetResolver: detail URL, explicit + * selections, list plugins, embedding records); '*' for URL/visibility + * changes, the newest news, events and anything unknown. + * - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page. + * - delete/move/undelete commands: pages, records and megamenu -> '*', + * content elements -> hosting page. * * Changes are queued per request and flushed ONCE in afterAllOperations, * so bulk operations (importer!) end up as a single purge call. @@ -31,7 +36,9 @@ use TYPO3\CMS\Core\Database\ConnectionPool; */ final class DataHandlerSsrPurge { - private const NAV_FIELDS = ['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; + private const MEGAMENU_PREFIX = 'tx_evomegamenujson_'; + + private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime']; /** @var array */ private static array $queue = []; @@ -59,6 +66,10 @@ final class DataHandlerSsrPurge } if ($table === 'tt_content') { $recordId = $this->realId($id, $dataHandler); + if ($this->isMegamenuElement($recordId)) { + self::$queue['*'] = true; + return; + } $pid = $fieldArray['pid'] ?? null; if (!is_numeric($pid) || (int)$pid <= 0) { $pid = $this->pidOfRecord($table, $recordId); @@ -68,8 +79,16 @@ final class DataHandlerSsrPurge } return; } - if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { + if (str_starts_with($table, self::MEGAMENU_PREFIX)) { self::$queue['*'] = true; + return; + } + if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { + $paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class) + ->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray)); + foreach ($paths as $path) { + self::$queue[$path] = true; + } } } @@ -94,17 +113,44 @@ final class DataHandlerSsrPurge return; } if ($table === 'tt_content') { + if ($this->isMegamenuElement((int)$id)) { + self::$queue['*'] = true; + return; + } $pid = $this->pidOfRecord($table, (int)$id); if ($pid > 0) { $this->queuePagePath($pid); } return; } - if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) { + if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_') + || str_starts_with($table, self::MEGAMENU_PREFIX)) { + // Delete, move, copy, undelete: lists change everywhere. self::$queue['*'] = true; } } + /** + * The megamenu element (CType evomegamenujson_megamenu) renders on + * every page through the site setting megamenu.contentUid. + */ + private function isMegamenuElement(int $uid): bool + { + if ($uid <= 0) { + return false; + } + try { + $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); + $qb->getRestrictions()->removeAll(); + $cType = $qb->select('CType')->from('tt_content') + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER))) + ->executeQuery()->fetchOne(); + return $cType === 'evomegamenujson_megamenu'; + } catch (\Throwable) { + return false; + } + } + public function processCmdmap_afterFinish(DataHandler $dataHandler): void { $this->flush('editor-command'); @@ -115,7 +161,7 @@ final class DataHandlerSsrPurge if (self::$queue === []) { return; } - $paths = array_keys(self::$queue); + $paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue)); self::$queue = []; GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger); } diff --git a/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php new file mode 100644 index 0000000..0c75d79 --- /dev/null +++ b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php @@ -0,0 +1,296 @@ + [parent field, parent table]. */ + private const CHILD_TABLES = [ + 'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'], + 'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'], + 'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'], + 'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'], + ]; + + /** Shown in the megamenu or rarely changed and used broadly. */ + private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard', + 'tx_news_domain_model_tag']; + + /** List plugins that show records of a table without a fixed selection. */ + private const LIST_CTYPES = [ + // Product lists (vitec_productlist) filter by category: productListPages(). + 'tx_vitec_domain_model_product' => ['vitec_productcheck'], + 'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'], + 'tx_vitec_domain_model_solution' => ['vitec_solutionlist'], + 'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'], + 'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'], + 'tx_vitec_domain_model_location' => ['vitec_locationlist'], + 'tx_vitec_domain_model_customer' => ['vitec_customerlogos'], + 'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'], + ]; + + /** Records that embed a record of the key table: [embedding table, field]. */ + private const EMBEDDED_IN = [ + 'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'], + ['tx_vitec_product_related_text', 'related_uid']], + 'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']], + 'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']], + 'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']], + ]; + + /** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */ + private const DETAIL_ROUTES = [ + 'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]], + 'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]], + 'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]], + ]; + + /** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */ + private const SPOTLIGHT_NEWS = 5; + private const SPOTLIGHT_NEWS_PIDS = [11, 442]; + + /** + * @param string[] $changedFields field names of the save ([] = unknown) + * @return list paths to purge, ['*'] for everything, [] for nothing visible + */ + public function resolve(string $table, int $uid, array $changedFields): array + { + try { + return $this->doResolve($table, $uid, $changedFields); + } catch (\Throwable) { + return [self::ALL]; + } + } + + private function doResolve(string $table, int $uid, array $changedFields): array + { + if (in_array($table, self::INTERNAL_TABLES, true)) { + return []; + } + if ($uid <= 0) { + return [self::ALL]; + } + if (isset(self::CHILD_TABLES[$table])) { + [$field, $parentTable] = self::CHILD_TABLES[$table]; + $parentUid = (int)$this->connection($table)->executeQuery( + 'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid] + )->fetchOne(); + return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL]; + } + if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) { + return [self::ALL]; + } + if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) { + return [self::ALL]; + } + if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) { + return [self::ALL]; + } + + $paths = $this->pathsOf($table, $uid); + foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) { + foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) { + $paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid)); + } + } + foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) { + $paths[] = $this->pagePath($pid); + } + if ($table === 'tx_vitec_domain_model_product') { + foreach ($this->productListPages($uid) as $pid) { + $paths[] = $this->pagePath($pid); + } + } + $paths = array_values(array_unique(array_filter($paths))); + return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths; + } + + /** Detail URL(s) plus the pages that select the record explicitly. */ + private function pathsOf(string $table, int $uid): array + { + $paths = $this->detailPaths($table, $uid); + $pids = $this->connection('sys_refindex')->executeQuery( + 'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid + WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0', + ['tt_content', $table, $uid] + )->fetchFirstColumn(); + foreach ($pids as $pid) { + $paths[] = $this->pagePath((int)$pid); + } + if ($table === 'tx_vitec_domain_model_solution') { + $detailPage = (int)$this->connection($table)->executeQuery( + 'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid] + )->fetchOne(); + if ($detailPage > 0) { + $paths[] = $this->pagePath($detailPage); + } + } + return $paths; + } + + /** @return list records embedding the given one */ + private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array + { + if ($embeddingTable === 'tx_vitec_product_related_text') { + $rows = $this->connection($embeddingTable)->executeQuery( + 'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid] + )->fetchFirstColumn(); + return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows); + } + $rows = $this->connection('sys_refindex')->executeQuery( + 'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?', + [$embeddingTable, $field, $table, $uid] + )->fetchFirstColumn(); + return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows); + } + + private function detailPaths(string $table, int $uid): array + { + if (!isset(self::DETAIL_ROUTES[$table])) { + return []; + } + [$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table]; + $paths = []; + foreach ($pageUids as $pageUid) { + try { + $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid); + $paths[] = $site->getRouter()->generateUri($pageUid, [ + $namespace => ['action' => $action, 'controller' => $controller, $argument => $uid], + ])->getPath(); + } catch (\Throwable) { + // Record not routable (hidden, no slug): its pages are covered by the lists. + } + } + return $paths; + } + + private function pagesWithCTypes(array $cTypes): array + { + if ($cTypes === []) { + return []; + } + return array_map('intval', $this->connection('tt_content')->executeQuery( + 'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)', + [$cTypes], + [Connection::PARAM_STR_ARRAY] + )->fetchFirstColumn()); + } + + /** + * Pages with a product list that can show the product. Mirrors + * ProductListJsonRenderer: selected products (settings.selectedproducts) + * show exactly those - covered by the refindex; otherwise a category + * filter matches the selected categories and their whole subtree; + * "all products" or no category shows every product. + */ + private function productListPages(int $productUid): array + { + // The product's categories plus all their ancestors. + $categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery( + "SELECT uid_local FROM sys_category_record_mm + WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?", + [$productUid] + )->fetchFirstColumn()); + $seen = []; + $queue = $categories; + while ($queue !== []) { + $cat = array_shift($queue); + if ($cat <= 0 || isset($seen[$cat])) { + continue; + } + $seen[$cat] = true; + $queue[] = (int)$this->connection('sys_category')->executeQuery( + 'SELECT parent FROM sys_category WHERE uid = ?', [$cat] + )->fetchOne(); + } + + $pages = []; + $lists = $this->connection('tt_content')->executeQuery( + "SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'" + )->fetchAllAssociative(); + foreach ($lists as $list) { + $refs = $this->connection('sys_refindex')->executeQuery( + "SELECT ref_table, ref_uid FROM sys_refindex + WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform' + AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')", + [(int)$list['uid']] + )->fetchAllAssociative(); + $selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product'); + if ($selected !== []) { + continue; // fixed selection: in the refindex pages already when it contains the product + } + $listCategories = array_map('intval', array_column( + array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'), + 'ref_uid' + )); + $allProducts = preg_match('#settings\.allproducts">\s*1<#', (string)$list['pi_flexform']) === 1; + if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) { + $pages[] = (int)$list['pid']; + } + } + return array_values(array_unique($pages)); + } + + private function isSpotlightNews(int $uid): bool + { + $newest = $this->connection('tx_news_domain_model_news')->executeQuery( + 'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?) + ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS, + [self::SPOTLIGHT_NEWS_PIDS], + [Connection::PARAM_INT_ARRAY] + )->fetchFirstColumn(); + return in_array($uid, array_map('intval', $newest), true); + } + + private function pagePath(int $pageId): string + { + if ($pageId <= 0) { + return ''; + } + try { + $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId); + return $site->getRouter()->generateUri($pageId)->getPath() ?: '/'; + } catch (\Throwable) { + return ''; // Folder or page outside a site: nothing cached for it. + } + } + + private function connection(string $table): Connection + { + return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); + } +} diff --git a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml index ee0f04d..bcd9064 100644 --- a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml +++ b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml @@ -130,7 +130,7 @@ settings: vitec.ssr.autoPurge: label: 'SSR auto-purge on editor changes' - description: 'ON: saving pages/content/news automatically evicts the affected pages from the SSR page cache (menu-relevant changes evict everything). OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.' + description: 'ON: saving pages, content, news or VITEC records automatically evicts the pages that show them from the SSR page cache - a product only from its detail page and the lists that contain it. Menu-relevant changes (titles, URLs, visibility), the newest news, events and the megamenu evict everything. OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.' category: VITEC type: bool default: true