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