Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.05% covered (warning)
52.05%
114 / 219
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateMessageJob
52.05% covered (warning)
52.05%
114 / 219
30.00% covered (danger)
30.00%
3 / 10
401.63
0.00% covered (danger)
0.00%
0 / 1
 newJob
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newRenameJob
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
42.42% covered (danger)
42.42%
14 / 33
0.00% covered (danger)
0.00%
0 / 1
9.77
 handleRename
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
56
 handleFuzzy
98.70% covered (success)
98.70%
76 / 77
0.00% covered (danger)
0.00%
0 / 1
28
 processTranslationChanges
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 removeFromCache
11.11% covered (danger)
11.11%
3 / 27
0.00% covered (danger)
0.00%
0 / 1
41.41
 fuzzyBotEdit
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 getFuzzyBot
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Synchronization;
5
6use FileBasedMessageGroup;
7use MediaWiki\CommentStore\CommentStoreComment;
8use MediaWiki\Content\ContentHandler;
9use MediaWiki\Content\TextContent;
10use MediaWiki\Extension\Translate\Jobs\GenericTranslateJob;
11use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
12use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
13use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
14use MediaWiki\Extension\Translate\MessageProcessing\TranslateReplaceTitle;
15use MediaWiki\Extension\Translate\Services;
16use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
17use MediaWiki\Extension\Translate\Utilities\Utilities;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Revision\SlotRecord;
20use MediaWiki\Storage\PageUpdater;
21use MediaWiki\Title\Title;
22use MediaWiki\User\User;
23use 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 */
33class 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}