diff --git a/README.md b/README.md index 660a31c..5185c83 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,35 @@ wiki-wide mode — it also applies inside Talk namespaces, where a Markdown talk signatures or threading. It still skips the Template and MediaWiki namespaces, where Markdown can act as neither a template nor an interface message; add those to `$wgNativeMarkdownNamespaces` to opt them in anyway. +### Converting existing pages + +Those defaults apply at page creation only, so enabling suffix detection or adding a namespace never touches +pages that already exist. The `NativeMarkdown:ConvertToMarkdownModel` maintenance script is the retroactive +counterpart: it switches existing pages to the Markdown model using the very same rules. It changes the +content model, not the page text — the stored wikitext is then reinterpreted as Markdown and may render +differently — so always start with `--dry-run` to review what would be converted. + +Two combinable selectors choose the pages, at least one being required: + +* `--md-suffix` follows live `.md` suffix detection: titles ending in `.md`, in any namespace except Template + and MediaWiki (Talk included). +* `--namespace ` follows `$wgNativeMarkdownNamespaces`: every page in that namespace. An explicit + namespace is a deliberate choice, so it works for any namespace, Template and MediaWiki included. + +Combined, they narrow to the `.md`-titled pages inside that one namespace, so `--md-suffix --namespace 10` +converts the `.md` pages in the Template namespace that `--md-suffix` alone would skip. Only pages whose +current model is wikitext are ever converted; other models are left untouched, and redirects are skipped. Use +`--batch-size` to control how many pages each batch processes. + +```shell script +php maintenance/run.php NativeMarkdown:ConvertToMarkdownModel --md-suffix --dry-run +php maintenance/run.php NativeMarkdown:ConvertToMarkdownModel --namespace 3000 +php maintenance/run.php NativeMarkdown:ConvertToMarkdownModel --md-suffix --namespace 10 +``` + +Each conversion is an ordinary revision by the maintenance user, visible in page history, and reversible per +page with `Special:ChangeContentModel` — still the tool for one-off, two-way model changes. + ## Templates and parser functions Markdown pages can use MediaWiki's `{{...}}` syntax. Because expansion delegates to the wikitext parser, this @@ -223,6 +252,9 @@ php tests/phpunit/phpunit.php extensions/NativeMarkdown/tests/phpunit/ ### Version 1.2.0 - Under development +* A `ConvertToMarkdownModel` maintenance script converts existing wikitext pages to the Markdown content + model, selecting them by `.md` suffix and/or namespace, the same way the activation settings select new + pages. It changes the content model rather than the page text, skips redirects, and supports `--dry-run` * Fenced code blocks whose info string names a language are now syntax highlighted, the same way a wikitext `` block is. This needs the [SyntaxHighlight extension] (bundled with MediaWiki) to be installed; without it, code blocks keep rendering as plain preformatted text diff --git a/extension.json b/extension.json index 4a0eec0..c4fe02d 100644 --- a/extension.json +++ b/extension.json @@ -39,6 +39,7 @@ "AutoloadNamespaces": { "ProfessionalWiki\\NativeMarkdown\\": "src/", + "ProfessionalWiki\\NativeMarkdown\\Maintenance\\": "maintenance/", "ProfessionalWiki\\NativeMarkdown\\Tests\\": "tests/phpunit/" }, diff --git a/maintenance/ConvertToMarkdownModel.php b/maintenance/ConvertToMarkdownModel.php new file mode 100644 index 0000000..f8ea444 --- /dev/null +++ b/maintenance/ConvertToMarkdownModel.php @@ -0,0 +1,278 @@ +addDescription( + 'Convert existing wikitext pages to the Markdown content model. This changes the content ' . + 'model, not the page text, so run with --dry-run first to review the selection.' + ); + + $this->addOption( + 'md-suffix', + 'Select pages the live .md suffix detection would default to Markdown: titles ending in ' . + '.md, outside the Template and MediaWiki namespaces. Combined with --namespace it instead ' . + 'selects the .md titles within that one namespace (which may be Template or MediaWiki).' + ); + $this->addOption( + 'namespace', + 'Select every wikitext page in this namespace ID, mirroring $wgNativeMarkdownNamespaces. An ' . + 'explicit namespace is a deliberate choice, so this works for any namespace, code pages aside.', + false, + true + ); + $this->addOption( 'dry-run', 'List the pages that would be converted without changing anything.' ); + + $this->setBatchSize( self::DEFAULT_BATCH_SIZE ); + + $this->requireExtension( 'Native Markdown' ); + } + + public function execute(): void { + if ( !$this->hasOption( 'md-suffix' ) && !$this->hasOption( 'namespace' ) ) { + $this->fatalError( 'Select pages to convert with --md-suffix, --namespace , or both.' ); + } + + if ( $this->hasOption( 'namespace' ) && !ctype_digit( (string)$this->getOption( 'namespace' ) ) ) { + // Reject a name where an ID is expected, so a typo like --namespace Help does not + // silently fall back to namespace 0 and convert the main namespace. + $this->fatalError( 'The --namespace value must be a namespace ID (a non-negative integer).' ); + } + + $dryRun = $this->hasOption( 'dry-run' ); + $policy = $this->newSelectionPolicy(); + $performer = $dryRun ? null : $this->newPerformer(); + + $candidateCount = 0; + $failureCount = 0; + $lastPageId = 0; + $batchSize = max( 1, $this->getBatchSize() ?? self::DEFAULT_BATCH_SIZE ); + + do { + $rows = $this->selectBatch( $lastPageId, $batchSize ); + $batchRowCount = $rows->numRows(); + + foreach ( $rows as $row ) { + /** @var \stdClass $row */ + $lastPageId = (int)$row->page_id; + $title = Title::newFromRow( $row ); + + if ( !$this->isConvertible( $title, $policy ) ) { + continue; + } + + $candidateCount++; + + if ( $performer !== null && !$this->convertPage( $title, $performer ) ) { + $failureCount++; + continue; + } + + $this->output( $title->getPrefixedText() . "\n" ); + } + + $this->waitForReplication(); + } while ( $batchRowCount === $batchSize ); + + $this->reportSummary( $dryRun, $candidateCount, $candidateCount - $failureCount ); + + if ( $failureCount > 0 ) { + $this->fatalError( "$failureCount pages could not be converted; see the errors above." ); + } + } + + /** + * The policy that decides, config-independently, which pages this run selects. It is built + * directly rather than from live wiki config, so the selectors mean the same on every wiki. + */ + private function newSelectionPolicy(): MarkdownDefaultPolicy { + if ( $this->hasOption( 'namespace' ) ) { + // Namespace mode, alone or combined: mirror $wgNativeMarkdownNamespaces for the one + // requested namespace. Combined mode layers the .md title check on top (isConvertible). + return new MarkdownDefaultPolicy( + namespaces: [ $this->namespaceOption() ], + everywhere: false, + suffixDetection: false + ); + } + + // --md-suffix alone: mirror the live suffix detection, namespace rules and all. + return new MarkdownDefaultPolicy( + namespaces: [], + everywhere: false, + suffixDetection: true + ); + } + + private function namespaceOption(): int { + return (int)$this->getOption( 'namespace' ); + } + + private function newPerformer(): User { + $user = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] ); + + if ( $user === null ) { + $this->fatalError( 'Could not obtain the maintenance system user to attribute conversions to.' ); + } + + return $user; + } + + /** + * A coarse batch of pages that could be candidates: current model wikitext (stored as such or + * left at the default), not a redirect, matching the selected namespace and/or .md suffix. The + * authoritative per-page checks live in isConvertible(); this only narrows the scan cheaply. + */ + private function selectBatch( int $afterPageId, int $batchSize ): IResultWrapper { + $db = $this->getReplicaDB(); + + $builder = $db->newSelectQueryBuilder() + ->select( [ 'page_id', 'page_namespace', 'page_title' ] ) + ->from( 'page' ) + ->where( [ + 'page_content_model' => [ null, $this->wikitextModel() ], + 'page_is_redirect' => 0, + ] ) + ->andWhere( $db->buildComparison( '>', [ 'page_id' => $afterPageId ] ) ) + ->orderBy( 'page_id' ) + ->limit( $batchSize ) + ->caller( __METHOD__ ); + + if ( $this->hasOption( 'namespace' ) ) { + $builder->andWhere( [ 'page_namespace' => $this->namespaceOption() ] ); + } + + if ( $this->hasOption( 'md-suffix' ) ) { + // page_title is the underscored DB key, but a trailing .md is unaffected by that. + $builder->andWhere( + $db->expr( 'page_title', IExpression::LIKE, new LikeValue( $db->anyString(), '.md' ) ) + ); + } + + return $builder->fetchResultSet(); + } + + /** + * @psalm-suppress UndefinedConstant CONTENT_MODEL_WIKITEXT is a MediaWiki global constant psalm + * cannot resolve from the scanned core files. + */ + private function wikitextModel(): string { + return (string)CONTENT_MODEL_WIKITEXT; + } + + /** + * Whether the page really qualifies, checked authoritatively rather than trusting the coarse + * query: its resolved current model must be wikitext (page_content_model can be NULL, which + * resolves through the default model), and the selection policy must accept it. + */ + private function isConvertible( Title $title, MarkdownDefaultPolicy $policy ): bool { + if ( $title->getContentModel() !== $this->wikitextModel() ) { + return false; + } + + $isTalkNamespace = $this->getServiceContainer()->getNamespaceInfo()->isTalk( $title->getNamespace() ); + + // pageExists is passed false deliberately: appliesTo() rejects every existing page, so we + // ask whether the title *would* default to Markdown if freshly created. + $applies = $policy->appliesTo( $title->getNamespace(), $isTalkNamespace, $title->getText(), false ); + + if ( !$applies ) { + return false; + } + + return !$this->requiresMdSuffixTitle() || str_ends_with( $title->getText(), '.md' ); + } + + /** + * In combined mode the explicit namespace is the deliberate namespace choice, so --md-suffix + * contributes only its title condition: the page must also end in .md. + */ + private function requiresMdSuffixTitle(): bool { + return $this->hasOption( 'md-suffix' ) && $this->hasOption( 'namespace' ); + } + + private function convertPage( Title $title, User $performer ): bool { + $change = $this->getServiceContainer() + ->getContentModelChangeFactory() + ->newContentModelChange( $performer, $title, NativeMarkdownExtension::CONTENT_MODEL ); + + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setTitle( $title ); + $context->setUser( $performer ); + + $status = $change->doContentModelChange( $context, self::EDIT_SUMMARY, true ); + + if ( $status->isGood() ) { + return true; + } + + $this->error( 'Failed to convert ' . $title->getPrefixedText() . ': ' . $this->describeStatus( $status ) ); + return false; + } + + private function describeStatus( Status $status ): string { + return trim( + $this->getServiceContainer()->getFormatterFactory() + ->getStatusFormatter( RequestContext::getMain() ) + ->getWikiText( $status ) + ); + } + + private function reportSummary( bool $dryRun, int $candidateCount, int $convertedCount ): void { + if ( $dryRun ) { + $this->output( "$candidateCount pages would be converted.\n" ); + return; + } + + $this->output( "Converted $convertedCount of $candidateCount candidate pages.\n" ); + } + +} + +// @codeCoverageIgnoreStart +$maintClass = ConvertToMarkdownModel::class; +/** @psalm-suppress UndefinedConstant, UnresolvableInclude */ +require_once RUN_MAINTENANCE_IF_MAIN; +// @codeCoverageIgnoreEnd diff --git a/phpcs.xml b/phpcs.xml index bf44b42..f68d551 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,6 +1,7 @@ src/ + maintenance/ tests/ diff --git a/phpstan.neon b/phpstan.neon index 17222a3..741bc56 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,9 +2,11 @@ parameters: level: 5 paths: - src + - maintenance - tests scanDirectories: - ../../includes + - ../../maintenance/includes - ../../tests/phpunit - ../../vendor # Optional Extension:SyntaxHighlight, referenced by the Persistence adapter @@ -14,3 +16,9 @@ parameters: bootstrapFiles: - ../../includes/AutoLoader.php treatPhpDocTypesAsCertain: false + ignoreErrors: + # RUN_MAINTENANCE_IF_MAIN is defined at runtime in maintenance/Maintenance.php, behind a + # defined() guard phpstan cannot resolve. It is the standard maintenance entry point constant. + - + message: '#^Constant RUN_MAINTENANCE_IF_MAIN not found\.$#' + path: maintenance/ConvertToMarkdownModel.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3786879..cb2f533 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,7 @@ tests/phpunit/EntryPoints + tests/phpunit/Maintenance tests/phpunit/Application/HeadingAnchorParityTest.php diff --git a/psalm.xml b/psalm.xml index 4ae1951..53cf0d5 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,9 +10,11 @@ > + + @@ -50,5 +52,22 @@ + + + + + + + + + + + + + + + + diff --git a/tests/phpunit/Maintenance/ConvertToMarkdownModelTest.php b/tests/phpunit/Maintenance/ConvertToMarkdownModelTest.php new file mode 100644 index 0000000..b8be665 --- /dev/null +++ b/tests/phpunit/Maintenance/ConvertToMarkdownModelTest.php @@ -0,0 +1,303 @@ +overrideConfigValues( [ + 'NativeMarkdownEverywhere' => false, + 'NativeMarkdownSuffixDetection' => false, + 'NativeMarkdownNamespaces' => [], + ] ); + } + + public function testMdSuffixModeConvertsMainNamespaceSuffixedPage(): void { + $plain = Title::makeTitle( NS_MAIN, 'Plain Wikitext' ); + $suffixed = Title::makeTitle( NS_MAIN, 'Guide.md' ); + $this->createWikitextPage( $plain ); + $this->createWikitextPage( $suffixed ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $suffixed ) ); + $this->assertSame( self::WIKITEXT_BODY, $this->currentTextOf( $suffixed ), 'The text must be unchanged.' ); + $this->assertSame( 2, $this->revisionCountOf( $suffixed ), 'Conversion must add exactly one revision.' ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $plain ), 'The non-.md page is untouched.' ); + } + + public function testMdSuffixModeConvertsTalkNamespaceSuffixedPage(): void { + $title = Title::makeTitle( NS_TALK, 'Discussion.md' ); + $this->createWikitextPage( $title ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $title ) ); + } + + public function testMdSuffixModeSkipsTemplateAndMediaWikiSuffixedPages(): void { + $included = Title::makeTitle( NS_MAIN, 'Included.md' ); + $template = Title::makeTitle( NS_TEMPLATE, 'Widget.md' ); + $interface = Title::makeTitle( NS_MEDIAWIKI, 'Notice.md' ); + $this->createWikitextPage( $included ); + $this->createWikitextPage( $template ); + $this->createWikitextPage( $interface ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $included ), 'A main-namespace .md page still converts.' ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $template ) ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $interface ) ); + } + + public function testMdSuffixModeIgnoresNonSuffixedPage(): void { + $title = Title::makeTitle( NS_MAIN, 'No Suffix Here' ); + $this->createWikitextPage( $title ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $title ) ); + $this->assertSame( 1, $this->revisionCountOf( $title ), 'No revision must be added.' ); + } + + public function testAlreadyMarkdownPageIsLeftUntouched(): void { + $title = Title::makeTitle( NS_MAIN, 'Native.md' ); + $this->createMarkdownPage( $title ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $title ) ); + $this->assertSame( 1, $this->revisionCountOf( $title ), 'A page already on markdown gets no new revision.' ); + } + + public function testConvertsPageWhoseStoredContentModelIsNull(): void { + $title = Title::makeTitle( NS_MAIN, 'Null Model.md' ); + $this->createWikitextPage( $title ); + $this->nullOutStoredContentModel( $title ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $title ) ); + } + + public function testNamespaceModeConvertsAllWikitextPagesInNamespace(): void { + $plain = Title::makeTitle( NS_HELP, 'Getting Started' ); + $suffixed = Title::makeTitle( NS_HELP, 'Notes.md' ); + $elsewhere = Title::makeTitle( NS_MAIN, 'Unrelated' ); + $this->createWikitextPage( $plain ); + $this->createWikitextPage( $suffixed ); + $this->createWikitextPage( $elsewhere ); + + $this->runConversion( [ 'namespace' => NS_HELP ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $plain ), 'A non-.md page in the namespace converts.' ); + $this->assertSame( 'markdown', $this->currentModelOf( $suffixed ) ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $elsewhere ), 'Other namespaces are untouched.' ); + } + + public function testNamespaceModeConvertsTemplatePageGivenExplicitTemplateNamespace(): void { + $title = Title::makeTitle( NS_TEMPLATE, 'Infobox' ); + $this->createWikitextPage( $title ); + + $this->runConversion( [ 'namespace' => NS_TEMPLATE ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $title ) ); + } + + public function testNamespaceModeSkipsCodePageTitles(): void { + $title = Title::makeTitle( NS_HELP, 'Gadget.js' ); + $this->createWikitextPage( $title ); + + $this->runConversion( [ 'namespace' => NS_HELP ] ); + + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $title ) ); + } + + public function testCombinedModeConvertsOnlySuffixedPagesInSelectedNamespace(): void { + $suffixedInside = Title::makeTitle( NS_HELP, 'Reference.md' ); + $plainInside = Title::makeTitle( NS_HELP, 'Overview' ); + $suffixedOutside = Title::makeTitle( NS_MAIN, 'Outside.md' ); + $this->createWikitextPage( $suffixedInside ); + $this->createWikitextPage( $plainInside ); + $this->createWikitextPage( $suffixedOutside ); + + $this->runConversion( [ 'md-suffix' => 1, 'namespace' => NS_HELP ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $suffixedInside ) ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $plainInside ), 'Non-.md pages in the namespace stay.' ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $suffixedOutside ), '.md pages outside the namespace stay.' ); + } + + public function testCombinedModeConvertsSuffixedTemplatePageGivenTemplateNamespace(): void { + $suffixed = Title::makeTitle( NS_TEMPLATE, 'Card.md' ); + $plain = Title::makeTitle( NS_TEMPLATE, 'Plain' ); + $this->createWikitextPage( $suffixed ); + $this->createWikitextPage( $plain ); + + $this->runConversion( [ 'md-suffix' => 1, 'namespace' => NS_TEMPLATE ] ); + + $this->assertSame( 'markdown', $this->currentModelOf( $suffixed ) ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $plain ) ); + } + + public function testRedirectPagesAreSkipped(): void { + $title = Title::makeTitle( NS_MAIN, 'Redirect Source.md' ); + $this->editPage( + $title, + new WikitextContent( '#REDIRECT [[Redirect Target]]' ), + '', + NS_MAIN, + $this->getTestSysop()->getUser() + ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $title ) ); + $this->assertTrue( $this->isStoredAsRedirect( $title ), 'The page must remain a redirect.' ); + } + + public function testDryRunListsCandidatesWithoutConverting(): void { + $title = Title::makeTitle( NS_MAIN, 'Preview.md' ); + $this->createWikitextPage( $title ); + + $this->expectOutputRegex( '/' . preg_quote( $title->getPrefixedText(), '/' ) . '.*would be converted/s' ); + + $this->runConversion( [ 'md-suffix' => 1, 'dry-run' => 1 ] ); + + $this->assertSame( CONTENT_MODEL_WIKITEXT, $this->currentModelOf( $title ), 'A dry run must change nothing.' ); + $this->assertSame( 1, $this->revisionCountOf( $title ) ); + } + + public function testFatalErrorWhenNoSelectorProvided(): void { + $this->expectCallToFatalError(); + + $this->maintenance->execute(); + } + + public function testFatalErrorWhenNamespaceIsNotNumeric(): void { + $this->expectCallToFatalError(); + $this->maintenance->setOption( 'namespace', 'Help' ); + + $this->maintenance->execute(); + } + + public function testConvertedRevisionIsAttributedToTheMaintenanceUser(): void { + $title = Title::makeTitle( NS_MAIN, 'Attributed.md' ); + $this->createWikitextPage( $title ); + + $this->runConversion( [ 'md-suffix' => 1 ] ); + + $this->assertSame( User::MAINTENANCE_SCRIPT_USER, $this->latestAuthorOf( $title ) ); + } + + public function testBatchingConvertsEveryCandidate(): void { + $first = Title::makeTitle( NS_MAIN, 'Batch One.md' ); + $second = Title::makeTitle( NS_MAIN, 'Batch Two.md' ); + $third = Title::makeTitle( NS_MAIN, 'Batch Three.md' ); + $this->createWikitextPage( $first ); + $this->createWikitextPage( $second ); + $this->createWikitextPage( $third ); + + // Drive the real option so a batch size smaller than the candidate count is exercised. + $this->maintenance->loadWithArgv( [ '--md-suffix', '--batch-size=1' ] ); + $this->maintenance->execute(); + + $this->assertSame( 'markdown', $this->currentModelOf( $first ) ); + $this->assertSame( 'markdown', $this->currentModelOf( $second ) ); + $this->assertSame( 'markdown', $this->currentModelOf( $third ) ); + } + + private function createWikitextPage( Title $title, string $text = self::WIKITEXT_BODY ): void { + $this->editPage( $title, new WikitextContent( $text ), '', NS_MAIN, $this->getTestSysop()->getUser() ); + } + + private function createMarkdownPage( Title $title ): void { + $this->editPage( $title, new MarkdownContent( "# Heading\n\nBody." ), '', NS_MAIN, $this->getTestSysop()->getUser() ); + } + + /** + * @param array $options + */ + private function runConversion( array $options ): void { + foreach ( $options as $name => $value ) { + $this->maintenance->setOption( $name, $value ); + } + + $this->maintenance->execute(); + } + + /** + * Mimics core storing NULL when a page's model equals its namespace default, so the resolved + * model (not the raw column) is what decides candidacy. + */ + private function nullOutStoredContentModel( Title $title ): void { + $this->getDb()->newUpdateQueryBuilder() + ->update( 'page' ) + ->set( [ 'page_content_model' => null ] ) + ->where( [ 'page_id' => $title->getId() ] ) + ->caller( __METHOD__ ) + ->execute(); + } + + private function currentModelOf( Title $title ): string { + return $this->latestRevision( $title )->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel(); + } + + private function currentTextOf( Title $title ): string { + return $this->latestRevision( $title )->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getContent()->serialize(); + } + + private function latestAuthorOf( Title $title ): string { + return $this->latestRevision( $title )->getUser( RevisionRecord::RAW )?->getName() ?? ''; + } + + private function latestRevision( Title $title ): RevisionRecord { + return $this->getServiceContainer()->getRevisionLookup() + ->getRevisionByTitle( $title, 0, IDBAccessObject::READ_LATEST ); + } + + private function revisionCountOf( Title $title ): int { + return (int)$this->getDb()->newSelectQueryBuilder() + ->select( 'COUNT(*)' ) + ->from( 'revision' ) + ->where( [ 'rev_page' => $title->getId() ] ) + ->caller( __METHOD__ ) + ->fetchField(); + } + + private function isStoredAsRedirect( Title $title ): bool { + return (bool)$this->getDb()->newSelectQueryBuilder() + ->select( 'page_is_redirect' ) + ->from( 'page' ) + ->where( [ 'page_id' => $title->getId() ] ) + ->caller( __METHOD__ ) + ->fetchField(); + } + +}