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