wikimedia/mediawiki-extensions-Translate

View on GitHub
src/MessageGroupProcessing/ManageMessageGroupsActionApi.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\MessageGroupProcessing;

use ApiBase;
use Exception;
use FormatJson;
use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
use MediaWiki\Extension\Translate\Synchronization\MessageChangeStorage;
use MediaWiki\Extension\Translate\Utilities\StringComparators\SimpleStringComparator;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\Title\Title;
use MessageGroup;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * API module for managing message group changes.
 * Marks message as a rename of another message or as a new message.
 * Updates the cdb file.
 * @author Abijeet Patro
 * @since 2019.10
 * @license GPL-2.0-or-later
 * @ingroup API TranslateAPI
 */
class ManageMessageGroupsActionApi extends ApiBase {
    private const RIGHT = 'translate-manage';

    public function execute(): void {
        $this->checkUserRightsAny( self::RIGHT );
        $params = $this->extractRequestParams();

        $groupId = $params['groupId'];
        $op = $params['operation'];
        $msgKey = $params['messageKey'];
        $name = $params['changesetName'] ?? MessageChangeStorage::DEFAULT_NAME;
        $changesetModifiedTime = $params['changesetModified'];
        $keyToRename = null;

        if ( !MessageChangeStorage::isValidCdbName( $name ) ) {
            $this->dieWithError(
                [ 'apierror-translate-invalid-changeset-name', wfEscapeWikiText( $name ) ],
                'invalidchangeset'
            );
        }
        $cdbPath = MessageChangeStorage::getCdbPath( $name );

        if ( !MessageChangeStorage::isModifiedSince( $cdbPath, $changesetModifiedTime ) ) {
            // Changeset file has been modified since the time the page was generated.
            $this->dieWithError( [ 'apierror-translate-changeset-modified' ] );
        }

        if ( $op === 'rename' ) {
            if ( !isset( $params['renameMessageKey'] ) ) {
                $this->dieWithError( [ 'apierror-missingparam', 'renameMessageKey' ] );
            }
            $keyToRename = $params['renameMessageKey'];
        }

        $sourceChanges = MessageChangeStorage::getGroupChanges( $cdbPath, $groupId );
        if ( $sourceChanges->getAllModifications() === [] ) {
            $this->dieWithError( [ 'apierror-translate-smg-nochanges' ] );
        }

        $group = MessageGroups::getGroup( $groupId );
        if ( $group === null ) {
            $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' );
        }

        try {
            if ( $op === 'rename' ) {
                $this->handleRename(
                    $group, $sourceChanges, $msgKey, $keyToRename, $group->getSourceLanguage()
                );
            } elseif ( $op === 'new' ) {
                $this->handleNew( $sourceChanges, $msgKey, $group->getSourceLanguage() );
            } else {
                $this->dieWithError(
                    [ 'apierror-translate-invalid-operation', wfEscapeWikiText( $op ),
                        wfEscapeWikiText( implode( '/', [ 'new', 'rename' ] ) ) ],
                    'invalidoperation'
                );
            }
        } catch ( Exception $ex ) {
            // Log necessary parameters and rethrow.
            $data = [
                'op' => $op,
                'newMsgKey' => $msgKey,
                'msgKey' => $keyToRename,
                'groupId' => $group->getId(),
                'group' => $group->getLabel(),
                'groupSourceLang' => $group->getSourceLanguage(),
                'exception' => $ex
            ];

            error_log(
                "Error while running: ManageMessageGroupsActionApi::execute. Inputs: \n" .
                FormatJson::encode( $data, true )
            );

            throw $ex;
        }

        // Write the source changes back to file.
        MessageChangeStorage::writeGroupChanges( $sourceChanges, $groupId, $cdbPath );

        $this->getResult()->addValue( null, $this->getModuleName(), [
            'success' => 1
        ] );
    }

    /** Handles rename requests */
    protected function handleRename(
        MessageGroup $group,
        MessageSourceChange $sourceChanges,
        string $msgKey,
        string $keyToRename,
        string $sourceLanguage
    ): void {
        $languages = $sourceChanges->getLanguages();

        foreach ( $languages as $code ) {
            $msgState = $renameMsgState = null;

            $isSourceLang = $sourceLanguage === $code;
            if ( $isSourceLang ) {
                $this->handleSourceRename( $sourceChanges, $code, $msgKey, $keyToRename );
                continue;
            }

            // Check for changes with the new key, then with the old key.
            // If there are no changes, we won't find anything at all, and
            // can skip this languageCode.
            $msg = $sourceChanges->findMessage( $code, $msgKey, [
                MessageSourceChange::ADDITION,
                MessageSourceChange::RENAME
            ], $msgState );

            // This case will arise if the message key has been changed in the source
            // language, but has not been modified in this language code.
            // NOTE: We are also searching under deletions. This means that if the source
            // language key is renamed, but one of the non source language keys is removed,
            // renaming it will not remove the translation, but only rename it. This
            // scenario is highly unlikely though.
            $msg ??= $sourceChanges->findMessage( $code, $keyToRename, [
                MessageSourceChange::DELETION,
                MessageSourceChange::CHANGE,
                MessageSourceChange::RENAME
            ], $msgState );

            if ( $msg === null ) {
                continue;
            }

            // Check for the renamed message in the rename list, and deleted list.
            $renameMsg = $sourceChanges->findMessage(
                $code,
                $keyToRename,
                [ MessageSourceChange::RENAME, MessageSourceChange::DELETION ],
                $renameMsgState
            );

            // content / msg will not be present if the message was deleted from the wiki or
            // was for some reason unavailable during processing incoming changes. We're going
            // to try and load it here again from the database. Very rare chance of this happening.
            if ( $renameMsg === null || !isset( $renameMsg['content'] ) ) {
                $title = Title::newFromText(
                    Utilities::title( $keyToRename, $code, $group->getNamespace() ),
                    $group->getNamespace()
                );

                $renameContent = Utilities::getContentForTitle( $title, true ) ?? '';

                $renameMsg = [
                    'key' => $keyToRename,
                    'content' => $renameContent
                ];

                // If the message was found in changes, this will be set, otherwise set it
                // to none
                $renameMsgState ??= MessageSourceChange::NONE;
            }

            // Remove previous states
            if ( $msgState === MessageSourceChange::RENAME ) {
                $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
            } else {
                $sourceChanges->removeBasedOnType( $code, [ $msg['key'] ], $msgState );
            }

            if ( $renameMsgState === MessageSourceChange::RENAME ) {
                $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
            } elseif ( $renameMsgState !== MessageSourceChange::NONE ) {
                $sourceChanges->removeBasedOnType( $code, [ $keyToRename ], $renameMsgState );
            }

            // This is done in case the key has not been renamed in the non-source language.
            $msg['key'] = $msgKey;

            // Add as rename
            $stringComparator = new SimpleStringComparator();
            $similarity = $stringComparator->getSimilarity(
                $msg['content'],
                $renameMsg['content']
            );
            $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
            $sourceChanges->setRenameState( $code, $msgKey, $msgState );
            $sourceChanges->setRenameState( $code, $keyToRename, $renameMsgState );
        }
    }

    protected function handleSourceRename(
        MessageSourceChange $sourceChanges,
        string $code,
        string $msgKey,
        string $keyToRename
    ): void {
        $msgState = $renameMsgState = null;

        $msg = $sourceChanges->findMessage(
            $code, $msgKey, [ MessageSourceChange::ADDITION, MessageSourceChange::RENAME ], $msgState
        );

        $renameMsg = $sourceChanges->findMessage(
            $code,
            $keyToRename,
            [ MessageSourceChange::DELETION, MessageSourceChange::RENAME ],
            $renameMsgState
        );

        if ( $msg === null || $renameMsg === null ) {
            $this->dieWithError( 'apierror-translate-rename-key-invalid' );
        }

        if ( $msgState === MessageSourceChange::RENAME ) {
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
            $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
        }

        if ( $renameMsgState === MessageSourceChange::RENAME ) {
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
            $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
        }

        // Ensure that one of them is an ADDITION, and one is DELETION
        if ( $msgState !== MessageSourceChange::ADDITION ||
            $renameMsgState !== MessageSourceChange::DELETION ) {
            $this->dieWithError( [
                'apierror-translate-rename-state-invalid',
                wfEscapeWikiText( $msgState ), wfEscapeWikiText( $renameMsgState )
            ] );
        }

        // Remove previous states
        $sourceChanges->removeAdditions( $code, [ $msgKey ] );
        $sourceChanges->removeDeletions( $code, [ $keyToRename ] );

        // Add as rename
        $stringComparator = new SimpleStringComparator();
        $similarity = $stringComparator->getSimilarity(
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
            $msg['content'],
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
            $renameMsg['content']
        );
        $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
    }

    /** Handles add message as new request */
    protected function handleNew(
        MessageSourceChange $sourceChanges,
        string $msgKey,
        string $sourceLang
    ): void {
        $msgState = null;
        $languages = $sourceChanges->getLanguages();

        foreach ( $languages as $code ) {
            $msg = $sourceChanges->findMessage(
                $code, $msgKey, [ MessageSourceChange::RENAME ], $msgState
            );

            if ( $code === $sourceLang && $msg === null ) {
                $this->dieWithError( 'apierror-translate-addition-key-invalid' );
            }

            if ( $code === $sourceLang && $msgState !== MessageSourceChange::RENAME ) {
                $this->dieWithError( 'apierror-translate-rename-msg-new' );
            }

            // For any other language, it's possible for the message to be not found.
            if ( $msg === null ) {
                continue;
            }

            // breakRename will add the message back to its previous state, nothing more to do
            $sourceChanges->breakRename( $code, $msg['key'] );
        }
    }

    protected function getAllowedParams(): array {
        return [
            'groupId' => [
                ParamValidator::PARAM_TYPE => 'string',
                ParamValidator::PARAM_REQUIRED => true,
            ],
            'renameMessageKey' => [
                ParamValidator::PARAM_TYPE => 'string',
                ParamValidator::PARAM_REQUIRED => false,
            ],
            'messageKey' => [
                ParamValidator::PARAM_TYPE => 'string',
                ParamValidator::PARAM_REQUIRED => true,
            ],
            'operation' => [
                ParamValidator::PARAM_TYPE => [ 'rename', 'new' ],
                ParamValidator::PARAM_ISMULTI => false,
                ParamValidator::PARAM_REQUIRED => true,
            ],
            'changesetName' => [
                ParamValidator::PARAM_TYPE => 'string',
                ParamValidator::PARAM_DEFAULT => MessageChangeStorage::DEFAULT_NAME
            ],
            'changesetModified' => [
                ParamValidator::PARAM_TYPE => 'integer',
                ParamValidator::PARAM_REQUIRED => true,
            ]
        ];
    }

    public function isInternal(): bool {
        return true;
    }

    public function needsToken(): string {
        return 'csrf';
    }
}