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