Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.23% covered (warning)
63.23%
184 / 291
33.33% covered (danger)
33.33%
11 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageGroups
63.45% covered (warning)
63.45%
184 / 290
33.33% covered (danger)
33.33%
11 / 33
654.70
0.00% covered (danger)
0.00%
0 / 1
 init
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 getCachedGroupDefinitions
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
4
 initGroupsFromDefinitions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 recache
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 clearCache
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 appendAutoloader
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
13.26
 getGroupLoaders
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
4.18
 getCacheGroupLoaders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 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 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 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
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use AggregateMessageGroup;
7use AggregateMessageGroupLoader;
8use CachedMessageGroupLoader;
9use DependencyWrapper;
10use InvalidArgumentException;
11use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
12use MediaWiki\Extension\Translate\MessageProcessing\StringMatcher;
13use MediaWiki\Extension\Translate\Services;
14use MediaWiki\Extension\Translate\Utilities\Utilities;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Title\Title;
17use MessageGroup;
18use MessageGroupBase;
19use MessageGroupLoader;
20use RuntimeException;
21use 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 */
33class 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
772class_alias( MessageGroups::class, 'MessageGroups' );