diff --git a/PersistentPageIdentifiers.alias.php b/PersistentPageIdentifiers.alias.php new file mode 100644 index 0000000..2aa203c --- /dev/null +++ b/PersistentPageIdentifiers.alias.php @@ -0,0 +1,7 @@ + [ 'PersistentPageIdentifierResolver' ], +]; diff --git a/extension.json b/extension.json index 447623f..0f66eef 100644 --- a/extension.json +++ b/extension.json @@ -61,7 +61,12 @@ } ], + "SpecialPages": { + "PersistentPageIdentifierResolver": "ProfessionalWiki\\PersistentPageIdentifiers\\EntryPoints\\SpecialPersistentPageIdentifierResolver" + }, + "ExtensionMessagesFiles": { + "PersistentPageIdentifiersAlias": "PersistentPageIdentifiers.alias.php", "PersistentPageIdentifiersMagic": "i18n/Magic/MagicWords.php" }, diff --git a/i18n/en.json b/i18n/en.json index 8d59222..9f58662 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -8,5 +8,7 @@ }, "persistentpageidentifiers-name": "Persistent Page Identifiers", "persistentpageidentifiers-description": "Stable unique identifiers for your wiki pages. UUID v7 or PURIs, accessible via parser function and REST API", - "persistentpageidentifiers-info-label": "Persistent page identifier" + "persistentpageidentifiers-info-label": "Persistent page identifier", + "persistentpageidentifierresolver": "Redirect by Persistent Page Identifier", + "persistentpageidentifierresolver-not-exists": "Persistent Page Identifier does not exist" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 3882b01..135083f 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -7,5 +7,7 @@ }, "persistentpageidentifiers-name": "{{Name}}", "persistentpageidentifiers-description": "{{Desc|name=PersistentPageIdentifiers|url=https://github.com/ProfessionalWiki/PersistentPageIdentifiers}}", - "persistentpageidentifiers-info-label": "Persistent page identifier label on action=info page" + "persistentpageidentifiers-info-label": "Persistent page identifier label on action=info page", + "persistentpageidentifierresolver": "Special page name for redirect by Persistent Page Identifier", + "persistentpageidentifierresolver-not-exists": "Error message when Persistent Page Identifier does not exist" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c3af1b1..7c51f05 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,6 +30,18 @@ parameters: count: 1 path: src/EntryPoints/PersistentPageIdentifiersHooks.php + - + message: '#^Method ProfessionalWiki\\PersistentPageIdentifiers\\EntryPoints\\SpecialPersistentPageIdentifierResolver\:\:getFormFields\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/EntryPoints/SpecialPersistentPageIdentifierResolver.php + + - + message: '#^Method ProfessionalWiki\\PersistentPageIdentifiers\\EntryPoints\\SpecialPersistentPageIdentifierResolver\:\:onSubmit\(\) has parameter \$data with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/EntryPoints/SpecialPersistentPageIdentifierResolver.php + - message: '#^Method ProfessionalWiki\\PersistentPageIdentifiers\\PersistentPageIdentifiersExtension\:\:getDatabase\(\) should return Wikimedia\\Rdbms\\IDatabase but returns Wikimedia\\Rdbms\\IDatabase\|false\.$#' identifier: return.type diff --git a/src/Adapters/DatabasePersistentPageIdentifiersRepo.php b/src/Adapters/DatabasePersistentPageIdentifiersRepo.php index d34d932..207b916 100644 --- a/src/Adapters/DatabasePersistentPageIdentifiersRepo.php +++ b/src/Adapters/DatabasePersistentPageIdentifiersRepo.php @@ -59,4 +59,14 @@ private function persistentIdsResultToArray( IResultWrapper $result ): array { return $rows; } + public function getPageIdFromPersistentId( string $persistentId ): ?int { + $pageId = $this->database->newSelectQueryBuilder() + ->select( 'page_id' ) + ->from( 'persistent_page_ids' ) + ->where( [ 'persistent_id' => $persistentId ] ) + ->fetchField(); + + return is_numeric( $pageId ) ? (int)$pageId : null; + } + } diff --git a/src/Adapters/StubPersistentPageIdentifiersRepo.php b/src/Adapters/StubPersistentPageIdentifiersRepo.php index 70deae3..2533e5d 100644 --- a/src/Adapters/StubPersistentPageIdentifiersRepo.php +++ b/src/Adapters/StubPersistentPageIdentifiersRepo.php @@ -25,4 +25,8 @@ public function getPersistentIds( array $pageIds ): array { return array_combine( $pageIds, array_fill( 0, count( $pageIds ), $this->id ) ); } + public function getPageIdFromPersistentId( string $persistentId ): ?int { + return $this->id ? 1337 : null; + } + } diff --git a/src/Application/PersistentPageIdentifiersRepo.php b/src/Application/PersistentPageIdentifiersRepo.php index d7d7412..3bd32c2 100644 --- a/src/Application/PersistentPageIdentifiersRepo.php +++ b/src/Application/PersistentPageIdentifiersRepo.php @@ -19,4 +19,5 @@ public function getPersistentId( int $pageId ): ?string; */ public function getPersistentIds( array $pageIds ): array; + public function getPageIdFromPersistentId( string $persistentId ): ?int; } diff --git a/src/EntryPoints/SpecialPersistentPageIdentifierResolver.php b/src/EntryPoints/SpecialPersistentPageIdentifierResolver.php new file mode 100644 index 0000000..5c6b076 --- /dev/null +++ b/src/EntryPoints/SpecialPersistentPageIdentifierResolver.php @@ -0,0 +1,90 @@ +getUrlFromPersistentId( $subPage ); + if ( $url !== null ) { + $this->getOutput()->redirect( $url ); + return; + } + + parent::execute( $subPage ); + } + + protected function getFormFields(): array { + return [ + 'persistentpageidentifier' => [ + 'type' => 'text', + 'label-message' => 'persistentpageidentifiers-info-label', + 'required' => true, + ] + ]; + } + + public function onSubmit( array $data ): Status|bool { + $url = $this->getUrlFromPersistentId( $data['persistentpageidentifier'] ); + if ( $url === null ) { + return Status::newFatal( $this->getMessagePrefix() . '-not-exists' ); + } + + $this->getOutput()->redirect( $url ); + return true; + } + + protected function getDisplayFormat(): string { + return 'ooui'; + } + + public function getGroupName(): string { + return 'redirects'; + } + + private function getUrlFromPersistentId( ?string $persistentId ): ?string { + if ( $persistentId === null || $persistentId === '' ) { + return null; + } + + $title = $this->getTitleFromPersistentId( $this->extractId( $persistentId ) ); + + if ( $title === null || !$title->exists() ) { + return null; + } + + return $title->getFullURL(); + } + + private function getTitleFromPersistentId( string $persistentId ): ?Title { + $pageId = $this->getPageIdFromPersistentId( $persistentId ); + + if ( $pageId !== null ) { + return Title::newFromID( $pageId ); + } + + return null; + } + + private function getPageIdFromPersistentId( string $persistentId ): ?int { + return PersistentPageIdentifiersExtension::getInstance()->getPersistentPageIdentifiersRepo()->getPageIdFromPersistentId( $persistentId ); + } + + private function extractId( string $input ): string { + return PersistentPageIdentifiersExtension::getInstance()->newPersistentPageIdFormatter()->extractId( $input ); + } + +} diff --git a/src/Presentation/PersistentPageIdFormatter.php b/src/Presentation/PersistentPageIdFormatter.php index 4846b02..6e64d9f 100644 --- a/src/Presentation/PersistentPageIdFormatter.php +++ b/src/Presentation/PersistentPageIdFormatter.php @@ -19,4 +19,10 @@ public function format( ?string $persistentId ): string { return str_replace( '$1', $persistentId, $this->format ); } + public function extractId( string $input ): string { + // \$1 because it is escaped in the format string + $pattern = '/^' . str_replace( '\$1', '(.*?)', preg_quote( $this->format, '/' ) ) . '$/'; + return preg_match( $pattern, $input, $matches ) ? $matches[1] : $input; + } + } diff --git a/tests/Adapters/DatabasePersistentPageIdentifiersRepoTest.php b/tests/Adapters/DatabasePersistentPageIdentifiersRepoTest.php index 6bcd3d7..6d71aee 100644 --- a/tests/Adapters/DatabasePersistentPageIdentifiersRepoTest.php +++ b/tests/Adapters/DatabasePersistentPageIdentifiersRepoTest.php @@ -102,4 +102,16 @@ public function testCanSaveAndRetrieveMultiplePersistentIds(): void { ); } + public function testGetPageIdFromPersistentId(): void { + $pageId = $this->createPageWithText()->getId(); + $persistentId = '00000000-0000-0000-0000-000000000042'; + + $this->repo->savePersistentIds( [ $pageId => $persistentId ] ); + $this->assertSame( $pageId, $this->repo->getPageIdFromPersistentId( $persistentId ) ); + } + + public function testGetPageIdFromNonExistentPersistentId(): void { + $this->assertNull( $this->repo->getPageIdFromPersistentId( 'non-existent' ) ); + } + } diff --git a/tests/Integration/SpecialPersistentPageIdentifierResolverIntegrationTest.php b/tests/Integration/SpecialPersistentPageIdentifierResolverIntegrationTest.php new file mode 100644 index 0000000..2ee9a68 --- /dev/null +++ b/tests/Integration/SpecialPersistentPageIdentifierResolverIntegrationTest.php @@ -0,0 +1,127 @@ +tablesUsed[] = 'persistent_page_ids'; + $this->repo = new DatabasePersistentPageIdentifiersRepo( $this->db ); + } + + protected function newSpecialPage(): SpecialPersistentPageIdentifierResolver { + return new SpecialPersistentPageIdentifierResolver(); + } + + public function testExecuteWithValidIdRedirects(): void { + $page = $this->createPageWithText(); + + $resolver = $this->newSpecialPage(); + $resolver->execute( $this->repo->getPersistentId( $page->getId() ) ); + + $this->assertSame( + $page->getTitle()->getFullURL(), + $resolver->getOutput()->getRedirect(), + 'Should redirect to the page associated with the persistent ID.' + ); + } + + public function testExecuteWithNonExistentIdReturns(): void { + $resolver = $this->newSpecialPage(); + // Call parent::execute first before attempting to execute with an invalid subpage + // This is only needed for the test + $resolver->getContext()->setTitle( $resolver->getPageTitle() ); + $resolver->execute( 'non-existent-id' ); + + $this->assertSame( + '', + $resolver->getOutput()->getRedirect(), + 'Should not redirect for a non-existent persistent ID.' + ); + } + + public function testExecuteWithEmptySubPageReturns(): void { + $resolver = $this->newSpecialPage(); + // Call parent::execute first before attempting to execute with an empty subpage + // This is only needed for the test + $resolver->getContext()->setTitle( $resolver->getPageTitle() ); + $resolver->execute( '' ); + + $this->assertSame( + '', + $resolver->getOutput()->getRedirect(), + 'Should not redirect for an empty subpage.' + ); + } + + public function testExecuteWithNullSubPageReturns(): void { + $resolver = $this->newSpecialPage(); + // Call parent::execute first before attempting to execute with a null subpage + // This is only needed for the test + $resolver->getContext()->setTitle( $resolver->getPageTitle() ); + $resolver->execute( null ); + + $this->assertSame( + '', + $resolver->getOutput()->getRedirect(), + 'Should not redirect for a null subpage.' + ); + } + + public function testOnSubmitWithValidIdRedirects(): void { + $this->page = $this->createPageWithText(); + + $resolver = $this->newSpecialPage(); + $status = $resolver->onSubmit( [ 'persistentpageidentifier' => $this->repo->getPersistentId( $this->page->getId() ) ] ); + + $this->assertTrue( $status, 'onSubmit should return true on success.' ); + $this->assertSame( + $this->page->getTitle()->getFullURL(), + $resolver->getOutput()->getRedirect(), + 'Should redirect to the page associated with the persistent ID on form submission.' + ); + } + + public function testOnSubmitWithNonExistentIdReturnsFatalStatus(): void { + $resolver = $this->newSpecialPage(); + $status = $resolver->onSubmit( [ 'persistentpageidentifier' => 'non-existent-id' ] ); + + $this->assertInstanceOf( + Status::class, + $status, + 'onSubmit should return a Status object for a non-existent ID.' + ); + $this->assertFalse( $status->isGood(), 'Status should be fatal.' ); + + $errors = $status->getErrors(); + $this->assertNotEmpty( $errors, 'Status should contain errors.' ); + $this->assertEquals( + 'persistentpageidentifierresolver-not-exists', + $errors[0]['message'], + 'Status message should indicate the ID does not exist.' + ); + + $this->assertSame( + '', + $resolver->getOutput()->getRedirect(), + 'Should not redirect when ID does not exist on form submission.' + ); + } + +} diff --git a/tests/Maintenance/GenerateMissingIdentifiersTest.php b/tests/Maintenance/GenerateMissingIdentifiersTest.php index cad1f99..ff1d58d 100644 --- a/tests/Maintenance/GenerateMissingIdentifiersTest.php +++ b/tests/Maintenance/GenerateMissingIdentifiersTest.php @@ -21,6 +21,11 @@ protected function setUp(): void { $this->maintenance = new GenerateMissingIdentifiers(); $this->maintenance->checkRequiredExtensions(); + + // Run first to ensure that all existing pages have persistent IDs + // This is required because the pages persist into GenerateMissingIdentifiersTest in + // older MediaWiki/PHPUnit version. This is not needed for MW 1.42+. + $this->maintenance->execute(); } protected function tearDown(): void { diff --git a/tests/Presentation/PersistentPageIdFormatterTest.php b/tests/Presentation/PersistentPageIdFormatterTest.php index 03068d9..e98bf5f 100644 --- a/tests/Presentation/PersistentPageIdFormatterTest.php +++ b/tests/Presentation/PersistentPageIdFormatterTest.php @@ -22,4 +22,13 @@ public function testReturnsFormattedId(): void { $this->assertSame( 'foo 42 bar', $formatter->format( '42' ) ); } + public function testExtractsIdMatchingFormat(): void { + $formatter = new PersistentPageIdFormatter( 'foo $1 bar' ); + $this->assertSame( '42', $formatter->extractId( 'foo 42 bar' ) ); + } + + public function testExtractsIdNotMatchingFormat(): void { + $formatter = new PersistentPageIdFormatter( 'foo $1 bar' ); + $this->assertSame( 'bar 42 foo', $formatter->extractId( 'bar 42 foo' ) ); + } } diff --git a/tests/TestDoubles/InMemoryPersistentPageIdentifiersRepo.php b/tests/TestDoubles/InMemoryPersistentPageIdentifiersRepo.php index b81736c..9f6aeb8 100644 --- a/tests/TestDoubles/InMemoryPersistentPageIdentifiersRepo.php +++ b/tests/TestDoubles/InMemoryPersistentPageIdentifiersRepo.php @@ -22,4 +22,9 @@ public function getPersistentIds( array $pageIds ): array { return array_intersect_key( $this->persistentIdsByPageId, array_flip( $pageIds ) ); } + public function getPageIdFromPersistentId( string $persistentId ): ?int { + $pageId = array_search( $persistentId, $this->persistentIds, true ); + return $pageId === false ? null : $pageId; + } + }