Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 168 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
TranslatableBundleMover | |
0.00% |
0 / 168 |
|
0.00% |
0 / 12 |
1980 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
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 / 32 |
|
0.00% |
0 / 1 |
42 | |||
lock | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
unlock | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
move | |
0.00% |
0 / 29 |
|
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\Page\MovePageFactory; |
15 | use MediaWiki\Title\Title; |
16 | use Message; |
17 | use ObjectCache; |
18 | use SplObjectStorage; |
19 | use Status; |
20 | use 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 | */ |
28 | class 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 | } |