[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); } }