MediaWiki master
SpecialMovePage.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Specials;
8
44use OOUI\ButtonInputWidget;
45use OOUI\CheckboxInputWidget;
46use OOUI\DropdownInputWidget;
47use OOUI\FieldLayout;
48use OOUI\FieldsetLayout;
49use OOUI\FormLayout;
50use OOUI\HtmlSnippet;
51use OOUI\PanelLayout;
52use OOUI\TextInputWidget;
53use StatusValue;
57use Wikimedia\Timestamp\TimestampFormat as TS;
58
66 protected $oldTitle = null;
67
69 protected $newTitle;
70
72 protected $reason;
73
75 protected $moveTalk;
76
78 protected $deleteAndMove;
79
81 protected $moveSubpages;
82
84 protected $fixRedirects;
85
87 protected $leaveRedirect;
88
90 protected $moveOverShared;
91
92 private bool $moveOverProtection;
93
95 private $watch = false;
96
97 public function __construct(
98 private readonly MovePageFactory $movePageFactory,
99 private readonly PermissionManager $permManager,
100 private readonly UserOptionsLookup $userOptionsLookup,
101 private readonly IConnectionProvider $dbProvider,
102 private readonly IContentHandlerFactory $contentHandlerFactory,
103 private readonly NamespaceInfo $nsInfo,
104 private readonly LinkBatchFactory $linkBatchFactory,
105 private readonly RepoGroup $repoGroup,
106 private readonly WikiPageFactory $wikiPageFactory,
107 private readonly SearchEngineFactory $searchEngineFactory,
108 private readonly WatchlistManager $watchlistManager,
109 private readonly WatchedItemStore $watchedItemStore,
110 private readonly RestrictionStore $restrictionStore,
111 private readonly TitleFactory $titleFactory,
112 private readonly DeletePageFactory $deletePageFactory,
113 private readonly RedirectLookup $redirectLookup,
114 private readonly TitleFormatter $titleFormatter
115 ) {
116 parent::__construct( 'Movepage' );
117 }
118
120 public function doesWrites() {
121 return true;
122 }
123
125 public function execute( $par ) {
127 $this->checkReadOnly();
128 $this->setHeaders();
129 $this->outputHeader();
130
131 $request = $this->getRequest();
132
133 // Beware: The use of WebRequest::getText() is wanted! See T22365
134 $target = $par ?? $request->getText( 'target' );
135 $oldTitleText = $request->getText( 'wpOldTitle', $target );
136 $this->oldTitle = Title::newFromText( $oldTitleText );
137
138 if ( !$this->oldTitle ) {
139 // Either oldTitle wasn't passed, or newFromText returned null
140 throw new ErrorPageError( 'notargettitle', 'notargettext' );
141 }
142 $this->getOutput()->addBacklinkSubtitle( $this->oldTitle );
143 // Various uses of Html::errorBox and Html::warningBox.
144 $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
145
146 if ( !$this->oldTitle->exists() ) {
147 throw new ErrorPageError( 'nopagetitle', 'nopagetext' );
148 }
149
150 $newTitleTextMain = $request->getText( 'wpNewTitleMain' );
151 $newTitleTextNs = $request->getInt( 'wpNewTitleNs', $this->oldTitle->getNamespace() );
152 // Backwards compatibility for forms submitting here from other sources
153 // which is more common than it should be.
154 $newTitleText_bc = $request->getText( 'wpNewTitle' );
155 $this->newTitle = strlen( $newTitleText_bc ) > 0
156 ? Title::newFromText( $newTitleText_bc )
157 : Title::makeTitleSafe( $newTitleTextNs, $newTitleTextMain );
158
159 $user = $this->getUser();
160 $isSubmit = $request->getRawVal( 'action' ) === 'submit' && $request->wasPosted();
161
162 $reasonList = $request->getText( 'wpReasonList', 'other' );
163 $reason = $request->getText( 'wpReason' );
164 if ( $reasonList === 'other' ) {
165 $this->reason = $reason;
166 } elseif ( $reason !== '' ) {
167 $this->reason = $reasonList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $reason;
168 } else {
169 $this->reason = $reasonList;
170 }
171 // Default to checked, but don't fill in true during submission (browsers only submit checked values)
172 // TODO: Use HTMLForm to take care of this.
173 $def = !$isSubmit;
174 $this->moveTalk = $request->getBool( 'wpMovetalk', $def );
175 $this->fixRedirects = $request->getBool( 'wpFixRedirects', $def );
176 $this->leaveRedirect = $request->getBool( 'wpLeaveRedirect', $def );
177 // T222953: Tick the "move subpages" box by default
178 $this->moveSubpages = $request->getBool( 'wpMovesubpages', $def );
179 $this->deleteAndMove = $request->getBool( 'wpDeleteAndMove' );
180 $this->moveOverShared = $request->getBool( 'wpMoveOverSharedFile' );
181 $this->moveOverProtection = $request->getBool( 'wpMoveOverProtection' );
182 $this->watch = $request->getCheck( 'wpWatch' ) && $user->isRegistered();
183
184 // Similar to other SpecialPage/Action classes, when tokens fail (likely due to reset or expiry),
185 // do not show an error but show the form again for easy re-submit.
186 if ( $isSubmit && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
187 // Check rights
188 $permStatus = $this->permManager->getPermissionStatus( 'move', $user, $this->oldTitle,
189 PermissionManager::RIGOR_SECURE );
190 // If the account is "hard" blocked, auto-block IP
191 $user->scheduleSpreadBlock();
192 if ( !$permStatus->isGood() ) {
193 throw new PermissionsError( 'move', $permStatus );
194 }
195 $this->doSubmit();
196 } else {
197 // Avoid primary DB connection on form view (T283265)
198 $permStatus = $this->permManager->getPermissionStatus( 'move', $user, $this->oldTitle,
199 PermissionManager::RIGOR_FULL );
200 if ( !$permStatus->isGood() ) {
201 $user->scheduleSpreadBlock();
202 throw new PermissionsError( 'move', $permStatus );
203 }
204 $this->showForm();
205 }
206 }
207
208 private function getRedirectTarget( PageIdentity $title ): string {
209 $target = $this->redirectLookup->getRedirectTarget( $title );
210 if ( !$target ) {
211 // This should never happen since the caller checks that it is a redirect
212 // but Phan complains otherwise
213 return '';
214 }
215 return $this->titleFormatter->getPrefixedText( $target );
216 }
217
225 private function showForm( ?StatusValue $status = null, ?StatusValue $talkStatus = null ) {
226 $this->getSkin()->setRelevantTitle( $this->oldTitle );
227
228 $out = $this->getOutput();
229 $out->setPageTitleMsg( $this->msg( 'move-page' )->plaintextParams( $this->oldTitle->getPrefixedText() ) );
230 $out->addModuleStyles( [
231 'mediawiki.special',
232 'mediawiki.interface.helpers.styles'
233 ] );
234 $out->addModules( 'mediawiki.misc-authed-ooui' );
235 $this->addHelpLink( 'Help:Moving a page' );
236
237 $handler = $this->contentHandlerFactory
238 ->getContentHandler( $this->oldTitle->getContentModel() );
239 $createRedirect = $handler->supportsRedirects() && !(
240 // Do not create redirects for wikitext message overrides (T376399).
241 // Maybe one day they will have a custom content model and this special case won't be needed.
242 $this->oldTitle->getNamespace() === NS_MEDIAWIKI &&
243 $this->oldTitle->getContentModel() === CONTENT_MODEL_WIKITEXT
244 );
245
246 if ( $this->getConfig()->get( MainConfigNames::FixDoubleRedirects ) ) {
247 $out->addWikiMsg( 'movepagetext' );
248 } else {
249 $out->addWikiMsg( $createRedirect ?
250 'movepagetext-noredirectfixer' :
251 'movepagetext-noredirectsupport' );
252 }
253
254 if ( $this->oldTitle->getNamespace() === NS_USER ) {
255 if ( !$this->oldTitle->isSubpage() ) {
256 $out->addHTML(
257 Html::warningBox(
258 $out->msg( 'moveuserpage-warning' )->parse(),
259 'mw-moveuserpage-warning'
260 )
261 );
262
263 // Deselect moveTalk unless it's explicitly given
264 $this->moveTalk = $this->getRequest()->getBool( "wpMovetalk", false );
265 } else {
266 $out->addHTML(
267 Html::warningBox(
268 $out->msg( 'moveusersubpage-warning' )->parse(),
269 'mw-moveusersubpage-warning'
270 )
271 );
272 }
273 } elseif ( $this->oldTitle->getNamespace() === NS_CATEGORY ) {
274 $out->addHTML(
275 Html::warningBox(
276 $out->msg( 'movecategorypage-warning' )->parse(),
277 'mw-movecategorypage-warning'
278 )
279 );
280 }
281
282 $deleteAndMove = [];
283 $moveOverShared = false;
284
285 $user = $this->getUser();
286 $newTitle = $this->newTitle;
287 $oldTalk = $this->oldTitle->getTalkPageIfDefined();
288
289 if ( !$newTitle ) {
290 # Show the current title as a default
291 # when the form is first opened.
292 $newTitle = $this->oldTitle;
293 } elseif ( !$status ) {
294 # If a title was supplied, probably from the move log revert
295 # link, check for validity. We can then show some diagnostic
296 # information and save a click.
297 $mp = $this->movePageFactory->newMovePage( $this->oldTitle, $newTitle );
298 $status = $mp->isValidMove();
299 $status->merge( $mp->probablyCanMove( $this->getAuthority() ) );
300 if ( $this->moveTalk ) {
301 $newTalk = $newTitle->getTalkPageIfDefined();
302 if ( $oldTalk && $newTalk && $oldTalk->exists() ) {
303 $mpTalk = $this->movePageFactory->newMovePage( $oldTalk, $newTalk );
304 $talkStatus = $mpTalk->isValidMove();
305 $talkStatus->merge( $mpTalk->probablyCanMove( $this->getAuthority() ) );
306 }
307 }
308 }
309 if ( !$status ) {
310 // Caller (execute) is responsible for checking that you have permission to move the page somewhere
311 $status = StatusValue::newGood();
312 }
313 if ( !$talkStatus ) {
314 if ( $oldTalk ) {
315 // If you don't have permission to move the talk page anywhere then complain about that now
316 // rather than only after submitting the form to move the page
317 $talkStatus = $this->permManager->getPermissionStatus( 'move', $user, $oldTalk,
318 PermissionManager::RIGOR_QUICK );
319 } else {
320 // If there's no talk page to move (for example the old page is in a namespace with no talk page)
321 // then this needs to be set to something ...
322 $talkStatus = StatusValue::newGood();
323 }
324 }
325
326 $oldTalk = $this->oldTitle->getTalkPageIfDefined();
327 $oldTitleSubpages = $this->oldTitle->hasSubpages();
328 $oldTitleTalkSubpages = $this->oldTitle->getTalkPageIfDefined()->hasSubpages();
329
330 $canMoveSubpage = ( $oldTitleSubpages || $oldTitleTalkSubpages ) &&
331 $this->permManager->quickUserCan(
332 'move-subpages',
333 $user,
334 $this->oldTitle
335 );
336 # We also want to be able to move assoc. subpage talk-pages even if base page
337 # has no associated talk page, so || with $oldTitleTalkSubpages.
338 $considerTalk = !$this->oldTitle->isTalkPage() &&
339 ( $oldTalk->exists()
340 || ( $oldTitleTalkSubpages && $canMoveSubpage ) );
341
342 if ( $this->getConfig()->get( MainConfigNames::FixDoubleRedirects ) ) {
343 $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
344 ->select( '1' )
345 ->from( 'redirect' )
346 ->where( [ 'rd_namespace' => $this->oldTitle->getNamespace() ] )
347 ->andWhere( [ 'rd_title' => $this->oldTitle->getDBkey() ] )
348 ->andWhere( [ 'rd_interwiki' => '' ] );
349
350 $hasRedirects = (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
351 } else {
352 $hasRedirects = false;
353 }
354
355 $newTalkTitle = $newTitle->getTalkPageIfDefined();
356 $talkOK = $talkStatus->isOK();
357 $mainOK = $status->isOK();
358 $talkIsArticle = $talkIsRedirect = $mainIsArticle = $mainIsRedirect = false;
359 if ( count( $status->getMessages() ) == 1 ) {
360 $mainIsArticle = $status->hasMessage( 'articleexists' )
361 && $this->permManager->quickUserCan( 'delete', $user, $newTitle );
362 $mainIsRedirect = $status->hasMessage( 'redirectexists' ) && (
363 // Any user that can delete normally can also delete a redirect here
364 $this->permManager->quickUserCan( 'delete-redirect', $user, $newTitle ) ||
365 $this->permManager->quickUserCan( 'delete', $user, $newTitle ) );
366 if ( $status->hasMessage( 'file-exists-sharedrepo' )
367 && $this->permManager->userHasRight( $user, 'reupload-shared' )
368 ) {
369 $out->addHTML(
370 Html::warningBox(
371 $out->msg( 'move-over-sharedrepo', $newTitle->getPrefixedText() )->parse()
372 )
373 );
374 $moveOverShared = true;
375 $status = StatusValue::newGood();
376 }
377 }
378 if ( count( $talkStatus->getMessages() ) == 1 ) {
379 $talkIsArticle = $talkStatus->hasMessage( 'articleexists' )
380 && $this->permManager->quickUserCan( 'delete', $user, $newTitle );
381 $talkIsRedirect = $talkStatus->hasMessage( 'redirectexists' ) && (
382 // Any user that can delete normally can also delete a redirect here
383 $this->permManager->quickUserCan( 'delete-redirect', $user, $newTitle ) ||
384 $this->permManager->quickUserCan( 'delete', $user, $newTitle ) );
385 // Talk page is by definition not a file so can't be shared
386 }
387 $warning = null;
388 // Case 1: Two pages need deletions of full history
389 // Either both are articles or one is an article and one is a redirect
390 if ( ( $talkIsArticle && $mainIsArticle ) ||
391 ( $talkIsArticle && $mainIsRedirect ) ||
392 ( $talkIsRedirect && $mainIsArticle )
393 ) {
394 $warning = $out->msg( 'delete_and_move_text_2',
395 $newTitle->getPrefixedText(),
396 $newTalkTitle->getPrefixedText()
397 );
398 $deleteAndMove = [ $newTitle, $newTalkTitle ];
399 // Case 2: Both need simple deletes
400 } elseif ( $mainIsRedirect && $talkIsRedirect ) {
401 $warning = $out->msg( 'delete_redirect_and_move_text_2',
402 $newTitle->getPrefixedText(),
403 $newTalkTitle->getPrefixedText(),
404 $this->getRedirectTarget( $newTitle ),
405 $this->getRedirectTarget( $newTalkTitle ),
406 );
407 $deleteAndMove = [ $newTitle, $newTalkTitle ];
408 // Case 3: The main page needs a full delete, the talk doesn't exist
409 // (or is a single-rev redirect to the source we can silently ignore)
410 } elseif ( $mainIsArticle && $talkOK ) {
411 $warning = $out->msg( 'delete_and_move_text', $newTitle->getPrefixedText() );
412 $deleteAndMove = [ $newTitle ];
413 // Case 4: The main page needs a simple delete, the talk doesn't exist
414 } elseif ( $mainIsRedirect && $talkOK ) {
415 $warning = $out->msg( 'delete_redirect_and_move_text',
416 $newTitle->getPrefixedText(),
417 $this->getRedirectTarget( $newTitle ),
418 );
419 $deleteAndMove = [ $newTitle ];
420 // Cases 5 and 6: The same for the talk page
421 } elseif ( $talkIsArticle && $mainOK ) {
422 $warning = $out->msg( 'delete_and_move_text', $newTalkTitle->getPrefixedText() );
423 $deleteAndMove = [ $newTalkTitle ];
424 } elseif ( $talkIsRedirect && $mainOK ) {
425 $warning = $out->msg( 'delete_redirect_and_move_text',
426 $newTalkTitle->getPrefixedText(),
427 $this->getRedirectTarget( $newTalkTitle ),
428 );
429 $deleteAndMove = [ $newTalkTitle ];
430 }
431 if ( $warning ) {
432 $out->addHTML( Html::warningBox( $warning->parse() ) );
433 } else {
434 $messages = $status->getMessages();
435 if ( $messages ) {
436 if ( $status instanceof PermissionStatus ) {
437 $action_desc = $this->msg( 'action-move' )->plain();
438 $errMsgHtml = $this->msg( 'permissionserrorstext-withaction',
439 count( $messages ), $action_desc )->parseAsBlock();
440 } else {
441 $errMsgHtml = $this->msg( 'cannotmove', count( $messages ) )->parseAsBlock();
442 }
443
444 if ( count( $messages ) == 1 ) {
445 $errMsgHtml .= $this->msg( $messages[0] )->parseAsBlock();
446 } else {
447 $errStr = [];
448
449 foreach ( $messages as $msg ) {
450 $errStr[] = $this->msg( $msg )->parse();
451 }
452
453 // Duplicate errors can easily arise since MovePage checks for both "edit" and "move" against
454 // the target page, and lots of things that block one also block the other
455 $errStr = array_unique( $errStr );
456
457 $errMsgHtml .= '<ul><li>' . implode( "</li>\n<li>", $errStr ) . "</li></ul>\n";
458 }
459 $out->addHTML( Html::errorBox( $errMsgHtml ) );
460 }
461 $talkMessages = $talkStatus->getMessages();
462 if ( $talkMessages ) {
463 // Can't use permissionerrorstext here since there's no specific action for moving the talk page
464 $errMsgHtml = $this->msg( 'cannotmovetalk', count( $talkMessages ) )->parseAsBlock();
465
466 if ( count( $talkMessages ) == 1 ) {
467 $errMsgHtml .= $this->msg( $talkMessages[0] )->parseAsBlock();
468 } else {
469 $errStr = [];
470
471 foreach ( $talkMessages as $msg ) {
472 $errStr[] = $this->msg( $msg )->parse();
473 }
474
475 $errMsgHtml .= '<ul><li>' . implode( "</li>\n<li>", $errStr ) . "</li></ul>\n";
476 }
477 $errMsgHtml .= $this->msg( 'movetalk-unselect' )->parse();
478 $out->addHTML( Html::errorBox( $errMsgHtml ) );
479 }
480 }
481
482 if ( $this->restrictionStore->isProtected( $this->oldTitle, 'move' ) ) {
483 # Is the title semi-protected?
484 if ( $this->restrictionStore->isSemiProtected( $this->oldTitle, 'move' ) ) {
485 $noticeMsg = 'semiprotectedpagemovewarning';
486 } else {
487 # Then it must be protected based on static groups (regular)
488 $noticeMsg = 'protectedpagemovewarning';
489 }
490 LogEventsList::showLogExtract(
491 $out,
492 'protect',
493 $this->oldTitle,
494 '',
495 [ 'lim' => 1, 'msgKey' => $noticeMsg ]
496 );
497 }
498 // Intentionally don't check moveTalk here since this is in the form before you specify whether
499 // to move the talk page
500 if ( $talkOK && $oldTalk && $oldTalk->exists() && $this->restrictionStore->isProtected( $oldTalk, 'move' ) ) {
501 # Is the title semi-protected?
502 if ( $this->restrictionStore->isSemiProtected( $oldTalk, 'move' ) ) {
503 $noticeMsg = 'semiprotectedtalkpagemovewarning';
504 } else {
505 # Then it must be protected based on static groups (regular)
506 $noticeMsg = 'protectedtalkpagemovewarning';
507 }
508 LogEventsList::showLogExtract(
509 $out,
510 'protect',
511 $oldTalk,
512 '',
513 [ 'lim' => 1, 'msgKey' => $noticeMsg ]
514 );
515 }
516
517 // Length limit for wpReason and wpNewTitleMain is enforced in the
518 // mediawiki.special.movePage module
519
520 $immovableNamespaces = [];
521 foreach ( $this->getLanguage()->getNamespaces() as $nsId => $_ ) {
522 if ( !$this->nsInfo->isMovable( $nsId ) ) {
523 $immovableNamespaces[] = $nsId;
524 }
525 }
526
527 $moveOverProtectionMain = false;
528 $moveOverProtectionTalk = false;
529 if ( $this->newTitle && $mainOK ) {
530 // If you can't move the page anyway then don't bother displaying warnings
531 // about it being protected
532 if ( $this->restrictionStore->isProtected( $this->newTitle, 'create' ) ) {
533 # Is the title semi-protected?
534 if ( $this->restrictionStore->isSemiProtected( $this->newTitle, 'create' ) ) {
535 $noticeMsg = 'semiprotectedpagemovecreatewarning';
536 } else {
537 # Then it must be protected based on static groups (regular)
538 $noticeMsg = 'protectedpagemovecreatewarning';
539 }
540 LogEventsList::showLogExtract(
541 $out,
542 'protect',
543 $this->newTitle,
544 '',
545 [ 'lim' => 1, 'msgKey' => $noticeMsg ]
546 );
547 $moveOverProtectionMain = true;
548 }
549 $newTalk = $newTitle->getTalkPageIfDefined();
550 if ( $oldTalk && $oldTalk->exists() && $talkOK &&
551 $newTalk && $this->restrictionStore->isProtected( $newTalk, 'create' )
552 ) {
553 # Is the title semi-protected?
554 if ( $this->restrictionStore->isSemiProtected( $newTalk, 'create' ) ) {
555 $noticeMsg = 'semiprotectedpagemovetalkcreatewarning';
556 } else {
557 # Then it must be protected based on static groups (regular)
558 $noticeMsg = 'protectedpagemovetalkcreatewarning';
559 }
560 LogEventsList::showLogExtract(
561 $out,
562 'protect',
563 $newTalk,
564 '',
565 [ 'lim' => 1, 'msgKey' => $noticeMsg ]
566 );
567 $moveOverProtectionTalk = true;
568 }
569 }
570
571 $out->enableOOUI();
572 $fields = [];
573
574 $fields[] = new FieldLayout(
575 new ComplexTitleInputWidget( [
576 'id' => 'wpNewTitle',
577 'namespace' => [
578 'id' => 'wpNewTitleNs',
579 'name' => 'wpNewTitleNs',
580 'value' => $newTitle->getNamespace(),
581 'exclude' => $immovableNamespaces,
582 ],
583 'title' => [
584 'id' => 'wpNewTitleMain',
585 'name' => 'wpNewTitleMain',
586 'value' => $newTitle->getText(),
587 // Inappropriate, since we're expecting the user to input a non-existent page's title
588 'suggestions' => false,
589 ],
590 'infusable' => true,
591 ] ),
592 [
593 'label' => $this->msg( 'newtitle' )->text(),
594 'align' => 'top',
595 ]
596 );
597
598 $options = Html::listDropdownOptions(
599 $this->msg( 'movepage-reason-dropdown' )
600 ->page( $this->oldTitle )
601 ->inContentLanguage()
602 ->text(),
603 [ 'other' => $this->msg( 'movereasonotherlist' )->text() ]
604 );
605 $options = Html::listDropdownOptionsOoui( $options );
606
607 $fields[] = new FieldLayout(
608 new DropdownInputWidget( [
609 'name' => 'wpReasonList',
610 'inputId' => 'wpReasonList',
611 'infusable' => true,
612 'value' => $this->getRequest()->getText( 'wpReasonList', 'other' ),
613 'options' => $options,
614 ] ),
615 [
616 'label' => $this->msg( 'movereason' )->text(),
617 'align' => 'top',
618 ]
619 );
620
621 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
622 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
623 // Unicode codepoints.
624 $fields[] = new FieldLayout(
625 new TextInputWidget( [
626 'name' => 'wpReason',
627 'id' => 'wpReason',
628 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
629 'infusable' => true,
630 'value' => $this->getRequest()->getText( 'wpReason' ),
631 ] ),
632 [
633 'label' => $this->msg( 'moveotherreason' )->text(),
634 'align' => 'top',
635 ]
636 );
637
638 if ( $considerTalk ) {
639 $fields[] = new FieldLayout(
640 new CheckboxInputWidget( [
641 'name' => 'wpMovetalk',
642 'id' => 'wpMovetalk',
643 'value' => '1',
644 // It's intentional that this box is still visible and checked by default even if you don't have
645 // permission to move the talk page; wanting to separate a base page from its talk page is so
646 // unusual that you should have to explicitly uncheck the box to do so
647 'selected' => $this->moveTalk,
648 ] ),
649 [
650 'label' => $this->msg( 'movetalk' )->text(),
651 'help' => new HtmlSnippet( $this->msg( 'movepagetalktext' )->parseAsBlock() ),
652 'helpInline' => true,
653 'align' => 'inline',
654 'id' => 'wpMovetalk-field',
655 ]
656 );
657 }
658
659 if ( $this->permManager->userHasRight( $user, 'suppressredirect' ) ) {
660 if ( $createRedirect ) {
661 $isChecked = $this->leaveRedirect;
662 $isDisabled = false;
663 } else {
664 $isChecked = false;
665 $isDisabled = true;
666 }
667 $fields[] = new FieldLayout(
668 new CheckboxInputWidget( [
669 'name' => 'wpLeaveRedirect',
670 'id' => 'wpLeaveRedirect',
671 'value' => '1',
672 'selected' => $isChecked,
673 'disabled' => $isDisabled,
674 ] ),
675 [
676 'label' => $this->msg( 'move-leave-redirect' )->text(),
677 'align' => 'inline',
678 ]
679 );
680 }
681
682 if ( $hasRedirects ) {
683 $fields[] = new FieldLayout(
684 new CheckboxInputWidget( [
685 'name' => 'wpFixRedirects',
686 'id' => 'wpFixRedirects',
687 'value' => '1',
688 'selected' => $this->fixRedirects,
689 ] ),
690 [
691 'label' => $this->msg( 'fix-double-redirects' )->text(),
692 'align' => 'inline',
693 ]
694 );
695 }
696
697 if ( $canMoveSubpage ) {
698 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
699 $fields[] = new FieldLayout(
700 new CheckboxInputWidget( [
701 'name' => 'wpMovesubpages',
702 'id' => 'wpMovesubpages',
703 'value' => '1',
704 'selected' => $this->moveSubpages,
705 ] ),
706 [
707 'label' => new HtmlSnippet(
708 $this->msg(
709 ( $this->oldTitle->hasSubpages()
710 ? 'move-subpages'
711 : 'move-talk-subpages' )
712 )->numParams( $maximumMovedPages )->params( $maximumMovedPages )->parse()
713 ),
714 'align' => 'inline',
715 ]
716 );
717 }
718
719 # Don't allow watching if user is not logged in
720 if ( $user->isRegistered() ) {
721 $watchChecked = ( $this->watch || $this->userOptionsLookup->getBoolOption( $user, 'watchmoves' )
722 || $this->watchlistManager->isWatched( $user, $this->oldTitle ) );
723 $fields[] = new FieldLayout(
724 new CheckboxInputWidget( [
725 'name' => 'wpWatch',
726 'id' => 'watch', # ew
727 'infusable' => true,
728 'value' => '1',
729 'selected' => $watchChecked,
730 ] ),
731 [
732 'label' => $this->msg( 'move-watch' )->text(),
733 'align' => 'inline',
734 ]
735 );
736
737 # Add a dropdown for watchlist expiry times in the form, T261230
738 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
739 $expiryOptions = WatchAction::getExpiryOptions(
740 $this->getContext(),
741 $this->watchedItemStore->getWatchedItem( $user, $this->oldTitle )
742 );
743 # Reformat the options to match what DropdownInputWidget wants.
744 $options = [];
745 foreach ( $expiryOptions['options'] as $label => $value ) {
746 $options[] = [ 'data' => $value, 'label' => $label ];
747 }
748
749 $fields[] = new FieldLayout(
750 new DropdownInputWidget( [
751 'name' => 'wpWatchlistExpiry',
752 'id' => 'wpWatchlistExpiry',
753 'infusable' => true,
754 'options' => $options,
755 ] ),
756 [
757 'label' => $this->msg( 'confirm-watch-label' )->text(),
758 'id' => 'wpWatchlistExpiryLabel',
759 'infusable' => true,
760 'align' => 'inline',
761 ]
762 );
763 }
764 }
765
766 $hiddenFields = '';
767 if ( $moveOverShared ) {
768 $hiddenFields .= Html::hidden( 'wpMoveOverSharedFile', '1' );
769 }
770
771 if ( $deleteAndMove ) {
772 // Suppress Phan false positives here - the array is either one or two elements, and is assigned above
773 // so the count clearly distinguishes the two cases
774 if ( count( $deleteAndMove ) == 2 ) {
775 $msg = $this->msg( 'delete_and_move_confirm_2',
776 $deleteAndMove[0]->getPrefixedText(),
777 $deleteAndMove[1]->getPrefixedText()
778 )->text();
779 } else {
780 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
781 $msg = $this->msg( 'delete_and_move_confirm', $deleteAndMove[0]->getPrefixedText() )->text();
782 }
783 $fields[] = new FieldLayout(
784 new CheckboxInputWidget( [
785 'name' => 'wpDeleteAndMove',
786 'id' => 'wpDeleteAndMove',
787 'value' => '1',
788 ] ),
789 [
790 'label' => $msg,
791 'align' => 'inline',
792 ]
793 );
794 }
795 if ( $moveOverProtectionMain || $moveOverProtectionTalk ) {
796 if ( $moveOverProtectionMain && $moveOverProtectionTalk ) {
797 $moveOverProtectionMsg = $this->msg(
798 'move_over_protection_confirm_both',
799 $this->newTitle->getPrefixedText(),
800 $newTalkTitle->getPrefixedText()
801 )->text();
802 } elseif ( $moveOverProtectionMain ) {
803 $moveOverProtectionMsg = $this->msg(
804 'move_over_protection_confirm',
805 $this->newTitle->getPrefixedText()
806 )->text();
807 } else {
808 $moveOverProtectionMsg = $this->msg(
809 'move_over_protection_confirm_talk',
810 $newTalkTitle->getPrefixedText()
811 )->text();
812 }
813 $fields[] = new FieldLayout(
814 new CheckboxInputWidget( [
815 'name' => 'wpMoveOverProtection',
816 'id' => 'wpMoveOverProtection',
817 'value' => '1',
818 ] ),
819 [
820 'label' => $moveOverProtectionMsg,
821 'align' => 'inline',
822 ]
823 );
824 }
825
826 $fields[] = new FieldLayout(
827 new ButtonInputWidget( [
828 'name' => 'wpMove',
829 'value' => $this->msg( 'movepagebtn' )->text(),
830 'label' => $this->msg( 'movepagebtn' )->text(),
831 'flags' => [ 'primary', 'progressive' ],
832 'type' => 'submit',
833 'accessKey' => Linker::accesskey( 'move' ),
834 'title' => Linker::titleAttrib( 'move' ),
835 ] ),
836 [
837 'align' => 'top',
838 ]
839 );
840
841 $fieldset = new FieldsetLayout( [
842 'label' => $this->msg( 'move-page-legend' )->text(),
843 'id' => 'mw-movepage-table',
844 'items' => $fields,
845 ] );
846
847 $form = new FormLayout( [
848 'method' => 'post',
849 'action' => $this->getPageTitle()->getLocalURL( 'action=submit' ),
850 'id' => 'movepage',
851 ] );
852 $form->appendContent(
853 $fieldset,
854 new HtmlSnippet(
855 $hiddenFields .
856 Html::hidden( 'wpOldTitle', $this->oldTitle->getPrefixedText() ) .
857 Html::hidden( 'wpEditToken', $user->getEditToken() )
858 )
859 );
860
861 $out->addHTML(
862 ( new PanelLayout( [
863 'classes' => [ 'movepage-wrapper' ],
864 'expanded' => false,
865 'padded' => true,
866 'framed' => true,
867 'content' => $form,
868 ] ) )->toString()
869 );
870 if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
871 $link = $this->getLinkRenderer()->makeKnownLink(
872 $this->msg( 'movepage-reason-dropdown' )->inContentLanguage()->getTitle(),
873 $this->msg( 'movepage-edit-reasonlist' )->text(),
874 [],
875 [ 'action' => 'edit' ]
876 );
877 $out->addHTML( Html::rawElement( 'p', [ 'class' => 'mw-movepage-editreasons' ], $link ) );
878 }
879
880 $this->showLogFragment( $this->oldTitle );
881 $this->showSubpages( $this->oldTitle );
882 }
883
884 private function vacateTitle( Title $title, User $user, Title $oldTitle ): StatusValue {
885 if ( !$title->exists() ) {
886 return StatusValue::newGood();
887 }
888 $redir2 = $title->isSingleRevRedirect();
889
890 $permStatus = $this->permManager->getPermissionStatus(
891 $redir2 ? 'delete-redirect' : 'delete',
892 $user, $title
893 );
894 if ( !$permStatus->isGood() ) {
895 if ( $redir2 ) {
896 if ( !$this->permManager->userCan( 'delete', $user, $title ) ) {
897 // Cannot delete-redirect, or delete normally
898 return $permStatus;
899 } else {
900 // Cannot delete-redirect, but can delete normally,
901 // so log as a normal deletion
902 $redir2 = false;
903 }
904 } else {
905 // Cannot delete normally
906 return $permStatus;
907 }
908 }
909
910 $page = $this->wikiPageFactory->newFromTitle( $title );
911 $delPage = $this->deletePageFactory->newDeletePage( $page, $user );
912
913 // Small safety margin to guard against concurrent edits
914 if ( $delPage->isBatchedDelete( 5 ) ) {
915 return StatusValue::newFatal( 'movepage-delete-first' );
916 }
917
918 $reason = $this->msg( 'delete_and_move_reason', $oldTitle->getPrefixedText() )->inContentLanguage()->text();
919
920 // Delete an associated image if there is
921 if ( $title->getNamespace() === NS_FILE ) {
922 $file = $this->repoGroup->getLocalRepo()->newFile( $title );
923 $file->load( IDBAccessObject::READ_LATEST );
924 if ( $file->exists() ) {
925 $file->deleteFile( $reason, $user, false );
926 }
927 }
928
929 $deletionLog = $redir2 ? 'delete_redir2' : 'delete';
930 $deleteStatus = $delPage
931 ->setLogSubtype( $deletionLog )
932 // Should be redundant thanks to the isBatchedDelete check above.
933 ->forceImmediate( true )
934 ->deleteUnsafe( $reason );
935
936 return $deleteStatus;
937 }
938
939 private function doSubmit() {
940 $user = $this->getUser();
941
942 if ( $user->pingLimiter( 'move' ) ) {
943 throw new ThrottledError;
944 }
945
946 $ot = $this->oldTitle;
947 $nt = $this->newTitle;
948
949 # don't allow moving to pages with # in
950 if ( !$nt || $nt->hasFragment() ) {
951 $this->showForm( StatusValue::newFatal( 'badtitletext' ) );
952
953 return;
954 }
955
956 $oldTalk = $ot->getTalkPageIfDefined();
957 $newTalk = $nt->getTalkPageIfDefined();
958
959 if ( $ot->isTalkPage() || $nt->isTalkPage() ) {
960 $this->moveTalk = false;
961 }
962
963 # Show a warning if the target file exists on a shared repo
964 if ( $nt->getNamespace() === NS_FILE
965 && !( $this->moveOverShared && $this->permManager->userHasRight( $user, 'reupload-shared' ) )
966 && !$this->repoGroup->getLocalRepo()->findFile( $nt )
967 && $this->repoGroup->findFile( $nt )
968 ) {
969 $this->showForm( StatusValue::newFatal( 'file-exists-sharedrepo' ) );
970
971 return;
972 }
973
974 # Show a warning if protected (showForm handles the warning )
975 if ( !$this->moveOverProtection ) {
976 if ( $this->restrictionStore->isProtected( $nt, 'create' ) ) {
977 $this->showForm();
978 return;
979 }
980 if ( $this->moveTalk && $newTalk && $this->restrictionStore->isProtected( $newTalk, 'create' ) ) {
981 $this->showForm();
982 return;
983 }
984 }
985
986 $handler = $this->contentHandlerFactory->getContentHandler( $ot->getContentModel() );
987
988 if ( !$handler->supportsRedirects() || (
989 // Do not create redirects for wikitext message overrides (T376399).
990 // Maybe one day they will have a custom content model and this special case won't be needed.
991 $ot->getNamespace() === NS_MEDIAWIKI &&
992 $ot->getContentModel() === CONTENT_MODEL_WIKITEXT
993 ) ) {
994 $createRedirect = false;
995 } elseif ( $this->permManager->userHasRight( $user, 'suppressredirect' ) ) {
996 $createRedirect = $this->leaveRedirect;
997 } else {
998 $createRedirect = true;
999 }
1000
1001 // Check perms
1002 $mp = $this->movePageFactory->newMovePage( $ot, $nt );
1003 $permStatusMain = $mp->authorizeMove( $this->getAuthority(), $this->reason );
1004 $permStatusMain->merge( $mp->isValidMove() );
1005
1006 $onlyMovingTalkSubpages = false;
1007 if ( $this->moveTalk && $oldTalk && $newTalk ) {
1008 $mpTalk = $this->movePageFactory->newMovePage( $oldTalk, $newTalk );
1009 $permStatusTalk = $mpTalk->authorizeMove( $this->getAuthority(), $this->reason );
1010 $permStatusTalk->merge( $mpTalk->isValidMove() );
1011 // Per the definition of $considerTalk in showForm you might be trying to move
1012 // subpages of the talk even if the talk itself doesn't exist, so let that happen
1013 if ( !$permStatusTalk->isOK() &&
1014 !$permStatusTalk->hasMessagesExcept( 'movepage-source-doesnt-exist' )
1015 ) {
1016 $permStatusTalk->setOK( true );
1017 $onlyMovingTalkSubpages = true;
1018 }
1019 } else {
1020 $permStatusTalk = StatusValue::newGood();
1021 $mpTalk = null;
1022 }
1023
1024 if ( $this->deleteAndMove ) {
1025 // This is done before the deletion (in order to minimize the impact of T265792)
1026 // so ignore "it already exists" checks (they will be repeated after the deletion)
1027 if ( $permStatusMain->hasMessagesExcept( 'redirectexists', 'articleexists' ) ||
1028 ( !$onlyMovingTalkSubpages &&
1029 $permStatusTalk->hasMessagesExcept( 'redirectexists', 'articleexists' ) )
1030 ) {
1031 $this->showForm( $permStatusMain, $permStatusTalk );
1032 return;
1033 }
1034 // If the code gets here, then it's passed all permission checks and the move should succeed
1035 // so start deleting things.
1036 // FIXME: This isn't atomic; it could delete things even if the move will later fail (T265792)
1037 // For example, if you manually specify deleteAndMove in the URL (the form UI won't show the checkbox)
1038 // and have `delete-redirect` and the main page is a single-revision redirect
1039 // but the talk page isn't it will delete the redirect and then fail, leaving it deleted
1040 $deleteStatus = $this->vacateTitle( $nt, $user, $ot );
1041 if ( !$deleteStatus->isGood() ) {
1042 $this->showForm( $deleteStatus );
1043 return;
1044 }
1045 if ( $this->moveTalk && $oldTalk && $newTalk ) {
1046 $deleteStatus = $this->vacateTitle( $newTalk, $user, $oldTalk );
1047 if ( !$deleteStatus->isGood() ) {
1048 // Ideally we would specify that the subject page redirect was deleted
1049 // but see the FIXME above
1050 $this->showForm( StatusValue::newGood(), $deleteStatus );
1051 return;
1052 }
1053 }
1054 } elseif ( !$permStatusMain->isOK() || !$permStatusTalk->isOK() ) {
1055 // If we're not going to delete then bail on all errors
1056 $this->showForm( $permStatusMain, $permStatusTalk );
1057 return;
1058 }
1059
1060 // Now we've confirmed you can do all of the moves you want and proceeding won't leave things inconsistent
1061 // so actually move the main page
1062 $mainStatus = $mp->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1063 if ( !$mainStatus->isOK() ) {
1064 $this->showForm( $mainStatus );
1065 return;
1066 }
1067
1068 $fixRedirects = $this->fixRedirects && $this->getConfig()->get( MainConfigNames::FixDoubleRedirects );
1069 if ( $fixRedirects ) {
1070 DoubleRedirectJob::fixRedirects( 'move', $ot );
1071 }
1072
1073 // Now try to move the talk page
1074 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1075
1076 $moveStatuses = [];
1077 $talkStatus = null;
1078 if ( $mpTalk && !$onlyMovingTalkSubpages ) {
1079 $talkStatus = $mpTalk->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1080 // moveIfAllowed returns a Status with an array as a value, however moveSubpages per-title statuses
1081 // have strings as values. Massage this status into the moveSubpages format so it fits in with
1082 // the later calls
1083 '@phan-var Status<string> $talkStatus';
1084 $talkStatus->value = $newTalk->getPrefixedText();
1085 $moveStatuses[$oldTalk->getPrefixedText()] = $talkStatus;
1086 }
1087
1088 // Now try to move subpages if asked
1089 if ( $this->moveSubpages ) {
1090 if ( $this->permManager->userCan( 'move-subpages', $user, $ot ) ) {
1091 $mp->setMaximumMovedPages( $maximumMovedPages - count( $moveStatuses ) );
1092 $subpageStatus = $mp->moveSubpagesIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1093 $moveStatuses = array_merge( $moveStatuses, $subpageStatus->value );
1094 }
1095 if ( $mpTalk && $oldTalk && $maximumMovedPages > count( $moveStatuses ) &&
1096 $this->permManager->userCan( 'move-subpages', $user, $oldTalk ) &&
1097 ( $onlyMovingTalkSubpages || $talkStatus->isOK() )
1098 ) {
1099 $mpTalk->setMaximumMovedPages( $maximumMovedPages - count( $moveStatuses ) );
1100 $talkSubStatus = $mpTalk->moveSubpagesIfAllowed(
1101 $this->getAuthority(), $this->reason, $createRedirect
1102 );
1103 $moveStatuses = array_merge( $moveStatuses, $talkSubStatus->value );
1104 }
1105 }
1106
1107 // Now we've moved everything we're going to move, so post-process the output,
1108 // create the UI, and fix double redirects
1109 $out = $this->getOutput();
1110 $out->setPageTitleMsg( $this->msg( 'pagemovedsub' ) );
1111
1112 $linkRenderer = $this->getLinkRenderer();
1113 $oldLink = $linkRenderer->makeLink(
1114 $ot,
1115 null,
1116 [ 'id' => 'movepage-oldlink' ],
1117 [ 'redirect' => 'no' ]
1118 );
1119 $newLink = $linkRenderer->makeKnownLink(
1120 $nt,
1121 null,
1122 [ 'id' => 'movepage-newlink' ]
1123 );
1124 $oldText = $ot->getPrefixedText();
1125 $newText = $nt->getPrefixedText();
1126
1127 $out->addHTML( $this->msg( 'movepage-moved' )->rawParams( $oldLink,
1128 $newLink )->params( $oldText, $newText )->parseAsBlock() );
1129 $out->addWikiMsg( isset( $mainStatus->getValue()['redirectRevision'] ) ?
1130 'movepage-moved-redirect' :
1131 'movepage-moved-noredirect' );
1132
1133 $this->getHookRunner()->onSpecialMovepageAfterMove( $this, $ot, $nt );
1134
1135 $extraOutput = [];
1136 foreach ( $moveStatuses as $oldSubpage => $subpageStatus ) {
1137 if ( $subpageStatus->hasMessage( 'movepage-max-pages' ) ) {
1138 $extraOutput[] = $this->msg( 'movepage-max-pages' )
1139 ->numParams( $maximumMovedPages )->escaped();
1140 continue;
1141 }
1142 $oldSubpage = Title::newFromText( $oldSubpage );
1143 $newSubpage = Title::newFromText( $subpageStatus->value );
1144 if ( $subpageStatus->isGood() ) {
1145 if ( $fixRedirects ) {
1146 DoubleRedirectJob::fixRedirects( 'move', $oldSubpage );
1147 }
1148 $oldLink = $linkRenderer->makeLink( $oldSubpage, null, [], [ 'redirect' => "no" ] );
1149 $newLink = $linkRenderer->makeKnownLink( $newSubpage );
1150
1151 $extraOutput[] = $this->msg( 'movepage-page-moved' )->rawParams(
1152 $oldLink, $newLink
1153 )->escaped();
1154 } elseif ( $subpageStatus->hasMessage( 'articleexists' )
1155 || $subpageStatus->hasMessage( 'redirectexists' )
1156 ) {
1157 $link = $linkRenderer->makeKnownLink( $newSubpage );
1158 $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped();
1159 } else {
1160 $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
1161 if ( $newSubpage ) {
1162 $newLink = $linkRenderer->makeLink( $newSubpage );
1163 } else {
1164 // It's not a valid title
1165 $newLink = htmlspecialchars( $subpageStatus->value );
1166 }
1167 $extraOutput[] = $this->msg( 'movepage-page-unmoved' )
1168 ->rawParams( $oldLink, $newLink )->escaped();
1169 }
1170 }
1171
1172 if ( $extraOutput !== [] ) {
1173 $out->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $extraOutput ) . "</li>\n</ul>" );
1174 }
1175
1176 # Deal with watches (we don't watch subpages)
1177 # Get text from expiry selection dropdown, T261230
1178 $expiry = $this->getRequest()->getText( 'wpWatchlistExpiry' );
1179 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && $expiry !== '' ) {
1180 $expiry = ExpiryDef::normalizeExpiry( $expiry, TS::ISO_8601 );
1181 } else {
1182 $expiry = null;
1183 }
1184
1185 $this->watchlistManager->setWatch(
1186 $this->watch,
1187 $this->getAuthority(),
1188 $ot,
1189 $expiry
1190 );
1191
1192 $this->watchlistManager->setWatch(
1193 $this->watch,
1194 $this->getAuthority(),
1195 $nt,
1196 $expiry
1197 );
1198 }
1199
1200 private function showLogFragment( Title $title ) {
1201 $moveLogPage = new LogPage( 'move' );
1202 $out = $this->getOutput();
1203 $out->addHTML( Html::element( 'h2', [], $moveLogPage->getName()->text() ) );
1204 LogEventsList::showLogExtract( $out, 'move', $title );
1205 }
1206
1213 private function showSubpages( $title ) {
1214 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1215 $nsHasSubpages = $this->nsInfo->hasSubpages( $title->getNamespace() );
1216 $subpages = $title->getSubpages( $maximumMovedPages + 1 );
1217 $count = $subpages instanceof TitleArrayFromResult ? $subpages->count() : 0;
1218
1219 $titleIsTalk = $title->isTalkPage();
1220 $subpagesTalk = $title->getTalkPage()->getSubpages( $maximumMovedPages + 1 );
1221 $countTalk = $subpagesTalk instanceof TitleArrayFromResult ? $subpagesTalk->count() : 0;
1222 $totalCount = $count + $countTalk;
1223
1224 if ( !$nsHasSubpages && $countTalk == 0 ) {
1225 return;
1226 }
1227
1228 $this->getOutput()->wrapWikiMsg(
1229 '== $1 ==',
1230 [ 'movesubpage', ( $titleIsTalk ? $count : $totalCount ) ]
1231 );
1232
1233 if ( $nsHasSubpages ) {
1234 $this->showSubpagesList(
1235 $subpages, $count, 'movesubpagetext', 'movesubpagetext-truncated', true
1236 );
1237 }
1238
1239 if ( !$titleIsTalk && $countTalk > 0 ) {
1240 $this->showSubpagesList(
1241 $subpagesTalk, $countTalk, 'movesubpagetalktext', 'movesubpagetalktext-truncated'
1242 );
1243 }
1244 }
1245
1246 private function showSubpagesList(
1247 TitleArrayFromResult $subpages, int $pagecount, string $msg, string $truncatedMsg, bool $noSubpageMsg = false
1248 ) {
1249 $out = $this->getOutput();
1250
1251 # No subpages.
1252 if ( $pagecount == 0 && $noSubpageMsg ) {
1253 $out->addWikiMsg( 'movenosubpage' );
1254 return;
1255 }
1256
1257 $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1258
1259 if ( $pagecount > $maximumMovedPages ) {
1260 $subpages = $this->truncateSubpagesList( $subpages );
1261 $out->addWikiMsg( $truncatedMsg, $this->getLanguage()->formatNum( $maximumMovedPages ) );
1262 } else {
1263 $out->addWikiMsg( $msg, $this->getLanguage()->formatNum( $pagecount ) );
1264 }
1265 $out->addHTML( "<ul>\n" );
1266
1267 $this->linkBatchFactory->newLinkBatch( $subpages )
1268 ->setCaller( __METHOD__ )
1269 ->execute();
1270 $linkRenderer = $this->getLinkRenderer();
1271
1272 foreach ( $subpages as $subpage ) {
1273 $link = $linkRenderer->makeLink( $subpage );
1274 $out->addHTML( "<li>$link</li>\n" );
1275 }
1276 $out->addHTML( "</ul>\n" );
1277 }
1278
1279 private function truncateSubpagesList( iterable $subpages ): array {
1280 $returnArray = [];
1281 foreach ( $subpages as $subpage ) {
1282 $returnArray[] = $subpage;
1283 if ( count( $returnArray ) >= $this->getConfig()->get( MainConfigNames::MaximumMovedPages ) ) {
1284 break;
1285 }
1286 }
1287 return $returnArray;
1288 }
1289
1298 public function prefixSearchSubpages( $search, $limit, $offset ) {
1299 return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
1300 }
1301
1303 protected function getGroupName() {
1304 return 'pagetools';
1305 }
1306}
1307
1308// @codeCoverageIgnoreStart
1313class_alias( SpecialMovePage::class, 'MovePageForm' );
1314// @codeCoverageIgnoreEnd
const NS_USER
Definition Defines.php:53
const NS_FILE
Definition Defines.php:57
const NS_MEDIAWIKI
Definition Defines.php:59
const CONTENT_MODEL_WIKITEXT
Definition Defines.php:235
const NS_CATEGORY
Definition Defines.php:65
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
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:30
This class is a collection of static functions that serve two purposes:
Definition Html.php:44
Fix any double redirects after moving a page.
Some internal bits split of from Skin.php.
Definition Linker.php:48
Class to simplify the use of log pages.
Definition LogPage.php:34
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()
Factory for LinkBatch objects to batch query page metadata.
Service for creating WikiPage objects.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
A StatusValue for permission errors.
Factory class for SearchEngine.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getUser()
Shortcut to get the User executing this instance.
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
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.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages By default the message key is the canonical name of...
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.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
__construct(private readonly MovePageFactory $movePageFactory, private readonly PermissionManager $permManager, private readonly UserOptionsLookup $userOptionsLookup, private readonly IConnectionProvider $dbProvider, private readonly IContentHandlerFactory $contentHandlerFactory, private readonly NamespaceInfo $nsInfo, private readonly LinkBatchFactory $linkBatchFactory, private readonly RepoGroup $repoGroup, private readonly WikiPageFactory $wikiPageFactory, private readonly SearchEngineFactory $searchEngineFactory, private readonly WatchlistManager $watchlistManager, private readonly WatchedItemStore $watchedItemStore, private readonly RestrictionStore $restrictionStore, private readonly TitleFactory $titleFactory, private readonly DeletePageFactory $deletePageFactory, private readonly RedirectLookup $redirectLookup, private readonly TitleFormatter $titleFormatter)
execute( $par)
Default execute method Checks user permissions.This must be overridden by subclasses; it will be made...
doesWrites()
Indicates whether POST requests to this special page require write access to the wiki....
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Creates Title objects.
A title formatter service for MediaWiki.
Represents a title within MediaWiki.
Definition Title.php:69
getTalkPageIfDefined()
Get a Title object associated with the talk page of this article, if such a talk page can exist.
Definition Title.php:1642
getNamespace()
Get the namespace index, i.e.
Definition Title.php:1034
getText()
Get the text form (spaces not underscores) of the main part.
Definition Title.php:1007
getPrefixedText()
Get the prefixed title with spaces.
Definition Title.php:1854
Provides access to user options.
User class for the MediaWiki software.
Definition User.php:129
Storage layer class for WatchedItems.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
static newGood( $value=null)
Factory function for good results.
Type definition for expiry timestamps.
Definition ExpiryDef.php:18
Service for page delete actions.
Service for page rename actions.
Interface for objects (potentially) representing an editable wiki page.
getNamespace()
Returns the page's namespace number.
Service for resolving a wiki page redirect.
Provide primary and replica IDatabase connections.
Interface for database access objects.
msg( $key,... $params)