Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
63.23% |
184 / 291 |
|
33.33% |
11 / 33 |
CRAP | |
0.00% |
0 / 1 |
MessageGroups | |
63.45% |
184 / 290 |
|
33.33% |
11 / 33 |
654.70 | |
0.00% |
0 / 1 |
init | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
6.28 | |||
getCachedGroupDefinitions | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
4 | |||
initGroupsFromDefinitions | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
recache | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
clearCache | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
clearProcessCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
appendAutoloader | |
16.67% |
1 / 6 |
|
0.00% |
0 / 1 |
13.26 | |||
getGroupLoaders | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
4.18 | |||
getCacheGroupLoaders | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getGroup | |
44.44% |
4 / 9 |
|
0.00% |
0 / 1 |
9.29 | |||
normalizeId | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
exists | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
labelExists | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getAllGroups | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPriority | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
setPriority | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
isDynamic | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getSharedGroups | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
getParentGroups | |
93.55% |
29 / 31 |
|
0.00% |
0 / 1 |
11.03 | |||
singleton | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getGroups | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getGroupsById | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
expandWildcards | |
66.67% |
10 / 15 |
|
0.00% |
0 / 1 |
8.81 | |||
getDynamicGroups | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getGroupsByType | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getGroupStructure | |
75.00% |
21 / 28 |
|
0.00% |
0 / 1 |
11.56 | |||
groupLabelSort | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
subGroups | |
69.57% |
16 / 23 |
|
0.00% |
0 / 1 |
5.70 | |||
haveSingleSourceLanguage | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
isTranslatableMessage | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
72 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\MessageGroupProcessing; |
5 | |
6 | use AggregateMessageGroup; |
7 | use AggregateMessageGroupLoader; |
8 | use CachedMessageGroupLoader; |
9 | use DependencyWrapper; |
10 | use InvalidArgumentException; |
11 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
12 | use MediaWiki\Extension\Translate\MessageProcessing\StringMatcher; |
13 | use MediaWiki\Extension\Translate\Services; |
14 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Title\Title; |
17 | use MessageGroup; |
18 | use MessageGroupBase; |
19 | use MessageGroupLoader; |
20 | use RuntimeException; |
21 | use WANObjectCache; |
22 | |
23 | /** |
24 | * Factory class for accessing message groups individually by id or |
25 | * all of them as a list. |
26 | * @todo Clean up the mixed static/member method interface. |
27 | * |
28 | * @author Niklas Laxström |
29 | * @author Siebrand Mazeland |
30 | * @copyright Copyright © 2008-2013, Niklas Laxström, Siebrand Mazeland |
31 | * @license GPL-2.0-or-later |
32 | */ |
33 | class MessageGroups { |
34 | /** @var MessageGroup[]|null Map of (group ID => MessageGroup) */ |
35 | private $groups; |
36 | /** @var MessageGroupLoader[]|null */ |
37 | private $groupLoaders; |
38 | /** @var WANObjectCache|null */ |
39 | private $cache; |
40 | |
41 | /** |
42 | * Tracks the current cache version. Update this when there are incompatible changes |
43 | * with the last version of the cache to force a new key to be used. The older cache |
44 | * will automatically expire and be cleared off. |
45 | * @var int |
46 | */ |
47 | private const CACHE_VERSION = 4; |
48 | |
49 | /** Initialises the list of groups */ |
50 | protected function init(): void { |
51 | if ( is_array( $this->groups ) ) { |
52 | return; // groups already initialized |
53 | } |
54 | |
55 | $value = $this->getCachedGroupDefinitions(); |
56 | $groups = $value['cc']; |
57 | |
58 | foreach ( $this->getGroupLoaders() as $loader ) { |
59 | $groups += $loader->getGroups(); |
60 | } |
61 | $this->initGroupsFromDefinitions( $groups ); |
62 | } |
63 | |
64 | /** @param string|false $recache Either "recache" or false */ |
65 | protected function getCachedGroupDefinitions( $recache = false ): array { |
66 | global $wgAutoloadClasses; |
67 | |
68 | $regenerator = function () { |
69 | global $wgAutoloadClasses; |
70 | |
71 | $groups = $deps = $autoload = []; |
72 | // This constructs the list of all groups from multiple different sources. |
73 | // When possible, a cache dependency is created to automatically recreate |
74 | // the cache when configuration changes. Currently used by other extensions |
75 | // such as Banner Messages and test cases to load message groups. |
76 | Services::getInstance()->getHookRunner() |
77 | ->onTranslatePostInitGroups( $groups, $deps, $autoload ); |
78 | // Register autoloaders for this request, both values modified by reference |
79 | self::appendAutoloader( $autoload, $wgAutoloadClasses ); |
80 | |
81 | $value = [ |
82 | 'ts' => wfTimestamp( TS_MW ), |
83 | 'cc' => $groups, |
84 | 'autoload' => $autoload |
85 | ]; |
86 | $wrapper = new DependencyWrapper( $value, $deps ); |
87 | $wrapper->initialiseDeps(); |
88 | |
89 | return $wrapper; // save the new value to cache |
90 | }; |
91 | |
92 | $cache = $this->getCache(); |
93 | /** @var DependencyWrapper $wrapper */ |
94 | $wrapper = $cache->getWithSetCallback( |
95 | $this->getCacheKey(), |
96 | $cache::TTL_DAY, |
97 | $regenerator, |
98 | [ |
99 | 'lockTSE' => 30, // avoid stampedes (mutex) |
100 | 'checkKeys' => [ $this->getCacheKey() ], |
101 | 'touchedCallback' => static function ( $value ) { |
102 | return ( $value instanceof DependencyWrapper && $value->isExpired() ) |
103 | ? time() // treat value as if it just expired (for "lockTSE") |
104 | : null; |
105 | }, |
106 | 'minAsOf' => $recache ? INF : $cache::MIN_TIMESTAMP_NONE, // "miss" on recache |
107 | ] |
108 | ); |
109 | |
110 | $value = $wrapper->getValue(); |
111 | self::appendAutoloader( $value['autoload'], $wgAutoloadClasses ); |
112 | |
113 | return $value; |
114 | } |
115 | |
116 | /** |
117 | * Expand process cached groups to objects |
118 | * |
119 | * @param array $groups Map of (group ID => mixed) |
120 | */ |
121 | protected function initGroupsFromDefinitions( array $groups ): void { |
122 | foreach ( $groups as $id => $mixed ) { |
123 | if ( !is_object( $mixed ) ) { |
124 | $groups[$id] = call_user_func( $mixed, $id ); |
125 | } |
126 | } |
127 | |
128 | $this->groups = $groups; |
129 | } |
130 | |
131 | /** Immediately update the cache. */ |
132 | public function recache(): void { |
133 | // Purge the value from all datacenters |
134 | $cache = $this->getCache(); |
135 | $cache->touchCheckKey( $this->getCacheKey() ); |
136 | |
137 | $this->clearProcessCache(); |
138 | |
139 | foreach ( $this->getCacheGroupLoaders() as $cacheLoader ) { |
140 | $cacheLoader->recache(); |
141 | } |
142 | |
143 | // Reload the cache value and update the local datacenter |
144 | $value = $this->getCachedGroupDefinitions( 'recache' ); |
145 | $groups = $value['cc']; |
146 | |
147 | foreach ( $this->getGroupLoaders() as $loader ) { |
148 | $groups += $loader->getGroups(); |
149 | } |
150 | |
151 | $this->initGroupsFromDefinitions( $groups ); |
152 | } |
153 | |
154 | /** |
155 | * Manually reset group cache. |
156 | * |
157 | * Use when automatic dependency tracking fails. |
158 | */ |
159 | public static function clearCache(): void { |
160 | $self = self::singleton(); |
161 | |
162 | $cache = $self->getCache(); |
163 | $cache->delete( $self->getCacheKey(), 1 ); |
164 | |
165 | foreach ( $self->getCacheGroupLoaders() as $cacheLoader ) { |
166 | $cacheLoader->clearCache(); |
167 | } |
168 | |
169 | $self->clearProcessCache(); |
170 | } |
171 | |
172 | /** |
173 | * Manually reset the process cache. |
174 | * |
175 | * This is helpful for long running scripts where the process cache might get stale |
176 | * even though the global cache is updated. |
177 | */ |
178 | public function clearProcessCache(): void { |
179 | $this->groups = null; |
180 | $this->groupLoaders = null; |
181 | } |
182 | |
183 | protected function getCache(): WANObjectCache { |
184 | return $this->cache ?? MediaWikiServices::getInstance()->getMainWANObjectCache(); |
185 | } |
186 | |
187 | /** Override cache, for example during tests. */ |
188 | public function setCache( WANObjectCache $cache ) { |
189 | $this->cache = $cache; |
190 | } |
191 | |
192 | /** Returns the cache key. */ |
193 | public function getCacheKey(): string { |
194 | return $this->getCache()->makeKey( 'translate-groups', 'v' . self::CACHE_VERSION ); |
195 | } |
196 | |
197 | /** |
198 | * Safely merges first array to second array, throwing warning on duplicates and removing |
199 | * duplicates from the first array. |
200 | * @param array $additions Things to append |
201 | * @param array &$to Where to append |
202 | */ |
203 | protected static function appendAutoloader( array $additions, array &$to ): void { |
204 | foreach ( $additions as $class => $file ) { |
205 | if ( isset( $to[$class] ) && $to[$class] !== $file ) { |
206 | $msg = "Autoload conflict for $class: $to[$class] !== $file"; |
207 | trigger_error( $msg, E_USER_WARNING ); |
208 | continue; |
209 | } |
210 | |
211 | $to[$class] = $file; |
212 | } |
213 | } |
214 | |
215 | /** |
216 | * Loads and returns group loaders. Group loaders must implement MessageGroupLoader |
217 | * and may additionally implement CachedMessageGroupLoader |
218 | * @return MessageGroupLoader[] |
219 | */ |
220 | protected function getGroupLoaders(): array { |
221 | if ( $this->groupLoaders !== null ) { |
222 | return $this->groupLoaders; |
223 | } |
224 | |
225 | $cache = $this->getCache(); |
226 | |
227 | $groupLoaderInstances = $this->groupLoaders = []; |
228 | |
229 | // Initialize the dependencies |
230 | $deps = [ |
231 | 'database' => Utilities::getSafeReadDB(), |
232 | 'cache' => $cache |
233 | ]; |
234 | |
235 | Services::getInstance()->getHookRunner() |
236 | ->onTranslateInitGroupLoaders( $groupLoaderInstances, $deps ); |
237 | |
238 | foreach ( $groupLoaderInstances as $loader ) { |
239 | if ( !$loader instanceof MessageGroupLoader ) { |
240 | throw new InvalidArgumentException( |
241 | "MessageGroupLoader - $loader must implement the " . |
242 | 'MessageGroupLoader interface.' |
243 | ); |
244 | } |
245 | |
246 | $this->groupLoaders[] = $loader; |
247 | } |
248 | |
249 | return $this->groupLoaders; |
250 | } |
251 | |
252 | /** |
253 | * Returns group loaders that implement the CachedMessageGroupLoader |
254 | * |
255 | * @return CachedMessageGroupLoader[] |
256 | */ |
257 | protected function getCacheGroupLoaders(): array { |
258 | // @phan-suppress-next-line PhanTypeMismatchReturn |
259 | return array_filter( |
260 | $this->getGroupLoaders(), |
261 | static fn ( $groupLoader ) => $groupLoader instanceof CachedMessageGroupLoader |
262 | ); |
263 | } |
264 | |
265 | /** |
266 | * Fetch a message group by id. |
267 | * |
268 | * @param string $id Message group id. |
269 | * @return MessageGroup|null if it doesn't exist. |
270 | */ |
271 | public static function getGroup( string $id ): ?MessageGroup { |
272 | $groups = self::singleton()->getGroups(); |
273 | $id = self::normalizeId( $id ); |
274 | |
275 | if ( isset( $groups[$id] ) ) { |
276 | return $groups[$id]; |
277 | } |
278 | |
279 | if ( $id !== '' && $id[0] === '!' ) { |
280 | $dynamic = self::getDynamicGroups(); |
281 | if ( isset( $dynamic[$id] ) ) { |
282 | return new $dynamic[$id]; |
283 | } |
284 | } |
285 | |
286 | return null; |
287 | } |
288 | |
289 | /** Fixes the id and resolves aliases. */ |
290 | public static function normalizeId( ?string $id ): string { |
291 | /* Translatable pages use spaces, but MW occasionally likes to |
292 | * normalize spaces to underscores */ |
293 | $id = (string)$id; |
294 | |
295 | if ( str_starts_with( $id, 'page-' ) ) { |
296 | $id = strtr( $id, '_', ' ' ); |
297 | } |
298 | |
299 | global $wgTranslateGroupAliases; |
300 | if ( isset( $wgTranslateGroupAliases[$id] ) ) { |
301 | $id = $wgTranslateGroupAliases[$id]; |
302 | } |
303 | |
304 | return $id; |
305 | } |
306 | |
307 | public static function exists( string $id ): bool { |
308 | return (bool)self::getGroup( $id ); |
309 | } |
310 | |
311 | /** Check if a particular aggregate group label exists */ |
312 | public static function labelExists( string $name ): bool { |
313 | $loader = AggregateMessageGroupLoader::getInstance(); |
314 | $labels = array_map( static function ( MessageGroupBase $g ) { |
315 | return $g->getLabel(); |
316 | }, $loader->loadAggregateGroups() ); |
317 | return in_array( $name, $labels, true ); |
318 | } |
319 | |
320 | /** |
321 | * Get all enabled message groups. |
322 | * @return MessageGroup[] Map of (string => MessageGroup) |
323 | */ |
324 | public static function getAllGroups(): array { |
325 | return self::singleton()->getGroups(); |
326 | } |
327 | |
328 | /** |
329 | * We want to de-emphasize time sensitive groups like news for 2009. |
330 | * They can still exist in the system, but should not appear in front |
331 | * of translators looking to do some useful work. |
332 | * |
333 | * @param MessageGroup|string $group Message group ID |
334 | * @return string Message group priority |
335 | */ |
336 | public static function getPriority( $group ): string { |
337 | if ( $group instanceof MessageGroup ) { |
338 | $id = $group->getId(); |
339 | } else { |
340 | $id = self::normalizeId( $group ); |
341 | } |
342 | |
343 | return Services::getInstance()->getMessageGroupReviewStore()->getGroupPriority( $id ) ?? ''; |
344 | } |
345 | |
346 | /** |
347 | * Sets the message group priority. |
348 | * |
349 | * @param MessageGroup|string $group Message group |
350 | * @param string $priority Priority (empty string to unset) |
351 | */ |
352 | public static function setPriority( $group, string $priority = '' ): void { |
353 | if ( $group instanceof MessageGroup ) { |
354 | $id = $group->getId(); |
355 | } else { |
356 | $id = self::normalizeId( $group ); |
357 | } |
358 | |
359 | $priority = $priority === '' ? null : $priority; |
360 | Services::getInstance()->getMessageGroupReviewStore()->setGroupPriority( $id, $priority ); |
361 | } |
362 | |
363 | public static function isDynamic( MessageGroup $group ): bool { |
364 | $id = $group->getId(); |
365 | |
366 | return ( $id[0] ?? null ) === '!'; |
367 | } |
368 | |
369 | /** |
370 | * Returns a list of message groups that share (certain) messages |
371 | * with this group. |
372 | */ |
373 | public static function getSharedGroups( MessageGroup $group ): array { |
374 | // Take the first message, get a handle for it and check |
375 | // if that message belongs to other groups. Those are the |
376 | // parent aggregate groups. Ideally we loop over all keys, |
377 | // but this should be enough. |
378 | $keys = array_keys( $group->getDefinitions() ); |
379 | $title = Title::makeTitle( $group->getNamespace(), $keys[0] ); |
380 | $handle = new MessageHandle( $title ); |
381 | $ids = $handle->getGroupIds(); |
382 | foreach ( $ids as $index => $id ) { |
383 | if ( $id === $group->getId() ) { |
384 | unset( $ids[$index] ); |
385 | break; |
386 | } |
387 | } |
388 | |
389 | return $ids; |
390 | } |
391 | |
392 | /** |
393 | * Returns a list of parent message groups. If message group exists |
394 | * in multiple places in the tree, multiple lists are returned. |
395 | */ |
396 | public static function getParentGroups( MessageGroup $targetGroup ): array { |
397 | $ids = self::getSharedGroups( $targetGroup ); |
398 | if ( $ids === [] ) { |
399 | return []; |
400 | } |
401 | |
402 | $targetId = $targetGroup->getId(); |
403 | |
404 | /* Get the group structure. We will be using this to find which |
405 | * of our candidates are top-level groups. Prefilter it to only |
406 | * contain aggregate groups. */ |
407 | $structure = self::getGroupStructure(); |
408 | foreach ( $structure as $index => $group ) { |
409 | if ( $group instanceof MessageGroup ) { |
410 | unset( $structure[$index] ); |
411 | } else { |
412 | $structure[$index] = array_shift( $group ); |
413 | } |
414 | } |
415 | |
416 | /* Now that we have all related groups, use them to find all paths |
417 | * from top-level groups to target group with any number of subgroups |
418 | * in between. */ |
419 | $paths = []; |
420 | |
421 | /* This function recursively finds paths to the target group */ |
422 | $pathFinder = static function ( &$paths, $group, $targetId, $prefix = '' ) |
423 | use ( &$pathFinder ) { |
424 | if ( $group instanceof AggregateMessageGroup ) { |
425 | foreach ( $group->getGroups() as $subgroup ) { |
426 | $subId = $subgroup->getId(); |
427 | if ( $subId === $targetId ) { |
428 | $paths[] = $prefix; |
429 | continue; |
430 | } |
431 | |
432 | $pathFinder( $paths, $subgroup, $targetId, "$prefix|$subId" ); |
433 | } |
434 | } |
435 | }; |
436 | |
437 | // Iterate over the top-level groups only |
438 | foreach ( $ids as $id ) { |
439 | // First, find a top level groups |
440 | $group = self::getGroup( $id ); |
441 | |
442 | // Quick escape for leaf groups |
443 | if ( !$group instanceof AggregateMessageGroup ) { |
444 | continue; |
445 | } |
446 | |
447 | foreach ( $structure as $rootGroup ) { |
448 | /** @var MessageGroup $rootGroup */ |
449 | if ( $rootGroup->getId() === $group->getId() ) { |
450 | // Yay we found a top-level group |
451 | $pathFinder( $paths, $rootGroup, $targetId, $id ); |
452 | break; // No we have one or more paths appended into $paths |
453 | } |
454 | } |
455 | } |
456 | |
457 | // And finally explode the strings |
458 | return array_map( static function ( string $pathString ): array { |
459 | return explode( '|', $pathString ); |
460 | }, $paths ); |
461 | } |
462 | |
463 | public static function singleton(): self { |
464 | static $instance; |
465 | if ( !$instance instanceof self ) { |
466 | $instance = new self(); |
467 | } |
468 | |
469 | return $instance; |
470 | } |
471 | |
472 | /** |
473 | * Get all enabled non-dynamic message groups. |
474 | * |
475 | * @return MessageGroup[] Map of (group ID => MessageGroup) |
476 | */ |
477 | public function getGroups(): array { |
478 | $this->init(); |
479 | |
480 | return $this->groups; |
481 | } |
482 | |
483 | /** |
484 | * Get message groups for corresponding message group ids. |
485 | * |
486 | * @param string[] $ids Group IDs |
487 | * @param bool $skipMeta Skip aggregate message groups |
488 | * @return MessageGroup[] |
489 | * @since 2012-02-13 |
490 | */ |
491 | public static function getGroupsById( array $ids, bool $skipMeta = false ): array { |
492 | $groups = []; |
493 | foreach ( $ids as $id ) { |
494 | $group = self::getGroup( $id ); |
495 | |
496 | if ( $group !== null ) { |
497 | if ( $skipMeta && $group->isMeta() ) { |
498 | continue; |
499 | } else { |
500 | $groups[$id] = $group; |
501 | } |
502 | } else { |
503 | wfDebug( __METHOD__ . ": Invalid message group id: $id\n" ); |
504 | } |
505 | } |
506 | |
507 | return $groups; |
508 | } |
509 | |
510 | /** |
511 | * If the list of message group ids contains wildcards, this function will match |
512 | * them against the list of all supported message groups and return matched |
513 | * message group ids. |
514 | * @param string[]|string $ids |
515 | * @return string[] |
516 | */ |
517 | public static function expandWildcards( $ids ): array { |
518 | $all = []; |
519 | |
520 | $ids = (array)$ids; |
521 | foreach ( $ids as $index => $id ) { |
522 | // Fast path, no wildcards |
523 | if ( strcspn( $id, '*?' ) === strlen( $id ) ) { |
524 | $g = self::getGroup( $id ); |
525 | if ( $g ) { |
526 | $all[] = $g->getId(); |
527 | } |
528 | unset( $ids[$index] ); |
529 | } |
530 | } |
531 | |
532 | if ( $ids === [] ) { |
533 | return $all; |
534 | } |
535 | |
536 | // Slow path for the ones with wildcards |
537 | $matcher = new StringMatcher( '', $ids ); |
538 | foreach ( self::getAllGroups() as $id => $_ ) { |
539 | if ( $matcher->matches( $id ) ) { |
540 | $all[] = $id; |
541 | } |
542 | } |
543 | |
544 | return $all; |
545 | } |
546 | |
547 | /** Contents on these groups changes on a whim. */ |
548 | public static function getDynamicGroups(): array { |
549 | return [ |
550 | '!recent' => 'RecentMessageGroup', |
551 | '!additions' => 'RecentAdditionsMessageGroup', |
552 | '!sandbox' => 'SandboxMessageGroup', |
553 | ]; |
554 | } |
555 | |
556 | /** |
557 | * Get only groups of specific type (class). |
558 | * @phan-template T |
559 | * @param string $type Class name of wanted type |
560 | * @phan-param class-string<T> $type |
561 | * @return MessageGroup[] Map of (group ID => MessageGroupBase) |
562 | * @phan-return array<T&MessageGroup> |
563 | * @since 2012-04-30 |
564 | */ |
565 | public static function getGroupsByType( string $type ): array { |
566 | $groups = self::getAllGroups(); |
567 | foreach ( $groups as $id => $group ) { |
568 | if ( !$group instanceof $type ) { |
569 | unset( $groups[$id] ); |
570 | } |
571 | } |
572 | |
573 | // @phan-suppress-next-line PhanTypeMismatchReturn |
574 | return $groups; |
575 | } |
576 | |
577 | /** |
578 | * Returns a tree of message groups. First group in each subgroup is |
579 | * the aggregate group. Groups can be nested infinitely, though in practice |
580 | * other code might not handle more than two (or even one) nesting levels. |
581 | * One group can exist multiple times in different parts of the tree. |
582 | * In other words: [Group1, Group2, [AggGroup, Group3, Group4]] |
583 | * |
584 | * @return array Map of (group ID => MessageGroup or recursive array) |
585 | */ |
586 | public static function getGroupStructure(): array { |
587 | $groups = self::getAllGroups(); |
588 | |
589 | // Determine the top level groups of the tree |
590 | $tree = $groups; |
591 | foreach ( $groups as $id => $o ) { |
592 | if ( !$o->exists() ) { |
593 | unset( $groups[$id], $tree[$id] ); |
594 | continue; |
595 | } |
596 | |
597 | if ( $o instanceof AggregateMessageGroup ) { |
598 | foreach ( $o->getGroups() as $sid => $so ) { |
599 | unset( $tree[$sid] ); |
600 | } |
601 | } |
602 | } |
603 | |
604 | usort( $tree, [ self::class, 'groupLabelSort' ] ); |
605 | |
606 | /* Now we have two things left in $tree array: |
607 | * - solitaries: top-level non-aggregate message groups |
608 | * - top-level aggregate message groups */ |
609 | foreach ( $tree as $index => $group ) { |
610 | if ( $group instanceof AggregateMessageGroup ) { |
611 | $tree[$index] = self::subGroups( $group ); |
612 | } |
613 | } |
614 | |
615 | /* Essentially we are done now. Cyclic groups can cause part of the |
616 | * groups not be included at all, because they have all unset each |
617 | * other in the first loop. So now we check if there are groups left |
618 | * over. */ |
619 | $used = []; |
620 | array_walk_recursive( |
621 | $tree, |
622 | static function ( MessageGroup $group ) use ( &$used ) { |
623 | $used[$group->getId()] = true; |
624 | } |
625 | ); |
626 | $unused = array_diff_key( $groups, $used ); |
627 | if ( $unused ) { |
628 | foreach ( $unused as $index => $group ) { |
629 | if ( !$group instanceof AggregateMessageGroup ) { |
630 | unset( $unused[$index] ); |
631 | } |
632 | } |
633 | |
634 | // Only list the aggregate groups, other groups cannot cause cycles |
635 | $participants = implode( ', ', array_keys( $unused ) ); |
636 | throw new RuntimeException( "Found cyclic aggregate message groups: $participants" ); |
637 | } |
638 | |
639 | return $tree; |
640 | } |
641 | |
642 | /** Sorts groups by label value */ |
643 | public static function groupLabelSort( MessageGroup $a, MessageGroup $b ): int { |
644 | $al = $a->getLabel(); |
645 | $bl = $b->getLabel(); |
646 | |
647 | return strcasecmp( $al, $bl ); |
648 | } |
649 | |
650 | /** |
651 | * Like getGroupStructure but start from one root which must be an |
652 | * AggregateMessageGroup. |
653 | * |
654 | * @param AggregateMessageGroup $parent |
655 | * @param string[] &$childIds Flat list of child group IDs [returned] |
656 | * @param string $fname Calling method name; used to identify recursion [optional] |
657 | * @return array |
658 | */ |
659 | public static function subGroups( |
660 | AggregateMessageGroup $parent, |
661 | array &$childIds = [], |
662 | string $fname = 'caller' |
663 | ): array { |
664 | static $recursionGuard = []; |
665 | |
666 | $pid = $parent->getId(); |
667 | if ( isset( $recursionGuard[$pid] ) ) { |
668 | $tid = $pid; |
669 | $path = [ $tid ]; |
670 | do { |
671 | $tid = $recursionGuard[$tid]; |
672 | $path[] = $tid; |
673 | // Until we have gone full cycle |
674 | } while ( $tid !== $pid ); |
675 | $path = implode( ' > ', $path ); |
676 | throw new RuntimeException( "Found cyclic aggregate message groups: $path" ); |
677 | } |
678 | |
679 | // We don't care about the ids. |
680 | $tree = array_values( $parent->getGroups() ); |
681 | usort( $tree, [ self::class, 'groupLabelSort' ] ); |
682 | // Expand aggregate groups (if any left) after sorting to form a tree |
683 | foreach ( $tree as $index => $group ) { |
684 | if ( $group instanceof AggregateMessageGroup ) { |
685 | $sid = $group->getId(); |
686 | $recursionGuard[$pid] = $sid; |
687 | $tree[$index] = self::subGroups( $group, $childIds, __METHOD__ ); |
688 | unset( $recursionGuard[$pid] ); |
689 | |
690 | $childIds[$sid] = 1; |
691 | } |
692 | } |
693 | |
694 | // Parent group must be first item in the array |
695 | array_unshift( $tree, $parent ); |
696 | |
697 | if ( $fname !== __METHOD__ ) { |
698 | // Move the IDs from the keys to the value for final return |
699 | $childIds = array_values( $childIds ); |
700 | } |
701 | |
702 | return $tree; |
703 | } |
704 | |
705 | /** |
706 | * Checks whether all the message groups have the same source language. |
707 | * @param array $groups A list of message groups objects. |
708 | * @return string Language code if the languages are the same, empty string otherwise. |
709 | */ |
710 | public static function haveSingleSourceLanguage( array $groups ): string { |
711 | $seen = ''; |
712 | |
713 | foreach ( $groups as $group ) { |
714 | $language = $group->getSourceLanguage(); |
715 | if ( $seen === '' ) { |
716 | $seen = $language; |
717 | } elseif ( $language !== $seen ) { |
718 | return ''; |
719 | } |
720 | } |
721 | |
722 | return $seen; |
723 | } |
724 | |
725 | /** |
726 | * Filters out messages that should not be translated under normal |
727 | * conditions. |
728 | * |
729 | * @param MessageHandle $handle Handle for the translation target. |
730 | * @param string $targetLanguage |
731 | * @return bool |
732 | */ |
733 | public static function isTranslatableMessage( MessageHandle $handle, string $targetLanguage ): bool { |
734 | static $cache = []; |
735 | |
736 | if ( !$handle->isValid() ) { |
737 | return false; |
738 | } |
739 | |
740 | $group = $handle->getGroup(); |
741 | $groupId = $group->getId(); |
742 | $cacheKey = "$groupId:$targetLanguage"; |
743 | |
744 | if ( !isset( $cache[$cacheKey] ) ) { |
745 | $supportedLanguages = Utilities::getLanguageNames( 'en' ); |
746 | $inclusionList = $group->getTranslatableLanguages() ?? $supportedLanguages; |
747 | |
748 | $included = isset( $inclusionList[$targetLanguage] ); |
749 | $excluded = Services::getInstance()->getMessageGroupMetadata()->isExcluded( $groupId, $targetLanguage ); |
750 | |
751 | $cache[$cacheKey] = [ |
752 | 'relevant' => $included && !$excluded, |
753 | 'tags' => [], |
754 | ]; |
755 | |
756 | $groupTags = $group->getTags(); |
757 | foreach ( [ 'ignored', 'optional' ] as $tag ) { |
758 | if ( isset( $groupTags[$tag] ) ) { |
759 | foreach ( $groupTags[$tag] as $key ) { |
760 | // TODO: ucfirst should not be here |
761 | $cache[$cacheKey]['tags'][ucfirst( $key )] = true; |
762 | } |
763 | } |
764 | } |
765 | } |
766 | |
767 | return $cache[$cacheKey]['relevant'] && |
768 | !isset( $cache[$cacheKey]['tags'][ucfirst( $handle->getKey() )] ); |
769 | } |
770 | } |
771 | |
772 | class_alias( MessageGroups::class, 'MessageGroups' ); |