Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.14% covered (warning)
63.14%
161 / 255
30.00% covered (danger)
30.00%
9 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageGroups
63.39% covered (warning)
63.39%
161 / 254
30.00% covered (danger)
30.00%
9 / 30
590.85
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 initGroupsFromDefinitions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 recache
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 clearProcessCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGroupLoaders
87.50% covered (warning)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
5.05
 getGroup
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 normalizeId
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 exists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 labelExists
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getAllGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPriority
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setPriority
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isDynamic
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSharedGroups
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getParentGroups
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
11.03
 singleton
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getGroupsById
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 expandWildcards
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
8.81
 getDynamicGroups
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupsByType
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupStructure
75.00% covered (warning)
75.00%
21 / 28
0.00% covered (danger)
0.00%
0 / 1
11.56
 groupLabelSort
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 subGroups
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
5.70
 haveSingleSourceLanguage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 isTranslatableMessage
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 overrideGroupsForTesting
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use AggregateMessageGroup;
7use CachedMessageGroupLoader;
8use InvalidArgumentException;
9use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
10use MediaWiki\Extension\Translate\MessageProcessing\StringMatcher;
11use MediaWiki\Extension\Translate\Services;
12use MediaWiki\Extension\Translate\Utilities\Utilities;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Title\Title;
15use MessageGroup;
16use MessageGroupLoader;
17use RuntimeException;
18use 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 */
30class 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
685class_alias( MessageGroups::class, 'MessageGroups' );