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