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