Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
52.05% |
114 / 219 |
|
30.00% |
3 / 10 |
CRAP | |
0.00% |
0 / 1 |
UpdateMessageJob | |
52.05% |
114 / 219 |
|
30.00% |
3 / 10 |
401.63 | |
0.00% |
0 / 1 |
newJob | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
newRenameJob | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
run | |
42.42% |
14 / 33 |
|
0.00% |
0 / 1 |
9.77 | |||
handleRename | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
56 | |||
handleFuzzy | |
98.70% |
76 / 77 |
|
0.00% |
0 / 1 |
28 | |||
processTranslationChanges | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
removeFromCache | |
11.11% |
3 / 27 |
|
0.00% |
0 / 1 |
41.41 | |||
fuzzyBotEdit | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
2.00 | |||
getFuzzyBot | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Synchronization; |
5 | |
6 | use FileBasedMessageGroup; |
7 | use MediaWiki\CommentStore\CommentStoreComment; |
8 | use MediaWiki\Content\ContentHandler; |
9 | use MediaWiki\Content\TextContent; |
10 | use MediaWiki\Extension\Translate\Jobs\GenericTranslateJob; |
11 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
12 | use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore; |
13 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
14 | use MediaWiki\Extension\Translate\MessageProcessing\TranslateReplaceTitle; |
15 | use MediaWiki\Extension\Translate\Services; |
16 | use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot; |
17 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
18 | use MediaWiki\MediaWikiServices; |
19 | use MediaWiki\Revision\SlotRecord; |
20 | use MediaWiki\Storage\PageUpdater; |
21 | use MediaWiki\Title\Title; |
22 | use MediaWiki\User\User; |
23 | use RecentChange; |
24 | |
25 | /** |
26 | * Job for updating translation pages when translation or message definition changes. |
27 | * |
28 | * @author Niklas Laxström |
29 | * @copyright Copyright © 2008-2013, Niklas Laxström |
30 | * @license GPL-2.0-or-later |
31 | * @ingroup JobQueue |
32 | */ |
33 | class UpdateMessageJob extends GenericTranslateJob { |
34 | private User $fuzzyBot; |
35 | |
36 | /** Create a normal message update job without a rename process */ |
37 | public static function newJob( |
38 | Title $target, string $content, bool $fuzzy = false |
39 | ): self { |
40 | $params = [ |
41 | 'content' => $content, |
42 | 'fuzzy' => $fuzzy, |
43 | ]; |
44 | |
45 | return new self( $target, $params ); |
46 | } |
47 | |
48 | /** |
49 | * Create a message update job containing a rename process |
50 | * @param Title $target |
51 | * @param string $targetStr |
52 | * @param string $replacement |
53 | * @param string|false $fuzzy |
54 | * @param string $content |
55 | * @param array $otherLangContents |
56 | * @return self |
57 | */ |
58 | public static function newRenameJob( |
59 | Title $target, |
60 | string $targetStr, |
61 | string $replacement, |
62 | $fuzzy, |
63 | string $content, |
64 | array $otherLangContents = [] |
65 | ): self { |
66 | $params = [ |
67 | 'target' => $targetStr, |
68 | 'replacement' => $replacement, |
69 | 'fuzzy' => $fuzzy, |
70 | 'rename' => 'rename', |
71 | 'content' => $content, |
72 | 'otherLangs' => $otherLangContents |
73 | ]; |
74 | |
75 | return new self( $target, $params ); |
76 | } |
77 | |
78 | public function __construct( Title $title, array $params = [] ) { |
79 | parent::__construct( 'UpdateMessageJob', $title, $params ); |
80 | } |
81 | |
82 | public function run(): bool { |
83 | $params = $this->params; |
84 | $isRename = $params['rename'] ?? false; |
85 | $isFuzzy = $params['fuzzy'] ?? false; |
86 | $otherLangs = $params['otherLangs'] ?? []; |
87 | $originalTitle = Title::newFromLinkTarget( $this->title->getTitleValue(), Title::NEW_CLONE ); |
88 | |
89 | if ( $isRename ) { |
90 | $this->title = $this->handleRename( $params['target'], $params['replacement'] ); |
91 | if ( $this->title === null ) { |
92 | // There was a failure, return true, but don't proceed further. |
93 | $this->logWarning( |
94 | 'Rename process could not find the source title.', |
95 | [ |
96 | 'replacement' => $params['replacement'], |
97 | 'target' => $params['target'] |
98 | ] |
99 | ); |
100 | |
101 | $this->removeFromCache( $originalTitle ); |
102 | return true; |
103 | } |
104 | } |
105 | $title = $this->title; |
106 | $baseRevId = $title->getLatestRevId(); |
107 | $updater = $this->fuzzyBotEdit( $title, $params['content'] ); |
108 | if ( !$updater->getStatus()->isOK() ) { |
109 | $this->logError( |
110 | 'Failed to update content for source message', |
111 | [ |
112 | 'content' => ContentHandler::makeContent( $params['content'], $this->title ), |
113 | 'errors' => $updater->getStatus()->getMessages() |
114 | ] |
115 | ); |
116 | } |
117 | |
118 | if ( $isRename ) { |
119 | // Update other language content if present. |
120 | $this->processTranslationChanges( $otherLangs, $params['replacement'], $params['namespace'] ); |
121 | } |
122 | |
123 | $this->handleFuzzy( $title, $isFuzzy, $updater, $baseRevId ); |
124 | |
125 | $this->removeFromCache( $originalTitle ); |
126 | return true; |
127 | } |
128 | |
129 | private function handleRename( string $target, string $replacement ): ?Title { |
130 | $newSourceTitle = null; |
131 | |
132 | $sourceMessageHandle = new MessageHandle( $this->title ); |
133 | $movableTitles = TranslateReplaceTitle::getTitlesForMove( $sourceMessageHandle, $replacement ); |
134 | |
135 | if ( $movableTitles === [] ) { |
136 | $this->logError( |
137 | 'No movable titles found with target text.', |
138 | [ |
139 | 'title' => $this->title->getPrefixedText(), |
140 | 'replacement' => $replacement, |
141 | 'target' => $target |
142 | ] |
143 | ); |
144 | return null; |
145 | } |
146 | |
147 | $renameSummary = wfMessage( 'translate-manage-import-rename-summary' ) |
148 | ->inContentLanguage()->plain(); |
149 | |
150 | foreach ( $movableTitles as [ $sourceTitle, $replacementTitle ] ) { |
151 | $mv = MediaWikiServices::getInstance() |
152 | ->getMovePageFactory() |
153 | ->newMovePage( $sourceTitle, $replacementTitle ); |
154 | |
155 | $status = $mv->move( $this->getFuzzyBot(), $renameSummary, false ); |
156 | if ( !$status->isOK() ) { |
157 | $this->logError( |
158 | 'Error moving message', |
159 | [ |
160 | 'target' => $sourceTitle->getPrefixedText(), |
161 | 'replacement' => $replacementTitle->getPrefixedText(), |
162 | 'errors' => $status->getMessages() |
163 | ] |
164 | ); |
165 | } |
166 | |
167 | [ , $targetCode ] = Utilities::figureMessage( $replacementTitle->getText() ); |
168 | if ( !$newSourceTitle && $sourceMessageHandle->getCode() === $targetCode ) { |
169 | $newSourceTitle = $replacementTitle; |
170 | } |
171 | } |
172 | |
173 | if ( $newSourceTitle ) { |
174 | return $newSourceTitle; |
175 | } else { |
176 | // This means that the old source Title was never moved |
177 | // which is not possible but handle it. |
178 | $this->logError( |
179 | 'Source title was not in the list of movable titles.', |
180 | [ 'title' => $this->title->getPrefixedText() ] |
181 | ); |
182 | return null; |
183 | } |
184 | } |
185 | |
186 | /** |
187 | * Handles fuzzying. Message documentation and the source language are excluded from |
188 | * fuzzying. The source language is the identified via the $title parameter |
189 | * |
190 | * If the edit to the source translation unit is a manual revert, then any translations |
191 | * whose tp:transver is set to the revision being reverted to are marked **unfuzzy** |
192 | * unless they have an explicit !!FUZZY!! or fail validation. |
193 | * |
194 | * Any revisions with other tp:transvers are marked fuzzy, unless invalidation skipping is used. |
195 | */ |
196 | private function handleFuzzy( Title $title, bool $invalidate, PageUpdater $updater, int $baseTranver ): void { |
197 | global $wgTranslateDocumentationLanguageCode; |
198 | $editResult = $updater->getEditResult(); |
199 | if ( !$invalidate && !$editResult->isExactRevert() ) { |
200 | return; |
201 | } |
202 | $oldRevId = $editResult->getOriginalRevisionId(); |
203 | $handle = new MessageHandle( $title ); |
204 | |
205 | $languages = Utilities::getLanguageNames( 'en' ); |
206 | |
207 | // Don't fuzzy the message documentation or the source language |
208 | unset( $languages[$wgTranslateDocumentationLanguageCode] ); |
209 | unset( $languages[$handle->getCode()] ); |
210 | |
211 | $languages = array_keys( $languages ); |
212 | |
213 | $fuzzies = []; |
214 | $unfuzzies = []; |
215 | $mwInstance = MediaWikiServices::getInstance(); |
216 | $revTagStore = Services::getInstance()->getRevTagStore(); |
217 | $revStore = $mwInstance->getRevisionStore(); |
218 | |
219 | if ( $oldRevId || $invalidate ) { |
220 | // We'll need to check if each possible tunit exists later on, so do that now |
221 | // as a batch |
222 | $batch = $mwInstance->getLinkBatchFactory()->newLinkBatch(); |
223 | $batch->setCaller( __METHOD__ ); |
224 | foreach ( $languages as $code ) { |
225 | $batch->addObj( $handle->getTitleForLanguage( $code ) ); |
226 | } |
227 | $batch->execute(); |
228 | } |
229 | $newRevision = $updater->getNewRevision(); |
230 | // $newRevision can be null if a change is made to only tvars and then the fuzzy checkbox is manually turned on |
231 | $targetSha = $newRevision ? $newRevision->getSha1() : null; |
232 | |
233 | foreach ( $languages as $code ) { |
234 | $otherTitle = $handle->getTitleForLanguage( $code ); |
235 | $shouldUnfuzzy = false; |
236 | if ( !$otherTitle->exists() ) { |
237 | // Don't care about fuzzy status for nonexistent tunits |
238 | continue; |
239 | } |
240 | $transverId = $revTagStore->getTransver( $otherTitle ); |
241 | if ( !$transverId ) { |
242 | // The page doesn't have a tp:transver at all |
243 | // This shouldn't happen, but it does in some edge cases like importing translations across wikis |
244 | $latest = $otherTitle->getLatestRevID(); |
245 | if ( $invalidate && !$revTagStore->isRevIdFuzzy( $otherTitle->getId(), $latest ) && $newRevision ) { |
246 | // If the (latest revision of the) translation isn't fuzzy and the source tunit was actually changed |
247 | // then assume the translation pertains to the latest revision of the source tunit |
248 | // (before the update that triggered this job and marked it fuzzy) |
249 | // and set its transver to that so "show differences" has something to show |
250 | $revTagStore->setTransver( $otherTitle, $latest, $baseTranver ); |
251 | } |
252 | // Don't do any revert checking |
253 | } elseif ( $oldRevId && $newRevision && $editResult->isExactRevert() ) { |
254 | // Only try to do revert analysis if the edit succeeded and is truly an exact revert |
255 | $transver = $revStore->getRevisionById( $transverId, 0, $title ); |
256 | if ( $oldRevId == $transverId ) { |
257 | // It's a straightforward revert |
258 | $shouldUnfuzzy = true; |
259 | } elseif ( $transver ) { |
260 | $transverSha = $transver->getSha1(); |
261 | if ( $transverSha == $targetSha ) { |
262 | // It's a deeper revert or otherwise wasn't detected by MediaWiki's builtin revert detection |
263 | $shouldUnfuzzy = true; |
264 | } // Else it's not a revert at all so leave shouldUnfuzzy false |
265 | } |
266 | // Else should never happen (it means tp:transver is corrupt) but it could concievably happen |
267 | // in some edge cases so do nothing (and fuzzy the tunit) rather than crashing the entire job |
268 | } |
269 | if ( $shouldUnfuzzy ) { |
270 | // In principle it's a revert so should unfuzzy, first check for validation failures |
271 | // or manual fuzzying |
272 | $otherHandle = new MessageHandle( $otherTitle ); |
273 | $wikiPage = $mwInstance->getWikiPageFactory()->newFromTitle( $otherTitle ); |
274 | $content = $wikiPage->getContent(); |
275 | if ( !$content instanceof TextContent ) { |
276 | // This should never happen (translation units should always be wikitext) but Phan complains |
277 | // otherwise |
278 | continue; |
279 | } |
280 | $text = $content->getText(); |
281 | if ( $otherHandle->isFuzzy() && !$otherHandle->needsFuzzy( $text ) ) { |
282 | $unfuzzies[] = $otherTitle; |
283 | } |
284 | // If it's not already fuzzy then that means the original change was done without invalidating |
285 | // translations and while the new change probably should have been done that way as well |
286 | // even if it wasn't it never makes sense to re-fuzzy in that case so just leave the fuzzy status alone |
287 | } elseif ( $invalidate ) { |
288 | $fuzzies[] = $otherTitle; |
289 | } |
290 | } |
291 | |
292 | $dbw = $mwInstance->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY ); |
293 | |
294 | if ( $fuzzies !== [] ) { |
295 | $inserts = []; |
296 | foreach ( $fuzzies as $otherTitle ) { |
297 | $inserts[] = [ |
298 | 'rt_type' => RevTagStore::FUZZY_TAG, |
299 | 'rt_page' => $otherTitle->getId(), |
300 | 'rt_revision' => $otherTitle->getLatestRevID(), |
301 | ]; |
302 | } |
303 | $dbw->newReplaceQueryBuilder() |
304 | ->replaceInto( 'revtag' ) |
305 | ->uniqueIndexFields( [ 'rt_type', 'rt_page', 'rt_revision' ] ) |
306 | ->rows( $inserts ) |
307 | ->caller( __METHOD__ ) |
308 | ->execute(); |
309 | } |
310 | if ( $unfuzzies !== [] ) { |
311 | foreach ( $unfuzzies as $otherTitle ) { |
312 | $dbw->newDeleteQueryBuilder() |
313 | ->deleteFrom( 'revtag' ) |
314 | ->where( [ |
315 | 'rt_type' => RevTagStore::FUZZY_TAG, |
316 | 'rt_page' => $otherTitle->getId(), |
317 | 'rt_revision' => $otherTitle->getLatestRevID(), |
318 | ] ) |
319 | ->caller( __METHOD__ ) |
320 | ->execute(); |
321 | } |
322 | } |
323 | } |
324 | |
325 | /** Updates the translation unit pages in non-source languages. */ |
326 | private function processTranslationChanges( |
327 | array $langChanges, |
328 | string $baseTitle, |
329 | int $groupNamespace |
330 | ): void { |
331 | foreach ( $langChanges as $code => $contentStr ) { |
332 | $titleStr = Utilities::title( $baseTitle, $code, $groupNamespace ); |
333 | $title = Title::newFromText( $titleStr, $groupNamespace ); |
334 | $updater = $this->fuzzyBotEdit( $title, $contentStr ); |
335 | |
336 | if ( !$updater->getStatus()->isOK() ) { |
337 | $this->logError( |
338 | 'Failed to update content for non-source message', |
339 | [ |
340 | 'title' => $title->getPrefixedText(), |
341 | 'errors' => $updater->getStatus()->getMessages() |
342 | ] |
343 | ); |
344 | } |
345 | } |
346 | } |
347 | |
348 | private function removeFromCache( Title $title ): void { |
349 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
350 | |
351 | if ( !$config->get( 'TranslateGroupSynchronizationCache' ) ) { |
352 | return; |
353 | } |
354 | |
355 | $currentTitle = $title; |
356 | // Check if the current title, is equal to the title passed. This condition will be |
357 | // true in case of rename where the old title would have been renamed. |
358 | if ( $this->title && $this->title->getPrefixedDBkey() !== $title->getPrefixedDBkey() ) { |
359 | $currentTitle = $this->title; |
360 | } |
361 | |
362 | $sourceMessageHandle = new MessageHandle( $currentTitle ); |
363 | $groupIds = $sourceMessageHandle->getGroupIds(); |
364 | if ( !$groupIds ) { |
365 | $this->logWarning( |
366 | "Could not find group Id for message title: {$currentTitle->getPrefixedDBkey()}", |
367 | $this->getParams() |
368 | ); |
369 | return; |
370 | } |
371 | |
372 | $groupId = $groupIds[0]; |
373 | $group = MessageGroups::getGroup( $groupId ); |
374 | |
375 | if ( !$group instanceof FileBasedMessageGroup ) { |
376 | return; |
377 | } |
378 | |
379 | $groupSyncCache = Services::getInstance()->getGroupSynchronizationCache(); |
380 | $messageKey = $title->getPrefixedDBkey(); |
381 | |
382 | if ( $groupSyncCache->isMessageBeingProcessed( $groupId, $messageKey ) ) { |
383 | $groupSyncCache->removeMessages( $groupId, $messageKey ); |
384 | $groupSyncCache->extendGroupExpiryTime( $groupId ); |
385 | } else { |
386 | $this->logWarning( |
387 | "Did not find key: $messageKey; in group: $groupId in group sync cache", |
388 | $this->getParams() |
389 | ); |
390 | } |
391 | } |
392 | |
393 | private function fuzzyBotEdit( Title $title, string $content ): PageUpdater { |
394 | $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory(); |
395 | $content = ContentHandler::makeContent( $content, $title ); |
396 | $page = $wikiPageFactory->newFromTitle( $title ); |
397 | $updater = $page->newPageUpdater( $this->getFuzzyBot() ) |
398 | ->setContent( SlotRecord::MAIN, $content ); |
399 | |
400 | if ( $this->getFuzzyBot()->authorizeWrite( 'autopatrol', $title ) ) { |
401 | $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); |
402 | } |
403 | |
404 | $summary = wfMessage( 'translate-manage-import-summary' ) |
405 | ->inContentLanguage()->plain(); |
406 | $updater->saveRevision( |
407 | CommentStoreComment::newUnsavedComment( $summary ), |
408 | EDIT_FORCE_BOT |
409 | ); |
410 | return $updater; |
411 | } |
412 | |
413 | private function getFuzzyBot(): User { |
414 | $this->fuzzyBot ??= FuzzyBot::getUser(); |
415 | return $this->fuzzyBot; |
416 | } |
417 | } |