Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.04% covered (warning)
88.04%
699 / 794
23.08% covered (danger)
23.08%
3 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMovePage
88.04% covered (warning)
88.04%
699 / 794
23.08% covered (danger)
23.08%
3 / 13
275.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
94.23% covered (success)
94.23%
49 / 52
0.00% covered (danger)
0.00%
0 / 1
12.03
 getRedirectTarget
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 showForm
86.82% covered (warning)
86.82%
428 / 493
0.00% covered (danger)
0.00%
0 / 1
117.12
 vacateTitle
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
11.27
 doSubmit
93.17% covered (success)
93.17%
150 / 161
0.00% covered (danger)
0.00%
0 / 1
63.23
 showLogFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 showSubpages
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
9.21
 showSubpagesList
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
5.03
 truncateSubpagesList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Actions\WatchAction;
10use MediaWiki\CommentStore\CommentStore;
11use MediaWiki\Content\IContentHandlerFactory;
12use MediaWiki\Exception\ErrorPageError;
13use MediaWiki\Exception\PermissionsError;
14use MediaWiki\Exception\ThrottledError;
15use MediaWiki\FileRepo\RepoGroup;
16use MediaWiki\Html\Html;
17use MediaWiki\JobQueue\Jobs\DoubleRedirectJob;
18use MediaWiki\Linker\Linker;
19use MediaWiki\Logging\LogEventsList;
20use MediaWiki\Logging\LogPage;
21use MediaWiki\MainConfigNames;
22use MediaWiki\Page\DeletePageFactory;
23use MediaWiki\Page\LinkBatchFactory;
24use MediaWiki\Page\MovePageFactory;
25use MediaWiki\Page\PageIdentity;
26use MediaWiki\Page\RedirectLookup;
27use MediaWiki\Page\WikiPageFactory;
28use MediaWiki\Permissions\PermissionManager;
29use MediaWiki\Permissions\PermissionStatus;
30use MediaWiki\Permissions\RestrictionStore;
31use MediaWiki\Search\SearchEngineFactory;
32use MediaWiki\SpecialPage\UnlistedSpecialPage;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\NamespaceInfo;
35use MediaWiki\Title\Title;
36use MediaWiki\Title\TitleArrayFromResult;
37use MediaWiki\Title\TitleFormatter;
38use MediaWiki\User\Options\UserOptionsLookup;
39use MediaWiki\User\User;
40use MediaWiki\Watchlist\WatchedItemStore;
41use MediaWiki\Watchlist\WatchlistManager;
42use MediaWiki\Widget\ComplexTitleInputWidget;
43use OOUI\ButtonInputWidget;
44use OOUI\CheckboxInputWidget;
45use OOUI\DropdownInputWidget;
46use OOUI\FieldLayout;
47use OOUI\FieldsetLayout;
48use OOUI\FormLayout;
49use OOUI\HtmlSnippet;
50use OOUI\PanelLayout;
51use OOUI\TextInputWidget;
52use StatusValue;
53use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
54use Wikimedia\Rdbms\IConnectionProvider;
55use Wikimedia\Rdbms\IDBAccessObject;
56use Wikimedia\Timestamp\TimestampFormat as TS;
57
58/**
59 * Implement Special:Movepage for changing page titles
60 *
61 * @ingroup SpecialPage
62 */
63class SpecialMovePage extends UnlistedSpecialPage {
64    /** @var Title */
65    protected $oldTitle = null;
66
67    /** @var Title */
68    protected $newTitle;
69
70    /** @var string Text input */
71    protected $reason;
72
73    /** @var bool */
74    protected $moveTalk;
75
76    /** @var bool */
77    protected $deleteAndMove;
78
79    /** @var bool */
80    protected $moveSubpages;
81
82    /** @var bool */
83    protected $fixRedirects;
84
85    /** @var bool */
86    protected $leaveRedirect;
87
88    /** @var bool */
89    protected $moveOverShared;
90
91    private bool $moveOverProtection;
92    private ?int $requestedPageId = null;
93
94    /** @var bool */
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 DeletePageFactory $deletePageFactory,
112        private readonly RedirectLookup $redirectLookup,
113        private readonly TitleFormatter $titleFormatter
114    ) {
115        parent::__construct( 'Movepage' );
116    }
117
118    /** @inheritDoc */
119    public function doesWrites() {
120        return true;
121    }
122
123    /** @inheritDoc */
124    public function execute( $par ) {
125        $this->useTransactionalTimeLimit();
126        $this->checkReadOnly();
127        $this->setHeaders();
128        $this->outputHeader();
129
130        $request = $this->getRequest();
131
132        // Beware: The use of WebRequest::getText() is wanted! See T22365
133        $target = $par ?? $request->getText( 'target' );
134        $oldTitleText = $request->getText( 'wpOldTitle', $target );
135        $this->oldTitle = Title::newFromText( $oldTitleText );
136
137        if ( !$this->oldTitle ) {
138            // Either oldTitle wasn't passed, or newFromText returned null
139            throw new ErrorPageError( 'notargettitle', 'notargettext' );
140        }
141        $this->getOutput()->addBacklinkSubtitle( $this->oldTitle );
142        // Various uses of Html::errorBox and Html::warningBox.
143        $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
144
145        if ( !$this->oldTitle->exists() ) {
146            throw new ErrorPageError( 'nopagetitle', 'nopagetext' );
147        }
148
149        $newTitleTextMain = $request->getText( 'wpNewTitleMain' );
150        $newTitleTextNs = $request->getInt( 'wpNewTitleNs', $this->oldTitle->getNamespace() );
151        // Backwards compatibility for forms submitting here from other sources
152        // which is more common than it should be.
153        $newTitleText_bc = $request->getText( 'wpNewTitle' );
154        $this->newTitle = strlen( $newTitleText_bc ) > 0
155            ? Title::newFromText( $newTitleText_bc )
156            : Title::makeTitleSafe( $newTitleTextNs, $newTitleTextMain );
157
158        $user = $this->getUser();
159        $isSubmit = $request->getRawVal( 'action' ) === 'submit' && $request->wasPosted();
160
161        $reasonList = $request->getText( 'wpReasonList', 'other' );
162        $reason = $request->getText( 'wpReason' );
163        if ( $reasonList === 'other' ) {
164            $this->reason = $reason;
165        } elseif ( $reason !== '' ) {
166            $this->reason = $reasonList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $reason;
167        } else {
168            $this->reason = $reasonList;
169        }
170        // Default to checked, but don't fill in true during submission (browsers only submit checked values)
171        // TODO: Use HTMLForm to take care of this.
172        $def = !$isSubmit;
173        $this->moveTalk = $request->getBool( 'wpMovetalk', $def );
174        $this->fixRedirects = $request->getBool( 'wpFixRedirects', $def );
175        $this->leaveRedirect = $request->getBool( 'wpLeaveRedirect', $def );
176        // T222953: Tick the "move subpages" box by default
177        $this->moveSubpages = $request->getBool( 'wpMovesubpages', $def );
178        $this->deleteAndMove = $request->getBool( 'wpDeleteAndMove' );
179        $this->moveOverShared = $request->getBool( 'wpMoveOverSharedFile' );
180        $this->moveOverProtection = $request->getBool( 'wpMoveOverProtection' );
181        $this->requestedPageId = $request->getIntOrNull( 'wpMovePageId' );
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
218    /**
219     * Show the form
220     *
221     * @param ?StatusValue $status Form submission status.
222     *   If it is a PermissionStatus, a special message will be shown.
223     * @param ?StatusValue $talkStatus Status for an attempt to move the talk page
224     */
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 = Html::hidden( 'wpMovePageId', $this->oldTitle->getArticleID() );
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        // Page has been moved after the form was loaded. Stop the request from moving the redirect
964        // page created from the first move.
965        if ( $this->requestedPageId && $ot->getArticleID() !== $this->requestedPageId ) {
966            $this->showForm( StatusValue::newFatal( 'movepage-already-moved' ) );
967            return;
968        }
969
970        # Show a warning if the target file exists on a shared repo
971        if ( $nt->getNamespace() === NS_FILE
972            && !( $this->moveOverShared && $this->permManager->userHasRight( $user, 'reupload-shared' ) )
973            && !$this->repoGroup->getLocalRepo()->findFile( $nt )
974            && $this->repoGroup->findFile( $nt )
975        ) {
976            $this->showForm( StatusValue::newFatal( 'file-exists-sharedrepo' ) );
977
978            return;
979        }
980
981        # Show a warning if protected (showForm handles the warning )
982        if ( !$this->moveOverProtection ) {
983            if ( $this->restrictionStore->isProtected( $nt, 'create' ) ) {
984                $this->showForm();
985                return;
986            }
987            if ( $this->moveTalk && $newTalk && $this->restrictionStore->isProtected( $newTalk, 'create' ) ) {
988                $this->showForm();
989                return;
990            }
991        }
992
993        $handler = $this->contentHandlerFactory->getContentHandler( $ot->getContentModel() );
994
995        if ( !$handler->supportsRedirects() || (
996            // Do not create redirects for wikitext message overrides (T376399).
997            // Maybe one day they will have a custom content model and this special case won't be needed.
998            $ot->getNamespace() === NS_MEDIAWIKI &&
999            $ot->getContentModel() === CONTENT_MODEL_WIKITEXT
1000        ) ) {
1001            $createRedirect = false;
1002        } elseif ( $this->permManager->userHasRight( $user, 'suppressredirect' ) ) {
1003            $createRedirect = $this->leaveRedirect;
1004        } else {
1005            $createRedirect = true;
1006        }
1007
1008        // Check perms
1009        $mp = $this->movePageFactory->newMovePage( $ot, $nt );
1010        $permStatusMain = $mp->authorizeMove( $this->getAuthority(), $this->reason );
1011        $permStatusMain->merge( $mp->isValidMove() );
1012
1013        $onlyMovingTalkSubpages = false;
1014        if ( $this->moveTalk && $oldTalk && $newTalk ) {
1015            $mpTalk = $this->movePageFactory->newMovePage( $oldTalk, $newTalk );
1016            $permStatusTalk = $mpTalk->authorizeMove( $this->getAuthority(), $this->reason );
1017            $permStatusTalk->merge( $mpTalk->isValidMove() );
1018            // Per the definition of $considerTalk in showForm you might be trying to move
1019            // subpages of the talk even if the talk itself doesn't exist, so let that happen
1020            if ( !$permStatusTalk->isOK() &&
1021                 !$permStatusTalk->hasMessagesExcept( 'movepage-source-doesnt-exist' )
1022            ) {
1023                $permStatusTalk->setOK( true );
1024                $onlyMovingTalkSubpages = true;
1025            }
1026        } else {
1027            $permStatusTalk = StatusValue::newGood();
1028            $mpTalk = null;
1029        }
1030
1031        if ( $this->deleteAndMove ) {
1032            // This is done before the deletion (in order to minimize the impact of T265792)
1033            // so ignore "it already exists" checks (they will be repeated after the deletion)
1034            if ( $permStatusMain->hasMessagesExcept( 'redirectexists', 'articleexists' ) ||
1035                ( !$onlyMovingTalkSubpages &&
1036                  $permStatusTalk->hasMessagesExcept( 'redirectexists', 'articleexists' ) )
1037            ) {
1038                $this->showForm( $permStatusMain, $permStatusTalk );
1039                return;
1040            }
1041            // If the code gets here, then it's passed all permission checks and the move should succeed
1042            // so start deleting things.
1043            // FIXME: This isn't atomic; it could delete things even if the move will later fail (T265792)
1044            // For example, if you manually specify deleteAndMove in the URL (the form UI won't show the checkbox)
1045            // and have `delete-redirect` and the main page is a single-revision redirect
1046            // but the talk page isn't it will delete the redirect and then fail, leaving it deleted
1047            $deleteStatus = $this->vacateTitle( $nt, $user, $ot );
1048            if ( !$deleteStatus->isGood() ) {
1049                $this->showForm( $deleteStatus );
1050                return;
1051            }
1052            if ( $this->moveTalk && $oldTalk && $newTalk ) {
1053                $deleteStatus = $this->vacateTitle( $newTalk, $user, $oldTalk );
1054                if ( !$deleteStatus->isGood() ) {
1055                    // Ideally we would specify that the subject page redirect was deleted
1056                    // but see the FIXME above
1057                    $this->showForm( StatusValue::newGood(), $deleteStatus );
1058                    return;
1059                }
1060            }
1061        } elseif ( !$permStatusMain->isOK() || !$permStatusTalk->isOK() ) {
1062            // If we're not going to delete then bail on all errors
1063            $this->showForm( $permStatusMain, $permStatusTalk );
1064            return;
1065        }
1066
1067        // Now we've confirmed you can do all of the moves you want and proceeding won't leave things inconsistent
1068        // so actually move the main page
1069        $mainStatus = $mp->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1070        if ( !$mainStatus->isOK() ) {
1071            $this->showForm( $mainStatus );
1072            return;
1073        }
1074
1075        $fixRedirects = $this->fixRedirects && $this->getConfig()->get( MainConfigNames::FixDoubleRedirects );
1076        if ( $fixRedirects ) {
1077            DoubleRedirectJob::fixRedirects( 'move', $ot );
1078        }
1079
1080        // Now try to move the talk page
1081        $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1082
1083        $moveStatuses = [];
1084        $talkStatus = null;
1085        if ( $mpTalk && !$onlyMovingTalkSubpages ) {
1086            $talkStatus = $mpTalk->moveIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1087            // moveIfAllowed returns a Status with an array as a value, however moveSubpages per-title statuses
1088            // have strings as values. Massage this status into the moveSubpages format so it fits in with
1089            // the later calls
1090            '@phan-var Status<string> $talkStatus';
1091            $talkStatus->value = $newTalk->getPrefixedText();
1092            $moveStatuses[$oldTalk->getPrefixedText()] = $talkStatus;
1093        }
1094
1095        // Now try to move subpages if asked
1096        if ( $this->moveSubpages ) {
1097            if ( $this->permManager->userCan( 'move-subpages', $user, $ot ) ) {
1098                $mp->setMaximumMovedPages( $maximumMovedPages - count( $moveStatuses ) );
1099                $subpageStatus = $mp->moveSubpagesIfAllowed( $this->getAuthority(), $this->reason, $createRedirect );
1100                $moveStatuses = array_merge( $moveStatuses, $subpageStatus->value );
1101            }
1102            if ( $mpTalk && $oldTalk && $maximumMovedPages > count( $moveStatuses ) &&
1103                 $this->permManager->userCan( 'move-subpages', $user, $oldTalk ) &&
1104                 ( $onlyMovingTalkSubpages || $talkStatus->isOK() )
1105            ) {
1106                $mpTalk->setMaximumMovedPages( $maximumMovedPages - count( $moveStatuses ) );
1107                $talkSubStatus = $mpTalk->moveSubpagesIfAllowed(
1108                    $this->getAuthority(), $this->reason, $createRedirect
1109                );
1110                $moveStatuses = array_merge( $moveStatuses, $talkSubStatus->value );
1111            }
1112        }
1113
1114        // Now we've moved everything we're going to move, so post-process the output,
1115        // create the UI, and fix double redirects
1116        $out = $this->getOutput();
1117        $out->setPageTitleMsg( $this->msg( 'pagemovedsub' ) );
1118
1119        $linkRenderer = $this->getLinkRenderer();
1120        $oldLink = $linkRenderer->makeLink(
1121            $ot,
1122            null,
1123            [ 'id' => 'movepage-oldlink' ],
1124            [ 'redirect' => 'no' ]
1125        );
1126        $newLink = $linkRenderer->makeKnownLink(
1127            $nt,
1128            null,
1129            [ 'id' => 'movepage-newlink' ]
1130        );
1131        $oldText = $ot->getPrefixedText();
1132        $newText = $nt->getPrefixedText();
1133
1134        $out->addHTML( $this->msg( 'movepage-moved' )->rawParams( $oldLink,
1135            $newLink )->params( $oldText, $newText )->parseAsBlock() );
1136        $out->addWikiMsg( isset( $mainStatus->getValue()['redirectRevision'] ) ?
1137            'movepage-moved-redirect' :
1138            'movepage-moved-noredirect' );
1139
1140        $this->getHookRunner()->onSpecialMovepageAfterMove( $this, $ot, $nt );
1141
1142        $extraOutput = [];
1143        foreach ( $moveStatuses as $oldSubpage => $subpageStatus ) {
1144            if ( $subpageStatus->hasMessage( 'movepage-max-pages' ) ) {
1145                $extraOutput[] = $this->msg( 'movepage-max-pages' )
1146                    ->numParams( $maximumMovedPages )->escaped();
1147                    continue;
1148            }
1149            $oldSubpage = Title::newFromText( $oldSubpage );
1150            $newSubpage = Title::newFromText( $subpageStatus->value );
1151            if ( $subpageStatus->isGood() ) {
1152                if ( $fixRedirects ) {
1153                    DoubleRedirectJob::fixRedirects( 'move', $oldSubpage );
1154                }
1155                $oldLink = $linkRenderer->makeLink( $oldSubpage, null, [], [ 'redirect' => "no" ] );
1156                $newLink = $linkRenderer->makeKnownLink( $newSubpage );
1157
1158                $extraOutput[] = $this->msg( 'movepage-page-moved' )->rawParams(
1159                    $oldLink, $newLink
1160                )->escaped();
1161            } elseif ( $subpageStatus->hasMessage( 'articleexists' )
1162                || $subpageStatus->hasMessage( 'redirectexists' )
1163            ) {
1164                $link = $linkRenderer->makeKnownLink( $newSubpage );
1165                $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped();
1166            } else {
1167                $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
1168                if ( $newSubpage ) {
1169                    $newLink = $linkRenderer->makeLink( $newSubpage );
1170                } else {
1171                    // It's not a valid title
1172                    $newLink = htmlspecialchars( $subpageStatus->value );
1173                }
1174                $extraOutput[] = $this->msg( 'movepage-page-unmoved' )
1175                        ->rawParams( $oldLink, $newLink )->escaped();
1176            }
1177        }
1178
1179        if ( $extraOutput !== [] ) {
1180            $out->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $extraOutput ) . "</li>\n</ul>" );
1181        }
1182
1183        # Deal with watches (we don't watch subpages)
1184        # Get text from expiry selection dropdown, T261230
1185        $expiry = $this->getRequest()->getText( 'wpWatchlistExpiry' );
1186        if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && $expiry !== '' ) {
1187            $expiry = ExpiryDef::normalizeExpiry( $expiry, TS::ISO_8601 );
1188        } else {
1189            $expiry = null;
1190        }
1191
1192        $this->watchlistManager->setWatch(
1193            $this->watch,
1194            $this->getAuthority(),
1195            $ot,
1196            $expiry
1197        );
1198
1199        $this->watchlistManager->setWatch(
1200            $this->watch,
1201            $this->getAuthority(),
1202            $nt,
1203            $expiry
1204        );
1205    }
1206
1207    private function showLogFragment( Title $title ) {
1208        $moveLogPage = new LogPage( 'move' );
1209        $out = $this->getOutput();
1210        $out->addHTML( Html::element( 'h2', [], $moveLogPage->getName()->text() ) );
1211        LogEventsList::showLogExtract( $out, 'move', $title );
1212    }
1213
1214    /**
1215     * Show subpages of the page being moved. Section is not shown if both current
1216     * namespace does not support subpages and no talk subpages were found.
1217     *
1218     * @param Title $title Page being moved.
1219     */
1220    private function showSubpages( $title ) {
1221        $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1222        $nsHasSubpages = $this->nsInfo->hasSubpages( $title->getNamespace() );
1223        $subpages = $title->getSubpages( $maximumMovedPages + 1 );
1224        $count = $subpages instanceof TitleArrayFromResult ? $subpages->count() : 0;
1225
1226        $titleIsTalk = $title->isTalkPage();
1227        $subpagesTalk = $title->getTalkPage()->getSubpages( $maximumMovedPages + 1 );
1228        $countTalk = $subpagesTalk instanceof TitleArrayFromResult ? $subpagesTalk->count() : 0;
1229        $totalCount = $count + $countTalk;
1230
1231        if ( !$nsHasSubpages && $countTalk == 0 ) {
1232            return;
1233        }
1234
1235        $this->getOutput()->wrapWikiMsg(
1236            '== $1 ==',
1237            [ 'movesubpage', ( $titleIsTalk ? $count : $totalCount ) ]
1238        );
1239
1240        if ( $nsHasSubpages ) {
1241            $this->showSubpagesList(
1242                $subpages, $count, 'movesubpagetext', 'movesubpagetext-truncated', true
1243            );
1244        }
1245
1246        if ( !$titleIsTalk && $countTalk > 0 ) {
1247            $this->showSubpagesList(
1248                $subpagesTalk, $countTalk, 'movesubpagetalktext', 'movesubpagetalktext-truncated'
1249            );
1250        }
1251    }
1252
1253    private function showSubpagesList(
1254        TitleArrayFromResult $subpages, int $pagecount, string $msg, string $truncatedMsg, bool $noSubpageMsg = false
1255    ) {
1256        $out = $this->getOutput();
1257
1258        # No subpages.
1259        if ( $pagecount == 0 && $noSubpageMsg ) {
1260            $out->addWikiMsg( 'movenosubpage' );
1261            return;
1262        }
1263
1264        $maximumMovedPages = $this->getConfig()->get( MainConfigNames::MaximumMovedPages );
1265
1266        if ( $pagecount > $maximumMovedPages ) {
1267            $subpages = $this->truncateSubpagesList( $subpages );
1268            $out->addWikiMsg( $truncatedMsg, $this->getLanguage()->formatNum( $maximumMovedPages ) );
1269        } else {
1270            $out->addWikiMsg( $msg, $this->getLanguage()->formatNum( $pagecount ) );
1271        }
1272        $out->addHTML( "<ul>\n" );
1273
1274        $this->linkBatchFactory->newLinkBatch( $subpages )
1275            ->setCaller( __METHOD__ )
1276            ->execute();
1277        $linkRenderer = $this->getLinkRenderer();
1278
1279        foreach ( $subpages as $subpage ) {
1280            $link = $linkRenderer->makeLink( $subpage );
1281            $out->addHTML( "<li>$link</li>\n" );
1282        }
1283        $out->addHTML( "</ul>\n" );
1284    }
1285
1286    private function truncateSubpagesList( iterable $subpages ): array {
1287        $returnArray = [];
1288        foreach ( $subpages as $subpage ) {
1289            $returnArray[] = $subpage;
1290            if ( count( $returnArray ) >= $this->getConfig()->get( MainConfigNames::MaximumMovedPages ) ) {
1291                break;
1292            }
1293        }
1294        return $returnArray;
1295    }
1296
1297    /**
1298     * Return an array of subpages beginning with $search that this special page will accept.
1299     *
1300     * @param string $search Prefix to search for
1301     * @param int $limit Maximum number of results to return (usually 10)
1302     * @param int $offset Number of results to skip (usually 0)
1303     * @return string[] Matching subpages
1304     */
1305    public function prefixSearchSubpages( $search, $limit, $offset ) {
1306        return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
1307    }
1308
1309    /** @inheritDoc */
1310    protected function getGroupName() {
1311        return 'pagetools';
1312    }
1313}
1314
1315// @codeCoverageIgnoreStart
1316/**
1317 * Retain the old class name for backwards compatibility.
1318 * @deprecated since 1.40
1319 */
1320class_alias( SpecialMovePage::class, 'MovePageForm' );
1321// @codeCoverageIgnoreEnd