diff --git a/Classes/Utility/TagUtility.php b/Classes/Utility/TagUtility.php index 4c14dce..a73764b 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,6 +54,45 @@ public static function collectTagsFromStrings(array $tagStrings): array public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array { + // 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'}()) { + // 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 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'])) { + continue; + } + $doktype = (int)$page['doktype']; + $matches = $registrationDoktype !== null + ? $doktype === $registrationDoktype + : RegistrationService::getRegistrationByCategoryDocumentType($doktype) !== null; + + if ($matches) { + $categoryUid = (int)$page['uid']; + break; + } + } + + if ($categoryUid) { + $demand->{'setCategory'}($categoryUid); + } else { + // No 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..7479455 --- /dev/null +++ b/Tests/Functional/Utility/TagUtilityTest.php @@ -0,0 +1,188 @@ +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. + // 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, []); + + 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'] ??= [