MediaWiki master
namespaceDupes.php
Go to the documentation of this file.
1<?php
13// @codeCoverageIgnoreStart
14require_once __DIR__ . '/Maintenance.php';
15// @codeCoverageIgnoreEnd
16
35
43
48 private $resolvablePages = 0;
49
54 private $totalPages = 0;
55
60 private $resolvableLinks = 0;
61
66 private $totalLinks = 0;
67
73 private $deletedLinks = 0;
74
75 public function __construct() {
76 parent::__construct();
77 $this->addDescription( 'Find and fix pages affected by namespace addition/removal' );
78 $this->addOption( 'fix', 'Attempt to automatically fix errors and delete broken links' );
79 $this->addOption( 'merge', "Instead of renaming conflicts, do a history merge with " .
80 "the correct title" );
81 $this->addOption( 'add-suffix', "Dupes will be renamed with correct namespace with " .
82 "<text> appended after the article name", false, true );
83 $this->addOption( 'add-prefix', "Dupes will be renamed with correct namespace with " .
84 "<text> prepended before the article name", false, true );
85 $this->addOption( 'source-pseudo-namespace', "Move all pages with the given source " .
86 "prefix (with an implied colon following it). If --dest-namespace is not specified, " .
87 "the colon will be replaced with a hyphen.",
88 false, true );
89 $this->addOption( 'dest-namespace', "In combination with --source-pseudo-namespace, " .
90 "specify the namespace ID of the destination.", false, true );
91 }
92
93 public function execute() {
94 $options = [
95 'fix' => $this->hasOption( 'fix' ),
96 'merge' => $this->hasOption( 'merge' ),
97 'add-suffix' => $this->getOption( 'add-suffix', '' ),
98 'add-prefix' => $this->getOption( 'add-prefix', '' ),
99 'source-pseudo-namespace' => $this->getOption( 'source-pseudo-namespace', '' ),
100 'dest-namespace' => intval( $this->getOption( 'dest-namespace', 0 ) )
101 ];
102
103 if ( $options['source-pseudo-namespace'] !== '' ) {
104 $retval = $this->checkPrefix( $options );
105 } else {
106 $retval = $this->checkAll( $options );
107 }
108
109 if ( $retval ) {
110 $this->output( "\nLooks good!\n" );
111 } else {
112 $this->output( "\nOh noeees\n" );
113 }
114 }
115
123 private function checkAll( $options ) {
124 $contLang = $this->getServiceContainer()->getContentLanguage();
125 $interwikis = [];
126 $spaces = [];
127
128 foreach ( $this->getInterwikiList() as $prefix ) {
129 $name = $contLang->ucfirst( $prefix );
130 $interwikis[$name] = 0;
131 }
132
133 // Now pull in all canonical and alias namespaces...
134 foreach (
135 $this->getServiceContainer()->getNamespaceInfo()->getCanonicalNamespaces()
136 as $ns => $name
137 ) {
138 // This includes $wgExtraNamespaces
139 if ( $name !== '' ) {
140 $spaces[$name] = $ns;
141 }
142 }
143 foreach ( $contLang->getNamespaces() as $ns => $name ) {
144 if ( $name !== '' ) {
145 $spaces[$name] = $ns;
146 }
147 }
148 foreach ( $contLang->getNamespaceAliases() as $name => $ns ) {
149 $spaces[$name] = $ns;
150 }
151
152 // We'll need to check for lowercase keys as well,
153 // since we're doing case-sensitive searches in the db.
154 $capitalLinks = $this->getConfig()->get( MainConfigNames::CapitalLinks );
155 foreach ( $spaces as $name => $ns ) {
156 $moreNames = [
157 $contLang->uc( $name ),
158 $contLang->ucfirst( $contLang->lc( $name ) ),
159 $contLang->ucwords( $name ),
160 $contLang->ucwords( $contLang->lc( $name ) ),
161 $contLang->ucwordbreaks( $name ),
162 $contLang->ucwordbreaks( $contLang->lc( $name ) ),
163 ];
164 if ( !$capitalLinks ) {
165 foreach ( $moreNames as $altName ) {
166 $moreNames[] = $contLang->lcfirst( $altName );
167 }
168 $moreNames[] = $contLang->lcfirst( $name );
169 }
170 foreach ( array_unique( $moreNames ) as $altName ) {
171 if ( $altName !== $name ) {
172 $spaces[$altName] = $ns;
173 }
174 }
175 }
176
177 // Add interwikis after all namespaces are set up to make sure namespaces overwrite interwikis
178 $spaces += $interwikis;
179
180 // Sort by namespace index, and if there are two with the same index,
181 // break the tie by sorting by name
182 $origSpaces = $spaces;
183 uksort( $spaces, static function ( $a, $b ) use ( $origSpaces ) {
184 return $origSpaces[$a] <=> $origSpaces[$b]
185 ?: $a <=> $b;
186 } );
187
188 $ok = true;
189 foreach ( $spaces as $name => $ns ) {
190 $ok = $this->checkNamespace( $ns, $name, $options ) && $ok;
191 }
192
193 $this->output(
194 "{$this->totalPages} pages to fix, " .
195 "{$this->resolvablePages} were resolvable.\n\n"
196 );
197
198 foreach ( $spaces as $name => $ns ) {
199 if ( $ns != 0 ) {
200 /* Fix up link destinations for non-interwiki links only.
201 *
202 * For example if a page has [[Foo:Bar]] and then a Foo namespace
203 * is introduced, pagelinks needs to be updated to have
204 * page_namespace = NS_FOO.
205 *
206 * If instead an interwiki prefix was introduced called "Foo",
207 * the link should instead be moved to the iwlinks table. If a new
208 * language is introduced called "Foo", or if there is a pagelink
209 * [[fr:Bar]] when interlanguage magic links are turned on, the
210 * link would have to be moved to the langlinks table. Let's put
211 * those cases in the too-hard basket for now. The consequences are
212 * not especially severe.
213 * @fixme Handle interwiki links, and pagelinks to Category:, File:
214 * which probably need reparsing.
215 */
216
217 $this->checkLinkTable( 'pagelinks', 'pl', $ns, $name, $options );
218 $this->checkLinkTable( 'templatelinks', 'tl', $ns, $name, $options );
219
220 // The redirect table has interwiki links randomly mixed in, we
221 // need to filter those out. For example [[w:Foo:Bar]] would
222 // have rd_interwiki=w and rd_namespace=0, which would match the
223 // query for a conflicting namespace "Foo" if filtering wasn't done.
224 $this->checkLinkTable( 'redirect', 'rd', $ns, $name, $options,
225 [ 'rd_interwiki' => '' ] );
226 }
227 }
228
229 $this->output(
230 "{$this->totalLinks} links to fix, " .
231 "{$this->resolvableLinks} were resolvable, " .
232 "{$this->deletedLinks} were deleted.\n"
233 );
234
235 return $ok;
236 }
237
241 private function getInterwikiList() {
242 $result = $this->getServiceContainer()->getInterwikiLookup()->getAllPrefixes();
243 return array_column( $result, 'iw_prefix' );
244 }
245
246 private function isSingleRevRedirectTo( Title $oldTitle, Title $newTitle ): bool {
247 if ( !$oldTitle->isSingleRevRedirect() ) {
248 return false;
249 }
250 $revStore = $this->getServiceContainer()->getRevisionStore();
251 $rev = $revStore->getRevisionByTitle( $oldTitle, 0, IDBAccessObject::READ_LATEST );
252 if ( !$rev ) {
253 return false;
254 }
255 $content = $rev->getContent( SlotRecord::MAIN );
256 if ( !$content ) {
257 return false;
258 }
259 $target = $content->getRedirectTarget();
260 return $target && $target->equals( $newTitle );
261 }
262
263 private function deletePage( Title $pageToDelete, string $reason ): Status {
264 $services = $this->getServiceContainer();
265 $page = $services->getWikiPageFactory()->newFromTitle( $pageToDelete );
266 $user = User::newSystemUser( "Maintenance script" );
267 $deletePage = $services->getDeletePageFactory()->newDeletePage( $page, $user );
268 return $deletePage->deleteUnsafe( $reason );
269 }
270
279 private function checkNamespace( $ns, $name, $options ) {
280 $targets = $this->getTargetList( $ns, $name );
281 $count = $targets->numRows();
282 $this->totalPages += $count;
283 if ( $count == 0 ) {
284 return true;
285 }
286
287 $dryRunNote = $options['fix'] ? '' : ' DRY RUN ONLY';
288
289 $ok = true;
290 foreach ( $targets as $row ) {
291 // Find the new title and determine the action to take
292
293 $newTitle = $this->getDestinationTitle(
294 $ns, $name, $row->page_namespace, $row->page_title );
295 $logStatus = false;
296 // $oldTitle is not a valid title by definition but the methods I use here
297 // shouldn't care
298 $oldTitle = Title::makeTitle( $row->page_namespace, $row->page_title );
299 if ( !$newTitle ) {
300 if ( $options['add-prefix'] == '' && $options['add-suffix'] == '' ) {
301 $logStatus = 'invalid title and --add-prefix not specified';
302 $action = 'abort';
303 } else {
304 $action = 'alternate';
305 }
306 } elseif ( $newTitle->exists( IDBAccessObject::READ_LATEST ) ) {
307 if ( $this->isSingleRevRedirectTo( $newTitle, $newTitle ) ) {
308 // Conceptually this is the new title redirecting to the old title
309 // except that the redirect target is parsed as wikitext so is actually
310 // appears to redirect to itself
311 $action = 'delete-new';
312 } elseif ( $options['merge'] ) {
313 if ( $this->canMerge( $row->page_id, $newTitle, $logStatus ) ) {
314 $action = 'merge';
315 } else {
316 $action = 'abort';
317 }
318 } elseif ( $options['add-prefix'] == '' && $options['add-suffix'] == '' ) {
319 $action = 'abort';
320 $logStatus = 'dest title exists and --add-prefix not specified';
321 } else {
322 $action = 'alternate';
323 }
324 } else {
325 $action = 'move';
326 $logStatus = 'no conflict';
327 }
328 if ( $action === 'alternate' ) {
329 [ $ns, $dbk ] = $this->getDestination( $ns, $name, $row->page_namespace,
330 $row->page_title );
331 $altTitle = $this->getAlternateTitle( $ns, $dbk, $options );
332 if ( !$altTitle ) {
333 $action = 'abort';
334 $logStatus = 'alternate title is invalid';
335 } elseif ( $altTitle->exists() ) {
336 $action = 'abort';
337 $logStatus = 'alternate title conflicts';
338 } elseif ( $this->isSingleRevRedirectTo( $oldTitle, $newTitle ) ) {
339 $action = 'delete-old';
340 $newTitle = $altTitle;
341 } else {
342 $action = 'move';
343 $logStatus = 'alternate';
344 $newTitle = $altTitle;
345 }
346 }
347
348 // Take the action or log a dry run message
349
350 $logTitle = "id={$row->page_id} ns={$row->page_namespace} dbk={$row->page_title}";
351 $pageOK = true;
352
353 switch ( $action ) {
354 case 'delete-old':
355 $this->output( "$logTitle move to " . $newTitle->getPrefixedDBKey() .
356 " then delete as single-revision redirect to new home$dryRunNote\n" );
357 if ( $options['fix'] ) {
358 // First move the page so the delete command gets a valid title
359 $pageOK = $this->movePage( $row->page_id, $newTitle );
360 if ( $pageOK ) {
361 $status = $this->deletePage(
362 $newTitle,
363 "Non-normalized title already redirects to new form"
364 );
365 if ( !$status->isOK() ) {
366 $this->error( $status );
367 $pageOK = false;
368 }
369 }
370 }
371 break;
372 case "delete-new":
373 $this->output( "$logTitle -> " .
374 $newTitle->getPrefixedDBkey() . " delete existing page $dryRunNote\n" );
375 if ( $options['fix'] ) {
376 $status = $this->deletePage( $newTitle, "Delete circular redirect to make way for move" );
377 $pageOK = $status->isOK();
378 if ( $pageOK ) {
379 $pageOK = $this->movePage( $row->page_id, $newTitle );
380 } else {
381 $this->error( $status );
382 }
383 }
384 break;
385 case 'abort':
386 $this->output( "$logTitle *** $logStatus\n" );
387 $pageOK = false;
388 break;
389 case 'move':
390 $this->output( "$logTitle -> " .
391 $newTitle->getPrefixedDBkey() . " ($logStatus)$dryRunNote\n" );
392
393 if ( $options['fix'] ) {
394 $pageOK = $this->movePage( $row->page_id, $newTitle );
395 }
396 break;
397 case 'merge':
398 $this->output( "$logTitle => " .
399 $newTitle->getPrefixedDBkey() . " (merge)$dryRunNote\n" );
400
401 if ( $options['fix'] ) {
402 $pageOK = $this->mergePage( $row, $newTitle );
403 }
404 break;
405 }
406
407 if ( $pageOK ) {
408 $this->resolvablePages++;
409 } else {
410 $ok = false;
411 }
412 }
413
414 return $ok;
415 }
416
426 private function checkLinkTable( $table, $fieldPrefix, $ns, $name, $options,
427 $extraConds = []
428 ) {
429 $domainMap = [
430 'templatelinks' => TemplateLinksTable::VIRTUAL_DOMAIN,
431 'imagelinks' => ImageLinksTable::VIRTUAL_DOMAIN,
432 'pagelinks' => PageLinksTable::VIRTUAL_DOMAIN,
433 ];
434
435 if ( isset( $domainMap[$table] ) ) {
436 $dbw = $this->getServiceContainer()->getConnectionProvider()->getPrimaryDatabase( $domainMap[$table] );
437 } else {
438 $dbw = $this->getPrimaryDB();
439 }
440
441 $batchConds = [];
442 $fromField = "{$fieldPrefix}_from";
443 $batchSize = 100;
444 $sqb = $dbw->newSelectQueryBuilder()
445 ->select( $fromField )
446 ->where( $extraConds )
447 ->limit( $batchSize );
448
449 $linksMigration = $this->getServiceContainer()->getLinksMigration();
450 if ( isset( $linksMigration::$mapping[$table] ) ) {
451 $sqb->queryInfo( $linksMigration->getQueryInfo( $table ) );
452 [ $namespaceField, $titleField ] = $linksMigration->getTitleFields( $table );
453 $linkTargetLookup = $this->getServiceContainer()->getLinkTargetLookup();
454 $targetIdField = $linksMigration::$mapping[$table]['target_id'];
455 } else {
456 $sqb->table( $table );
457 $namespaceField = "{$fieldPrefix}_namespace";
458 $titleField = "{$fieldPrefix}_title";
459 $sqb->fields( [ $namespaceField, $titleField ] );
460 // Variables only used for links migration, init only
461 $linkTargetLookup = null;
462 $targetIdField = '';
463 }
464 $sqb->andWhere( [
465 $namespaceField => 0,
466 $dbw->expr( $titleField, IExpression::LIKE, new LikeValue( "$name:", $dbw->anyString() ) ),
467 ] )
468 ->orderBy( [ $titleField, $fromField ] )
469 ->caller( __METHOD__ );
470
471 $updateRowsPerQuery = $this->getConfig()->get( MainConfigNames::UpdateRowsPerQuery );
472 while ( true ) {
473 $res = ( clone $sqb )
474 ->andWhere( $batchConds )
475 ->fetchResultSet();
476 if ( $res->numRows() == 0 ) {
477 break;
478 }
479
480 $rowsToDeleteIfStillExists = [];
481
482 foreach ( $res as $row ) {
483 $logTitle = "from={$row->$fromField} ns={$row->$namespaceField} " .
484 "dbk={$row->$titleField}";
485 $destTitle = $this->getDestinationTitle(
486 $ns, $name, $row->$namespaceField, $row->$titleField );
487 $this->totalLinks++;
488 if ( !$destTitle ) {
489 $this->output( "$table $logTitle *** INVALID\n" );
490 continue;
491 }
492 $this->resolvableLinks++;
493 if ( !$options['fix'] ) {
494 $this->output( "$table $logTitle -> " .
495 $destTitle->getPrefixedDBkey() . " DRY RUN\n" );
496 continue;
497 }
498
499 if ( isset( $linksMigration::$mapping[$table] ) ) {
500 $setValue = [
501 $targetIdField => $linkTargetLookup->acquireLinkTargetId( $destTitle, $dbw )
502 ];
503 $whereCondition = $linksMigration->getLinksConditions(
504 $table,
505 new TitleValue( 0, $row->$titleField )
506 );
507 $deleteCondition = $linksMigration->getLinksConditions(
508 $table,
509 new TitleValue( (int)$row->$namespaceField, $row->$titleField )
510 );
511 } else {
512 $setValue = [
513 $namespaceField => $destTitle->getNamespace(),
514 $titleField => $destTitle->getDBkey()
515 ];
516 $whereCondition = [
517 $namespaceField => 0,
518 $titleField => $row->$titleField
519 ];
520 $deleteCondition = [
521 $namespaceField => $row->$namespaceField,
522 $titleField => $row->$titleField,
523 ];
524 }
525
526 $dbw->newUpdateQueryBuilder()
527 ->update( $table )
528 ->ignore()
529 ->set( $setValue )
530 ->where( [ $fromField => $row->$fromField ] )
531 ->andWhere( $whereCondition )
532 ->caller( __METHOD__ )
533 ->execute();
534
535 // In case there is a key conflict on UPDATE IGNORE the row needs deletion
536 $rowsToDeleteIfStillExists[] = array_merge( [ $fromField => $row->$fromField ], $deleteCondition );
537
538 $this->output( "$table $logTitle -> " .
539 $destTitle->getPrefixedDBkey() . "\n"
540 );
541 }
542
543 if ( $options['fix'] && count( $rowsToDeleteIfStillExists ) > 0 ) {
544 $affectedRows = 0;
545 $deleteBatches = array_chunk( $rowsToDeleteIfStillExists, $updateRowsPerQuery );
546 foreach ( $deleteBatches as $deleteBatch ) {
547 $dbw->newDeleteQueryBuilder()
548 ->deleteFrom( $table )
549 ->where( $dbw->factorConds( $deleteBatch ) )
550 ->caller( __METHOD__ )
551 ->execute();
552 $affectedRows += $dbw->affectedRows();
553 if ( count( $deleteBatches ) > 1 ) {
554 $this->waitForReplication();
555 }
556 }
557
558 $this->deletedLinks += $affectedRows;
559 $this->resolvableLinks -= $affectedRows;
560 }
561
562 $batchConds = [
563 $dbw->buildComparison( '>', [
564 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable rows contains at least one item
565 $titleField => $row->$titleField,
566 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable rows contains at least one item
567 $fromField => $row->$fromField,
568 ] )
569 ];
570
571 $this->waitForReplication();
572 }
573 }
574
582 private function checkPrefix( $options ) {
583 $prefix = $options['source-pseudo-namespace'];
584 $ns = $options['dest-namespace'];
585 $this->output( "Checking prefix \"$prefix\" vs namespace $ns\n" );
586
587 return $this->checkNamespace( $ns, $prefix, $options );
588 }
589
599 private function getTargetList( $ns, $name ) {
600 $dbw = $this->getPrimaryDB();
601
602 if (
603 $this->getServiceContainer()->getNamespaceInfo()->isSubject( $ns )
604 ) {
605 $checkNamespaces = [ NS_MAIN, NS_TALK ];
606 } else {
607 $checkNamespaces = NS_MAIN;
608 }
609
610 return $dbw->newSelectQueryBuilder()
611 ->select( [ 'page_id', 'page_title', 'page_namespace' ] )
612 ->from( 'page' )
613 ->where( [
614 'page_namespace' => $checkNamespaces,
615 $dbw->expr( 'page_title', IExpression::LIKE, new LikeValue( "$name:", $dbw->anyString() ) ),
616 ] )
617 ->caller( __METHOD__ )->fetchResultSet();
618 }
619
628 private function getDestination( $ns, $name, $sourceNs, $sourceDbk ) {
629 $dbk = substr( $sourceDbk, strlen( "$name:" ) );
630 if ( $ns <= 0 ) {
631 // An interwiki or an illegal namespace like "Special" or "Media"
632 // try an alternate encoding with '-' for ':'
633 $dbk = "$name-" . $dbk;
634 $ns = 0;
635 }
636 $destNS = $ns;
637 $nsInfo = $this->getServiceContainer()->getNamespaceInfo();
638 if ( $sourceNs == NS_TALK && $nsInfo->isSubject( $ns ) ) {
639 // This is an associated talk page
640 $destNS = $nsInfo->getTalk( $destNS );
641 }
642 return [ $destNS, $dbk ];
643 }
644
653 private function getDestinationTitle( $ns, $name, $sourceNs, $sourceDbk ) {
654 [ $destNS, $dbk ] = $this->getDestination( $ns, $name, $sourceNs, $sourceDbk );
655 $newTitle = Title::makeTitleSafe( $destNS, $dbk );
656 if ( !$newTitle || !$newTitle->canExist() ) {
657 return false;
658 }
659 return $newTitle;
660 }
661
671 private function getAlternateTitle( $ns, $dbk, $options ) {
672 $prefix = $options['add-prefix'];
673 $suffix = $options['add-suffix'];
674 if ( $prefix == '' && $suffix == '' ) {
675 return false;
676 }
677 $newDbk = $prefix . $dbk . $suffix;
678 return Title::makeTitleSafe( $ns, $newDbk );
679 }
680
688 private function movePage( $id, LinkTarget $newLinkTarget ) {
689 $dbw = $this->getPrimaryDB();
690
691 $update = $dbw->newUpdateQueryBuilder()
692 ->update( 'page' )
693 ->set( [
694 "page_namespace" => $newLinkTarget->getNamespace(),
695 "page_title" => $newLinkTarget->getDBkey(),
696 ] )
697 ->where( [
698 "page_id" => $id,
699 ] )
700 ->caller( __METHOD__ );
701 $update->execute();
702 $this->getServiceContainer()->getLinkWriteDuplicator()->duplicate( $update );
703
704 // Update *_from_namespace in links tables
705 $fromNamespaceTables = [
706 [ 'templatelinks', 'tl', [ 'tl_target_id' ] ],
707 [ 'pagelinks', 'pl', [ 'pl_target_id' ] ],
708 [ 'imagelinks', 'il', [ 'il_target_id' ] ],
709 ];
710
711 $updateRowsPerQuery = $this->getConfig()->get( MainConfigNames::UpdateRowsPerQuery );
712
713 foreach ( $fromNamespaceTables as [ $table, $fieldPrefix, $additionalPrimaryKeyFields ] ) {
714 $domainMap = [
715 'templatelinks' => TemplateLinksTable::VIRTUAL_DOMAIN,
716 'imagelinks' => ImageLinksTable::VIRTUAL_DOMAIN,
717 'pagelinks' => PageLinksTable::VIRTUAL_DOMAIN,
718 ];
719
720 $dbw = $this->getServiceContainer()->getConnectionProvider()->getPrimaryDatabase( $domainMap[$table] );
721
722 $fromField = "{$fieldPrefix}_from";
723 $fromNamespaceField = "{$fieldPrefix}_from_namespace";
724
725 $res = $dbw->newSelectQueryBuilder()
726 ->select( $additionalPrimaryKeyFields )
727 ->from( $table )
728 ->where( [ $fromField => $id ] )
729 ->andWhere( $dbw->expr( $fromNamespaceField, '!=', $newLinkTarget->getNamespace() ) )
730 ->caller( __METHOD__ )
731 ->fetchResultSet();
732 if ( !$res ) {
733 continue;
734 }
735
736 $updateConds = [];
737 foreach ( $res as $row ) {
738 $updateConds[] = array_merge( [ $fromField => $id ], (array)$row );
739 }
740 $updateBatches = array_chunk( $updateConds, $updateRowsPerQuery );
741 foreach ( $updateBatches as $updateBatch ) {
742 $this->beginTransactionRound( __METHOD__ );
743 $dbw->newUpdateQueryBuilder()
744 ->update( $table )
745 ->set( [ $fromNamespaceField => $newLinkTarget->getNamespace() ] )
746 ->where( $dbw->factorConds( $updateBatch ) )
747 ->caller( __METHOD__ )
748 ->execute();
749 $this->commitTransactionRound( __METHOD__ );
750 }
751 }
752
753 return true;
754 }
755
768 private function canMerge( $id, PageIdentity $page, &$logStatus ) {
769 $revisionLookup = $this->getServiceContainer()->getRevisionLookup();
770 $latestDest = $revisionLookup->getRevisionByTitle( $page, 0,
771 IDBAccessObject::READ_LATEST );
772 $latestSource = $revisionLookup->getRevisionByPageId( $id, 0,
773 IDBAccessObject::READ_LATEST );
774 if ( $latestSource->getTimestamp() > $latestDest->getTimestamp() ) {
775 $logStatus = 'cannot merge since source is later';
776 return false;
777 } else {
778 return true;
779 }
780 }
781
789 private function mergePage( $row, Title $newTitle ) {
790 $updateRowsPerQuery = $this->getConfig()->get( MainConfigNames::UpdateRowsPerQuery );
791
792 $id = $row->page_id;
793
794 // Construct the WikiPage object we will need later, while the
795 // page_id still exists. Note that this cannot use makeTitleSafe(),
796 // we are deliberately constructing an invalid title.
797 $sourceTitle = Title::makeTitle( $row->page_namespace, $row->page_title );
798 $sourceTitle->resetArticleID( $id );
799 $wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $sourceTitle );
800 $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
801 $destId = $newTitle->getArticleID();
802
803 $dbw = $this->getPrimaryDB();
804 $this->beginTransactionRound( __METHOD__ );
805 $revIds = $dbw->newSelectQueryBuilder()
806 ->select( 'rev_id' )
807 ->from( 'revision' )
808 ->where( [ 'rev_page' => $id ] )
809 ->caller( __METHOD__ )
810 ->fetchFieldValues();
811 $updateBatches = array_chunk( array_map( 'intval', $revIds ), $updateRowsPerQuery );
812 foreach ( $updateBatches as $updateBatch ) {
813 $dbw->newUpdateQueryBuilder()
814 ->update( 'revision' )
815 ->set( [ 'rev_page' => $destId ] )
816 ->where( [ 'rev_id' => $updateBatch ] )
817 ->caller( __METHOD__ )
818 ->execute();
819 if ( count( $updateBatches ) > 1 ) {
820 $this->commitTransactionRound( __METHOD__ );
821 $this->beginTransactionRound( __METHOD__ );
822 }
823 }
824
825 $delete = $dbw->newDeleteQueryBuilder()
826 ->deleteFrom( 'page' )
827 ->where( [ 'page_id' => $id ] )
828 ->caller( __METHOD__ );
829 $delete->execute();
830 $this->getServiceContainer()->getLinkWriteDuplicator()->duplicate( $delete );
831 $this->commitTransactionRound( __METHOD__ );
832
833 /* Call LinksDeletionUpdate to delete outgoing links from the old title,
834 * and update category counts.
835 *
836 * Calling external code with a fake broken Title is a fairly dubious
837 * idea. It's necessary because it's quite a lot of code to duplicate,
838 * but that also makes it fragile since it would be easy for someone to
839 * accidentally introduce an assumption of title validity to the code we
840 * are calling.
841 */
842 DeferredUpdates::addUpdate( new LinksDeletionUpdate( $wikiPage ) );
843 DeferredUpdates::doUpdates();
844
845 return true;
846 }
847}
848
849// @codeCoverageIgnoreStart
850$maintClass = NamespaceDupes::class;
851require_once RUN_MAINTENANCE_IF_MAIN;
852// @codeCoverageIgnoreEnd
const NS_MAIN
Definition Defines.php:51
const NS_TALK
Definition Defines.php:52
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
Defer callable updates to run later in the PHP process.
Update object handling the cleanup of links tables after a page was deleted.
A class containing constants representing the names of configuration variables.
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
output( $out, $channel=null)
Throw some output to the user.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
hasOption( $name)
Checks to see if a particular option was set.
getOption( $name, $default=null)
Get an option, or return the default.
getServiceContainer()
Returns the main service container.
addDescription( $text)
Set the description text.
Value object representing a content slot associated with a page revision.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Represents the target of a wiki link.
Represents a title within MediaWiki.
Definition Title.php:69
canExist()
Can this title represent a page in the wiki's database?
Definition Title.php:1205
exists( $flags=0)
Check if page exists.
Definition Title.php:3133
getArticleID( $flags=0)
Get the article ID for this Title from the link cache, adding it if necessary.
Definition Title.php:2562
getPrefixedDBkey()
Get the prefixed database key form.
Definition Title.php:1845
User class for the MediaWiki software.
Definition User.php:130
Maintenance script that checks for articles to fix after adding/deleting namespaces.
execute()
Do the actual work.
__construct()
Default constructor.
Content of like value.
Definition LikeValue.php:14
Represents the target of a wiki link.
getNamespace()
Get the namespace index.
getDBkey()
Get the main part of the link target, in canonical database form.
Interface for objects (potentially) representing an editable wiki page.
Interface for database access objects.
Result wrapper for grabbing data queried from an IDatabase object.
$maintClass