From 7ee23584a0663a7ce32c07c2c96522b7ab4ed326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Wed, 30 Jul 2025 12:58:18 +0200 Subject: [PATCH 1/4] Update TagUtility.php to filter out global tags that are not within the current object space Feature for #69964 --- Classes/Utility/TagUtility.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Classes/Utility/TagUtility.php b/Classes/Utility/TagUtility.php index 70d30e8..b675a1e 100644 --- a/Classes/Utility/TagUtility.php +++ b/Classes/Utility/TagUtility.php @@ -30,6 +30,28 @@ public static function collectTagsFromQueryResult(QueryResultInterface $objects) public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { + // Ensure the demand is filtered by the current blog instance (category) + if (!$demand->{'getCategory'}()) { + // Automatically determine the current blog category from the current page context + $currentPage = RootLineUtility::getCurrentPage(); + $pagesAbove = RootLineUtility::collectPagesAbove($currentPage, true); + + // Find the blog category page (doktype 93) in the rootline + $blogCategoryUid = null; + foreach ($pagesAbove as $page) { + if (isset($page['doktype']) && (int)$page['doktype'] === 93) { + $blogCategoryUid = (int)$page['uid']; + break; + } + } + + if ($blogCategoryUid) { + $demand->{'setCategory'}($blogCategoryUid); + } else { + // If no blog category found, return null to avoid showing all tags + return null; + } + } // Override language if ($languageUid !== null) { $querySettings = $repository->getDefaultQuerySettings(); @@ -49,6 +71,14 @@ public static function getTags(ObjectDemandInterface $demand, RepositoryInterfac return null; } + // Find objects and return their tags + if ($objects = $repository->findByDemand($ignoreTagsFromDemand === true ? $demand->setTags(null) : $demand)) { + return self::collectTagsFromQueryResult($objects); + } + + return null; + } + public static function getTagsByDemand(ObjectDemandInterface $demand, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { if (($registration = RegistrationService::getRegistrationByDemand($demand)) && ($repository = $registration->getObject()->getRepositoryClass()) instanceof RepositoryInterface) { From 9f43354adbdd1c2139119cb7299ac4ab689da289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Wed, 30 Jul 2025 13:00:41 +0200 Subject: [PATCH 2/4] Update TagUtility.php --- Classes/Utility/TagUtility.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Classes/Utility/TagUtility.php b/Classes/Utility/TagUtility.php index b675a1e..a961c95 100644 --- a/Classes/Utility/TagUtility.php +++ b/Classes/Utility/TagUtility.php @@ -28,7 +28,7 @@ public static function collectTagsFromQueryResult(QueryResultInterface $objects) return $tags; } - public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array + public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { // Ensure the demand is filtered by the current blog instance (category) if (!$demand->{'getCategory'}()) { @@ -70,15 +70,7 @@ public static function getTags(ObjectDemandInterface $demand, RepositoryInterfac return null; } - - // Find objects and return their tags - if ($objects = $repository->findByDemand($ignoreTagsFromDemand === true ? $demand->setTags(null) : $demand)) { - return self::collectTagsFromQueryResult($objects); - } - - return null; - } - + public static function getTagsByDemand(ObjectDemandInterface $demand, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { if (($registration = RegistrationService::getRegistrationByDemand($demand)) && ($repository = $registration->getObject()->getRepositoryClass()) instanceof RepositoryInterface) { From 19e5bfd3591b35cb4906f1c3be7c5e231537fce7 Mon Sep 17 00:00:00 2001 From: Jens Schaller Date: Tue, 10 Mar 2026 12:18:42 +0100 Subject: [PATCH 3/4] [FEATURE] Add nonglobal tag scoping via feature flag Introduce the pagebased.nonglobalTags feature flag (off by default). When enabled, TagUtility::getTags() auto-detects the current blog category from the rootline and scopes tag queries to that category. Returns null if no registered category page is found, preventing a global tag dump. Skips detection when category is already set on demand. The findTagStrings/collectTagsFromStrings optimisation path is preserved and active regardless of flag state. Also fixes a bug from the feature branch: the rootline lookup now uses RegistrationService::getRegistrationByCategoryDocumentType() instead of a hardcoded doktype value. Enable per installation in AdditionalConfiguration.php: $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true; --- Classes/Utility/TagUtility.php | 40 ++-- .../Fixtures/Database/pages_tags.csv | 9 + Tests/Functional/Utility/TagUtilityTest.php | 186 ++++++++++++++++++ ext_localconf.php | 4 + 4 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 Tests/Functional/Fixtures/Database/pages_tags.csv create mode 100644 Tests/Functional/Utility/TagUtilityTest.php diff --git a/Classes/Utility/TagUtility.php b/Classes/Utility/TagUtility.php index afa0694..203575a 100644 --- a/Classes/Utility/TagUtility.php +++ b/Classes/Utility/TagUtility.php @@ -4,6 +4,7 @@ namespace Zeroseven\Pagebased\Utility; +use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Persistence\QueryResultInterface; use Zeroseven\Pagebased\Domain\Model\Demand\ObjectDemandInterface; @@ -53,28 +54,31 @@ public static function collectTagsFromStrings(array $tagStrings): array public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { - // Ensure the demand is filtered by the current blog instance (category) - if (!$demand->{'getCategory'}()) { - // Automatically determine the current blog category from the current page context - $currentPage = RootLineUtility::getCurrentPage(); - $pagesAbove = RootLineUtility::collectPagesAbove($currentPage, true); - - // Find the blog category page (doktype 93) in the rootline - $blogCategoryUid = null; - foreach ($pagesAbove as $page) { - if (isset($page['doktype']) && (int)$page['doktype'] === 93) { - $blogCategoryUid = (int)$page['uid']; - break; + // When the nonglobal-tags feature is enabled, scope tags to the current rootline category. + if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('pagebased.nonglobalTags')) { + if (!$demand->{'getCategory'}()) { + // Automatically determine the current blog category from the current page context + $currentPage = RootLineUtility::getCurrentPage(); + $pagesAbove = RootLineUtility::collectPagesAbove($currentPage, true); + + // Find the first registered category page in the rootline + $blogCategoryUid = null; + foreach ($pagesAbove as $page) { + if (isset($page['doktype']) && RegistrationService::getRegistrationByCategoryDocumentType((int)$page['doktype']) !== null) { + $blogCategoryUid = (int)$page['uid']; + break; + } } - } - if ($blogCategoryUid) { - $demand->{'setCategory'}($blogCategoryUid); - } else { - // If no blog category found, return null to avoid showing all tags - return null; + if ($blogCategoryUid) { + $demand->{'setCategory'}($blogCategoryUid); + } else { + // No blog category found in rootline → return null to avoid showing all tags + return null; + } } } + // Override language if ($languageUid !== null) { $querySettings = $repository->getDefaultQuerySettings(); diff --git a/Tests/Functional/Fixtures/Database/pages_tags.csv b/Tests/Functional/Fixtures/Database/pages_tags.csv new file mode 100644 index 0000000..c8a5b7e --- /dev/null +++ b/Tests/Functional/Fixtures/Database/pages_tags.csv @@ -0,0 +1,9 @@ +pages,,,,,,,,,,,,,,, +,uid,pid,title,doktype,sorting,deleted,hidden,nav_hide,sys_language_uid,l10n_parent,_pagebased_registration,_pagebased_site,_pagebased_child_object,pagebased_tags +,1,0,Site Root,1,256,0,0,0,0,0,,1,0, +,10,1,Category A,199,128,0,0,0,0,0,,1,0, +,20,10,Object A1,1,128,0,0,0,0,0,test_news,1,0,"php,typo3" +,21,10,Object A2,1,256,0,0,0,0,0,test_news,1,0,"php,symfony" +,30,1,Category B,199,256,0,0,0,0,0,,1,0, +,31,30,Object B1,1,128,0,0,0,0,0,test_news,1,0,"javascript,typo3" +,40,1,Standalone Page,1,384,0,0,0,0,0,,1,0, diff --git a/Tests/Functional/Utility/TagUtilityTest.php b/Tests/Functional/Utility/TagUtilityTest.php new file mode 100644 index 0000000..571dab0 --- /dev/null +++ b/Tests/Functional/Utility/TagUtilityTest.php @@ -0,0 +1,186 @@ +bootstrapTestRegistration(); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Database/pages_tags.csv'); + + $this->repository = $this->get(TestObjectRepository::class); + + // Ensure the feature flag is off by default for each test. + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false; + } + + protected function tearDown(): void + { + // Clear the static RootLineUtility cache so page-context changes between + // tests do not bleed into one another. + $reflection = new \ReflectionProperty(RootLineUtility::class, 'cache'); + $reflection->setValue(null, []); + + unset($_GET['id']); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false; + + parent::tearDown(); + } + + private function bootstrapTestRegistration(): void + { + $objectRegistration = new ObjectRegistration('Test Object'); + $objectRegistration->setClassName(TestObject::class); + $objectRegistration->setRepositoryClass(TestObjectRepository::class); + + $categoryRegistration = new CategoryRegistration('Test Category'); + $categoryRegistration->setClassName(TestCategory::class); + $categoryRegistration->setRepositoryClass(TestCategoryRepository::class); + $categoryRegistration->setDocumentType(199); + + $registration = new Registration('test', 'test_news'); + $registration->setObject($objectRegistration); + $registration->setCategory($categoryRegistration); + + RegistrationService::addRegistration($registration); + } + + // --------------------------------------------------------------------------- + // Feature flag OFF (default behaviour) + // --------------------------------------------------------------------------- + + /** @test */ + public function getTagsReturnsGlobalTagsWhenFeatureFlagIsOff(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false; + + $demand = $this->repository->initializeDemand(); + $tags = TagUtility::getTags($demand, $this->repository); + + // All objects across all categories are included: php, typo3, symfony, javascript + self::assertNotNull($tags); + self::assertContains('php', $tags); + self::assertContains('typo3', $tags); + self::assertContains('symfony', $tags); + self::assertContains('javascript', $tags); + self::assertSame(['javascript', 'php', 'symfony', 'typo3'], $tags); + } + + // --------------------------------------------------------------------------- + // Feature flag ON – rootline detection active + // --------------------------------------------------------------------------- + + /** @test */ + public function getTagsScopesTagsToCategoryFoundInRootlineWhenFlagIsOn(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true; + + // Simulate browsing object page 20 (child of Category A, uid=10) + $_GET['id'] = '20'; + + $demand = $this->repository->initializeDemand(); + $tags = TagUtility::getTags($demand, $this->repository); + + // Only objects in Category A (uid 20, 21): php, typo3, symfony + self::assertNotNull($tags); + self::assertSame(['php', 'symfony', 'typo3'], $tags); + self::assertNotContains('javascript', $tags); + } + + /** @test */ + public function getTagsReturnsNullWhenFlagIsOnButNoCategoryPageInRootline(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true; + + // Simulate browsing a standalone page (uid=40) with no registered category ancestor + $_GET['id'] = '40'; + + $demand = $this->repository->initializeDemand(); + $result = TagUtility::getTags($demand, $this->repository); + + self::assertNull($result); + } + + /** @test */ + public function getTagsSkipsRootlineDetectionWhenCategoryAlreadySetInDemand(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true; + + // Category B (uid=30) is set explicitly – rootline should not be consulted. + // $_GET['id'] points to a page outside any category to prove rootline is skipped. + $_GET['id'] = '40'; + + $demand = $this->repository->initializeDemand(); + $demand->{'setCategory'}(30); + + $tags = TagUtility::getTags($demand, $this->repository); + + // Only objects in Category B (uid 31): javascript, typo3 + self::assertNotNull($tags); + self::assertSame(['javascript', 'typo3'], $tags); + self::assertNotContains('php', $tags); + self::assertNotContains('symfony', $tags); + } + + /** @test */ + public function getTagsOnCategoryPageItselfResolvesCorrectlyWhenFlagIsOn(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true; + + // Simulate browsing the Category A page itself (uid=10, doktype=199) + $_GET['id'] = '10'; + + $demand = $this->repository->initializeDemand(); + $tags = TagUtility::getTags($demand, $this->repository); + + // collectPagesAbove with includingStartingPoint=true includes uid=10 itself + self::assertNotNull($tags); + self::assertSame(['php', 'symfony', 'typo3'], $tags); + } +} diff --git a/ext_localconf.php b/ext_localconf.php index 9149894..484c53e 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -7,6 +7,10 @@ \Zeroseven\Pagebased\Hooks\IconFactory\OverrideIconOverlay::register(); \Zeroseven\Pagebased\Middleware\RssFeed::registerCache(); +// Opt-in: scope tag queries to the blog category found in the current page's rootline. +// Enable per installation in AdditionalConfiguration.php or via the Install Tool Features panel. +$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] ??= false; + // Tag query result cache – stores distinct tag lists per registration/category/language. // TYPO3 clears this automatically when pages with matching tags are modified. $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pagebased_tags'] ??= [ From 6d91db1aeb56018b06c1aa2277ff9d8c30e62e98 Mon Sep 17 00:00:00 2001 From: Jens Schaller Date: Tue, 10 Mar 2026 13:34:06 +0100 Subject: [PATCH 4/4] [BUGFIX] Address PR #8 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scope rootline category lookup to the repository's own registration doktype to avoid cross-registration matches in multi-registration installations; falls back to any registered doktype when registration cannot be resolved. - Rename $blogCategoryUid to $categoryUid and remove 'blog' wording from inline comments – TagUtility is generic, not blog-specific. - Add comment in tearDown() explaining why setAccessible(true) is intentionally absent (no-op / deprecated since PHP 8.1). --- Classes/Utility/TagUtility.php | 30 +++++++++++++++------ Tests/Functional/Utility/TagUtilityTest.php | 2 ++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Classes/Utility/TagUtility.php b/Classes/Utility/TagUtility.php index 203575a..a73764b 100644 --- a/Classes/Utility/TagUtility.php +++ b/Classes/Utility/TagUtility.php @@ -57,23 +57,37 @@ public static function getTags(ObjectDemandInterface $demand, RepositoryInterfac // When the nonglobal-tags feature is enabled, scope tags to the current rootline category. if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('pagebased.nonglobalTags')) { if (!$demand->{'getCategory'}()) { - // Automatically determine the current blog category from the current page context + // Resolve the category doktype for this specific repository to avoid matching + // category pages from a different registration in multi-registration installations. + $registrationDoktype = RegistrationService::getRegistrationByRepository($repository) + ?->getCategory() + ?->getDocumentType(); + $currentPage = RootLineUtility::getCurrentPage(); $pagesAbove = RootLineUtility::collectPagesAbove($currentPage, true); - // Find the first registered category page in the rootline - $blogCategoryUid = null; + // Find the first category page in the rootline that belongs to this registration. + // Fall back to any registered category doktype when the registration cannot be resolved. + $categoryUid = null; foreach ($pagesAbove as $page) { - if (isset($page['doktype']) && RegistrationService::getRegistrationByCategoryDocumentType((int)$page['doktype']) !== null) { - $blogCategoryUid = (int)$page['uid']; + if (!isset($page['doktype'])) { + continue; + } + $doktype = (int)$page['doktype']; + $matches = $registrationDoktype !== null + ? $doktype === $registrationDoktype + : RegistrationService::getRegistrationByCategoryDocumentType($doktype) !== null; + + if ($matches) { + $categoryUid = (int)$page['uid']; break; } } - if ($blogCategoryUid) { - $demand->{'setCategory'}($blogCategoryUid); + if ($categoryUid) { + $demand->{'setCategory'}($categoryUid); } else { - // No blog category found in rootline → return null to avoid showing all tags + // No category found in rootline → return null to avoid showing all tags return null; } } diff --git a/Tests/Functional/Utility/TagUtilityTest.php b/Tests/Functional/Utility/TagUtilityTest.php index 571dab0..7479455 100644 --- a/Tests/Functional/Utility/TagUtilityTest.php +++ b/Tests/Functional/Utility/TagUtilityTest.php @@ -64,6 +64,8 @@ protected function tearDown(): void { // Clear the static RootLineUtility cache so page-context changes between // tests do not bleed into one another. + // Note: setAccessible(true) is intentionally omitted – it is a no-op since PHP 8.1 + // and produces a deprecation notice on PHP 8.4. $reflection = new \ReflectionProperty(RootLineUtility::class, 'cache'); $reflection->setValue(null, []);