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