Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
82.46% |
141 / 171 |
|
58.82% |
20 / 34 |
CRAP | |
0.00% |
0 / 1 |
MessageSourceChange | |
82.46% |
141 / 171 |
|
58.82% |
20 / 34 |
94.71 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
addChange | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addAddition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addDeletion | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addRename | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
setRenameState | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
5.93 | |||
addModification | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getChanges | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDeletions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAdditions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
findMessage | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
8 | |||
breakRename | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
7.14 | |||
getRenames | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getModification | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeAdditions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeDeletions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeChanges | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeRenames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeBasedOnType | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
removeChangesForLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
removeModification | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 | |||
getAllModifications | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModificationsForLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
loadModifications | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLanguages | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
hasOnly | |
75.00% |
18 / 24 |
|
0.00% |
0 / 1 |
10.27 | |||
isPreviousState | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getMatchedMessage | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getMatchedKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSimilarity | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
isEqual | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
isSimilar | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
areStringsSimilar | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
areStringsEqual | |
0.00% |
0 / 1 |
|
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 | |
9 | namespace MediaWiki\Extension\Translate\MessageSync; |
10 | |
11 | use 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 | */ |
19 | class 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 | } |