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