wikimedia/mediawiki-extensions-Translate

View on GitHub
src/MessageSync/MessageSourceChange.php

Summary

Maintainability
D
1 day
Test Coverage
<?php
/**
 * Contains a class to track changes to the messages when importing messages from remote source.
 * @author Abijeet Patro
 * @license GPL-2.0-or-later
 * @file
 */

namespace MediaWiki\Extension\Translate\MessageSync;

use InvalidArgumentException;

/**
 * Class is used to track the changes made when importing messages from the remote sources
 * using importExternalTranslations.php. Also provides an interface to query these changes, and
 * update them.
 * @since 2019.10
 */
class MessageSourceChange {
    /**
     * @var array[][][]
     * @phpcs:ignore Generic.Files.LineLength
     * @phan-var array<string,array<string,array<string|int,array{key:string,content:string,similarity?:float,matched_to?:string,previous_state?:string}>>>
     */
    protected $changes = [];
    public const ADDITION = 'addition';
    public const CHANGE = 'change';
    public const DELETION = 'deletion';
    public const RENAME = 'rename';
    public const NONE = 'none';

    private const SIMILARITY_THRESHOLD = 0.9;

    /**
     * Contains a mapping of message type, and the corresponding addition function
     * @var callable[]
     */
    protected $addFunctionMap;
    /**
     * Contains a mapping of message type, and the corresponding removal function
     * @var callable[]
     */
    protected $removeFunctionMap;

    /** @param array[][][] $changes */
    public function __construct( $changes = [] ) {
        $this->changes = $changes;
        $this->addFunctionMap = [
            self::ADDITION => [ $this, 'addAddition' ],
            self::DELETION => [ $this, 'addDeletion' ],
            self::CHANGE => [ $this, 'addChange' ]
        ];

        $this->removeFunctionMap = [
            self::ADDITION => [ $this, 'removeAdditions' ],
            self::DELETION => [ $this, 'removeDeletions' ],
            self::CHANGE => [ $this, 'removeChanges' ]
        ];
    }

    /**
     * Add a change under a message group for a specific language
     * @param string $language
     * @param string $key
     * @param string $content
     */
    public function addChange( $language, $key, $content ) {
        $this->addModification( $language, self::CHANGE, $key, $content );
    }

    /**
     * Add an addition under a message group for a specific language
     * @param string $language
     * @param string $key
     * @param string $content
     */
    public function addAddition( $language, $key, $content ) {
        $this->addModification( $language, self::ADDITION, $key, $content );
    }

    /**
     * Adds a deletion under a message group for a specific language
     * @param string $language
     * @param string $key
     * @param string $content
     */
    public function addDeletion( $language, $key, $content ) {
        $this->addModification( $language, self::DELETION, $key, $content );
    }

    /**
     * Adds a rename under a message group for a specific language
     * @param string $language
     * @param string[] $addedMessage
     * @param string[] $deletedMessage
     * @param float $similarity
     */
    public function addRename( $language, $addedMessage, $deletedMessage, $similarity = 0 ) {
        $this->changes[$language][self::RENAME][$addedMessage['key']] = [
            'content' => $addedMessage['content'],
            'similarity' => $similarity,
            'matched_to' => $deletedMessage['key'],
            'previous_state' => self::ADDITION,
            'key' => $addedMessage['key']
        ];

        $this->changes[$language][self::RENAME][$deletedMessage['key']] = [
            'content' => $deletedMessage['content'],
            'similarity' => $similarity,
            'matched_to' => $addedMessage['key'],
            'previous_state' => self::DELETION,
            'key' => $deletedMessage['key']
        ];
    }

    public function setRenameState( $language, $msgKey, $state ) {
        $possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION,
            self::NONE, self::RENAME ];
        if ( !in_array( $state, $possibleStates ) ) {
            throw new InvalidArgumentException(
                "Invalid state passed - '$state'. Possible states - "
                . implode( ', ', $possibleStates )
            );
        }

        $languageChanges = null;
        if ( isset( $this->changes[ $language ] ) ) {
            $languageChanges = &$this->changes[ $language ];
        }
        if ( $languageChanges !== null && isset( $languageChanges[ 'rename' ][ $msgKey ] ) ) {
            $languageChanges[ 'rename' ][ $msgKey ][ 'previous_state' ] = $state;
        }
    }

    /**
     * @param string $language
     * @param string $type
     * @param string $key
     * @param string $content
     */
    protected function addModification( $language, $type, $key, $content ) {
        $this->changes[$language][$type][] = [
            'key' => $key,
            'content' => $content,
        ];
    }

    /**
     * Fetch changes for a message group under a language
     * @param string $language
     * @return array[]
     */
    public function getChanges( $language ) {
        return $this->getModification( $language, self::CHANGE );
    }

    /**
     * Fetch deletions for a message group under a language
     * @param string $language
     * @return array[]
     */
    public function getDeletions( $language ) {
        return $this->getModification( $language, self::DELETION );
    }

    /**
     * Fetch additions for a message group under a language
     * @param string $language
     * @return array[]
     */
    public function getAdditions( $language ) {
        return $this->getModification( $language, self::ADDITION );
    }

    /**
     * Finds a message with the given key across different types of modifications.
     * @param string $language
     * @param string $key
     * @param string[] $possibleStates
     * @param string|null &$modificationType
     * @return array|null
     */
    public function findMessage( $language, $key, $possibleStates = [], &$modificationType = null ) {
        $allChanges = [];
        $allChanges[self::ADDITION] = $this->getAdditions( $language );
        $allChanges[self::DELETION] = $this->getDeletions( $language );
        $allChanges[self::CHANGE] = $this->getChanges( $language );
        $allChanges[self::RENAME] = $this->getRenames( $language );

        if ( $possibleStates === [] ) {
            $possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION, self::RENAME ];
        }

        foreach ( $allChanges as $type => $modifications ) {
            if ( !in_array( $type, $possibleStates ) ) {
                continue;
            }

            if ( $type === self::RENAME ) {
                if ( isset( $modifications[$key] ) ) {
                    $modificationType = $type;
                    return $modifications[$key];
                }
                continue;
            }

            foreach ( $modifications as $modification ) {
                $currentKey = $modification['key'];
                if ( $currentKey === $key ) {
                    $modificationType = $type;
                    return $modification;
                }
            }
        }

        $modificationType = null;
        return null;
    }

    /**
     * Break renames, and put messages back into their previous state.
     * @param string $languageCode
     * @param string $msgKey
     * @return string|null previous state of the message
     */
    public function breakRename( $languageCode, $msgKey ) {
        $msg = $this->findMessage( $languageCode, $msgKey, [ self::RENAME ] );
        if ( $msg === null ) {
            return null;
        }
        $matchedMsg = $this->getMatchedMessage( $languageCode, $msg['key'] );
        if ( $matchedMsg === null ) {
            return null;
        }

        // Remove them from the renames array
        $this->removeRenames( $languageCode, [ $matchedMsg['key'], $msg['key'] ] );

        $matchedMsgState = $matchedMsg[ 'previous_state' ];
        $msgState = $msg[ 'previous_state' ];

        // Add them to the changes under the appropriate state
        if ( $matchedMsgState !== self::NONE ) {
            if ( $matchedMsgState === self::CHANGE ) {
                $matchedMsg['key'] = $msg['key'];
            }
            call_user_func(
                $this->addFunctionMap[ $matchedMsgState ],
                $languageCode,
                $matchedMsg['key'],
                $matchedMsg['content']
            );
        }

        if ( $msgState !== self::NONE ) {
            if ( $msgState === self::CHANGE ) {
                $msg['key'] = $matchedMsg['key'];
            }
            call_user_func(
                $this->addFunctionMap[ $msgState ],
                $languageCode,
                $msg['key'],
                $msg['content']
            );
        }

        return $msgState;
    }

    /**
     * Fetch renames for a message group under a language
     * @param string $language
     * @return array[]
     */
    public function getRenames( $language ) {
        $renames = $this->getModification( $language, self::RENAME );
        foreach ( $renames as $key => &$rename ) {
            $rename['key'] = $key;
        }

        return $renames;
    }

    /**
     * @param string $language
     * @param string $type
     * @return array[]
     */
    protected function getModification( $language, $type ) {
        return $this->changes[$language][$type] ?? [];
    }

    /**
     * Remove additions for a language under the group.
     * @param string $language
     * @param array|null $keysToRemove
     */
    public function removeAdditions( $language, $keysToRemove ) {
        $this->removeModification( $language, self::ADDITION, $keysToRemove );
    }

    /**
     * Remove deletions for a language under the group.
     * @param string $language
     * @param array|null $keysToRemove
     */
    public function removeDeletions( $language, $keysToRemove ) {
        $this->removeModification( $language, self::DELETION, $keysToRemove );
    }

    /**
     * Remove changes for a language under the group.
     * @param string $language
     * @param array|null $keysToRemove
     */
    public function removeChanges( $language, $keysToRemove ) {
        $this->removeModification( $language, self::CHANGE, $keysToRemove );
    }

    /**
     * Remove renames for a language under the group.
     * @param string $language
     * @param array|null $keysToRemove
     */
    public function removeRenames( $language, $keysToRemove ) {
        $this->removeModification( $language, self::RENAME, $keysToRemove );
    }

    /**
     * Remove modifications based on the type. Avoids usage of ugly if / switch
     * statement.
     * @param string $language
     * @param array $keysToRemove
     * @param string $type One of ADDITION, CHANGE, DELETION
     */
    public function removeBasedOnType( $language, $keysToRemove, $type ) {
        $callable = $this->removeFunctionMap[ $type ] ?? null;

        if ( $callable === null ) {
            throw new InvalidArgumentException( 'Type should be one of ' .
                implode( ', ', [ self::ADDITION, self::CHANGE, self::DELETION ] ) .
                ". Invalid type $type passed."
            );
        }

        call_user_func( $callable, $language, $keysToRemove );
    }

    /**
     * Remove all language related changes for a group.
     * @param string $language
     */
    public function removeChangesForLanguage( $language ) {
        unset( $this->changes[ $language ] );
    }

    protected function removeModification( $language, $type, $keysToRemove = null ) {
        if ( !isset( $this->changes[$language][$type] ) || $keysToRemove === [] ) {
            return;
        }

        if ( $keysToRemove === null ) {
            unset( $this->changes[$language][$type] );
            return;
        }

        if ( $type === self::RENAME ) {
            $this->changes[$language][$type] =
                array_diff_key( $this->changes[$language][$type], array_flip( $keysToRemove ) );
        } else {
            $this->changes[$language][$type] = array_filter(
                $this->changes[$language][$type],
                static function ( $change ) use ( $keysToRemove ) {
                    return !in_array( $change['key'], $keysToRemove, true );
                }
            );
        }
    }

    /**
     * Return all modifications for the group.
     * @return array[][][]
     */
    public function getAllModifications() {
        return $this->changes;
    }

    /**
     * Get all for a language under the group.
     * @param string $language
     * @return array[][]
     */
    public function getModificationsForLanguage( $language ) {
        return $this->changes[$language] ?? [];
    }

    /**
     * Loads the changes, and returns an instance of the class.
     * @param array $changesData
     * @return self
     */
    public static function loadModifications( $changesData ) {
        return new self( $changesData );
    }

    /**
     * Get all language keys with modifications under the group
     * @return string[]
     */
    public function getLanguages() {
        return array_keys( $this->changes );
    }

    /**
     * Determines if the group has only a certain type of change under a language.
     *
     * @param string $language
     * @param string $type
     * @return bool
     */
    public function hasOnly( $language, $type ) {
        $deletions = $this->getDeletions( $language );
        $additions = $this->getAdditions( $language );
        $renames = $this->getRenames( $language );
        $changes = $this->getChanges( $language );
        $hasOnlyAdditions = $hasOnlyRenames =
            $hasOnlyChanges = $hasOnlyDeletions = true;

        if ( $deletions ) {
            $hasOnlyAdditions = $hasOnlyRenames = $hasOnlyChanges = false;
        }

        if ( $renames ) {
            $hasOnlyDeletions = $hasOnlyAdditions = $hasOnlyChanges = false;
        }

        if ( $changes ) {
            $hasOnlyAdditions = $hasOnlyRenames = $hasOnlyDeletions = false;
        }

        if ( $additions ) {
            $hasOnlyDeletions = $hasOnlyRenames = $hasOnlyChanges = false;
        }

        if ( $type === self::DELETION ) {
            $response = $hasOnlyDeletions;
        } elseif ( $type === self::RENAME ) {
            $response = $hasOnlyRenames;
        } elseif ( $type === self::CHANGE ) {
            $response = $hasOnlyChanges;
        } elseif ( $type === self::ADDITION ) {
            $response = $hasOnlyAdditions;
        } else {
            throw new InvalidArgumentException( "Unknown $type passed." );
        }

        return $response;
    }

    /**
     * Checks if the previous state of a renamed message matches a given value
     * @param string $languageCode
     * @param string $key
     * @param string[] $types
     * @return bool
     */
    public function isPreviousState( $languageCode, $key, array $types ) {
        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );

        return isset( $msg['previous_state'] ) && in_array( $msg['previous_state'], $types );
    }

    /**
     * Get matched rename message for a given key
     * @param string $languageCode
     * @param string $key
     * @return array|null Matched message if found, else null
     */
    public function getMatchedMessage( $languageCode, $key ) {
        $matchedKey = $this->getMatchedKey( $languageCode, $key );
        if ( $matchedKey ) {
            return $this->changes[ $languageCode ][ self::RENAME ][ $matchedKey ] ?? null;
        }

        return null;
    }

    /**
     * Get matched rename key for a given key
     * @param string $languageCode
     * @param string $key
     * @return string|null Matched key if found, else null
     */
    public function getMatchedKey( $languageCode, $key ) {
        return $this->changes[ $languageCode ][ self::RENAME ][ $key ][ 'matched_to' ] ?? null;
    }

    /**
     * Returns the calculated similarity for a rename
     * @param string $languageCode
     * @param string $key
     * @return float|null
     */
    public function getSimilarity( $languageCode, $key ) {
        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );

        return $msg[ 'similarity' ] ?? null;
    }

    /**
     * Checks if a given key is equal to matched rename message
     * @param string $languageCode
     * @param string $key
     * @return bool
     */
    public function isEqual( $languageCode, $key ) {
        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
        return $msg && $this->areStringsEqual( $msg[ 'similarity' ] );
    }

    /**
     * Checks if a given key is similar to matched rename message
     *
     * @param string $languageCode
     * @param string $key
     * @return bool
     */
    public function isSimilar( $languageCode, $key ) {
        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
        return $msg && $this->areStringsSimilar( $msg[ 'similarity' ] );
    }

    /**
     * Checks if the similarity percent passed passes the min threshold
     * @param float $similarity
     * @return bool
     */
    public function areStringsSimilar( $similarity ) {
        return $similarity >= self::SIMILARITY_THRESHOLD;
    }

    /**
     * Checks if the similarity percent passed
     * @param float $similarity
     * @return bool
     */
    public function areStringsEqual( $similarity ) {
        return $similarity === 1;
    }
}