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