Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.46% covered (warning)
82.46%
141 / 171
58.82% covered (warning)
58.82%
20 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageSourceChange
82.46% covered (warning)
82.46%
141 / 171
58.82% covered (warning)
58.82%
20 / 34
94.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 addChange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addAddition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDeletion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRename
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 setRenameState
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
5.93
 addModification
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getChanges
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDeletions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAdditions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findMessage
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 breakRename
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
7.14
 getRenames
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getModification
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeAdditions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeDeletions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeChanges
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeRenames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeBasedOnType
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 removeChangesForLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeModification
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 getAllModifications
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModificationsForLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadModifications
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOnly
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
10.27
 isPreviousState
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMatchedMessage
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getMatchedKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSimilarity
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isEqual
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isSimilar
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 areStringsSimilar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 areStringsEqual
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Contains a class to track changes to the messages when importing messages from remote source.
4 * @author Abijeet Patro
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Extension\Translate\MessageSync;
10
11use InvalidArgumentException;
12
13/**
14 * Class is used to track the changes made when importing messages from the remote sources
15 * using importExternalTranslations.php. Also provides an interface to query these changes, and
16 * update them.
17 * @since 2019.10
18 */
19class MessageSourceChange {
20    /**
21     * @var array[][][]
22     * @phpcs:ignore Generic.Files.LineLength
23     * @phan-var array<string,array<string,array<string|int,array{key:string,content:string,similarity?:float,matched_to?:string,previous_state?:string}>>>
24     */
25    protected $changes = [];
26    public const ADDITION = 'addition';
27    public const CHANGE = 'change';
28    public const DELETION = 'deletion';
29    public const RENAME = 'rename';
30    public const NONE = 'none';
31
32    private const SIMILARITY_THRESHOLD = 0.9;
33
34    /**
35     * Contains a mapping of message type, and the corresponding addition function
36     * @var callable[]
37     */
38    protected $addFunctionMap;
39    /**
40     * Contains a mapping of message type, and the corresponding removal function
41     * @var callable[]
42     */
43    protected $removeFunctionMap;
44
45    /** @param array[][][] $changes */
46    public function __construct( $changes = [] ) {
47        $this->changes = $changes;
48        $this->addFunctionMap = [
49            self::ADDITION => [ $this, 'addAddition' ],
50            self::DELETION => [ $this, 'addDeletion' ],
51            self::CHANGE => [ $this, 'addChange' ]
52        ];
53
54        $this->removeFunctionMap = [
55            self::ADDITION => [ $this, 'removeAdditions' ],
56            self::DELETION => [ $this, 'removeDeletions' ],
57            self::CHANGE => [ $this, 'removeChanges' ]
58        ];
59    }
60
61    /**
62     * Add a change under a message group for a specific language
63     * @param string $language
64     * @param string $key
65     * @param string $content
66     */
67    public function addChange( $language, $key, $content ) {
68        $this->addModification( $language, self::CHANGE, $key, $content );
69    }
70
71    /**
72     * Add an addition under a message group for a specific language
73     * @param string $language
74     * @param string $key
75     * @param string $content
76     */
77    public function addAddition( $language, $key, $content ) {
78        $this->addModification( $language, self::ADDITION, $key, $content );
79    }
80
81    /**
82     * Adds a deletion under a message group for a specific language
83     * @param string $language
84     * @param string $key
85     * @param string $content
86     */
87    public function addDeletion( $language, $key, $content ) {
88        $this->addModification( $language, self::DELETION, $key, $content );
89    }
90
91    /**
92     * Adds a rename under a message group for a specific language
93     * @param string $language
94     * @param string[] $addedMessage
95     * @param string[] $deletedMessage
96     * @param float $similarity
97     */
98    public function addRename( $language, $addedMessage, $deletedMessage, $similarity = 0 ) {
99        $this->changes[$language][self::RENAME][$addedMessage['key']] = [
100            'content' => $addedMessage['content'],
101            'similarity' => $similarity,
102            'matched_to' => $deletedMessage['key'],
103            'previous_state' => self::ADDITION,
104            'key' => $addedMessage['key']
105        ];
106
107        $this->changes[$language][self::RENAME][$deletedMessage['key']] = [
108            'content' => $deletedMessage['content'],
109            'similarity' => $similarity,
110            'matched_to' => $addedMessage['key'],
111            'previous_state' => self::DELETION,
112            'key' => $deletedMessage['key']
113        ];
114    }
115
116    public function setRenameState( $language, $msgKey, $state ) {
117        $possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION,
118            self::NONE, self::RENAME ];
119        if ( !in_array( $state, $possibleStates ) ) {
120            throw new InvalidArgumentException(
121                "Invalid state passed - '$state'. Possible states - "
122                . implode( ', ', $possibleStates )
123            );
124        }
125
126        $languageChanges = null;
127        if ( isset( $this->changes[ $language ] ) ) {
128            $languageChanges = &$this->changes[ $language ];
129        }
130        if ( $languageChanges !== null && isset( $languageChanges[ 'rename' ][ $msgKey ] ) ) {
131            $languageChanges[ 'rename' ][ $msgKey ][ 'previous_state' ] = $state;
132        }
133    }
134
135    /**
136     * @param string $language
137     * @param string $type
138     * @param string $key
139     * @param string $content
140     */
141    protected function addModification( $language, $type, $key, $content ) {
142        $this->changes[$language][$type][] = [
143            'key' => $key,
144            'content' => $content,
145        ];
146    }
147
148    /**
149     * Fetch changes for a message group under a language
150     * @param string $language
151     * @return array[]
152     */
153    public function getChanges( $language ) {
154        return $this->getModification( $language, self::CHANGE );
155    }
156
157    /**
158     * Fetch deletions for a message group under a language
159     * @param string $language
160     * @return array[]
161     */
162    public function getDeletions( $language ) {
163        return $this->getModification( $language, self::DELETION );
164    }
165
166    /**
167     * Fetch additions for a message group under a language
168     * @param string $language
169     * @return array[]
170     */
171    public function getAdditions( $language ) {
172        return $this->getModification( $language, self::ADDITION );
173    }
174
175    /**
176     * Finds a message with the given key across different types of modifications.
177     * @param string $language
178     * @param string $key
179     * @param string[] $possibleStates
180     * @param string|null &$modificationType
181     * @return array|null
182     */
183    public function findMessage( $language, $key, $possibleStates = [], &$modificationType = null ) {
184        $allChanges = [];
185        $allChanges[self::ADDITION] = $this->getAdditions( $language );
186        $allChanges[self::DELETION] = $this->getDeletions( $language );
187        $allChanges[self::CHANGE] = $this->getChanges( $language );
188        $allChanges[self::RENAME] = $this->getRenames( $language );
189
190        if ( $possibleStates === [] ) {
191            $possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION, self::RENAME ];
192        }
193
194        foreach ( $allChanges as $type => $modifications ) {
195            if ( !in_array( $type, $possibleStates ) ) {
196                continue;
197            }
198
199            if ( $type === self::RENAME ) {
200                if ( isset( $modifications[$key] ) ) {
201                    $modificationType = $type;
202                    return $modifications[$key];
203                }
204                continue;
205            }
206
207            foreach ( $modifications as $modification ) {
208                $currentKey = $modification['key'];
209                if ( $currentKey === $key ) {
210                    $modificationType = $type;
211                    return $modification;
212                }
213            }
214        }
215
216        $modificationType = null;
217        return null;
218    }
219
220    /**
221     * Break renames, and put messages back into their previous state.
222     * @param string $languageCode
223     * @param string $msgKey
224     * @return string|null previous state of the message
225     */
226    public function breakRename( $languageCode, $msgKey ) {
227        $msg = $this->findMessage( $languageCode, $msgKey, [ self::RENAME ] );
228        if ( $msg === null ) {
229            return null;
230        }
231        $matchedMsg = $this->getMatchedMessage( $languageCode, $msg['key'] );
232        if ( $matchedMsg === null ) {
233            return null;
234        }
235
236        // Remove them from the renames array
237        $this->removeRenames( $languageCode, [ $matchedMsg['key'], $msg['key'] ] );
238
239        $matchedMsgState = $matchedMsg[ 'previous_state' ];
240        $msgState = $msg[ 'previous_state' ];
241
242        // Add them to the changes under the appropriate state
243        if ( $matchedMsgState !== self::NONE ) {
244            if ( $matchedMsgState === self::CHANGE ) {
245                $matchedMsg['key'] = $msg['key'];
246            }
247            call_user_func(
248                $this->addFunctionMap[ $matchedMsgState ],
249                $languageCode,
250                $matchedMsg['key'],
251                $matchedMsg['content']
252            );
253        }
254
255        if ( $msgState !== self::NONE ) {
256            if ( $msgState === self::CHANGE ) {
257                $msg['key'] = $matchedMsg['key'];
258            }
259            call_user_func(
260                $this->addFunctionMap[ $msgState ],
261                $languageCode,
262                $msg['key'],
263                $msg['content']
264            );
265        }
266
267        return $msgState;
268    }
269
270    /**
271     * Fetch renames for a message group under a language
272     * @param string $language
273     * @return array[]
274     */
275    public function getRenames( $language ) {
276        $renames = $this->getModification( $language, self::RENAME );
277        foreach ( $renames as $key => &$rename ) {
278            $rename['key'] = $key;
279        }
280
281        return $renames;
282    }
283
284    /**
285     * @param string $language
286     * @param string $type
287     * @return array[]
288     */
289    protected function getModification( $language, $type ) {
290        return $this->changes[$language][$type] ?? [];
291    }
292
293    /**
294     * Remove additions for a language under the group.
295     * @param string $language
296     * @param array|null $keysToRemove
297     */
298    public function removeAdditions( $language, $keysToRemove ) {
299        $this->removeModification( $language, self::ADDITION, $keysToRemove );
300    }
301
302    /**
303     * Remove deletions for a language under the group.
304     * @param string $language
305     * @param array|null $keysToRemove
306     */
307    public function removeDeletions( $language, $keysToRemove ) {
308        $this->removeModification( $language, self::DELETION, $keysToRemove );
309    }
310
311    /**
312     * Remove changes for a language under the group.
313     * @param string $language
314     * @param array|null $keysToRemove
315     */
316    public function removeChanges( $language, $keysToRemove ) {
317        $this->removeModification( $language, self::CHANGE, $keysToRemove );
318    }
319
320    /**
321     * Remove renames for a language under the group.
322     * @param string $language
323     * @param array|null $keysToRemove
324     */
325    public function removeRenames( $language, $keysToRemove ) {
326        $this->removeModification( $language, self::RENAME, $keysToRemove );
327    }
328
329    /**
330     * Remove modifications based on the type. Avoids usage of ugly if / switch
331     * statement.
332     * @param string $language
333     * @param array $keysToRemove
334     * @param string $type One of ADDITION, CHANGE, DELETION
335     */
336    public function removeBasedOnType( $language, $keysToRemove, $type ) {
337        $callable = $this->removeFunctionMap[ $type ] ?? null;
338
339        if ( $callable === null ) {
340            throw new InvalidArgumentException( 'Type should be one of ' .
341                implode( ', ', [ self::ADDITION, self::CHANGE, self::DELETION ] ) .
342                ". Invalid type $type passed."
343            );
344        }
345
346        call_user_func( $callable, $language, $keysToRemove );
347    }
348
349    /**
350     * Remove all language related changes for a group.
351     * @param string $language
352     */
353    public function removeChangesForLanguage( $language ) {
354        unset( $this->changes[ $language ] );
355    }
356
357    protected function removeModification( $language, $type, $keysToRemove = null ) {
358        if ( !isset( $this->changes[$language][$type] ) || $keysToRemove === [] ) {
359            return;
360        }
361
362        if ( $keysToRemove === null ) {
363            unset( $this->changes[$language][$type] );
364            return;
365        }
366
367        if ( $type === self::RENAME ) {
368            $this->changes[$language][$type] =
369                array_diff_key( $this->changes[$language][$type], array_flip( $keysToRemove ) );
370        } else {
371            $this->changes[$language][$type] = array_filter(
372                $this->changes[$language][$type],
373                static function ( $change ) use ( $keysToRemove ) {
374                    return !in_array( $change['key'], $keysToRemove, true );
375                }
376            );
377        }
378    }
379
380    /**
381     * Return all modifications for the group.
382     * @return array[][][]
383     */
384    public function getAllModifications() {
385        return $this->changes;
386    }
387
388    /**
389     * Get all for a language under the group.
390     * @param string $language
391     * @return array[][]
392     */
393    public function getModificationsForLanguage( $language ) {
394        return $this->changes[$language] ?? [];
395    }
396
397    /**
398     * Loads the changes, and returns an instance of the class.
399     * @param array $changesData
400     * @return self
401     */
402    public static function loadModifications( $changesData ) {
403        return new self( $changesData );
404    }
405
406    /**
407     * Get all language keys with modifications under the group
408     * @return string[]
409     */
410    public function getLanguages() {
411        return array_keys( $this->changes );
412    }
413
414    /**
415     * Determines if the group has only a certain type of change under a language.
416     *
417     * @param string $language
418     * @param string $type
419     * @return bool
420     */
421    public function hasOnly( $language, $type ) {
422        $deletions = $this->getDeletions( $language );
423        $additions = $this->getAdditions( $language );
424        $renames = $this->getRenames( $language );
425        $changes = $this->getChanges( $language );
426        $hasOnlyAdditions = $hasOnlyRenames =
427            $hasOnlyChanges = $hasOnlyDeletions = true;
428
429        if ( $deletions ) {
430            $hasOnlyAdditions = $hasOnlyRenames = $hasOnlyChanges = false;
431        }
432
433        if ( $renames ) {
434            $hasOnlyDeletions = $hasOnlyAdditions = $hasOnlyChanges = false;
435        }
436
437        if ( $changes ) {
438            $hasOnlyAdditions = $hasOnlyRenames = $hasOnlyDeletions = false;
439        }
440
441        if ( $additions ) {
442            $hasOnlyDeletions = $hasOnlyRenames = $hasOnlyChanges = false;
443        }
444
445        if ( $type === self::DELETION ) {
446            $response = $hasOnlyDeletions;
447        } elseif ( $type === self::RENAME ) {
448            $response = $hasOnlyRenames;
449        } elseif ( $type === self::CHANGE ) {
450            $response = $hasOnlyChanges;
451        } elseif ( $type === self::ADDITION ) {
452            $response = $hasOnlyAdditions;
453        } else {
454            throw new InvalidArgumentException( "Unknown $type passed." );
455        }
456
457        return $response;
458    }
459
460    /**
461     * Checks if the previous state of a renamed message matches a given value
462     * @param string $languageCode
463     * @param string $key
464     * @param string[] $types
465     * @return bool
466     */
467    public function isPreviousState( $languageCode, $key, array $types ) {
468        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
469
470        return isset( $msg['previous_state'] ) && in_array( $msg['previous_state'], $types );
471    }
472
473    /**
474     * Get matched rename message for a given key
475     * @param string $languageCode
476     * @param string $key
477     * @return array|null Matched message if found, else null
478     */
479    public function getMatchedMessage( $languageCode, $key ) {
480        $matchedKey = $this->getMatchedKey( $languageCode, $key );
481        if ( $matchedKey ) {
482            return $this->changes[ $languageCode ][ self::RENAME ][ $matchedKey ] ?? null;
483        }
484
485        return null;
486    }
487
488    /**
489     * Get matched rename key for a given key
490     * @param string $languageCode
491     * @param string $key
492     * @return string|null Matched key if found, else null
493     */
494    public function getMatchedKey( $languageCode, $key ) {
495        return $this->changes[ $languageCode ][ self::RENAME ][ $key ][ 'matched_to' ] ?? null;
496    }
497
498    /**
499     * Returns the calculated similarity for a rename
500     * @param string $languageCode
501     * @param string $key
502     * @return float|null
503     */
504    public function getSimilarity( $languageCode, $key ) {
505        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
506
507        return $msg[ 'similarity' ] ?? null;
508    }
509
510    /**
511     * Checks if a given key is equal to matched rename message
512     * @param string $languageCode
513     * @param string $key
514     * @return bool
515     */
516    public function isEqual( $languageCode, $key ) {
517        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
518        return $msg && $this->areStringsEqual( $msg[ 'similarity' ] );
519    }
520
521    /**
522     * Checks if a given key is similar to matched rename message
523     *
524     * @param string $languageCode
525     * @param string $key
526     * @return bool
527     */
528    public function isSimilar( $languageCode, $key ) {
529        $msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
530        return $msg && $this->areStringsSimilar( $msg[ 'similarity' ] );
531    }
532
533    /**
534     * Checks if the similarity percent passed passes the min threshold
535     * @param float $similarity
536     * @return bool
537     */
538    public function areStringsSimilar( $similarity ) {
539        return $similarity >= self::SIMILARITY_THRESHOLD;
540    }
541
542    /**
543     * Checks if the similarity percent passed
544     * @param float $similarity
545     * @return bool
546     */
547    public function areStringsEqual( $similarity ) {
548        return $similarity === 1;
549    }
550}