wikimedia/mediawiki-extensions-Translate

View on GitHub
src/Synchronization/UpdateMessageJob.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\Synchronization;

use ContentHandler;
use FileBasedMessageGroup;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\TextContent;
use MediaWiki\Extension\Translate\Jobs\GenericTranslateJob;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
use MediaWiki\Extension\Translate\MessageProcessing\TranslateReplaceTitle;
use MediaWiki\Extension\Translate\Services;
use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\PageUpdater;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use RecentChange;

/**
 * Job for updating translation pages when translation or message definition changes.
 *
 * @author Niklas Laxström
 * @copyright Copyright © 2008-2013, Niklas Laxström
 * @license GPL-2.0-or-later
 * @ingroup JobQueue
 */
class UpdateMessageJob extends GenericTranslateJob {
    private User $fuzzyBot;

    /** Create a normal message update job without a rename process */
    public static function newJob(
        Title $target, string $content, bool $fuzzy = false
    ): self {
        $params = [
            'content' => $content,
            'fuzzy' => $fuzzy,
        ];

        return new self( $target, $params );
    }

    /**
     * Create a message update job containing a rename process
     * @param Title $target
     * @param string $targetStr
     * @param string $replacement
     * @param string|false $fuzzy
     * @param string $content
     * @param array $otherLangContents
     * @return self
     */
    public static function newRenameJob(
        Title $target,
        string $targetStr,
        string $replacement,
        $fuzzy,
        string $content,
        array $otherLangContents = []
    ): self {
        $params = [
            'target' => $targetStr,
            'replacement' => $replacement,
            'fuzzy' => $fuzzy,
            'rename' => 'rename',
            'content' => $content,
            'otherLangs' => $otherLangContents
        ];

        return new self( $target, $params );
    }

    public function __construct( Title $title, array $params = [] ) {
        parent::__construct( 'UpdateMessageJob', $title, $params );
    }

    public function run(): bool {
        $params = $this->params;
        $isRename = $params['rename'] ?? false;
        $isFuzzy = $params['fuzzy'] ?? false;
        $otherLangs = $params['otherLangs'] ?? [];
        $originalTitle = Title::newFromLinkTarget( $this->title->getTitleValue(), Title::NEW_CLONE );

        if ( $isRename ) {
            $this->title = $this->handleRename( $params['target'], $params['replacement'] );
            if ( $this->title === null ) {
                // There was a failure, return true, but don't proceed further.
                $this->logWarning(
                    'Rename process could not find the source title.',
                    [
                        'replacement' => $params['replacement'],
                        'target' => $params['target']
                    ]
                );

                $this->removeFromCache( $originalTitle );
                return true;
            }
        }
        $title = $this->title;
        $baseRevId = $title->getLatestRevId();
        $updater = $this->fuzzyBotEdit( $title, $params['content'] );
        if ( !$updater->getStatus()->isOK() ) {
            $this->logError(
                'Failed to update content for source message',
                [
                    'content' => ContentHandler::makeContent( $params['content'], $this->title ),
                    'errors' => $updater->getStatus()->getMessages()
                ]
            );
        }

        if ( $isRename ) {
            // Update other language content if present.
            $this->processTranslationChanges( $otherLangs, $params['replacement'], $params['namespace'] );
        }

        $this->handleFuzzy( $title, $isFuzzy, $updater, $baseRevId );

        $this->removeFromCache( $originalTitle );
        return true;
    }

    private function handleRename( string $target, string $replacement ): ?Title {
        $newSourceTitle = null;

        $sourceMessageHandle = new MessageHandle( $this->title );
        $movableTitles = TranslateReplaceTitle::getTitlesForMove( $sourceMessageHandle, $replacement );

        if ( $movableTitles === [] ) {
            $this->logError(
                'No movable titles found with target text.',
                [
                    'title' => $this->title->getPrefixedText(),
                    'replacement' => $replacement,
                    'target' => $target
                ]
            );
            return null;
        }

        $renameSummary = wfMessage( 'translate-manage-import-rename-summary' )
            ->inContentLanguage()->plain();

        foreach ( $movableTitles as [ $sourceTitle, $replacementTitle ] ) {
            $mv = MediaWikiServices::getInstance()
                ->getMovePageFactory()
                ->newMovePage( $sourceTitle, $replacementTitle );

            $status = $mv->move( $this->getFuzzyBot(), $renameSummary, false );
            if ( !$status->isOK() ) {
                $this->logError(
                    'Error moving message',
                    [
                        'target' => $sourceTitle->getPrefixedText(),
                        'replacement' => $replacementTitle->getPrefixedText(),
                        'errors' => $status->getMessages()
                    ]
                );
            }

            [ , $targetCode ] = Utilities::figureMessage( $replacementTitle->getText() );
            if ( !$newSourceTitle && $sourceMessageHandle->getCode() === $targetCode ) {
                $newSourceTitle = $replacementTitle;
            }
        }

        if ( $newSourceTitle ) {
            return $newSourceTitle;
        } else {
            // This means that the old source Title was never moved
            // which is not possible but handle it.
            $this->logError(
                'Source title was not in the list of movable titles.',
                [ 'title' => $this->title->getPrefixedText() ]
            );
            return null;
        }
    }

    /**
     * Handles fuzzying. Message documentation and the source language are excluded from
     * fuzzying. The source language is the identified via the $title parameter
     *
     * If the edit to the source translation unit is a manual revert, then any translations
     * whose tp:transver is set to the revision being reverted to are marked **unfuzzy**
     * unless they have an explicit !!FUZZY!! or fail validation.
     *
     * Any revisions with other tp:transvers are marked fuzzy, unless invalidation skipping is used.
     */
    private function handleFuzzy( Title $title, bool $invalidate, PageUpdater $updater, int $baseTranver ): void {
        global $wgTranslateDocumentationLanguageCode;
        $editResult = $updater->getEditResult();
        if ( !$invalidate && !$editResult->isExactRevert() ) {
            return;
        }
        $oldRevId = $editResult->getOriginalRevisionId();
        $handle = new MessageHandle( $title );

        $languages = Utilities::getLanguageNames( 'en' );

        // Don't fuzzy the message documentation or the source language
        unset( $languages[$wgTranslateDocumentationLanguageCode] );
        unset( $languages[$handle->getCode()] );

        $languages = array_keys( $languages );

        $fuzzies = [];
        $unfuzzies = [];
        $mwInstance = MediaWikiServices::getInstance();
        $revTagStore = Services::getInstance()->getRevTagStore();
        $revStore = $mwInstance->getRevisionStore();

        if ( $oldRevId || $invalidate ) {
            // We'll need to check if each possible tunit exists later on, so do that now
            // as a batch
            $batch = $mwInstance->getLinkBatchFactory()->newLinkBatch();
            $batch->setCaller( __METHOD__ );
            foreach ( $languages as $code ) {
                $batch->addObj( $handle->getTitleForLanguage( $code ) );
            }
            $batch->execute();
        }
        $newRevision = $updater->getNewRevision();
        // $newRevision can be null if a change is made to only tvars and then the fuzzy checkbox is manually turned on
        $targetSha = $newRevision ? $newRevision->getSha1() : null;

        foreach ( $languages as $code ) {
            $otherTitle = $handle->getTitleForLanguage( $code );
            $shouldUnfuzzy = false;
            if ( !$otherTitle->exists() ) {
                // Don't care about fuzzy status for nonexistent tunits
                continue;
            }
            $transverId = $revTagStore->getTransver( $otherTitle );
            if ( !$transverId ) {
                // The page doesn't have a tp:transver at all
                // This shouldn't happen, but it does in some edge cases like importing translations across wikis
                $latest = $otherTitle->getLatestRevID();
                if ( $invalidate && !$revTagStore->isRevIdFuzzy( $otherTitle->getId(), $latest ) && $newRevision ) {
                    // If the (latest revision of the) translation isn't fuzzy and the source tunit was actually changed
                    // then assume the translation pertains to the latest revision of the source tunit
                    // (before the update that triggered this job and marked it fuzzy)
                    // and set its transver to that so "show differences" has something to show
                    $revTagStore->setTransver( $otherTitle, $latest, $baseTranver );
                }
                // Don't do any revert checking
            } elseif ( $oldRevId && $newRevision && $editResult->isExactRevert() ) {
                // Only try to do revert analysis if the edit succeeded and is truly an exact revert
                $transver = $revStore->getRevisionById( $transverId, 0, $title );
                if ( $oldRevId == $transverId ) {
                    // It's a straightforward revert
                    $shouldUnfuzzy = true;
                } elseif ( $transver ) {
                    $transverSha = $transver->getSha1();
                    if ( $transverSha == $targetSha ) {
                        // It's a deeper revert or otherwise wasn't detected by MediaWiki's builtin revert detection
                        $shouldUnfuzzy = true;
                    } // Else it's not a revert at all so leave shouldUnfuzzy false
                }
                // Else should never happen (it means tp:transver is corrupt) but it could concievably happen
                // in some edge cases so do nothing (and fuzzy the tunit) rather than crashing the entire job
            }
            if ( $shouldUnfuzzy ) {
                // In principle it's a revert so should unfuzzy, first check for validation failures
                // or manual fuzzying
                $otherHandle = new MessageHandle( $otherTitle );
                $wikiPage = $mwInstance->getWikiPageFactory()->newFromTitle( $otherTitle );
                $content = $wikiPage->getContent();
                if ( !$content instanceof TextContent ) {
                    // This should never happen (translation units should always be wikitext) but Phan complains
                    // otherwise
                    continue;
                }
                $text = $content->getText();
                if ( $otherHandle->isFuzzy() && !$otherHandle->needsFuzzy( $text ) ) {
                    $unfuzzies[] = $otherTitle;
                }
                // If it's not already fuzzy then that means the original change was done without invalidating
                // translations and while the new change probably should have been done that way as well
                // even if it wasn't it never makes sense to re-fuzzy in that case so just leave the fuzzy status alone
            } elseif ( $invalidate ) {
                $fuzzies[] = $otherTitle;
            }
        }

        $dbw = $mwInstance->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );

        if ( $fuzzies !== [] ) {
            $inserts = [];
            foreach ( $fuzzies as $otherTitle ) {
                $inserts[] = [
                    'rt_type' => RevTagStore::FUZZY_TAG,
                    'rt_page' => $otherTitle->getId(),
                    'rt_revision' => $otherTitle->getLatestRevID(),
                ];
            }
            $dbw->newReplaceQueryBuilder()
                ->replaceInto( 'revtag' )
                ->uniqueIndexFields( [ 'rt_type', 'rt_page', 'rt_revision' ] )
                ->rows( $inserts )
                ->caller( __METHOD__ )
                ->execute();
        }
        if ( $unfuzzies !== [] ) {
            foreach ( $unfuzzies as $otherTitle ) {
                $dbw->newDeleteQueryBuilder()
                    ->deleteFrom( 'revtag' )
                    ->where( [
                        'rt_type' => RevTagStore::FUZZY_TAG,
                        'rt_page' => $otherTitle->getId(),
                        'rt_revision' => $otherTitle->getLatestRevID(),
                    ] )
                    ->caller( __METHOD__ )
                    ->execute();
            }
        }
    }

    /** Updates the translation unit pages in non-source languages. */
    private function processTranslationChanges(
        array $langChanges,
        string $baseTitle,
        int $groupNamespace
    ): void {
        foreach ( $langChanges as $code => $contentStr ) {
            $titleStr = Utilities::title( $baseTitle, $code, $groupNamespace );
            $title = Title::newFromText( $titleStr, $groupNamespace );
            $updater = $this->fuzzyBotEdit( $title, $contentStr );

            if ( !$updater->getStatus()->isOK() ) {
                $this->logError(
                    'Failed to update content for non-source message',
                    [
                        'title' => $title->getPrefixedText(),
                        'errors' => $updater->getStatus()->getMessages()
                    ]
                );
            }
        }
    }

    private function removeFromCache( Title $title ): void {
        $config = MediaWikiServices::getInstance()->getMainConfig();

        if ( !$config->get( 'TranslateGroupSynchronizationCache' ) ) {
            return;
        }

        $currentTitle = $title;
        // Check if the current title, is equal to the title passed. This condition will be
        // true in case of rename where the old title would have been renamed.
        if ( $this->title && $this->title->getPrefixedDBkey() !== $title->getPrefixedDBkey() ) {
            $currentTitle = $this->title;
        }

        $sourceMessageHandle = new MessageHandle( $currentTitle );
        $groupIds = $sourceMessageHandle->getGroupIds();
        if ( !$groupIds ) {
            $this->logWarning(
                "Could not find group Id for message title: {$currentTitle->getPrefixedDBkey()}",
                $this->getParams()
            );
            return;
        }

        $groupId = $groupIds[0];
        $group = MessageGroups::getGroup( $groupId );

        if ( !$group instanceof FileBasedMessageGroup ) {
            return;
        }

        $groupSyncCache = Services::getInstance()->getGroupSynchronizationCache();
        $messageKey = $title->getPrefixedDBkey();

        if ( $groupSyncCache->isMessageBeingProcessed( $groupId, $messageKey ) ) {
            $groupSyncCache->removeMessages( $groupId, $messageKey );
            $groupSyncCache->extendGroupExpiryTime( $groupId );
        } else {
            $this->logWarning(
                "Did not find key: $messageKey; in group: $groupId in group sync cache",
                $this->getParams()
            );
        }
    }

    private function fuzzyBotEdit( Title $title, string $content ): PageUpdater {
        $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory();
        $content = ContentHandler::makeContent( $content, $title );
        $page = $wikiPageFactory->newFromTitle( $title );
        $updater = $page->newPageUpdater( $this->getFuzzyBot() )
            ->setContent( SlotRecord::MAIN, $content );

        if ( $this->getFuzzyBot()->authorizeWrite( 'autopatrol', $title ) ) {
            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
        }

        $summary = wfMessage( 'translate-manage-import-summary' )
            ->inContentLanguage()->plain();
        $updater->saveRevision(
            CommentStoreComment::newUnsavedComment( $summary ),
            EDIT_FORCE_BOT
        );
        return $updater;
    }

    private function getFuzzyBot(): User {
        $this->fuzzyBot ??= FuzzyBot::getUser();
        return $this->fuzzyBot;
    }
}