Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 179 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
TranslatableBundleMover | |
0.00% |
0 / 179 |
|
0.00% |
0 / 12 |
2162 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getPageMoveCollection | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
306 | |||
moveAsynchronously | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
2 | |||
moveSynchronously | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
disablePageMoveLimit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
enablePageMoveLimit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
shouldLeaveRedirect | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getPagesToMove | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
72 | |||
lock | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
unlock | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
move | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
30 | |||
getRenameMoveBlocker | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\PageTranslation; |
5 | |
6 | use JobQueueGroup; |
7 | use LogicException; |
8 | use MediaWiki\Cache\LinkBatchFactory; |
9 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MoveTranslatableBundleJob; |
10 | use MediaWiki\Extension\Translate\MessageGroupProcessing\SubpageListBuilder; |
11 | use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle; |
12 | use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory; |
13 | use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot; |
14 | use MediaWiki\Message\Message; |
15 | use MediaWiki\Page\MovePageFactory; |
16 | use MediaWiki\Status\Status; |
17 | use MediaWiki\Title\Title; |
18 | use MediaWiki\User\User; |
19 | use ObjectCacheFactory; |
20 | use SplObjectStorage; |
21 | use Wikimedia\ObjectCache\BagOStuff; |
22 | use 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 | */ |
30 | class 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 | } |