Files
VITEC-website/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php
T
orascheandClaude Opus 5.5 e14f94ba63 SSR auto-purge: purge only the pages that show a saved record, megamenu purges everything
SsrPurgeTargetResolver finds the paths of a saved VITEC or news record: its detail URL, the pages that select it (sys_refindex), product lists by category or all products, list plugins of its type and the records embedding it one level deep. URL, visibility and timing changes, the newest news, events, unknown tables and more than 150 paths still purge everything. Megamenu records and the megamenu element now purge everything (before: only its storage page). New read-only command vitec:ssr-purge-targets shows what a save would purge. Reference index rebuilt (117 updates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:16:44 +01:00

223 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
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;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Auto-purge: tells the SSR node which pages to drop from its full-page
* cache after editors change content (ticket 0054-FE section 3).
*
* Mapping rules (v1, deliberately conservative):
* - 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; 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.
* Runs only while the SSR routing flag is ON and the Site Set switch
* vitec.ssr.autoPurge is enabled.
*/
final class DataHandlerSsrPurge
{
private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
/** @var array<string,true> */
private static array $queue = [];
public function processDatamap_afterDatabaseOperations(
string $status,
string $table,
$id,
array $fieldArray,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
if (array_intersect(array_keys($fieldArray), self::NAV_FIELDS) !== []) {
self::$queue['*'] = true;
return;
}
$pageId = $this->realId($id, $dataHandler);
if ($pageId > 0) {
$this->queuePagePath($pageId);
}
return;
}
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);
}
if ((int)$pid > 0) {
$this->queuePagePath((int)$pid);
}
return;
}
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;
}
}
}
public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
{
$this->flush('editor-save');
}
public function processCmdmap_postProcess(
string $command,
string $table,
$id,
$value,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
// Delete/move/copy of a page changes menus everywhere.
self::$queue['*'] = true;
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_')
|| 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');
}
private function flush(string $trigger): void
{
if (self::$queue === []) {
return;
}
$paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
self::$queue = [];
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
}
private function isActive(): bool
{
if (!file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag')) {
return false;
}
try {
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
if ($site->getSettings()->get('vitec.ssr.autoPurge', true)) {
return true;
}
}
} catch (\Throwable) {
return false;
}
return false;
}
private function queuePagePath(int $pageId): void
{
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
$uri = $site->getRouter()->generateUri($pageId);
$path = $uri->getPath() ?: '/';
self::$queue[$path] = true;
} catch (\Throwable) {
// Page outside a site (folders etc.) - nothing cached for it.
}
}
private function realId($id, DataHandler $dataHandler): int
{
if (is_numeric($id)) {
return (int)$id;
}
return (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
}
private function pidOfRecord(string $table, int $uid): int
{
if ($uid <= 0) {
return 0;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$qb->getRestrictions()->removeAll();
$pid = $qb->select('pid')->from($table)
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return (int)$pid;
} catch (\Throwable) {
return 0;
}
}
}