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