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