Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
ExternalMessageSourceStateComparator.php
1<?php
2declare( strict_types = 1 );
3
5
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Page\PageStore;
16use MediaWiki\Revision\RevisionLookup;
17use MediaWiki\Utils\MWTimestamp;
18use RuntimeException;
19
27
28 public function __construct(
29 private readonly StringComparator $stringComparator,
30 private readonly RevisionLookup $revisionLookup,
31 private readonly PageStore $pageStore,
32 ) {
33 }
34
54 $changes = new MessageSourceChange();
55 $languages = $group->getTranslatableLanguages() ?? Utilities::getLanguageNames( 'en' );
56
57 // Process the source language before others. Source language might not
58 // be included in $group->getTranslatableLanguages(). The expected
59 // behavior is that source language is always processed.
60 $sourceLanguage = $group->getSourceLanguage();
61 $this->processLanguage( $group, $sourceLanguage, $changes );
62 unset( $languages[ $sourceLanguage] );
63
64 foreach ( array_keys( $languages ) as $language ) {
65 $this->processLanguage( $group, $language, $changes );
66 }
67
68 return $changes;
69 }
70
71 private function processLanguage(
72 FileBasedMessageGroup $group,
73 string $language,
74 MessageSourceChange $changes
75 ): void {
76 $cache = $group->getMessageGroupCache( $language );
77 $reason = 0;
78 if ( !$cache->isValid( $reason ) ) {
79 $this->addMessageUpdateChanges( $group, $language, $changes, $reason, $cache );
80
81 if ( $changes->getModificationsForLanguage( $language ) === [] ) {
82 /* Update the cache immediately if file and wiki state match.
83 * Otherwise the cache will get outdated compared to file state
84 * and will give false positive conflicts later. */
85 $cache->create();
86 }
87 }
88 }
89
106 protected function addMessageUpdateChanges(
108 string $language,
109 MessageSourceChange $changes,
110 int $reason,
111 MessageGroupCache $cache
112 ): void {
113 // initCollection returns empty list before first import
114 $wiki = $group->initCollection( $language );
115 $wiki->filter( MessageCollection::FILTER_HAS_TRANSLATION, MessageCollection::INCLUDE_MATCHING );
116 $wiki->loadTranslations();
117 $wikiKeys = $wiki->getMessageKeys();
118
119 $sourceLanguage = $group->getSourceLanguage();
120 // By-pass cached message definitions
121 $ffs = $group->getFFS();
122 if ( $language === $sourceLanguage && !$ffs->exists( $language ) ) {
123 $path = $group->getSourceFilePath( $language );
124 // Warning: this exception message contains a local filesystem path. If this method
125 // is ever called from a web-facing context, the message must be sanitized to avoid
126 // disclosing server directory structure to end users.
127 throw new RuntimeException( "Source message file for {$group->getId()} does not exist: $path" );
128 }
129
130 $file = $ffs->read( $language );
131
132 // Does not exist
133 if ( $file === false ) {
134 return;
135 }
136
137 // Something went wrong
138 if ( !isset( $file['MESSAGES'] ) ) {
139 $id = $group->getId();
140 $ffsClass = get_class( $ffs );
141
142 error_log( "$id has an FFS ($ffsClass) - it didn't return cake for $language" );
143
144 return;
145 }
146
147 $fileKeys = array_keys( $file['MESSAGES'] );
148
149 $common = array_intersect( $fileKeys, $wikiKeys );
150
151 $supportsFuzzy = $ffs->supportsFuzzy();
152 $changesToRemove = [];
153
154 foreach ( $common as $key ) {
155 $sourceContent = $file['MESSAGES'][$key];
157 $wikiMessage = $wiki[$key];
158 $wikiContent = $wikiMessage->translation();
159
160 // @todo: Fuzzy checking can also be moved to $ffs->isContentEqual();
161 // If FFS doesn't support it, ignore fuzziness as difference
162 $wikiContent = str_replace( TRANSLATE_FUZZY, '', $wikiContent );
163
164 // But if it does, ensure we have exactly one fuzzy marker prefixed
165 if ( $supportsFuzzy === 'yes' && $wikiMessage->hasTag( 'fuzzy' ) ) {
166 $wikiContent = TRANSLATE_FUZZY . $wikiContent;
167 }
168
169 if ( $ffs->isContentEqual( $sourceContent, $wikiContent ) ) {
170 // File and wiki stage agree, nothing to do
171 continue;
172 }
173
174 // Check against interim cache to see whether we have changes
175 // in the wiki, in the file or both.
176
177 if ( $reason !== MessageGroupCache::NO_CACHE ) {
178 $cacheContent = $cache->get( $key );
179
180 /* We want to ignore the following situations:
181 * 1. The string in the wiki has been changed since the last export.
182 * Hence we check that source === cache && cache !== wiki
183 * 2. Missing cache entry due to the string being translated on translatewiki.net,
184 * exported and then being updated on translatewiki.net again.
185 */
186 if (
187 $this->hasCacheEntry( $cache, $wiki, $key ) &&
188 !$ffs->isContentEqual( $wikiContent, $cacheContent ) &&
189 $ffs->isContentEqual( $sourceContent, $cacheContent )
190 ) {
191 continue;
192 }
193 }
194
195 if ( $language !== $sourceLanguage ) {
196 // Assuming that this is the old key, lets check if it has a corresponding
197 // rename in the source language. The key of the matching message will be
198 // the new renamed key.
199 $renameMsg = $changes->getMatchedMessage( $sourceLanguage, $key );
200 if ( $renameMsg !== null ) {
201 // Rename present in source language but this message has a content change
202 // with the OLD key in a non-source language. We will not process this
203 // here but add it as a rename instead. This way, the key will be renamed
204 // and then the content updated.
205 $this->addNonSourceRenames(
206 $changes, $key, $renameMsg['key'], $sourceContent, $wikiContent, $language
207 );
208 $changesToRemove[] = $key;
209 continue;
210 }
211 }
212 $changes->addChange( $language, $key, $sourceContent );
213 }
214
215 $changes->removeChanges( $language, $changesToRemove );
216
217 $added = array_diff( $fileKeys, $wikiKeys );
218 foreach ( $added as $key ) {
219 $sourceContent = $file['MESSAGES'][$key];
220 $changes->addAddition( $language, $key, $sourceContent );
221 }
222
223 /* Should the cache not exist, don't consider the messages
224 * missing from the file as deleted - they probably aren't
225 * yet exported. For example new language translations are
226 * exported the first time. */
227 if ( $reason !== MessageGroupCache::NO_CACHE ) {
228 $deleted = array_diff( $wikiKeys, $fileKeys );
229 foreach ( $deleted as $key ) {
230 if ( $cache->get( $key ) === false ) {
231 /* This message has never existed in the cache, so it
232 * must be a newly made in the wiki. */
233 continue;
234 }
235 $changes->addDeletion( $language, $key, $wiki[$key]->translation() );
236 }
237 }
238
239 if ( $language === $sourceLanguage ) {
240 $this->findAndMarkSourceRenames( $changes, $language );
241 } else {
242 // Non source language
243 $this->checkNonSourceAdditionsForRename(
244 $changes, $sourceLanguage, $language, $wiki, $wikiKeys
245 );
246 }
247 }
248
258 private function checkNonSourceAdditionsForRename(
259 MessageSourceChange $changes,
260 string $sourceLanguage,
261 string $targetLanguage,
262 MessageCollection $wiki,
263 array $wikiKeys
264 ): void {
265 $additions = $changes->getAdditions( $targetLanguage );
266 if ( $additions === [] ) {
267 return;
268 }
269
270 $additionsToRemove = [];
271 $deletionsToRemove = [];
272 foreach ( $additions as $addedMsg ) {
273 $addedMsgKey = $addedMsg['key'];
274
275 // Check if this key is renamed in source.
276 $renamedSourceMsg = $changes->findMessage(
277 $sourceLanguage, $addedMsgKey, [ MessageSourceChange::RENAME ]
278 );
279
280 if ( $renamedSourceMsg === null ) {
281 continue;
282 }
283
284 // Since this key is new, and is present in the renames for the source language,
285 // we will add it as a rename.
286 $deletedSource = $changes->getMatchedMessage( $sourceLanguage, $renamedSourceMsg['key'] );
287 if ( $deletedSource === null ) {
288 continue;
289 }
290 $deletedMsgKey = $deletedSource['key'];
291 $deletedMsg = $changes->findMessage(
292 $targetLanguage, $deletedMsgKey, [ MessageSourceChange::DELETION ]
293 );
294
295 // Sometimes when the cache does not have the translations, the deleted message
296 // is not added in the translations. It is also possible that for this non-source
297 // language the key has not been removed.
298 if ( $deletedMsg === null ) {
299 $content = '';
300 if ( in_array( $deletedMsgKey, $wikiKeys ) ) {
301 $content = $wiki[ $deletedMsgKey ]->translation();
302 }
303 $deletedMsg = [
304 'key' => $deletedMsgKey,
305 'content' => $content
306 ];
307 }
308
309 $similarityPercent = $this->stringComparator->getSimilarity(
310 $addedMsg['content'], $deletedMsg['content']
311 );
312
313 $changes->addRename( $targetLanguage, [
314 'key' => $addedMsgKey,
315 'content' => $addedMsg['content']
316 ], [
317 'key' => $deletedMsgKey,
318 'content' => $deletedMsg['content']
319 ], $similarityPercent );
320
321 $deletionsToRemove[] = $deletedMsgKey;
322 $additionsToRemove[] = $addedMsgKey;
323 }
324
325 $changes->removeAdditions( $targetLanguage, $additionsToRemove );
326 $changes->removeDeletions( $targetLanguage, $deletionsToRemove );
327 }
328
334 private function findAndMarkSourceRenames( MessageSourceChange $changes, string $sourceLanguage ): void {
335 // Now check for renames. To identify renames we need to compare
336 // the contents of the added messages with the deleted ones and
337 // identify messages that match.
338 $deletions = $changes->getDeletions( $sourceLanguage );
339 $additions = $changes->getAdditions( $sourceLanguage );
340 if ( $deletions === [] || $additions === [] ) {
341 return;
342 }
343
344 // This array contains a dictionary with matching renames in the following structure -
345 // [ A1|D1 => 1.0, A1|D2 => 0.95, A2|D1 => 0.95 ]
346 $potentialRenames = [];
347 foreach ( $additions as $addedMsg ) {
348 $addedMsgKey = $addedMsg['key'];
349
350 foreach ( $deletions as $deletedMsg ) {
351 $similarityPercent = $this->stringComparator->getSimilarity(
352 $addedMsg['content'], $deletedMsg['content']
353 );
354
355 if ( $changes->areStringsSimilar( $similarityPercent ) ) {
356 $potentialRenames[ $addedMsgKey . '|' . $deletedMsg['key'] ] = $similarityPercent;
357 }
358 }
359 }
360
361 $this->matchRenames( $changes, $potentialRenames, $sourceLanguage );
362 }
363
365 private function addNonSourceRenames(
366 MessageSourceChange $changes,
367 string $key,
368 string $renameKey,
369 string $sourceContent,
370 string $wikiContent,
371 string $language
372 ): void {
373 $addedMsg = [
374 'key' => $renameKey,
375 'content' => $sourceContent
376 ];
377
378 $removedMsg = [
379 'key' => $key,
380 'content' => $wikiContent
381 ];
382
383 $similarityPercent = $this->stringComparator->getSimilarity(
384 $sourceContent, $wikiContent
385 );
386 $changes->addRename( $language, $addedMsg, $removedMsg, $similarityPercent );
387 }
388
396 private function matchRenames( MessageSourceChange $changes, array $trackRename, string $language ): void {
397 arsort( $trackRename, SORT_NUMERIC );
398
399 $alreadyRenamed = $additionsToRemove = $deletionsToRemove = [];
400 foreach ( $trackRename as $key => $similarityPercent ) {
401 [ $addKey, $deleteKey ] = explode( '|', $key, 2 );
402 if ( isset( $alreadyRenamed[ $addKey ] ) || isset( $alreadyRenamed[ $deleteKey ] ) ) {
403 // Already mapped with another name.
404 continue;
405 }
406
407 // Using key should be faster than saving values and searching for them in the array.
408 $alreadyRenamed[ $addKey ] = 1;
409 $alreadyRenamed[ $deleteKey ] = 1;
410
411 $addMsg = $changes->findMessage( $language, $addKey, [ MessageSourceChange::ADDITION ] );
412 $deleteMsg = $changes->findMessage( $language, $deleteKey, [ MessageSourceChange::DELETION ] );
413
414 $changes->addRename( $language, $addMsg, $deleteMsg, $similarityPercent );
415
416 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
417 $additionsToRemove[] = $addMsg['key'];
418 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
419 $deletionsToRemove[] = $deleteMsg['key'];
420 }
421
422 $changes->removeAdditions( $language, $additionsToRemove );
423 $changes->removeDeletions( $language, $deletionsToRemove );
424 }
425
431 private function hasCacheEntry(
432 MessageGroupCache $cache,
433 MessageCollection $collection,
434 string $messageKey
435 ): bool {
436 $cacheContent = $cache->get( $messageKey );
437 if ( $cacheContent !== false ) {
438 return true;
439 }
440
441 $cacheUpdateTime = $cache->getUpdateTimestamp();
442 $cacheUpdateTime = $cacheUpdateTime !== false ? MWTimestamp::convert( TS_MW, $cacheUpdateTime ) : false;
443
444 $pageIdentity = $this->pageStore->getPageForLink( $collection->keys()[ $messageKey ] );
445 $oldestRevision = $this->revisionLookup->getFirstRevision( $pageIdentity );
446 $latestRevision = $this->revisionLookup->getRevisionByTitle( $pageIdentity );
447
448 $logger = LoggerFactory::getInstance( LogNames::GROUP_SYNCHRONIZATION );
449 // Here we are checking for the following:
450 // 1. New translation was added for a message on translatewiki.net
451 // 2. Translation was exported
452 // 3. Translation was updated on translatewiki.net
453 // In this case the cache does not have the message
454 if (
455 $cacheUpdateTime !== false &&
456 ( $oldestRevision && $oldestRevision->getTimestamp() < $cacheUpdateTime ) &&
457 ( $latestRevision && $cacheUpdateTime < $latestRevision->getTimestamp() )
458 ) {
459 $logger->info(
460 'Expected cache miss for {messageKey} in language: {language}. Cache update time: {cacheUpdateTime}',
461 [
462 'messageKey' => $messageKey,
463 'language' => $collection->getLanguage(),
464 'cacheUpdateTime' => $cacheUpdateTime,
465 'oldestRevisionTs' => $oldestRevision->getTimestamp(),
466 'latestRevisionTs' => $latestRevision->getTimestamp()
467 ]
468 );
469 return false;
470 }
471
472 $logger->warning(
473 'Unexpected cache miss for {messageKey} in language: {language}. Cache update time: {cacheUpdateTime}',
474 [
475 'messageKey' => $messageKey,
476 'language' => $collection->getLanguage(),
477 'cacheUpdateTime' => $cacheUpdateTime,
478 'oldestRevisionTs' => $oldestRevision ? $oldestRevision->getTimestamp() : 'N/A',
479 'latestRevisionTs' => $latestRevision ? $latestRevision->getTimestamp() : 'N/A'
480 ]
481 );
482 return false;
483 }
484
485}
Constants for log channel names used in this extension.
Definition LogNames.php:13
Caches messages of file based message group source file.
This class implements default behavior for file based message groups.
getSourceLanguage()
Returns language code depicting the language of source text.
This file contains the class for core message collections implementation.
Interface for message objects used by MessageCollection.
Definition Message.php:13
Class is used to track the changes made when importing messages from the remote sources using importE...
addAddition( $language, $key, $content)
Add an addition under a message group for a specific language.
getMatchedMessage( $languageCode, $key)
Get matched rename message for a given key.
addChange( $language, $key, $content)
Add a change under a message group for a specific language.
removeChanges( $language, $keysToRemove)
Remove changes for a language under the group.
addDeletion( $language, $key, $content)
Adds a deletion under a message group for a specific language.
processGroup(FileBasedMessageGroup $group)
Finds modifications in external sources compared to wiki state.
addMessageUpdateChanges(FileBasedMessageGroup $group, string $language, MessageSourceChange $changes, int $reason, MessageGroupCache $cache)
This is the detective novel.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
An interface to be implemented by comparators that will compare percentage of similarity between stri...
Finds external changes for file based message groups.