Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 280 |
|
0.00% |
0 / 15 |
CRAP | |
0.00% |
0 / 1 |
MoveTranslatableBundleSpecialPage | |
0.00% |
0 / 280 |
|
0.00% |
0 / 15 |
2862 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
132 | |||
doBasicChecks | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
authorizeMove | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
checkToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
showErrors | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
showForm | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
6 | |||
showConfirmation | |
0.00% |
0 / 95 |
|
0.00% |
0 / 1 |
110 | |||
addSectionHeaderAndMessage | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getSubactionFromRequest | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
getCommonFormFields | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
2 | |||
getSpecialPageTitle | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getLogPageWikiLink | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\PageTranslation; |
5 | |
6 | use ErrorPageError; |
7 | use HTMLForm; |
8 | use InvalidArgumentException; |
9 | use MediaWiki\CommentStore\CommentStore; |
10 | use MediaWiki\Extension\Translate\MessageBundleTranslation\MessageBundle; |
11 | use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle; |
12 | use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory; |
13 | use MediaWiki\Html\Html; |
14 | use MediaWiki\Permissions\PermissionManager; |
15 | use MediaWiki\Permissions\PermissionStatus; |
16 | use MediaWiki\Title\Title; |
17 | use Message; |
18 | use OutputPage; |
19 | use PermissionsError; |
20 | use ReadOnlyError; |
21 | use SplObjectStorage; |
22 | use ThrottledError; |
23 | use UnlistedSpecialPage; |
24 | use Wikimedia\ObjectFactory\ObjectFactory; |
25 | |
26 | /** |
27 | * Replacement for Special:Movepage to allow renaming a translatable bundle and |
28 | * all pages associated with it. |
29 | * |
30 | * @author Niklas Laxström |
31 | * @license GPL-2.0-or-later |
32 | * @ingroup SpecialPage PageTranslation |
33 | */ |
34 | class MoveTranslatableBundleSpecialPage extends UnlistedSpecialPage { |
35 | // Form parameters both as text and as titles |
36 | private string $oldText; |
37 | private string $reason; |
38 | private bool $moveTalkpages = true; |
39 | private bool $moveSubpages = true; |
40 | private bool $leaveRedirect = true; |
41 | private ObjectFactory $objectFactory; |
42 | private TranslatableBundleMover $bundleMover; |
43 | private PermissionManager $permissionManager; |
44 | private TranslatableBundleFactory $bundleFactory; |
45 | private $movePageSpec; |
46 | // Other |
47 | private ?Title $oldTitle; |
48 | |
49 | public function __construct( |
50 | ObjectFactory $objectFactory, |
51 | PermissionManager $permissionManager, |
52 | TranslatableBundleMover $bundleMover, |
53 | TranslatableBundleFactory $bundleFactory, |
54 | $movePageSpec |
55 | ) { |
56 | parent::__construct( 'Movepage' ); |
57 | $this->objectFactory = $objectFactory; |
58 | $this->permissionManager = $permissionManager; |
59 | $this->bundleMover = $bundleMover; |
60 | $this->bundleFactory = $bundleFactory; |
61 | $this->movePageSpec = $movePageSpec; |
62 | } |
63 | |
64 | public function doesWrites(): bool { |
65 | return true; |
66 | } |
67 | |
68 | protected function getGroupName(): string { |
69 | return 'pagetools'; |
70 | } |
71 | |
72 | /** @inheritDoc */ |
73 | public function execute( $par ) { |
74 | $request = $this->getRequest(); |
75 | $user = $this->getUser(); |
76 | $this->addHelpLink( 'Help:Extension:Translate/Move_translatable_page' ); |
77 | $out = $this->getOutput(); |
78 | $out->addModuleStyles( 'ext.translate.special.movetranslatablebundles.styles' ); |
79 | |
80 | $this->oldText = $request->getText( |
81 | 'wpOldTitle', |
82 | $request->getText( 'target', $par ?? '' ) |
83 | ); |
84 | $newText = $request->getText( 'wpNewTitle' ); |
85 | |
86 | $this->oldTitle = Title::newFromText( $this->oldText ); |
87 | $newTitle = Title::newFromText( $newText ); |
88 | // Normalize input |
89 | if ( $this->oldTitle ) { |
90 | $this->oldText = $this->oldTitle->getPrefixedText(); |
91 | } |
92 | |
93 | $this->reason = $request->getText( 'reason' ); |
94 | |
95 | // This will throw exceptions if there is an error. |
96 | $this->doBasicChecks(); |
97 | |
98 | // Real stuff starts here |
99 | $bundle = $this->bundleFactory->getBundle( $this->oldTitle ); |
100 | if ( $bundle && $bundle->isMoveable() ) { |
101 | $this->getOutput()->setPageTitle( $this->getSpecialPageTitle( $bundle ) ); |
102 | |
103 | if ( !$user->isAllowed( 'pagetranslation' ) ) { |
104 | throw new PermissionsError( 'pagetranslation' ); |
105 | } |
106 | |
107 | $subaction = $this->getSubactionFromRequest( $request->getText( 'subaction' ) ); |
108 | |
109 | $isValidPostRequest = $this->checkToken() && $request->wasPosted(); |
110 | if ( $isValidPostRequest && $subaction === 'check' ) { |
111 | try { |
112 | $pageCollection = $this->bundleMover->getPageMoveCollection( |
113 | $this->oldTitle, |
114 | $newTitle, |
115 | $user, |
116 | $this->reason, |
117 | $this->moveSubpages, |
118 | $this->moveTalkpages, |
119 | $this->leaveRedirect |
120 | ); |
121 | } catch ( ImpossiblePageMove $e ) { |
122 | $this->showErrors( $e->getBlockers() ); |
123 | $this->showForm( $bundle ); |
124 | return; |
125 | } |
126 | |
127 | $this->showConfirmation( $pageCollection, $bundle ); |
128 | } elseif ( $isValidPostRequest && $subaction === 'perform' ) { |
129 | $this->moveSubpages = $request->getBool( 'subpages' ); |
130 | $this->moveTalkpages = $request->getBool( 'talkpages' ); |
131 | $this->leaveRedirect = $request->getBool( 'redirect' ); |
132 | |
133 | // This will throw exceptions if there is an error. |
134 | $this->authorizeMove(); |
135 | |
136 | $this->bundleMover->moveAsynchronously( |
137 | $this->oldTitle, |
138 | $newTitle, |
139 | $this->moveSubpages, |
140 | $this->getUser(), |
141 | $this->reason, |
142 | $this->moveTalkpages, |
143 | $this->leaveRedirect |
144 | ); |
145 | $this->getOutput()->addWikiMsg( |
146 | 'pt-movepage-started', |
147 | $this->getLogPageWikiLink( $this->bundleFactory->getValidBundle( $this->oldTitle ) ) |
148 | ); |
149 | } else { |
150 | $this->showForm( $bundle ); |
151 | } |
152 | } else { |
153 | // Delegate... don't want to reimplement this |
154 | $sp = $this->objectFactory->createObject( $this->movePageSpec ); |
155 | $sp->execute( $par ); |
156 | } |
157 | } |
158 | |
159 | /** |
160 | * Do the basic checks whether moving is possible and whether |
161 | * the input looks anywhere near sane. |
162 | * @throws PermissionsError|ErrorPageError|ReadOnlyError|ThrottledError |
163 | */ |
164 | protected function doBasicChecks(): void { |
165 | $this->checkReadOnly(); |
166 | |
167 | if ( $this->oldTitle === null ) { |
168 | throw new ErrorPageError( 'notargettitle', 'notargettext' ); |
169 | } |
170 | |
171 | if ( !$this->oldTitle->exists() ) { |
172 | throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); |
173 | } |
174 | |
175 | // Since MW 1.36, Authority should be used to check permissions |
176 | $status = PermissionStatus::newEmpty(); |
177 | if ( !$this->getAuthority() |
178 | ->definitelyCan( 'move', $this->oldTitle, $status ) |
179 | ) { |
180 | throw new PermissionsError( 'move', $status ); |
181 | } |
182 | } |
183 | |
184 | /** Checks permissions, user blocks and rate limits. */ |
185 | protected function authorizeMove(): void { |
186 | if ( class_exists( PermissionStatus::class ) |
187 | && method_exists( PermissionStatus::class, 'isRateLimitExceeded' ) |
188 | ) { |
189 | // Since MW 1.41, Authority will implicitly enforce rate limits |
190 | // and user blocks. |
191 | $status = PermissionStatus::newEmpty(); |
192 | $this->getAuthority() |
193 | ->authorizeWrite( 'move', $this->oldTitle, $status ); |
194 | |
195 | if ( !$status->isOK() ) { |
196 | if ( $status->isRateLimitExceeded() ) { |
197 | throw new ThrottledError; |
198 | } else { |
199 | throw new PermissionsError( 'move', $status ); |
200 | } |
201 | } |
202 | } else { |
203 | if ( $this->getUser()->pingLimiter( 'move' ) ) { |
204 | throw new ThrottledError; |
205 | } |
206 | } |
207 | } |
208 | |
209 | /** Checks token to protect against CSRF. */ |
210 | protected function checkToken(): bool { |
211 | return $this->getContext()->getCsrfTokenSet()->matchTokenField( 'wpEditToken' ); |
212 | } |
213 | |
214 | /** Pretty-print the list of errors. */ |
215 | protected function showErrors( SplObjectStorage $errors ): void { |
216 | // If there are many errors, for performance reasons we must parse them all at once |
217 | $s = ''; |
218 | $context = 'pt-movepage-error-placeholder'; |
219 | foreach ( $errors as $title ) { |
220 | $titleText = $title->getPrefixedText(); |
221 | $s .= "'''$titleText'''\n\n"; |
222 | $s .= $errors[ $title ]->getWikiText( false, $context ); |
223 | } |
224 | |
225 | $out = $this->getOutput(); |
226 | $out->addHTML( |
227 | Html::errorBox( |
228 | $out->msg( |
229 | 'pt-movepage-blockers', |
230 | $this->getLanguage()->formatNum( count( $errors ) ) |
231 | )->parseAsBlock() . |
232 | $out->parseAsContent( $s ) |
233 | ) |
234 | ); |
235 | } |
236 | |
237 | /** The query form. */ |
238 | public function showForm( TranslatableBundle $bundle ) { |
239 | $this->getOutput()->addBacklinkSubtitle( $this->oldTitle ); |
240 | $this->getOutput()->addWikiMsg( |
241 | 'pt-movepage-intro', |
242 | $this->getLogPageWikiLink( |
243 | $this->bundleFactory->getBundle( Title::newFromText( $this->oldText ) ) |
244 | ) |
245 | ); |
246 | |
247 | HTMLForm::factory( 'ooui', $this->getCommonFormFields(), $this->getContext() ) |
248 | ->setMethod( 'post' ) |
249 | ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) |
250 | ->setSubmitName( 'subaction' ) |
251 | ->setSubmitTextMsg( 'pt-movepage-action-check' ) |
252 | ->setWrapperLegendMsg( |
253 | $bundle instanceof MessageBundle ? 'pt-movepage-messagebundle-legend' : 'pt-movepage-legend' |
254 | ) |
255 | ->prepareForm() |
256 | ->displayForm( false ); |
257 | } |
258 | |
259 | /** |
260 | * The second form, which still allows changing some things. |
261 | * Lists all the action which would take place. |
262 | */ |
263 | protected function showConfirmation( PageMoveCollection $pageCollection, TranslatableBundle $bundle ): void { |
264 | $out = $this->getOutput(); |
265 | $out->addBacklinkSubtitle( $this->oldTitle ); |
266 | $out->addWikiMsg( |
267 | 'pt-movepage-intro', |
268 | $this->getLogPageWikiLink( |
269 | $this->bundleFactory->getBundle( Title::newFromText( $this->oldText ) ) |
270 | ) |
271 | ); |
272 | |
273 | $count = 0; |
274 | $subpagesCount = 0; |
275 | $talkpagesCount = 0; |
276 | |
277 | /** @var PageMoveOperation[][] */ |
278 | $pagesToMove = [ |
279 | 'pt-movepage-list-source' => [ $pageCollection->getTranslatablePage() ], |
280 | 'pt-movepage-list-translation' => $pageCollection->getTranslationPagesPair(), |
281 | 'pt-movepage-list-section' => $pageCollection->getUnitPagesPair() |
282 | ]; |
283 | |
284 | $subpages = $pageCollection->getSubpagesPair(); |
285 | if ( $subpages ) { |
286 | $pagesToMove[ 'pt-movepage-list-other'] = $subpages; |
287 | } |
288 | |
289 | $out->wrapWikiMsg( '== $1 ==', [ 'pt-movepage-list-pages' ] ); |
290 | |
291 | foreach ( $pagesToMove as $type => $pages ) { |
292 | $this->addSectionHeaderAndMessage( $out, $type, $pages ); |
293 | |
294 | if ( !$pages ) { |
295 | continue; |
296 | } |
297 | |
298 | $lines = []; |
299 | |
300 | foreach ( $pages as $pagePairs ) { |
301 | $count++; |
302 | |
303 | if ( $type === 'pt-movepage-list-other' ) { |
304 | $subpagesCount++; |
305 | } |
306 | |
307 | $old = $pagePairs->getOldTitle(); |
308 | $new = $pagePairs->getNewTitle(); |
309 | $line = '* ' . $old->getPrefixedText() . ' → ' . $new->getPrefixedText(); |
310 | if ( $pagePairs->hasTalkpage() ) { |
311 | $count++; |
312 | $talkpagesCount++; |
313 | $line .= ' ' . $this->msg( 'pt-movepage-talkpage-exists' )->text(); |
314 | } |
315 | |
316 | $lines[] = $line; |
317 | } |
318 | |
319 | $out->addWikiTextAsInterface( implode( "\n", $lines ) ); |
320 | } |
321 | |
322 | $translatableSubpages = $pageCollection->getTranslatableSubpages(); |
323 | $sectionType = 'pt-movepage-list-translatable'; |
324 | $this->addSectionHeaderAndMessage( $out, $sectionType, $translatableSubpages ); |
325 | if ( $translatableSubpages ) { |
326 | $lines = []; |
327 | $out->wrapWikiMsg( "'''$1'''", $this->msg( 'pt-movepage-list-translatable-note' ) ); |
328 | foreach ( $translatableSubpages as $page ) { |
329 | $lines[] = '* ' . $page->getPrefixedText(); |
330 | } |
331 | $out->addWikiTextAsInterface( implode( "\n", $lines ) ); |
332 | } |
333 | |
334 | $out->addWikiTextAsInterface( "----\n" ); |
335 | $out->addWikiMsg( |
336 | 'pt-movepage-list-count', |
337 | $this->getLanguage()->formatNum( $count ), |
338 | $this->getLanguage()->formatNum( $subpagesCount ), |
339 | $this->getLanguage()->formatNum( $talkpagesCount ) |
340 | ); |
341 | |
342 | $formDescriptor = array_merge( |
343 | $this->getCommonFormFields(), |
344 | [ |
345 | 'subpages' => [ |
346 | 'type' => 'check', |
347 | 'name' => 'subpages', |
348 | 'id' => 'mw-subpages', |
349 | 'label-message' => 'pt-movepage-subpages', |
350 | 'default' => $this->moveSubpages, |
351 | ], |
352 | 'redirect' => [ |
353 | 'type' => 'check', |
354 | 'name' => 'redirect', |
355 | 'id' => 'mw-leave-redirect', |
356 | 'label-message' => 'pt-leave-redirect', |
357 | 'default' => $this->leaveRedirect, |
358 | ], |
359 | 'talkpages' => [ |
360 | 'type' => 'check', |
361 | 'name' => 'talkpages', |
362 | 'id' => 'mw-talkpages', |
363 | 'label-message' => 'pt-movepage-talkpages', |
364 | 'default' => $this->moveTalkpages |
365 | ] |
366 | ] |
367 | ); |
368 | |
369 | $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); |
370 | $htmlForm |
371 | ->addButton( [ |
372 | 'name' => 'subaction', |
373 | 'value' => $this->msg( 'pt-movepage-action-other' )->text(), |
374 | ] ) |
375 | ->setMethod( 'post' ) |
376 | ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) |
377 | ->setSubmitName( 'subaction' ) |
378 | ->setSubmitTextMsg( 'pt-movepage-action-perform' ) |
379 | ->setWrapperLegendMsg( |
380 | $bundle instanceof MessageBundle ? 'pt-movepage-messagebundle-legend' : 'pt-movepage-legend' |
381 | ) |
382 | ->prepareForm() |
383 | ->displayForm( false ); |
384 | } |
385 | |
386 | /** Add section header and no page message if there are no pages */ |
387 | private function addSectionHeaderAndMessage( OutputPage $out, string $type, array $pages ): void { |
388 | $leaveRedirect = TranslatableBundleMover::shouldLeaveRedirect( $type, $this->leaveRedirect ); |
389 | |
390 | $pageCount = count( $pages ); |
391 | if ( $leaveRedirect ) { |
392 | $headingRedirectLabel = $this->msg( |
393 | 'pt-leave-redirect-label', |
394 | $this->msg( $type, $pageCount )->text() |
395 | )->text(); |
396 | $out->addWikiTextAsInterface( "<h3 class=\"mw-translate-leave-redirect\">$headingRedirectLabel</h3>" ); |
397 | } else { |
398 | $out->wrapWikiMsg( '=== $1 ===', [ $type, $pageCount ] ); |
399 | } |
400 | |
401 | if ( !$pageCount ) { |
402 | $out->addWikiMsg( 'pt-movepage-list-no-pages' ); |
403 | } |
404 | } |
405 | |
406 | private function getSubactionFromRequest( string $subactionText ): string { |
407 | switch ( $subactionText ) { |
408 | case $this->msg( 'pt-movepage-action-check' )->text(): |
409 | return 'check'; |
410 | case $this->msg( 'pt-movepage-action-perform' )->text(): |
411 | return 'perform'; |
412 | case $this->msg( 'pt-movepage-action-other' )->text(): |
413 | return 'show-form'; |
414 | default: |
415 | return 'show-form'; |
416 | } |
417 | } |
418 | |
419 | private function getCommonFormFields(): array { |
420 | return [ |
421 | 'wpOldTitle' => [ |
422 | 'type' => 'text', |
423 | 'name' => 'wpOldTitle', |
424 | 'label-message' => 'pt-movepage-current', |
425 | 'default' => $this->oldText, |
426 | 'readonly' => true, |
427 | ], |
428 | 'wpNewTitle' => [ |
429 | 'type' => 'text', |
430 | 'name' => 'wpNewTitle', |
431 | 'label-message' => 'pt-movepage-new', |
432 | ], |
433 | 'reason' => [ |
434 | 'type' => 'text', |
435 | 'name' => 'reason', |
436 | 'label-message' => 'pt-movepage-reason', |
437 | 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT, |
438 | 'default' => $this->reason, |
439 | ], |
440 | 'redirect' => [ |
441 | 'type' => 'hidden', |
442 | 'name' => 'redirect', |
443 | 'id' => 'mw-leave-redirect', |
444 | 'label-message' => 'pt-leave-redirect', |
445 | 'default' => $this->leaveRedirect, |
446 | ], |
447 | 'subpages' => [ |
448 | 'type' => 'hidden', |
449 | 'name' => 'subpages', |
450 | 'default' => $this->moveSubpages, |
451 | ], |
452 | 'talkpages' => [ |
453 | 'type' => 'hidden', |
454 | 'name' => 'talkpages', |
455 | 'default' => $this->moveTalkpages |
456 | ] |
457 | ]; |
458 | } |
459 | |
460 | private function getSpecialPageTitle( TranslatableBundle $bundle ): Message { |
461 | if ( $bundle instanceof TranslatablePage ) { |
462 | return $this->msg( 'pt-movepage-title', $this->oldText ); |
463 | } elseif ( $bundle instanceof MessageBundle ) { |
464 | return $this->msg( 'pt-movepage-messagebundle-title', $this->oldText ); |
465 | } |
466 | |
467 | throw new InvalidArgumentException( 'TranslatableBundle is neither a TranslatablePage or MessageBundle' ); |
468 | } |
469 | |
470 | private function getLogPageWikiLink( ?TranslatableBundle $bundle ): string { |
471 | if ( $bundle instanceof MessageBundle ) { |
472 | return 'Special:Log/messagebundle'; |
473 | } |
474 | |
475 | // Default to page translation log in case of errors |
476 | return 'Special:Log/pagetranslation'; |
477 | } |
478 | } |