MediaWiki master
SpecialMovePage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
27use LogPage;
46use OOUI\ButtonInputWidget;
47use OOUI\CheckboxInputWidget;
48use OOUI\DropdownInputWidget;
49use OOUI\FieldLayout;
50use OOUI\FieldsetLayout;
51use OOUI\FormLayout;
52use OOUI\HtmlSnippet;
53use OOUI\PanelLayout;
54use OOUI\TextInputWidget;
56use RepoGroup;
58use StringUtils;
63use Xml;
64
72 protected $oldTitle = null;
73
75 protected $newTitle;
76
78 protected $reason;
79
81 protected $moveTalk;
82
84 protected $deleteAndMove;
85
87 protected $moveSubpages;
88
90 protected $fixRedirects;
91
93 protected $leaveRedirect;
94
96 protected $moveOverShared;
97
98 private $watch = false;
99
100 private MovePageFactory $movePageFactory;
101 private PermissionManager $permManager;
102 private UserOptionsLookup $userOptionsLookup;
103 private IConnectionProvider $dbProvider;
104 private IContentHandlerFactory $contentHandlerFactory;
105 private NamespaceInfo $nsInfo;
106 private LinkBatchFactory $linkBatchFactory;
107 private RepoGroup $repoGroup;
108 private WikiPageFactory $wikiPageFactory;
109 private SearchEngineFactory $searchEngineFactory;
110 private WatchlistManager $watchlistManager;
111 private RestrictionStore $restrictionStore;
112 private TitleFactory $titleFactory;
113
129 public function __construct(
130 MovePageFactory $movePageFactory,
131 PermissionManager $permManager,
132 UserOptionsLookup $userOptionsLookup,
133 IConnectionProvider $dbProvider,
134 IContentHandlerFactory $contentHandlerFactory,
135 NamespaceInfo $nsInfo,
136 LinkBatchFactory $linkBatchFactory,
137 RepoGroup $repoGroup,
138 WikiPageFactory $wikiPageFactory,
139 SearchEngineFactory $searchEngineFactory,
140 WatchlistManager $watchlistManager,
141 RestrictionStore $restrictionStore,
142 TitleFactory $titleFactory
143 ) {
144 parent::__construct( 'Movepage' );
145 $this->movePageFactory = $movePageFactory;
146 $this->permManager = $permManager;
147 $this->userOptionsLookup = $userOptionsLookup;
148 $this->dbProvider = $dbProvider;
149 $this->contentHandlerFactory = $contentHandlerFactory;
150 $this->nsInfo = $nsInfo;
151 $this->linkBatchFactory = $linkBatchFactory;
152 $this->repoGroup = $repoGroup;
153 $this->wikiPageFactory = $wikiPageFactory;
154 $this->searchEngineFactory = $searchEngineFactory;
155 $this->watchlistManager = $watchlistManager;
156 $this->restrictionStore = $restrictionStore;
157 $this->titleFactory = $titleFactory;
158 }
159
160 public function doesWrites() {
161 return true;
162 }
163
164 public function execute( $par ) {
166 $this->checkReadOnly();
167 $this->setHeaders();
168 $this->outputHeader();
169
170 $request = $this->getRequest();
171
172 // Beware: The use of WebRequest::getText() is wanted! See T22365
173 $target = $par ?? $request->getText( 'target' );
174 $oldTitleText = $request->getText( 'wpOldTitle', $target );
175 $this->oldTitle = Title::newFromText( $oldTitleText );
176
177 if ( !$this->oldTitle ) {
178 // Either oldTitle wasn't passed, or newFromText returned null
179 throw new ErrorPageError( 'notargettitle', 'notargettext' );
180 }
181 $this->getOutput()->addBacklinkSubtitle( $this->oldTitle );
182
183 if ( !$this->oldTitle->exists() ) {
184 throw new ErrorPageError( 'nopagetitle', 'nopagetext' );
185 }
186
187 $newTitleTextMain = $request->getText( 'wpNewTitleMain' );
188 $newTitleTextNs = $request->getInt( 'wpNewTitleNs', $this->oldTitle->getNamespace() );
189 // Backwards compatibility for forms submitting here from other sources
190 // which is more common than it should be.
191 $newTitleText_bc = $request->getText( 'wpNewTitle' );
192 $this->newTitle = strlen( $newTitleText_bc ) > 0
193 ? Title::newFromText( $newTitleText_bc )
194 : Title::makeTitleSafe( $newTitleTextNs, $newTitleTextMain );
195
196 $user = $this->getUser();
197 $isSubmit = $request->getRawVal( 'action' ) === 'submit' && $request->wasPosted();
198
199 $reasonList = $request->getText( 'wpReasonList', 'other' );
200 $reason = $request->getText( 'wpReason' );
201 if ( $reasonList === 'other' ) {
202 $this->reason = $reason;
203 } elseif ( $reason !== '' ) {
204 $this->reason = $reasonList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $reason;
205 } else {
206 $this->reason = $reasonList;
207 }
208 // Default to checked, but don't fill in true during submission (browsers only submit checked values)
209 // TODO: Use HTMLForm to take care of this.
210 $def = !$isSubmit;
211 $this->moveTalk = $request->getBool( 'wpMovetalk', $def );
212 $this->fixRedirects = $request->getBool( 'wpFixRedirects', $def );
213 $this->leaveRedirect = $request->getBool( 'wpLeaveRedirect', $def );
214 // T222953: Tick the "move subpages" box by default
215 $this->moveSubpages = $request->getBool( 'wpMovesubpages', $def );
216 $this->deleteAndMove = $request->getBool( 'wpDeleteAndMove' );
217 $this->moveOverShared = $request->getBool( 'wpMoveOverSharedFile' );
218 $this->watch = $request->getCheck( 'wpWatch' ) && $user->isRegistered();
219
220 // Similar to other SpecialPage/Action classes, when tokens fail (likely due to reset or expiry),
221 // do not show an error but show the form again for easy re-submit.
222 if ( $isSubmit && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
223 // Check rights
224 $permErrors = $this->permManager->getPermissionErrors( 'move', $user, $this->oldTitle,
225 PermissionManager::RIGOR_SECURE );
226 // If the account is "hard" blocked, auto-block IP
227 DeferredUpdates::addCallableUpdate( [ $user, 'spreadAnyEditBlock' ] );
228 if ( $permErrors ) {
229 throw new PermissionsError( 'move', $permErrors );
230 }
231 $this->doSubmit();
232 } else {
233 // Avoid primary DB connection on form view (T283265)
234 $permErrors = $this->permManager->getPermissionErrors( 'move', $user, $this->oldTitle,
235 PermissionManager::RIGOR_FULL );
236 if ( $permErrors ) {
237 DeferredUpdates::addCallableUpdate( [ $user, 'spreadAnyEditBlock' ] );
238 throw new PermissionsError( 'move', $permErrors );
239 }
240 $this->showForm( [] );
241 }
242 }
243
252 protected function showForm( $err, $isPermError = false ) {
253 $this->getSkin()->setRelevantTitle( $this->oldTitle );
254
255 $out = $this->getOutput();
256 $out->setPageTitleMsg( $this->msg( 'move-page' )->plaintextParams( $this->oldTitle->getPrefixedText() ) );
257 $out->addModuleStyles( [
258 'mediawiki.special',
259 'mediawiki.interface.helpers.styles'
260 ] );
261 $out->addModules( 'mediawiki.misc-authed-ooui' );
262 $this->addHelpLink( 'Help:Moving a page' );
263
264 $handlerSupportsRedirects = $this->contentHandlerFactory
265 ->getContentHandler( $this->oldTitle->getContentModel() )
266 ->supportsRedirects();
267
268 if ( $this->getConfig()->get( MainConfigNames::FixDoubleRedirects ) ) {
269 $out->addWikiMsg( 'movepagetext' );
270 } else {
271 $out->addWikiMsg( $handlerSupportsRedirects ?
272 'movepagetext-noredirectfixer' :
273 'movepagetext-noredirectsupport' );
274 }
275
276 if ( $this->oldTitle->getNamespace() === NS_USER && !$this->oldTitle->isSubpage() ) {
277 $out->addHTML(
278 Html::warningBox(
279 $out->msg( 'moveuserpage-warning' )->parse(),
280 'mw-moveuserpage-warning'
281 )
282 );
283 } elseif ( $this->oldTitle->getNamespace() === NS_CATEGORY ) {
284 $out->addHTML(
285 Html::warningBox(
286 $out->msg( 'movecategorypage-warning' )->parse(),
287 'mw-movecategorypage-warning'
288 )
289 );
290 }
291
292 $deleteAndMove = false;
293 $moveOverShared = false;
294
295 $user = $this->getUser();
296 $newTitle = $this->newTitle;
297
298 if ( !$newTitle ) {
299 # Show the current title as a default
300 # when the form is first opened.
302 } elseif ( !count( $err ) ) {
303 # If a title was supplied, probably from the move log revert
304 # link, check for validity. We can then show some diagnostic
305 # information and save a click.
306 $mp = $this->movePageFactory->newMovePage( $this->oldTitle, $newTitle );
307 $status = $mp->isValidMove();
308 $status->merge( $mp->probablyCanMove( $this->getAuthority() ) );
309 if ( $status->getErrors() ) {
310 $err = $status->getErrorsArray();
311 }
312 }
313
314 if ( count( $err ) == 1 && isset( $err[0][0] ) ) {
315 if ( $err[0][0] == 'articleexists'
316 && $this->permManager->quickUserCan( 'delete', $user, $newTitle )
317 ) {
318 $out->addHTML(
319 Html::warningBox(
320 $out->msg( 'delete_and_move_text', $newTitle->getPrefixedText() )->parse()
321 )
322 );
323 $deleteAndMove = true;
324 $err = [];
325 } elseif ( $err[0][0] == 'redirectexists' && (
326 // Any user that can delete normally can also delete a redirect here
327 $this->permManager->quickUserCan( 'delete-redirect', $user, $newTitle ) ||
328 $this->permManager->quickUserCan( 'delete', $user, $newTitle ) )
329 ) {
330 $out->addHTML(
331 Html::warningBox(
332 $out->msg( 'delete_redirect_and_move_text', $newTitle->getPrefixedText() )->parse()
333 )
334 );
335 $deleteAndMove = true;
336 $err = [];
337 } elseif ( $err[0][0] == 'file-exists-sharedrepo'
338 && $this->permManager->userHasRight( $user, 'reupload-shared' )
339 ) {
340 $out->addHTML(
341 Html::warningBox(
342 $out->msg( 'move-over-sharedrepo', $newTitle->getPrefixedText() )->parse()
343 )
344 );
345 $moveOverShared = true;
346 $err = [];
347 }
348 }
349
350 $oldTalk = $this->oldTitle->getTalkPage();
351 $oldTitleSubpages = $this->oldTitle->hasSubpages();
352 $oldTitleTalkSubpages = $this->oldTitle->getTalkPage()->hasSubpages();
353
354 $canMoveSubpage = ( $oldTitleSubpages || $oldTitleTalkSubpages ) &&
355 $this->permManager->quickUserCan(
356 'move-subpages',
357 $user,
358 $this->oldTitle
359 );
360
361 # We also want to be able to move assoc. subpage talk-pages even if base page
362 # has no associated talk page, so || with $oldTitleTalkSubpages.
363 $considerTalk = !$this->oldTitle->isTalkPage() &&
364 ( $oldTalk->exists()
365 || ( $oldTitleTalkSubpages && $canMoveSubpage ) );
366
367 if ( $this->getConfig()->get( MainConfigNames::FixDoubleRedirects ) ) {
368 $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
369 ->select( '1' )
370 ->from( 'redirect' )
371 ->where( [ 'rd_namespace' => $this->oldTitle->getNamespace() ] )
372 ->andWhere( [ 'rd_title' => $this->oldTitle->getDBkey() ] )
373 ->andWhere( [ 'rd_interwiki' => '' ] );
374
375 $hasRedirects = (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
376 } else {
377 $hasRedirects = false;
378 }
379
380 if ( count( $err ) ) {
381 '@phan-var array[] $err';
382 if ( $isPermError ) {
383 $action_desc = $this->msg( 'action-move' )->plain();
384 $errMsgHtml = $this->msg( 'permissionserrorstext-withaction',
385 count( $err ), $action_desc )->parseAsBlock();
386 } else {
387 $errMsgHtml = $this->msg( 'cannotmove', count( $err ) )->parseAsBlock();
388 }
389
390 if ( count( $err ) == 1 ) {
391 $errMsg = $err[0];
392 $errMsgName = array_shift( $errMsg );
393
394 if ( $errMsgName == 'hookaborted' ) {
395 $errMsgHtml .= "<p>{$errMsg[0]}</p>\n";
396 } else {
397 $errMsgHtml .= $this->msg( $errMsgName, $errMsg )->parseAsBlock();
398 }
399 } else {
400 $errStr = [];
401
402 foreach ( $err as $errMsg ) {
403 if ( $errMsg[0] == 'hookaborted' ) {
404 $errStr[] = $errMsg[1];
405 } else {
406 $errMsgName = array_shift( $errMsg );
407 $errStr[] = $this->msg( $errMsgName, $errMsg )->parse();
408 }
409 }
410
411 $errMsgHtml .= '<ul><li>' . implode( "</li>\n<li>", $errStr ) . "</li></ul>\n";
412 }
413 $out->addHTML( Html::errorBox( $errMsgHtml ) );
414 }
415
416 if ( $this->restrictionStore->isProtected( $this->oldTitle, 'move' ) ) {
417 # Is the title semi-protected?
418 if ( $this->restrictionStore->isSemiProtected( $this->oldTitle, 'move' ) ) {
419 $noticeMsg = 'semiprotectedpagemovewarning';
420 } else {
421 # Then it must be protected based on static groups (regular)
422 $noticeMsg = 'protectedpagemovewarning';
423 }
424 LogEventsList::showLogExtract(
425 $out,
426 'protect',
427 $this->oldTitle,
428 '',
429 [ 'lim' => 1, 'msgKey' => $noticeMsg ]
430 );
431 }
432
433 // Length limit for wpReason and wpNewTitleMain is enforced in the
434 // mediawiki.special.movePage module
435
436 $immovableNamespaces = [];
437 foreach ( $this->getLanguage()->getNamespaces() as $nsId => $_ ) {
438 if ( !$this->nsInfo->isMovable( $nsId ) ) {
439 $immovableNamespaces[] = $nsId;
440 }
441 }
442
443 $out->enableOOUI();
444 $fields = [];
445
446 $fields[] = new FieldLayout(
448 'id' => 'wpNewTitle',
449 'namespace' => [
450 'id' => 'wpNewTitleNs',
451 'name' => 'wpNewTitleNs',
452 'value' => $newTitle->getNamespace(),
453 'exclude' => $immovableNamespaces,
454 ],
455 'title' => [
456 'id' => 'wpNewTitleMain',
457 'name' => 'wpNewTitleMain',
458 'value' => $newTitle->getText(),
459 // Inappropriate, since we're expecting the user to input a non-existent page's title
460 'suggestions' => false,
461 ],
462 'infusable' => true,
463 ] ),
464 [
465 'label' => $this->msg( 'newtitle' )->text(),
466 'align' => 'top',
467 ]
468 );
469
470 $options = Html::listDropdownOptions(
471 $this->msg( 'movepage-reason-dropdown' )
472 ->page( $this->oldTitle )
473 ->inContentLanguage()
474 ->text(),
475 [ 'other' => $this->msg( 'movereasonotherlist' )->text() ]
476 );
477 $options = Html::listDropdownOptionsOoui( $options );
478
479 $fields[] = new FieldLayout(
480 new DropdownInputWidget( [
481 'name' => 'wpReasonList',
482 'inputId' => 'wpReasonList',
483 'infusable' => true,
484 'value' => $this->getRequest()->getText( 'wpReasonList', 'other' ),
485 'options' => $options,
486 ] ),
487 [
488 'label' => $this->msg( 'movereason' )->text(),
489 'align' => 'top',
490 ]
491 );
492
493 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
494 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
495 // Unicode codepoints.
496 $fields[] = new FieldLayout(
497 new TextInputWidget( [
498 'name' => 'wpReason',
499 'id' => 'wpReason',
500 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
501 'infusable' => true,
502 'value' => $this->getRequest()->getText( 'wpReason' ),
503 ] ),
504 [
505 'label' => $this->msg( 'moveotherreason' )->text(),
506 'align' => 'top',
507 ]
508 );
509
510 if ( $considerTalk ) {
511 $fields[] = new FieldLayout(
512 new CheckboxInputWidget( [
513 'name' => 'wpMovetalk',
514 'id' => 'wpMovetalk',
515 'value' => '1',
516 'selected' => $this->moveTalk,
517 ] ),
518 [
519 'label' => $this->msg( 'movetalk' )->text(),
520 'help' => new HtmlSnippet( $this->msg( 'movepagetalktext' )->parseAsBlock() ),
521 'helpInline' => true,
522 'align' => 'inline',
523 'id' => 'wpMovetalk-field',
524 ]
525 );
526 }
527
528 if ( $this->permManager->userHasRight( $user, 'suppressredirect' ) ) {
529 if ( $handlerSupportsRedirects ) {
530 $isChecked = $this->leaveRedirect;
531 $isDisabled = false;
532 } else {
533 $isChecked = false;
534 $isDisabled = true;
535 }
536 $fields[] = new FieldLayout(
537 new CheckboxInputWidget( [
538 'name' => 'wpLeaveRedirect',
539 'id' => 'wpLeaveRedirect',
540 'value' => '1',
541 'selected' => $isChecked,
542 'disabled' => $isDisabled,
543 ] ),
544 [
545 'label' => $this->msg( 'move-leave-redirect' )->text(),
546 'align' => 'inline',
547 ]
548 );
549 }
550
551 if ( $hasRedirects ) {
552 $fields[] = new FieldLayout(
553 new CheckboxInputWidget( [
554 'name' => 'wpFixRedirects',
555 'id' => 'wpFixRedirects',
556 'value' => '1',
557 'selected' => $this->fixRedirects,
558 ] ),
559 [
560 'label' => $this->msg( 'fix-double-redirects' )->text(),
561 'align' => 'inline',
562 ]
563 );
564 }
565
566 if ( $canMoveSubpage ) {
567 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
568 $fields[] = new FieldLayout(
569 new CheckboxInputWidget( [
570 'name' => 'wpMovesubpages',
571 'id' => 'wpMovesubpages',
572 'value' => '1',
573 'selected' => $this->moveSubpages,
574 ] ),
575 [
576 'label' => new HtmlSnippet(
577 $this->msg(
578 ( $this->oldTitle->hasSubpages()
579 ? 'move-subpages'
580 : 'move-talk-subpages' )
581 )->numParams( $maximumMovedPages )->params( $maximumMovedPages )->parse()
582 ),
583 'align' => 'inline',
584 ]
585 );
586 }
587
588 # Don't allow watching if user is not logged in
589 if ( $user->isRegistered() ) {
590 $watchChecked = ( $this->watch || $this->userOptionsLookup->getBoolOption( $user, 'watchmoves' )
591 || $this->watchlistManager->isWatched( $user, $this->oldTitle ) );
592 $fields[] = new FieldLayout(
593 new CheckboxInputWidget( [
594 'name' => 'wpWatch',
595 'id' => 'watch', # ew
596 'value' => '1',
597 'selected' => $watchChecked,
598 ] ),
599 [
600 'label' => $this->msg( 'move-watch' )->text(),
601 'align' => 'inline',
602 ]
603 );
604 }
605
606 $hiddenFields = '';
607 if ( $moveOverShared ) {
608 $hiddenFields .= Html::hidden( 'wpMoveOverSharedFile', '1' );
609 }
610
611 if ( $deleteAndMove ) {
612 $fields[] = new FieldLayout(
613 new CheckboxInputWidget( [
614 'name' => 'wpDeleteAndMove',
615 'id' => 'wpDeleteAndMove',
616 'value' => '1',
617 ] ),
618 [
619 'label' => $this->msg( 'delete_and_move_confirm' )->text(),
620 'align' => 'inline',
621 ]
622 );
623 }
624
625 $fields[] = new FieldLayout(
626 new ButtonInputWidget( [
627 'name' => 'wpMove',
628 'value' => $this->msg( 'movepagebtn' )->text(),
629 'label' => $this->msg( 'movepagebtn' )->text(),
630 'flags' => [ 'primary', 'progressive' ],
631 'type' => 'submit',
632 ] ),
633 [
634 'align' => 'top',
635 ]
636 );
637
638 $fieldset = new FieldsetLayout( [
639 'label' => $this->msg( 'move-page-legend' )->text(),
640 'id' => 'mw-movepage-table',
641 'items' => $fields,
642 ] );
643
644 $form = new FormLayout( [
645 'method' => 'post',
646 'action' => $this->getPageTitle()->getLocalURL( 'action=submit' ),
647 'id' => 'movepage',
648 ] );
649 $form->appendContent(
650 $fieldset,
651 new HtmlSnippet(
652 $hiddenFields .
653 Html::hidden( 'wpOldTitle', $this->oldTitle->getPrefixedText() ) .
654 Html::hidden( 'wpEditToken', $user->getEditToken() )
655 )
656 );
657
658 $out->addHTML(
659 new PanelLayout( [
660 'classes' => [ 'movepage-wrapper' ],
661 'expanded' => false,
662 'padded' => true,
663 'framed' => true,
664 'content' => $form,
665 ] )
666 );
667 if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
668 $link = $this->getLinkRenderer()->makeKnownLink(
669 $this->msg( 'movepage-reason-dropdown' )->inContentLanguage()->getTitle(),
670 $this->msg( 'movepage-edit-reasonlist' )->text(),
671 [],
672 [ 'action' => 'edit' ]
673 );
674 $out->addHTML( Html::rawElement( 'p', [ 'class' => 'mw-movepage-editreasons' ], $link ) );
675 }
676
677 $this->showLogFragment( $this->oldTitle );
678 $this->showSubpages( $this->oldTitle );
679 }
680
681 private function doSubmit() {
682 $user = $this->getUser();
683
684 if ( $user->pingLimiter( 'move' ) ) {
685 throw new ThrottledError;
686 }
687
688 $ot = $this->oldTitle;
689 $nt = $this->newTitle;
690
691 # don't allow moving to pages with # in
692 if ( !$nt || $nt->hasFragment() ) {
693 $this->showForm( [ [ 'badtitletext' ] ] );
694
695 return;
696 }
697
698 # Show a warning if the target file exists on a shared repo
699 if ( $nt->getNamespace() === NS_FILE
700 && !( $this->moveOverShared && $this->permManager->userHasRight( $user, 'reupload-shared' ) )
701 && !$this->repoGroup->getLocalRepo()->findFile( $nt )
702 && $this->repoGroup->findFile( $nt )
703 ) {
704 $this->showForm( [ [ 'file-exists-sharedrepo' ] ] );
705
706 return;
707 }
708
709 # Delete to make way if requested
710 if ( $this->deleteAndMove ) {
711 $redir2 = $nt->isSingleRevRedirect();
712
713 $permErrors = $this->permManager->getPermissionErrors(
714 $redir2 ? 'delete-redirect' : 'delete',
715 $user, $nt
716 );
717 if ( count( $permErrors ) ) {
718 if ( $redir2 ) {
719 if ( count( $this->permManager->getPermissionErrors( 'delete', $user, $nt ) ) ) {
720 // Cannot delete-redirect, or delete normally
721 // Only show the first error
722 $this->showForm( $permErrors, true );
723 return;
724 } else {
725 // Cannot delete-redirect, but can delete normally,
726 // so log as a normal deletion
727 $redir2 = false;
728 }
729 } else {
730 // Cannot delete normally
731 // Only show first error
732 $this->showForm( $permErrors, true );
733 return;
734 }
735 }
736
737 $page = $this->wikiPageFactory->newFromTitle( $nt );
738
739 // Small safety margin to guard against concurrent edits
740 if ( $page->isBatchedDelete( 5 ) ) {
741 $this->showForm( [ [ 'movepage-delete-first' ] ] );
742
743 return;
744 }
745
746 $reason = $this->msg( 'delete_and_move_reason', $ot )->inContentLanguage()->text();
747
748 // Delete an associated image if there is
749 if ( $nt->getNamespace() === NS_FILE ) {
750 $file = $this->repoGroup->getLocalRepo()->newFile( $nt );
751 $file->load( IDBAccessObject::READ_LATEST );
752 if ( $file->exists() ) {
753 $file->deleteFile( $reason, $user, false );
754 }
755 }
756
757 $error = ''; // passed by ref
758 $deletionLog = $redir2 ? 'delete_redir2' : 'delete';
759 $deleteStatus = $page->doDeleteArticleReal(
760 $reason, $user, false, null, $error,
761 null, [], $deletionLog
762 );
763 if ( !$deleteStatus->isGood() ) {
764 $this->showForm( $deleteStatus->getErrorsArray() );
765
766 return;
767 }
768 }
769
770 $handler = $this->contentHandlerFactory->getContentHandler( $ot->getContentModel() );
771
772 if ( !$handler->supportsRedirects() ) {
773 $createRedirect = false;
774 } elseif ( $this->permManager->userHasRight( $user, 'suppressredirect' ) ) {
775 $createRedirect = $this->leaveRedirect;
776 } else {
777 $createRedirect = true;
778 }
779
780 # Do the actual move.
781 $mp = $this->movePageFactory->newMovePage( $ot, $nt );
782
783 # check whether the requested actions are permitted / possible
784 $userPermitted = $mp->authorizeMove( $this->getAuthority(), $this->reason )->isOK();
785 if ( $ot->isTalkPage() || $nt->isTalkPage() ) {
786 $this->moveTalk = false;
787 }
788 if ( $this->moveSubpages ) {
789 $this->moveSubpages = $this->permManager->userCan( 'move-subpages', $user, $ot );
790 }
791
792 $status = $mp->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
793 if ( !$status->isOK() ) {
794 $this->showForm( $status->getErrorsArray(), !$userPermitted );
795 return;
796 }
797
798 if ( $this->getConfig()->get( MainConfigNames::FixDoubleRedirects ) &&
799 $this->fixRedirects ) {
800 DoubleRedirectJob::fixRedirects( 'move', $ot );
801 }
802
803 $out = $this->getOutput();
804 $out->setPageTitleMsg( $this->msg( 'pagemovedsub' ) );
805
806 $linkRenderer = $this->getLinkRenderer();
807 $oldLink = $linkRenderer->makeLink(
808 $ot,
809 null,
810 [ 'id' => 'movepage-oldlink' ],
811 [ 'redirect' => 'no' ]
812 );
813 $newLink = $linkRenderer->makeKnownLink(
814 $nt,
815 null,
816 [ 'id' => 'movepage-newlink' ]
817 );
818 $oldText = $ot->getPrefixedText();
819 $newText = $nt->getPrefixedText();
820
821 if ( $status->getValue()['redirectRevision'] !== null ) {
822 $msgName = 'movepage-moved-redirect';
823 } else {
824 $msgName = 'movepage-moved-noredirect';
825 }
826
827 $out->addHTML( $this->msg( 'movepage-moved' )->rawParams( $oldLink,
828 $newLink )->params( $oldText, $newText )->parseAsBlock() );
829 $out->addWikiMsg( $msgName );
830
831 $this->getHookRunner()->onSpecialMovepageAfterMove( $this, $ot, $nt );
832
833 /*
834 * Now we move extra pages we've been asked to move: subpages and talk
835 * pages.
836 *
837 * First, make a list of id's. This might be marginally less efficient
838 * than a more direct method, but this is not a highly performance-cri-
839 * tical code path and readable code is more important here.
840 *
841 * If the target namespace doesn't allow subpages, moving with subpages
842 * would mean that you couldn't move them back in one operation, which
843 * is bad.
844 * @todo FIXME: A specific error message should be given in this case.
845 */
846
847 // @todo FIXME: Use MovePage::moveSubpages() here
848 $dbr = $this->dbProvider->getReplicaDatabase();
849 if ( $this->moveSubpages && (
850 $this->nsInfo->hasSubpages( $nt->getNamespace() ) || (
851 $this->moveTalk
852 && $this->nsInfo->hasSubpages( $nt->getTalkPage()->getNamespace() )
853 )
854 ) ) {
855 $conds = [
856 $dbr->expr(
857 'page_title',
858 IExpression::LIKE,
859 new LikeValue( $ot->getDBkey() . '/', $dbr->anyString() )
860 )->or( 'page_title', '=', $ot->getDBkey() )
861 ];
862 $conds['page_namespace'] = [];
863 if ( $this->nsInfo->hasSubpages( $nt->getNamespace() ) ) {
864 $conds['page_namespace'][] = $ot->getNamespace();
865 }
866 if ( $this->moveTalk &&
867 $this->nsInfo->hasSubpages( $nt->getTalkPage()->getNamespace() )
868 ) {
869 $conds['page_namespace'][] = $ot->getTalkPage()->getNamespace();
870 }
871 } elseif ( $this->moveTalk ) {
872 $conds = [
873 'page_namespace' => $ot->getTalkPage()->getNamespace(),
874 'page_title' => $ot->getDBkey()
875 ];
876 } else {
877 # Skip the query
878 $conds = null;
879 }
880
881 $extraPages = [];
882 if ( $conds !== null ) {
883 $extraPages = $this->titleFactory->newTitleArrayFromResult(
884 $dbr->newSelectQueryBuilder()
885 ->select( [ 'page_id', 'page_namespace', 'page_title' ] )
886 ->from( 'page' )
887 ->where( $conds )
888 ->caller( __METHOD__ )->fetchResultSet()
889 );
890 }
891
892 $extraOutput = [];
893 $count = 1;
894 foreach ( $extraPages as $oldSubpage ) {
895 if ( $ot->equals( $oldSubpage ) || $nt->equals( $oldSubpage ) ) {
896 # Already did this one.
897 continue;
898 }
899
900 $newPageName = preg_replace(
901 '#^' . preg_quote( $ot->getDBkey(), '#' ) . '#',
902 StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # T23234
903 $oldSubpage->getDBkey()
904 );
905
906 if ( $oldSubpage->isSubpage() && ( $ot->isTalkPage() xor $nt->isTalkPage() ) ) {
907 // Moving a subpage from a subject namespace to a talk namespace or vice-versa
908 $newNs = $nt->getNamespace();
909 } elseif ( $oldSubpage->isTalkPage() ) {
910 $newNs = $nt->getTalkPage()->getNamespace();
911 } else {
912 $newNs = $nt->getSubjectPage()->getNamespace();
913 }
914
915 # T16385: we need makeTitleSafe because the new page names may
916 # be longer than 255 characters.
917 $newSubpage = Title::makeTitleSafe( $newNs, $newPageName );
918 if ( !$newSubpage ) {
919 $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
920 $extraOutput[] = $this->msg( 'movepage-page-unmoved' )->rawParams( $oldLink )
921 ->params( Title::makeName( $newNs, $newPageName ) )->escaped();
922 continue;
923 }
924
925 $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage );
926 # This was copy-pasted from Renameuser, bleh.
927 if ( $newSubpage->exists() && !$mp->isValidMove()->isOK() ) {
928 $link = $linkRenderer->makeKnownLink( $newSubpage );
929 $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped();
930 } else {
931 $status = $mp->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
932
933 if ( $status->isOK() ) {
934 if ( $this->fixRedirects ) {
935 DoubleRedirectJob::fixRedirects( 'move', $oldSubpage );
936 }
937 $oldLink = $linkRenderer->makeLink(
938 $oldSubpage,
939 null,
940 [],
941 [ 'redirect' => 'no' ]
942 );
943
944 $newLink = $linkRenderer->makeKnownLink( $newSubpage );
945 $extraOutput[] = $this->msg( 'movepage-page-moved' )
946 ->rawParams( $oldLink, $newLink )->escaped();
947 ++$count;
948
949 $maximumMovedPages =
951 if ( $count >= $maximumMovedPages ) {
952 $extraOutput[] = $this->msg( 'movepage-max-pages' )
953 ->numParams( $maximumMovedPages )->escaped();
954 break;
955 }
956 } else {
957 $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
958 $newLink = $linkRenderer->makeLink( $newSubpage );
959 $extraOutput[] = $this->msg( 'movepage-page-unmoved' )
960 ->rawParams( $oldLink, $newLink )->escaped();
961 }
962 }
963 }
964
965 if ( $extraOutput !== [] ) {
966 $out->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $extraOutput ) . "</li>\n</ul>" );
967 }
968
969 # Deal with watches (we don't watch subpages)
970 $this->watchlistManager->setWatch( $this->watch, $this->getAuthority(), $ot );
971 $this->watchlistManager->setWatch( $this->watch, $this->getAuthority(), $nt );
972 }
973
974 private function showLogFragment( $title ) {
975 $moveLogPage = new LogPage( 'move' );
976 $out = $this->getOutput();
977 $out->addHTML( Xml::element( 'h2', null, $moveLogPage->getName()->text() ) );
978 LogEventsList::showLogExtract( $out, 'move', $title );
979 }
980
987 private function showSubpages( $title ) {
988 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
989 $nsHasSubpages = $this->nsInfo->hasSubpages( $title->getNamespace() );
990 $subpages = $title->getSubpages( $maximumMovedPages + 1 );
991 $count = $subpages instanceof TitleArrayFromResult ? $subpages->count() : 0;
992
993 $titleIsTalk = $title->isTalkPage();
994 $subpagesTalk = $title->getTalkPage()->getSubpages( $maximumMovedPages + 1 );
995 $countTalk = $subpagesTalk instanceof TitleArrayFromResult ? $subpagesTalk->count() : 0;
996 $totalCount = $count + $countTalk;
997
998 if ( !$nsHasSubpages && $countTalk == 0 ) {
999 return;
1000 }
1001
1002 $this->getOutput()->wrapWikiMsg(
1003 '== $1 ==',
1004 [ 'movesubpage', ( $titleIsTalk ? $count : $totalCount ) ]
1005 );
1006
1007 if ( $nsHasSubpages ) {
1008 $this->showSubpagesList(
1009 $subpages, $count, 'movesubpagetext', 'movesubpagetext-truncated', true
1010 );
1011 }
1012
1013 if ( !$titleIsTalk && $countTalk > 0 ) {
1014 $this->showSubpagesList(
1015 $subpagesTalk, $countTalk, 'movesubpagetalktext', 'movesubpagetalktext-truncated'
1016 );
1017 }
1018 }
1019
1020 private function showSubpagesList( $subpages, $pagecount, $msg, $truncatedMsg, $noSubpageMsg = false ) {
1021 $out = $this->getOutput();
1022
1023 # No subpages.
1024 if ( $pagecount == 0 && $noSubpageMsg ) {
1025 $out->addWikiMsg( 'movenosubpage' );
1026 return;
1027 }
1028
1029 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1030
1031 if ( $pagecount > $maximumMovedPages ) {
1032 $subpages = $this->truncateSubpagesList( $subpages );
1033 $out->addWikiMsg( $truncatedMsg, $this->getLanguage()->formatNum( $maximumMovedPages ) );
1034 } else {
1035 $out->addWikiMsg( $msg, $this->getLanguage()->formatNum( $pagecount ) );
1036 }
1037 $out->addHTML( "<ul>\n" );
1038
1039 $linkBatch = $this->linkBatchFactory->newLinkBatch( $subpages );
1040 $linkBatch->setCaller( __METHOD__ );
1041 $linkBatch->execute();
1042 $linkRenderer = $this->getLinkRenderer();
1043
1044 foreach ( $subpages as $subpage ) {
1045 $link = $linkRenderer->makeLink( $subpage );
1046 $out->addHTML( "<li>$link</li>\n" );
1047 }
1048 $out->addHTML( "</ul>\n" );
1049 }
1050
1051 private function truncateSubpagesList( iterable $subpages ): array {
1052 $returnArray = [];
1053 foreach ( $subpages as $subpage ) {
1054 $returnArray[] = $subpage;
1055 if ( count( $returnArray ) >= $this->getConfig()->get( MainConfigNames::MaximumMovedPages ) ) {
1056 break;
1057 }
1058 }
1059 return $returnArray;
1060 }
1061
1070 public function prefixSearchSubpages( $search, $limit, $offset ) {
1071 return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
1072 }
1073
1074 protected function getGroupName() {
1075 return 'pagetools';
1076 }
1077}
1078
1083class_alias( SpecialMovePage::class, 'MovePageForm' );
const NS_USER
Definition Defines.php:66
const NS_FILE
Definition Defines.php:70
const NS_CATEGORY
Definition Defines.php:78
Fix any double redirects after moving a page.
static fixRedirects( $reason, $redirTitle)
Insert jobs into the job queue to fix redirects to the given title.
An error page which can definitely be safely rendered using the OutputPage.
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition LogPage.php:44
Handle database storage of comments such as edit summaries and log reasons.
Defer callable updates to run later in the PHP process.
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A class containing constants representing the names of configuration variables.
const FixDoubleRedirects
Name constant for the FixDoubleRedirects setting, for use with Config::get()
const MaximumMovedPages
Name constant for the MaximumMovedPages setting, for use with Config::get()
Service for creating WikiPage objects.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getSkin()
Shortcut to get the skin being used for this instance.
getUser()
Shortcut to get the User executing this instance.
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
getPageTitle( $subpage=false)
Get a self-referential title object.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
getConfig()
Shortcut to get main config object.
getRequest()
Get the WebRequest being used for this instance.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getOutput()
Get the OutputPage being used for this instance.
getAuthority()
Shortcut to get the Authority executing this instance.
getLanguage()
Shortcut to get user's language.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Shortcut to construct a special page which is unlisted by default.
Implement Special:Movepage for changing page titles.
prefixSearchSubpages( $search, $limit, $offset)
Return an array of subpages beginning with $search that this special page will accept.
showForm( $err, $isPermError=false)
Show the form.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
__construct(MovePageFactory $movePageFactory, PermissionManager $permManager, UserOptionsLookup $userOptionsLookup, IConnectionProvider $dbProvider, IContentHandlerFactory $contentHandlerFactory, NamespaceInfo $nsInfo, LinkBatchFactory $linkBatchFactory, RepoGroup $repoGroup, WikiPageFactory $wikiPageFactory, SearchEngineFactory $searchEngineFactory, WatchlistManager $watchlistManager, RestrictionStore $restrictionStore, TitleFactory $titleFactory)
execute( $par)
Default execute method Checks user permissions.
doesWrites()
Indicates whether this special page may perform database writes.
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Creates Title objects.
Represents a title within MediaWiki.
Definition Title.php:78
getTalkPage()
Get a Title object associated with the talk page of this article.
Definition Title.php:1619
getNamespace()
Get the namespace index, i.e.
Definition Title.php:1044
getText()
Get the text form (spaces not underscores) of the main part.
Definition Title.php:1017
getPrefixedText()
Get the prefixed title with spaces.
Definition Title.php:1861
Provides access to user options.
Show an error when a user tries to do something they do not have the necessary permissions for.
Prioritized list of file repositories.
Definition RepoGroup.php:30
Factory class for SearchEngine.
A collection of static methods to play with strings.
Show an error when the user hits a rate limit.
Content of like value.
Definition LikeValue.php:14
Module of static functions for generating XML.
Definition Xml.php:33
Interface for database access objects.
Service for page rename actions.
Provide primary and replica IDatabase connections.
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...