Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 280
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
MoveTranslatableBundleSpecialPage
0.00% covered (danger)
0.00%
0 / 280
0.00% covered (danger)
0.00%
0 / 15
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
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
 execute
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
132
 doBasicChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 authorizeMove
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 checkToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showErrors
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 showForm
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 showConfirmation
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
110
 addSectionHeaderAndMessage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getSubactionFromRequest
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getCommonFormFields
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
2
 getSpecialPageTitle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getLogPageWikiLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use ErrorPageError;
7use HTMLForm;
8use InvalidArgumentException;
9use MediaWiki\CommentStore\CommentStore;
10use MediaWiki\Extension\Translate\MessageBundleTranslation\MessageBundle;
11use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle;
12use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory;
13use MediaWiki\Html\Html;
14use MediaWiki\Permissions\PermissionManager;
15use MediaWiki\Permissions\PermissionStatus;
16use MediaWiki\Title\Title;
17use Message;
18use OutputPage;
19use PermissionsError;
20use ReadOnlyError;
21use SplObjectStorage;
22use ThrottledError;
23use UnlistedSpecialPage;
24use 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 */
34class 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}