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