28 public function __construct(
30 private readonly RevisionLookup $revisionLookup,
31 private readonly PageStore $pageStore,
61 $this->processLanguage( $group, $sourceLanguage, $changes );
62 unset( $languages[ $sourceLanguage] );
64 foreach ( array_keys( $languages ) as $language ) {
65 $this->processLanguage( $group, $language, $changes );
71 private function processLanguage(
72 FileBasedMessageGroup $group,
74 MessageSourceChange $changes
76 $cache = $group->getMessageGroupCache( $language );
78 if ( !$cache->isValid( $reason ) ) {
79 $this->addMessageUpdateChanges( $group, $language, $changes, $reason, $cache );
81 if ( $changes->getModificationsForLanguage( $language ) === [] ) {
114 $wiki = $group->initCollection( $language );
115 $wiki->filter( MessageCollection::FILTER_HAS_TRANSLATION, MessageCollection::INCLUDE_MATCHING );
116 $wiki->loadTranslations();
117 $wikiKeys = $wiki->getMessageKeys();
121 $ffs = $group->getFFS();
122 if ( $language === $sourceLanguage && !$ffs->exists( $language ) ) {
127 throw new RuntimeException(
"Source message file for {$group->getId()} does not exist: $path" );
130 $file = $ffs->read( $language );
133 if ( $file ===
false ) {
138 if ( !isset( $file[
'MESSAGES'] ) ) {
139 $id = $group->
getId();
140 $ffsClass = get_class( $ffs );
142 error_log(
"$id has an FFS ($ffsClass) - it didn't return cake for $language" );
147 $fileKeys = array_keys( $file[
'MESSAGES'] );
149 $common = array_intersect( $fileKeys, $wikiKeys );
151 $supportsFuzzy = $ffs->supportsFuzzy();
152 $changesToRemove = [];
154 foreach ( $common as $key ) {
155 $sourceContent = $file[
'MESSAGES'][$key];
157 $wikiMessage = $wiki[$key];
158 $wikiContent = $wikiMessage->translation();
162 $wikiContent = str_replace( TRANSLATE_FUZZY,
'', $wikiContent );
165 if ( $supportsFuzzy ===
'yes' && $wikiMessage->hasTag(
'fuzzy' ) ) {
166 $wikiContent = TRANSLATE_FUZZY . $wikiContent;
169 if ( $ffs->isContentEqual( $sourceContent, $wikiContent ) ) {
177 if ( $reason !== MessageGroupCache::NO_CACHE ) {
178 $cacheContent = $cache->
get( $key );
187 $this->hasCacheEntry( $cache, $wiki, $key ) &&
188 !$ffs->isContentEqual( $wikiContent, $cacheContent ) &&
189 $ffs->isContentEqual( $sourceContent, $cacheContent )
195 if ( $language !== $sourceLanguage ) {
200 if ( $renameMsg !==
null ) {
205 $this->addNonSourceRenames(
206 $changes, $key, $renameMsg[
'key'], $sourceContent, $wikiContent, $language
208 $changesToRemove[] = $key;
212 $changes->
addChange( $language, $key, $sourceContent );
217 $added = array_diff( $fileKeys, $wikiKeys );
218 foreach ( $added as $key ) {
219 $sourceContent = $file[
'MESSAGES'][$key];
220 $changes->
addAddition( $language, $key, $sourceContent );
227 if ( $reason !== MessageGroupCache::NO_CACHE ) {
228 $deleted = array_diff( $wikiKeys, $fileKeys );
229 foreach ( $deleted as $key ) {
230 if ( $cache->
get( $key ) ===
false ) {
235 $changes->
addDeletion( $language, $key, $wiki[$key]->translation() );
239 if ( $language === $sourceLanguage ) {
240 $this->findAndMarkSourceRenames( $changes, $language );
243 $this->checkNonSourceAdditionsForRename(
244 $changes, $sourceLanguage, $language, $wiki, $wikiKeys
258 private function checkNonSourceAdditionsForRename(
259 MessageSourceChange $changes,
260 string $sourceLanguage,
261 string $targetLanguage,
262 MessageCollection $wiki,
265 $additions = $changes->getAdditions( $targetLanguage );
266 if ( $additions === [] ) {
270 $additionsToRemove = [];
271 $deletionsToRemove = [];
272 foreach ( $additions as $addedMsg ) {
273 $addedMsgKey = $addedMsg[
'key'];
276 $renamedSourceMsg = $changes->findMessage(
277 $sourceLanguage, $addedMsgKey, [ MessageSourceChange::RENAME ]
280 if ( $renamedSourceMsg ===
null ) {
286 $deletedSource = $changes->getMatchedMessage( $sourceLanguage, $renamedSourceMsg[
'key'] );
287 if ( $deletedSource ===
null ) {
290 $deletedMsgKey = $deletedSource[
'key'];
291 $deletedMsg = $changes->findMessage(
292 $targetLanguage, $deletedMsgKey, [ MessageSourceChange::DELETION ]
298 if ( $deletedMsg ===
null ) {
300 if ( in_array( $deletedMsgKey, $wikiKeys ) ) {
301 $content = $wiki[ $deletedMsgKey ]->translation();
304 'key' => $deletedMsgKey,
305 'content' => $content
309 $similarityPercent = $this->stringComparator->getSimilarity(
310 $addedMsg[
'content'], $deletedMsg[
'content']
313 $changes->addRename( $targetLanguage, [
314 'key' => $addedMsgKey,
315 'content' => $addedMsg[
'content']
317 'key' => $deletedMsgKey,
318 'content' => $deletedMsg[
'content']
319 ], $similarityPercent );
321 $deletionsToRemove[] = $deletedMsgKey;
322 $additionsToRemove[] = $addedMsgKey;
325 $changes->removeAdditions( $targetLanguage, $additionsToRemove );
326 $changes->removeDeletions( $targetLanguage, $deletionsToRemove );
334 private function findAndMarkSourceRenames( MessageSourceChange $changes,
string $sourceLanguage ): void {
338 $deletions = $changes->getDeletions( $sourceLanguage );
339 $additions = $changes->getAdditions( $sourceLanguage );
340 if ( $deletions === [] || $additions === [] ) {
346 $potentialRenames = [];
347 foreach ( $additions as $addedMsg ) {
348 $addedMsgKey = $addedMsg[
'key'];
350 foreach ( $deletions as $deletedMsg ) {
351 $similarityPercent = $this->stringComparator->getSimilarity(
352 $addedMsg[
'content'], $deletedMsg[
'content']
355 if ( $changes->areStringsSimilar( $similarityPercent ) ) {
356 $potentialRenames[ $addedMsgKey .
'|' . $deletedMsg[
'key'] ] = $similarityPercent;
361 $this->matchRenames( $changes, $potentialRenames, $sourceLanguage );
365 private function addNonSourceRenames(
366 MessageSourceChange $changes,
369 string $sourceContent,
375 'content' => $sourceContent
380 'content' => $wikiContent
383 $similarityPercent = $this->stringComparator->getSimilarity(
384 $sourceContent, $wikiContent
386 $changes->addRename( $language, $addedMsg, $removedMsg, $similarityPercent );
396 private function matchRenames( MessageSourceChange $changes, array $trackRename,
string $language ): void {
397 arsort( $trackRename, SORT_NUMERIC );
399 $alreadyRenamed = $additionsToRemove = $deletionsToRemove = [];
400 foreach ( $trackRename as $key => $similarityPercent ) {
401 [ $addKey, $deleteKey ] = explode(
'|', $key, 2 );
402 if ( isset( $alreadyRenamed[ $addKey ] ) || isset( $alreadyRenamed[ $deleteKey ] ) ) {
408 $alreadyRenamed[ $addKey ] = 1;
409 $alreadyRenamed[ $deleteKey ] = 1;
411 $addMsg = $changes->findMessage( $language, $addKey, [ MessageSourceChange::ADDITION ] );
412 $deleteMsg = $changes->findMessage( $language, $deleteKey, [ MessageSourceChange::DELETION ] );
414 $changes->addRename( $language, $addMsg, $deleteMsg, $similarityPercent );
417 $additionsToRemove[] = $addMsg[
'key'];
419 $deletionsToRemove[] = $deleteMsg[
'key'];
422 $changes->removeAdditions( $language, $additionsToRemove );
423 $changes->removeDeletions( $language, $deletionsToRemove );
431 private function hasCacheEntry(
432 MessageGroupCache $cache,
433 MessageCollection $collection,
436 $cacheContent = $cache->get( $messageKey );
437 if ( $cacheContent !==
false ) {
441 $cacheUpdateTime = $cache->getUpdateTimestamp();
442 $cacheUpdateTime = $cacheUpdateTime !==
false ? MWTimestamp::convert( TS_MW, $cacheUpdateTime ) : false;
444 $pageIdentity = $this->pageStore->getPageForLink( $collection->keys()[ $messageKey ] );
445 $oldestRevision = $this->revisionLookup->getFirstRevision( $pageIdentity );
446 $latestRevision = $this->revisionLookup->getRevisionByTitle( $pageIdentity );
448 $logger = LoggerFactory::getInstance( LogNames::GROUP_SYNCHRONIZATION );
455 $cacheUpdateTime !==
false &&
456 ( $oldestRevision && $oldestRevision->getTimestamp() < $cacheUpdateTime ) &&
457 ( $latestRevision && $cacheUpdateTime < $latestRevision->getTimestamp() )
460 'Expected cache miss for {messageKey} in language: {language}. Cache update time: {cacheUpdateTime}',
462 'messageKey' => $messageKey,
463 'language' => $collection->getLanguage(),
464 'cacheUpdateTime' => $cacheUpdateTime,
465 'oldestRevisionTs' => $oldestRevision->getTimestamp(),
466 'latestRevisionTs' => $latestRevision->getTimestamp()
473 'Unexpected cache miss for {messageKey} in language: {language}. Cache update time: {cacheUpdateTime}',
475 'messageKey' => $messageKey,
476 'language' => $collection->getLanguage(),
477 'cacheUpdateTime' => $cacheUpdateTime,
478 'oldestRevisionTs' => $oldestRevision ? $oldestRevision->getTimestamp() :
'N/A',
479 'latestRevisionTs' => $latestRevision ? $latestRevision->getTimestamp() :
'N/A'