Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslatableBundleMover
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 12
2162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getPageMoveCollection
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
306
 moveAsynchronously
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 moveSynchronously
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 disablePageMoveLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enablePageMoveLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldLeaveRedirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getPagesToMove
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 lock
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 unlock
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 move
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getRenameMoveBlocker
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use JobQueueGroup;
7use LogicException;
8use MediaWiki\Cache\LinkBatchFactory;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\MoveTranslatableBundleJob;
10use MediaWiki\Extension\Translate\MessageGroupProcessing\SubpageListBuilder;
11use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle;
12use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory;
13use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
14use MediaWiki\Message\Message;
15use MediaWiki\Page\MovePageFactory;
16use MediaWiki\Status\Status;
17use MediaWiki\Title\Title;
18use MediaWiki\User\User;
19use ObjectCacheFactory;
20use SplObjectStorage;
21use Wikimedia\ObjectCache\BagOStuff;
22use Wikimedia\Rdbms\IConnectionProvider;
23
24/**
25 * Contains the core logic to validate and move translatable bundles
26 * @author Abijeet Patro
27 * @license GPL-2.0-or-later
28 * @since 2021.03
29 */
30class TranslatableBundleMover {
31    private const LOCK_TIMEOUT = 3600 * 2;
32    private const FETCH_TRANSLATABLE_SUBPAGES = true;
33    private MovePageFactory $movePageFactory;
34    private ?int $pageMoveLimit;
35    private JobQueueGroup $jobQueue;
36    private LinkBatchFactory $linkBatchFactory;
37    private TranslatableBundleFactory $bundleFactory;
38    private SubpageListBuilder $subpageBuilder;
39    private IConnectionProvider $dbProvider;
40    private BagOStuff $cache;
41    private bool $pageMoveLimitEnabled = true;
42
43    private const REDIRECTABLE_PAGE_TYPES = [
44        'pt-movepage-list-source' => true,
45        'pt-movepage-list-section' => false,
46        'pt-movepage-list-nonmovable' => false,
47        'pt-movepage-list-translatable' => false,
48        'pt-movepage-list-translation' => false,
49        'pt-movepage-list-other' => true
50    ];
51
52    public function __construct(
53        MovePageFactory $movePageFactory,
54        JobQueueGroup $jobQueue,
55        LinkBatchFactory $linkBatchFactory,
56        TranslatableBundleFactory $bundleFactory,
57        SubpageListBuilder $subpageBuilder,
58        IConnectionProvider $dbProvider,
59        ObjectCacheFactory $objectCacheFactory,
60        ?int $pageMoveLimit
61    ) {
62        $this->movePageFactory = $movePageFactory;
63        $this->jobQueue = $jobQueue;
64        $this->pageMoveLimit = $pageMoveLimit;
65        $this->linkBatchFactory = $linkBatchFactory;
66        $this->bundleFactory = $bundleFactory;
67        $this->subpageBuilder = $subpageBuilder;
68        $this->dbProvider = $dbProvider;
69        $this->cache = $objectCacheFactory->getInstance( CACHE_ANYTHING );
70    }
71
72    public function getPageMoveCollection(
73        Title $source,
74        ?Title $target,
75        User $user,
76        string $reason,
77        bool $moveSubPages,
78        bool $moveTalkPages,
79        bool $leaveRedirect
80    ): PageMoveCollection {
81        $blockers = new SplObjectStorage();
82
83        if ( !$target ) {
84            $blockers[$source] = Status::newFatal( 'pt-movepage-block-base-invalid' );
85            throw new ImpossiblePageMove( $blockers );
86        }
87
88        if ( $target->inNamespaces( NS_MEDIAWIKI, NS_TRANSLATIONS ) ) {
89            $blockers[$source] = Status::newFatal( 'immobile-target-namespace', $target->getNsText() );
90            throw new ImpossiblePageMove( $blockers );
91        }
92
93        $movePage = $this->movePageFactory->newMovePage( $source, $target );
94        $status = $movePage->isValidMove();
95        $status->merge( $movePage->probablyCanMove( $user, $reason ) );
96        if ( !$status->isOK() ) {
97            $blockers[$source] = $status;
98        }
99
100        // Don't spam the same errors for all pages if base page fails
101        if ( count( $blockers ) ) {
102            throw new ImpossiblePageMove( $blockers );
103        }
104
105        $pageCollection = $this->getPagesToMove(
106            $source, $target, $moveSubPages, self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages, $leaveRedirect
107        );
108
109        // Collect all the old and new titles for checks
110        $titles = [
111            'tp' => $pageCollection->getTranslationPagesPair(),
112            'subpage' => $pageCollection->getSubpagesPair(),
113            'section' => $pageCollection->getUnitPagesPair()
114        ];
115
116        // Check that all new titles are valid and count them. Add 1 for source page.
117        $moveCount = 1;
118        $lb = $this->linkBatchFactory->newLinkBatch();
119        foreach ( $titles as $type => $list ) {
120            $moveCount += count( $list );
121            // Give grep a chance to find the usages:
122            // pt-movepage-block-tp-invalid, pt-movepage-block-section-invalid,
123            // pt-movepage-block-subpage-invalid
124            foreach ( $list as $pair ) {
125                $old = $pair->getOldTitle();
126                $new = $pair->getNewTitle();
127
128                if ( $new === null ) {
129                    $blockers[$old] = $this->getRenameMoveBlocker( $old, $type, $pair->getRenameErrorCode() );
130                    continue;
131                }
132                $lb->addObj( $old );
133                $lb->addObj( $new );
134            }
135        }
136
137        if ( $this->pageMoveLimitEnabled ) {
138            if ( $this->pageMoveLimit !== null && $moveCount > $this->pageMoveLimit ) {
139                $blockers[$source] = Status::newFatal(
140                    'pt-movepage-page-count-limit',
141                    Message::numParam( $this->pageMoveLimit )
142                );
143            }
144        }
145
146        // Stop further validation if there are blockers already.
147        if ( count( $blockers ) ) {
148            throw new ImpossiblePageMove( $blockers );
149        }
150
151        // Check that there are no move blockers
152        $lb->setCaller( __METHOD__ )->execute();
153        foreach ( $titles as $type => $list ) {
154            foreach ( $list as $pair ) {
155                $old = $pair->getOldTitle();
156                $new = $pair->getNewTitle();
157
158                /* This method has terrible performance:
159                 * - 2 queries by core
160                 * - 3 queries by lqt
161                 * - and no obvious way to preload the data! */
162                $movePage = $this->movePageFactory->newMovePage( $old, $new );
163                $status = $movePage->isValidMove();
164                // Do not check for permissions here, as these pages are not editable/movable
165                // in regular use
166                if ( !$status->isOK() ) {
167                    $blockers[$old] = $status;
168                }
169
170                /* Because of the poor performance, check only one of the possibly thousands
171                 * of section pages and assume rest are fine. This assumes section pages are
172                 * listed last in the array. */
173                if ( $type === 'section' ) {
174                    break;
175                }
176            }
177        }
178
179        if ( count( $blockers ) ) {
180            throw new ImpossiblePageMove( $blockers );
181        }
182
183        return $pageCollection;
184    }
185
186    public function moveAsynchronously(
187        Title $source,
188        Title $target,
189        bool $moveSubPages,
190        User $user,
191        string $moveReason,
192        bool $moveTalkPages,
193        bool $leaveRedirect
194    ): void {
195        $pageCollection = $this->getPagesToMove(
196            $source, $target, $moveSubPages, !self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages, $leaveRedirect
197        );
198        $pagesToMove = $pageCollection->getListOfPages();
199        $pagesToLeaveRedirect = $pageCollection->getListOfPagesToRedirect();
200
201        $job = MoveTranslatableBundleJob::newJob(
202            $source,
203            $target,
204            $pagesToMove,
205            $pagesToLeaveRedirect,
206            $moveReason,
207            $user,
208        );
209
210        $this->lock( array_keys( $pagesToMove ) );
211        $this->lock( array_values( $pagesToMove ) );
212
213        $this->jobQueue->push( $job );
214    }
215
216    /**
217     * @param Title $source
218     * @param Title $target
219     * @param string[] $pagesToMove
220     * @param array<string,bool> $pagesToRedirect
221     * @param User $performer
222     * @param string $moveReason
223     * @param ?callable $progressCallback
224     */
225    public function moveSynchronously(
226        Title $source,
227        Title $target,
228        array $pagesToMove,
229        array $pagesToRedirect,
230        User $performer,
231        string $moveReason,
232        ?callable $progressCallback = null
233    ): void {
234        $sourceBundle = $this->bundleFactory->getValidBundle( $source );
235
236        $this->move( $sourceBundle, $performer, $pagesToMove, $pagesToRedirect, $moveReason, $progressCallback );
237
238        $this->bundleFactory->getStore( $sourceBundle )->move( $source, $target );
239
240        $this->bundleFactory->getPageMoveLogger( $sourceBundle )
241            ->logSuccess( $performer, $target, $moveReason );
242    }
243
244    public function disablePageMoveLimit(): void {
245        $this->pageMoveLimitEnabled = false;
246    }
247
248    public function enablePageMoveLimit(): void {
249        $this->pageMoveLimitEnabled = true;
250    }
251
252    public static function shouldLeaveRedirect( string $pageType, bool $leaveRedirect ): bool {
253        return self::REDIRECTABLE_PAGE_TYPES[ $pageType ] && $leaveRedirect;
254    }
255
256    private function getPagesToMove(
257        Title $source,
258        Title $target,
259        bool $moveSubPages,
260        bool $fetchTranslatableSubpages,
261        bool $moveTalkPages,
262        bool $leaveRedirect
263    ): PageMoveCollection {
264        $sourceBundle = $this->bundleFactory->getValidBundle( $source );
265
266        $classifiedSubpages = $this->subpageBuilder->getSubpagesPerType( $sourceBundle, $moveTalkPages );
267
268        $talkPages = $moveTalkPages ? $classifiedSubpages['talkPages'] : [];
269        $subpages = $moveSubPages ? $classifiedSubpages['normalSubpages'] : [];
270        $nonMovableSubpages = [];
271
272        $relatedTranslatablePageList = [];
273        if ( $fetchTranslatableSubpages ) {
274            $relatedTranslatablePageList = array_merge(
275                $classifiedSubpages['translatableSubpages'],
276                $classifiedSubpages['translatableTalkPages']
277            );
278        }
279
280        $movePageFactory = $this->movePageFactory;
281        $pageTitleRenamer = new PageTitleRenamer( $source, $target );
282        $createOps = function ( array $pages, string $pageType )
283            use ( $pageTitleRenamer, $talkPages, &$nonMovableSubpages, $leaveRedirect, $movePageFactory ) {
284            $leaveRedirect = self::shouldLeaveRedirect( $pageType, $leaveRedirect );
285            $ops = [];
286            foreach ( $pages as $from ) {
287                $to = $pageTitleRenamer->getNewTitle( $from );
288                $op = new PageMoveOperation( $from, $to );
289
290                if ( $pageType === 'pt-movepage-list-other' ) {
291                    // TODO: In the future, think of moving all checks regarding whether a page
292                    // is movable into this method. Currently its' being checked in two places
293                    // making things slow.
294                    $movePage = $movePageFactory->newMovePage( $from, $to );
295                    $status = $movePage->isValidMove();
296
297                    // Remove non movable subpages
298                    if ( !$status->isOK() ) {
299                        $nonMovableSubpages[ $from->getPrefixedText() ] = $status;
300                        continue;
301                    }
302                }
303
304                $op->setLeaveRedirect( $leaveRedirect );
305
306                $talkPage = $talkPages[ $from->getPrefixedDBkey() ] ?? null;
307                if ( $talkPage ) {
308                    $op->setTalkpage( $talkPage, $pageTitleRenamer->getNewTitle( $talkPage ) );
309                }
310                $ops[] = $op;
311            }
312
313                return $ops;
314        };
315
316        return new PageMoveCollection(
317            $createOps( [ $source ], 'pt-movepage-list-source' )[0],
318            $createOps( $classifiedSubpages['translationPages'], 'pt-movepage-list-translation' ),
319            $createOps( $classifiedSubpages['translationUnitPages'], 'pt-movepage-list-section' ),
320            $createOps( $subpages, 'pt-movepage-list-other' ),
321            $nonMovableSubpages,
322            $relatedTranslatablePageList
323        );
324    }
325
326    /** @param string[] $titles */
327    private function lock( array $titles ): void {
328        $data = [];
329        foreach ( $titles as $title ) {
330            $data[$this->cache->makeKey( 'pt-lock', sha1( $title ) )] = 'locked';
331        }
332
333        // Do not lock pages indefinitely during translatable page moves since
334        // they can fail. Add a timeout so that the locks expire by themselves.
335        // Timeout value has been chosen by a gut feeling
336        $this->cache->setMulti( $data, self::LOCK_TIMEOUT );
337    }
338
339    /** @param string[] $titles */
340    private function unlock( array $titles ): void {
341        foreach ( $titles as $title ) {
342            $this->cache->delete( $this->cache->makeKey( 'pt-lock', sha1( $title ) ) );
343        }
344    }
345
346    /**
347     * @param TranslatableBundle $sourceBundle
348     * @param User $performer
349     * @param string[] $pagesToMove
350     * @param array<string,bool> $pagesToRedirect
351     * @param string $reason
352     * @param ?callable $progressCallback
353     */
354    private function move(
355        TranslatableBundle $sourceBundle,
356        User $performer,
357        array $pagesToMove,
358        array $pagesToRedirect,
359        string $reason,
360        ?callable $progressCallback = null
361    ): void {
362        $fuzzyBot = FuzzyBot::getUser();
363
364        Hooks::$allowTargetEdit = true;
365
366        $processed = 0;
367
368        $this->dbProvider->getPrimaryDatabase()->startAtomic( __METHOD__ );
369        foreach ( $pagesToMove as $source => $target ) {
370            $sourceTitle = Title::newFromText( $source );
371            $targetTitle = Title::newFromText( $target );
372
373            if ( $source === $sourceBundle->getTitle()->getPrefixedText() ) {
374                $user = $performer;
375                $moveSummary = $reason;
376            } else {
377                $user = $fuzzyBot;
378                $moveSummary = wfMessage(
379                    'pt-movepage-logreason', $sourceBundle->getTitle()->getPrefixedText()
380                )->text();
381            }
382
383            $mover = $this->movePageFactory->newMovePage( $sourceTitle, $targetTitle );
384            $status = $mover->move( $user, $moveSummary, $pagesToRedirect[$source] ?? false );
385            $processed++;
386
387            if ( $progressCallback ) {
388                $progressCallback(
389                    $sourceTitle,
390                    $targetTitle,
391                    $status,
392                    count( $pagesToMove ),
393                    $processed
394                );
395            }
396
397            if ( !$status->isOK() ) {
398                $this->bundleFactory->getPageMoveLogger( $sourceBundle )
399                    ->logError( $performer, $sourceTitle, $targetTitle, $status );
400            }
401
402            $this->unlock( [ $source, $target ] );
403        }
404        $this->dbProvider->getPrimaryDatabase()->endAtomic( __METHOD__ );
405
406        Hooks::$allowTargetEdit = false;
407    }
408
409    private function getRenameMoveBlocker( Title $old, string $pageType, int $renameError ): Status {
410        if ( $renameError === PageTitleRenamer::NO_ERROR ) {
411            throw new LogicException(
412                'Trying to fetch MoveBlocker when there was no error during rename. Title: ' .
413                $old->getPrefixedText() . ', page type: ' . $pageType
414            );
415        }
416
417        if ( $renameError === PageTitleRenamer::UNKNOWN_PAGE ) {
418            $status = Status::newFatal( 'pt-movepage-block-unknown-page', $old->getPrefixedText() );
419        } elseif ( $renameError === PageTitleRenamer::NS_TALK_UNSUPPORTED ) {
420            $status = Status::newFatal( 'pt-movepage-block-ns-talk-unsupported', $old->getPrefixedText() );
421        } elseif ( $renameError === PageTitleRenamer::RENAME_FAILED ) {
422            $status = Status::newFatal( 'pt-movepage-block-rename-failed', $old->getPrefixedText() );
423        } else {
424            return Status::newFatal( "pt-movepage-block-$pageType-invalid", $old->getPrefixedText() );
425        }
426
427        return $status;
428    }
429}