Skip to content
7 changes: 7 additions & 0 deletions PersistentPageIdentifiers.alias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php
$specialPageAliases = [];

/** English (English) */
$specialPageAliases['en'] = [
'PersistentPageIdentifierResolver' => [ 'PersistentPageIdentifierResolver' ],
];
5 changes: 5 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@
}
],

"SpecialPages": {
"PersistentPageIdentifierResolver": "ProfessionalWiki\\PersistentPageIdentifiers\\EntryPoints\\SpecialPersistentPageIdentifierResolver"
},

"ExtensionMessagesFiles": {
"PersistentPageIdentifiersAlias": "PersistentPageIdentifiers.alias.php",
"PersistentPageIdentifiersMagic": "i18n/Magic/MagicWords.php"
},

Expand Down
4 changes: 3 additions & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 3 additions & 1 deletion i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
12 changes: 12 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/Adapters/DatabasePersistentPageIdentifiersRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
4 changes: 4 additions & 0 deletions src/Adapters/StubPersistentPageIdentifiersRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
1 change: 1 addition & 0 deletions src/Application/PersistentPageIdentifiersRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public function getPersistentId( int $pageId ): ?string;
*/
public function getPersistentIds( array $pageIds ): array;

public function getPageIdFromPersistentId( string $persistentId ): ?int;
}
90 changes: 90 additions & 0 deletions src/EntryPoints/SpecialPersistentPageIdentifierResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\PersistentPageIdentifiers\EntryPoints;

use FormSpecialPage;
use ProfessionalWiki\PersistentPageIdentifiers\PersistentPageIdentifiersExtension;
use ProfessionalWiki\PersistentPageIdentifiers\Presentation\PersistentPageIdFormatter;
use Status;
use Title;

class SpecialPersistentPageIdentifierResolver extends FormSpecialPage {

public function __construct() {
parent::__construct( 'PersistentPageIdentifierResolver' );
}

public function execute( $subPage ): void {
// Redirect to the page immediately if it is valid
$url = $this->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 );
}

}
6 changes: 6 additions & 0 deletions src/Presentation/PersistentPageIdFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
12 changes: 12 additions & 0 deletions tests/Adapters/DatabasePersistentPageIdentifiersRepoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\PersistentPageIdentifiers\Tests\Integration;

use ProfessionalWiki\PersistentPageIdentifiers\Adapters\DatabasePersistentPageIdentifiersRepo;
use ProfessionalWiki\PersistentPageIdentifiers\Application\PersistentPageIdentifiersRepo;
use ProfessionalWiki\PersistentPageIdentifiers\EntryPoints\SpecialPersistentPageIdentifierResolver;
use ProfessionalWiki\PersistentPageIdentifiers\Tests\PersistentPageIdentifiersIntegrationTest;
use Status;
use WikiPage;

/**
* @covers \ProfessionalWiki\PersistentPageIdentifiers\EntryPoints\SpecialPersistentPageIdentifierResolver
* @group Database
*/
class SpecialPersistentPageIdentifierResolverIntegrationTest extends PersistentPageIdentifiersIntegrationTest {

private PersistentPageIdentifiersRepo $repo;

protected function setUp(): void {
parent::setUp();
$this->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.'
);
}

}
5 changes: 5 additions & 0 deletions tests/Maintenance/GenerateMissingIdentifiersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions tests/Presentation/PersistentPageIdFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}
}
5 changes: 5 additions & 0 deletions tests/TestDoubles/InMemoryPersistentPageIdentifiersRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}