Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExternalMessageSourceStateImporter
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 7
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 import
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
132
 canImportGroup
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 createUpdateMessageJobs
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 updateGroupSyncInfo
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 identifySafeLanguages
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
156
 isLanguageSafe
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4/**
5 * Finds external changes for file based message groups.
6 * @author Niklas Laxström
7 * @license GPL-2.0-or-later
8 * @since 2016.02
9 */
10namespace MediaWiki\Extension\Translate\Synchronization;
11
12use FileBasedMessageGroup;
13use JobQueueGroup;
14use MediaWiki\Config\ServiceOptions;
15use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
16use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscription;
17use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
18use MediaWiki\Extension\Translate\MessageLoading\MessageIndex;
19use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
20use MediaWiki\Language\RawMessage;
21use MediaWiki\Status\Status;
22use MediaWiki\Title\TitleFactory;
23use MessageGroup;
24use Psr\Log\LoggerInterface;
25use RuntimeException;
26use function wfWarn;
27
28class ExternalMessageSourceStateImporter {
29    private GroupSynchronizationCache $groupSynchronizationCache;
30    private JobQueueGroup $jobQueueGroup;
31    private LoggerInterface $logger;
32    private MessageIndex $messageIndex;
33    private TitleFactory $titleFactory;
34    private MessageGroupSubscription $messageGroupSubscription;
35    private bool $isGroupSyncCacheEnabled;
36    // Do not perform any import
37    public const IMPORT_NONE = 1;
38    // Import changes in a language for a group if it only has additions
39    public const IMPORT_SAFE = 2;
40    // Import changes in a language for a group if it only has additions or changes, but
41    // not deletions as it may be a rename of an addition
42    public const IMPORT_NON_RENAMES = 3;
43    public const CONSTRUCTOR_OPTIONS = [ 'TranslateGroupSynchronizationCache' ];
44
45    public function __construct(
46        GroupSynchronizationCache $groupSynchronizationCache,
47        JobQueueGroup $jobQueueGroup,
48        LoggerInterface $logger,
49        MessageIndex $messageIndex,
50        TitleFactory $titleFactory,
51        MessageGroupSubscription $messageGroupSubscription,
52        ServiceOptions $options
53    ) {
54        $this->groupSynchronizationCache = $groupSynchronizationCache;
55        $this->jobQueueGroup = $jobQueueGroup;
56        $this->logger = $logger;
57        $this->messageIndex = $messageIndex;
58        $this->titleFactory = $titleFactory;
59        $this->messageGroupSubscription = $messageGroupSubscription;
60        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
61        $this->isGroupSyncCacheEnabled = $options->get( 'TranslateGroupSynchronizationCache' );
62    }
63
64    /**
65     * @param MessageSourceChange[] $changeData
66     * @param string $name
67     * @param int $importStrategy
68     * @return array
69     */
70    public function import( array $changeData, string $name, int $importStrategy ): array {
71        $processed = [];
72        $skipped = [];
73        $jobs = [];
74
75        foreach ( $changeData as $groupId => $changesForGroup ) {
76            $group = MessageGroups::getGroup( $groupId );
77            if ( !$group ) {
78                unset( $changeData[$groupId] );
79                continue;
80            }
81
82            if ( !$group instanceof FileBasedMessageGroup ) {
83                $this->logger->warning(
84                    '[ExternalMessageSourceStateImporter] Expected FileBasedMessageGroup, ' .
85                    'but got {class} for group {groupId}',
86                    [
87                        'class' => get_class( $group ),
88                        'groupId' => $groupId
89                    ]
90                );
91                unset( $changeData[$groupId] );
92                continue;
93            }
94
95            $processed[$groupId] = [];
96            $languages = $changesForGroup->getLanguages();
97            $groupJobs = [];
98
99            $groupSafeLanguages = $this->identifySafeLanguages( $group, $changesForGroup, $importStrategy );
100
101            foreach ( $languages as $language ) {
102                if ( !$groupSafeLanguages[ $language ] ) {
103                    $skipped[$groupId] = true;
104                    continue;
105                }
106
107                $additions = $changesForGroup->getAdditions( $language );
108                if ( $additions === [] ) {
109                    continue;
110                }
111
112                [ $groupLanguageJobs, $groupProcessed ] = $this->createUpdateMessageJobs(
113                    $group, $additions, $language
114                );
115
116                $groupJobs = array_merge( $groupJobs, $groupLanguageJobs );
117                $processed[$groupId][$language] = $groupProcessed;
118
119                // We only remove additions since if less-safe-import option is used, then
120                // changes to existing messages might still need to be processed manually.
121                $changesForGroup->removeAdditions( $language, null );
122                $group->getMessageGroupCache( $language )->create();
123            }
124
125            // Mark the skipped group as in review
126            if ( $this->isGroupSyncCacheEnabled && isset( $skipped[$groupId] ) ) {
127                $this->groupSynchronizationCache->markGroupAsInReview( $groupId );
128            }
129
130            if ( $groupJobs !== [] ) {
131                if ( $this->isGroupSyncCacheEnabled ) {
132                    $this->updateGroupSyncInfo( $groupId, $groupJobs );
133                }
134                $jobs = array_merge( $jobs, $groupJobs );
135            }
136        }
137
138        $this->messageGroupSubscription->queueNotificationJob();
139
140        // Remove groups where everything was imported
141        $changeData = array_filter( $changeData, static function ( MessageSourceChange $change ) {
142            return $change->getAllModifications() !== [];
143        } );
144
145        // Remove groups with no imports
146        $processed = array_filter( $processed );
147
148        $file = MessageChangeStorage::getCdbPath( $name );
149        MessageChangeStorage::writeChanges( $changeData, $file );
150        $this->jobQueueGroup->push( $jobs );
151
152        return [
153            'processed' => $processed,
154            'skipped' => $skipped,
155            'name' => $name,
156        ];
157    }
158
159    public function canImportGroup( MessageGroup $group, bool $skipGroupSyncCache ): Status {
160        $groupId = $group->getId();
161        if ( !$group instanceof FileBasedMessageGroup ) {
162            $error = "Group $groupId expected to be FileBasedMessageGroup, got " . get_class( $group ) . " instead.";
163            return Status::newFatal( new RawMessage( $error ) );
164        }
165
166        if ( $this->isGroupSyncCacheEnabled && !$skipGroupSyncCache ) {
167            if ( $this->groupSynchronizationCache->isGroupBeingProcessed( $groupId ) ) {
168                $error = "Group $groupId is currently being synchronized; skipping processing of changes\n";
169                return Status::newFatal( new RawMessage( $error ) );
170            }
171
172            if ( $this->groupSynchronizationCache->groupHasErrors( $groupId ) ) {
173                $error = "Skipping $groupId due to an error during synchronization\n";
174                return Status::newFatal( new RawMessage( $error ) );
175            }
176        }
177
178        return Status::newGood();
179    }
180
181    /**
182     * Creates UpdateMessageJobs additions for a language under a group. Also queues a message
183     * for notification if the addition is in the source language
184     */
185    private function createUpdateMessageJobs(
186        FileBasedMessageGroup $group,
187        array $additions,
188        string $language
189    ): array {
190        $groupId = $group->getId();
191        $isSourceLanguage = $group->getSourceLanguage() === $language;
192        $jobs = [];
193        $processed = 0;
194        foreach ( $additions as $addition ) {
195            $namespace = $group->getNamespace();
196            $name = "{$addition['key']}/$language";
197
198            $title = $this->titleFactory->makeTitleSafe( $namespace, $name );
199            if ( !$title ) {
200                wfWarn( "Invalid title for group $groupId key {$addition['key']}" );
201                continue;
202            }
203
204            $jobs[] = UpdateMessageJob::newJob( $title, $addition['content'] );
205            $processed++;
206
207            if ( $isSourceLanguage ) {
208                $this->messageGroupSubscription->queueMessage(
209                    $title,
210                    MessageGroupSubscription::STATE_ADDED,
211                    $groupId
212                );
213            }
214        }
215
216        return [ $jobs, $processed ];
217    }
218
219    /**
220     * @param string $groupId
221     * @param UpdateMessageJob[] $groupJobs
222     */
223    private function updateGroupSyncInfo( string $groupId, array $groupJobs ): void {
224        $messageParams = [];
225        $groupMessageKeys = [];
226        foreach ( $groupJobs as $job ) {
227            $messageParams[] = MessageUpdateParameter::createFromJob( $job );
228            // Ensure there are no duplicates as the same key may be present in
229            // multiple languages
230            $groupMessageKeys[( new MessageHandle( $job->getTitle() ) )->getKey()] = true;
231        }
232
233        $group = MessageGroups::getGroup( $groupId );
234        if ( $group === null ) {
235            // How did we get here? This should never happen.
236            throw new RuntimeException( "Did not find group $groupId" );
237        }
238
239        $this->messageIndex->storeInterim( $group, array_keys( $groupMessageKeys ) );
240
241        $this->groupSynchronizationCache->addMessages( $groupId, ...$messageParams );
242        $this->groupSynchronizationCache->markGroupForSync( $groupId );
243
244        $this->logger->info(
245            '[ExternalMessageSourceStateImporter] Synchronization started for {groupId}',
246            [ 'groupId' => $groupId ]
247        );
248    }
249
250    /**
251     * Identifies languages in a message group that are safe to import
252     * @return array<string,bool>
253     */
254    private function identifySafeLanguages(
255        FileBasedMessageGroup $group,
256        MessageSourceChange $changesForGroup,
257        int $importStrategy
258    ): array {
259        $sourceLanguage = $group->getSourceLanguage();
260        $safeLanguagesMap = [];
261        $modifiedLanguages = $changesForGroup->getLanguages();
262
263        // Set all languages to not safe to start with.
264        $safeLanguagesMap[ $sourceLanguage ] = false;
265        foreach ( $modifiedLanguages as $language ) {
266            $safeLanguagesMap[ $language ] = false;
267        }
268
269        if ( !self::isLanguageSafe( $changesForGroup, $sourceLanguage, $importStrategy ) ) {
270            return $safeLanguagesMap;
271        }
272
273        $sourceLanguageKeyCache = [];
274        foreach ( $changesForGroup->getAdditions( $sourceLanguage ) as $change ) {
275            if ( $change['content'] === '' ) {
276                return $safeLanguagesMap;
277            }
278
279            $sourceLanguageKeyCache[ $change['key'] ] = true;
280        }
281
282        $safeLanguagesMap[ $sourceLanguage ] = true;
283
284        $groupNamespace = $group->getNamespace();
285
286        // Remove source language from the modifiedLanguage list if present since it's already processed.
287        // The $sourceLanguageKeyCache will only have values if sourceLanguage has safe changes.
288        if ( $sourceLanguageKeyCache ) {
289            array_splice( $modifiedLanguages, array_search( $sourceLanguage, $modifiedLanguages ), 1 );
290        }
291
292        foreach ( $modifiedLanguages as $language ) {
293            if ( !self::isLanguageSafe( $changesForGroup, $sourceLanguage, $importStrategy ) ) {
294                continue;
295            }
296
297            foreach ( $changesForGroup->getAdditions( $language ) as $change ) {
298                if ( $change['content'] === '' ) {
299                    continue 2;
300                }
301
302                $msgKey = $change['key'];
303
304                if ( !isset( $sourceLanguageKeyCache[ $msgKey ] ) ) {
305                    // This is either a new external translation which is not added in the same sync
306                    // as the source language key, or this translation does not have a corresponding
307                    // definition. We will check the message index to determine which of the two.
308                    $sourceHandle = new MessageHandle( $this->titleFactory->makeTitle( $groupNamespace, $msgKey ) );
309                    $sourceLanguageKeyCache[ $msgKey ] = $sourceHandle->isValid();
310                }
311
312                if ( !$sourceLanguageKeyCache[ $msgKey ] ) {
313                    continue 2;
314                }
315            }
316
317            $safeLanguagesMap[ $language ] = true;
318        }
319
320        return $safeLanguagesMap;
321    }
322
323    private static function isLanguageSafe(
324        MessageSourceChange $changesForGroup,
325        string $languageCode,
326        int $importStrategy
327    ): bool {
328        if ( $importStrategy === self::IMPORT_NONE ) {
329            // If import strategy is none, every change needs to be reviewed.
330            return false;
331        }
332
333        if ( $importStrategy === self::IMPORT_SAFE ) {
334            return $changesForGroup->hasOnly( $languageCode, MessageSourceChange::ADDITION );
335        }
336
337        // If language has deletions, we consider additions also unsafe because deletions
338        // maybe renames of messages that have been added, so they have to be reviewed.
339        return $changesForGroup->getDeletions( $languageCode ) === [];
340    }
341}