From e271d862005e2eec2c8dd9d15cfedd3707b91329 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Fri, 17 Jul 2026 03:13:49 +0200 Subject: [PATCH] Add maintenance script to convert existing pages to the markdown content model Fixes https://github.com/ProfessionalWiki/NativeMarkdown/issues/40 The activation settings ($wgNativeMarkdownSuffixDetection, $wgNativeMarkdownNamespaces) only default new pages to the Markdown content model; pages that already exist keep their stored model. This adds the retroactive counterpart: a maintenance script that selects existing wikitext pages the same way those settings select new ones and switches them to Markdown. Run it as `php maintenance/run.php NativeMarkdown:ConvertToMarkdownModel`, with `--dry-run` and `--batch-size` supported. Two combinable selectors pick the pages, at least one required. `--md-suffix` mirrors live `.md` suffix detection (titles ending in `.md`, outside the Template and MediaWiki namespaces, Talk included), and `--namespace ` mirrors $wgNativeMarkdownNamespaces (every page in that namespace, any namespace, since an explicit namespace is a deliberate choice). Both build MarkdownDefaultPolicy directly, the same policy the creation-time hook uses, so the script and the live behaviour cannot drift, and they are independent of the wiki's own configuration. Given together the selectors intersect to the `.md`-titled pages within that one namespace, so `--md-suffix --namespace 10` converts the `.md` pages in the Template namespace that `--md-suffix` alone deliberately skips. The alternative, intersecting the suffix policy's own namespace rules, was rejected because it would make that combination select nothing. Only pages whose resolved current model is wikitext are ever converted, checked in PHP via Title::getContentModel() rather than trusting page_content_model, which core stores as NULL whenever a page's model equals its namespace default. Such NULL-model pages are candidates, while a NULL that resolves to a css/js/json code model is correctly left alone. Redirects are skipped: the Markdown model reads the same `#REDIRECT [[Target]]` syntax, so converting one would only add a revision without changing how it behaves. Each conversion goes through core's ContentModelChange service, which writes a new revision with the text unchanged plus a `contentmodel` log entry, attributed to the maintenance system user, which satisfies the issue's attribution requirement. The class name is UpperCamel (ConvertToMarkdownModel), not the issue's `convertToMarkdownModel` example, because run.php resolves extension scripts by directory name and case-sensitive filename and expects the filename to match the class for clean PSR-4 autoloading; CirrusSearch and AbuseFilter follow the same convention. Note that MediaWiki 1.43 does not ship maintenance/changeContentModel.php (the stopgap the issue mentions), so this script is the only command-line path for a bulk model change; Special:ChangeContentModel remains the per-page, two-way tool. Co-Authored-By: Claude Opus 4.8 --- README.md | 32 ++ extension.json | 1 + maintenance/ConvertToMarkdownModel.php | 278 ++++++++++++++++ phpcs.xml | 1 + phpstan.neon | 8 + phpunit.xml.dist | 1 + psalm.xml | 19 ++ .../ConvertToMarkdownModelTest.php | 303 ++++++++++++++++++ 8 files changed, 643 insertions(+) create mode 100644 maintenance/ConvertToMarkdownModel.php create mode 100644 tests/phpunit/Maintenance/ConvertToMarkdownModelTest.php 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(); + } + +}