Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
ExternalMessageSourceStateImporter.php
1<?php
2declare( strict_types = 1 );
3
11
13use JobQueueGroup;
14use MediaWiki\Config\ServiceOptions;
20use MediaWiki\Language\RawMessage;
21use MediaWiki\Status\Status;
22use MediaWiki\Title\TitleFactory;
23use MessageGroup;
24use Psr\Log\LoggerInterface;
25use RuntimeException;
26use function wfWarn;
27
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
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
185 private function createUpdateMessageJobs(
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
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
254 private function identifySafeLanguages(
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}
This class implements default behavior for file based message groups.
Manage user subscriptions to message groups and trigger notifications.
Factory class for accessing message groups individually by id or all of them as a list.
Class for pointing to messages, like Title class is for titles.
Creates a database of keys in all groups, so that namespace and key can be used to get the groups the...
Class is used to track the changes made when importing messages from the remote sources using importE...
static getCdbPath(string $fileName)
Get a full path to file in a known location.
static writeChanges(array $changes, string $file)
Writes change array as a serialized file.
getSourceLanguage()
Returns language code depicting the language of source text.
getNamespace()
Returns the namespace where messages are placed.
Interface for message groups.
Finds external changes for file based message groups.