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