Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.98% covered (warning)
50.98%
52 / 102
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListFilterGroupContainer
50.98% covered (warning)
50.98%
52 / 102
55.56% covered (warning)
55.56%
10 / 18
383.87
0.00% covered (danger)
0.00%
0 / 1
 getIterator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerGroup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addPendingConflict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 popPendingConflicts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setDefaults
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 fillPriority
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 areFiltersInConflict
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
14.18
 addOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 modifyLegacyQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 modifyChangesListQuery
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 fixContradictoryOptions
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
7.54
 getLegacyShowHideFilters
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getJsData
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getSubpageParams
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 applyCssClassIfNeeded
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\RecentChanges;
4
5use ArrayIterator;
6use MediaWiki\Context\IContextSource;
7use MediaWiki\Html\FormOptions;
8use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
9use MediaWiki\SpecialPage\ChangesListSpecialPage;
10use Wikimedia\Rdbms\IReadableDatabase;
11
12/**
13 * A container holding changes list filter groups. Helps ChangesListSpecialPage
14 * to iterate over all groups. Provides strongly typed accessors.
15 *
16 * @internal
17 * @ingroup RecentChanges
18 */
19class ChangesListFilterGroupContainer implements \IteratorAggregate {
20    /**
21     * Filter groups, and their contained filters
22     * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
23     *
24     * @var ChangesListFilterGroup[]
25     */
26    private $filterGroups = [];
27
28    /**
29     * Pending conflicts indexed by group name and filter name
30     *
31     * @var array<string,array<string,array<int,array{0:ChangesListFilter,1:array}>>>
32     */
33    private $pendingConflicts = [];
34
35    /**
36     * The priority of the last inserted group
37     *
38     * @var int
39     */
40    private $autoPriority = 0;
41
42    /**
43     * Iterate over defined filter groups.
44     *
45     * This is mostly for b/c with the FetchChangesList hook. When core needs
46     * to iterate over filter groups, there are usually specific wrapper
47     * functions.
48     *
49     * @return ArrayIterator<ChangesListFilterGroup>
50     */
51    public function getIterator(): ArrayIterator {
52        return new ArrayIterator( $this->filterGroups );
53    }
54
55    /**
56     * Get the filter groups as an associative array. This can be removed when
57     * ChangesListSpecialPage::getFilterGroups() is removed.
58     * @return ChangesListFilterGroup[]
59     */
60    public function toArray(): array {
61        return $this->filterGroups;
62    }
63
64    /**
65     * Gets a specified ChangesListFilterGroup by name
66     *
67     * @param string $name Name of group
68     * @return ChangesListFilterGroup|null Group, or null if not registered
69     */
70    public function getGroup( $name ) {
71        return $this->filterGroups[$name] ?? null;
72    }
73
74    /**
75     * Check if a group with a specific name is registered
76     *
77     * @param string $name
78     * @return bool
79     */
80    public function hasGroup( string $name ): bool {
81        return isset( $this->filterGroups[$name] );
82    }
83
84    /**
85     * Register a structured changes list filter group
86     */
87    public function registerGroup( ChangesListFilterGroup $group ) {
88        $groupName = $group->getName();
89
90        $this->filterGroups[$groupName] = $group;
91    }
92
93    /**
94     * Register a conflict where the source exists but the destination doesn't
95     * exist yet.
96     *
97     * @param ChangesListFilter $sourceFilter
98     * @param string $conflictingGroupName
99     * @param string $conflictingFilterName
100     * @param array $opts
101     */
102    public function addPendingConflict(
103        $sourceFilter,
104        $conflictingGroupName,
105        $conflictingFilterName,
106        $opts
107    ) {
108        $this->pendingConflicts[$conflictingGroupName][$conflictingFilterName][]
109            = [ $sourceFilter, $opts ];
110    }
111
112    /**
113     * Get any pending conflicts for the specified filter which is in the
114     * specified group, and remove the conflicts from the container.
115     *
116     * @param ChangesListFilterGroup $group
117     * @param ChangesListFilter $filter
118     * @return iterable<array{0:ChangesListFilter,1:array}>
119     */
120    public function popPendingConflicts(
121        ChangesListFilterGroup $group,
122        ChangesListFilter $filter
123    ) {
124        $groupName = $group->getName();
125        $filterName = $filter->getName();
126        $conflicts = $this->pendingConflicts[$groupName][$filterName] ?? [];
127        if ( $conflicts ) {
128            unset( $this->pendingConflicts[$groupName][$filterName] );
129        }
130        return $conflicts;
131    }
132
133    /**
134     * Apply a set of default overrides to the registered filters. Ignore any
135     * filters that don't exist.
136     *
137     * @param array<string,array<string,bool>|string> $defaults The key is the group name.
138     *   For string options groups, the value is a string. For boolean groups,
139     *   the value is an array mapping the filter name to the default value.
140     */
141    public function setDefaults( array $defaults ) {
142        foreach ( $defaults as $groupName => $groupDefault ) {
143            $this->getGroup( $groupName )?->setDefault( $groupDefault );
144        }
145    }
146
147    /**
148     * If a priority is passed, update the current auto-priority and return the
149     * passed value. If the priority is null, return the next auto-priority value.
150     *
151     * @param ?int $priority
152     * @return int
153     */
154    public function fillPriority( ?int $priority ) {
155        if ( $priority === null ) {
156            return --$this->autoPriority;
157        } else {
158            $this->autoPriority = $priority;
159            return $priority;
160        }
161    }
162
163    /**
164     * Check if filters are in conflict and guaranteed to return no results.
165     *
166     * @return bool
167     */
168    public function areFiltersInConflict( FormOptions $opts ) {
169        foreach ( $this->filterGroups as $group ) {
170            if ( $group->getConflictingGroups() ) {
171                wfLogWarning(
172                    $group->getName() .
173                    " specifies conflicts with other groups but these are not supported yet."
174                );
175            }
176
177            foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
178                if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
179                    return true;
180                }
181            }
182
183            foreach ( $group->getFilters() as $filter ) {
184                foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
185                    if (
186                        $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
187                        $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
188                    ) {
189                        return true;
190                    }
191                }
192
193            }
194
195        }
196        return false;
197    }
198
199    /**
200     * Add all the options represented by registered filter groups to $opts
201     *
202     * @param FormOptions $opts
203     * @param bool $allowDefaults
204     * @param bool $isStructuredFiltersEnabled
205     */
206    public function addOptions( FormOptions $opts, $allowDefaults, $isStructuredFiltersEnabled ) {
207        foreach ( $this->filterGroups as $filterGroup ) {
208            $filterGroup->addOptions( $opts, $allowDefaults, $isStructuredFiltersEnabled );
209        }
210    }
211
212    /**
213     * Modifies the query according to the current filter groups.
214     *
215     * The modification is only done if the filter group is in effect.  This means that
216     * one or more valid and allowed filters were selected.
217     *
218     * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
219     * @param ChangesListSpecialPage $specialPage Current special page
220     * @param array &$tables Array of tables; see IDatabase::select $table
221     * @param array &$fields Array of fields; see IDatabase::select $vars
222     * @param array &$conds Array of conditions; see IDatabase::select $conds
223     * @param array &$query_options Array of query options; see IDatabase::select $options
224     * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
225     * @param FormOptions $opts Wrapper for the current request options and their defaults
226     * @param bool $isStructuredFiltersEnabled True if the Structured UI is currently enabled
227     */
228    public function modifyLegacyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
229        &$tables, &$fields, &$conds, &$query_options, &$join_conds,
230        FormOptions $opts, $isStructuredFiltersEnabled
231    ) {
232        foreach ( $this->filterGroups as $filterGroup ) {
233            $filterGroup->modifyQuery( $dbr, $specialPage, $tables, $fields, $conds,
234                $query_options, $join_conds, $opts, $isStructuredFiltersEnabled );
235        }
236    }
237
238    /**
239     * Modifies the query according to the current filter groups
240     *
241     * The modification is only done if the filter group is in effect.  This means that
242     * one or more valid and allowed filters were selected.
243     *
244     * @param ChangesListQuery $query
245     * @param FormOptions $opts
246     * @param bool $isStructuredFiltersEnabled
247     */
248    public function modifyChangesListQuery(
249        ChangesListQuery $query,
250        FormOptions $opts,
251        $isStructuredFiltersEnabled
252    ) {
253        if ( $this->areFiltersInConflict( $opts ) ) {
254            $query->forceEmptySet();
255            return;
256        }
257        foreach ( $this->filterGroups as $filterGroup ) {
258            $filterGroup->modifyChangesListQuery( $query, $opts, $isStructuredFiltersEnabled );
259        }
260    }
261
262    /**
263     * Fix invalid options by resetting pairs that should never appear together.
264     *
265     * @param FormOptions $opts
266     * @return bool True if any option was reset
267     */
268    public function fixContradictoryOptions( FormOptions $opts ) {
269        $fixed = false;
270        foreach ( $this->filterGroups as $filterGroup ) {
271            if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
272                $filters = $filterGroup->getFilters();
273
274                if ( count( $filters ) === 1 ) {
275                    // legacy boolean filters should not be considered
276                    continue;
277                }
278
279                $allInGroupEnabled = array_reduce(
280                    $filters,
281                    static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) {
282                        return $carry && $opts[ $filter->getName() ];
283                    },
284                    /* initialValue */ count( $filters ) > 0
285                );
286
287                if ( $allInGroupEnabled ) {
288                    foreach ( $filters as $filter ) {
289                        $opts[ $filter->getName() ] = false;
290                    }
291
292                    $fixed = true;
293                }
294            }
295        }
296        return $fixed;
297    }
298
299    /**
300     * Get the boolean filters which are displayed on the unstructured UI, with
301     * the filter name in the key.
302     *
303     * @return array<string,ChangesListBooleanFilter>
304     */
305    public function getLegacyShowHideFilters() {
306        $filters = [];
307        foreach ( $this->filterGroups as $group ) {
308            if ( $group instanceof ChangesListBooleanFilterGroup ) {
309                foreach ( $group->getFilters() as $key => $filter ) {
310                    if ( $filter->displaysOnUnstructuredUi() ) {
311                        $filters[ $key ] = $filter;
312                    }
313                }
314            }
315        }
316        return $filters;
317    }
318
319    /**
320     * Gets structured filter information needed by JS
321     *
322     * Currently, this intentionally only includes filters that display
323     * in the structured UI.  This can be changed easily, though, if we want
324     * to include data on filters that use the unstructured UI.  messageKeys is a
325     * special top-level value, with the value being an array of the message keys to
326     * send to the client.
327     *
328     * @return array Associative array
329     *   - array $return['groups'] Group data
330     *   - array $return['messageKeys'] Array of message keys
331     */
332    public function getJsData() {
333        $output = [
334            'groups' => [],
335            'messageKeys' => [],
336        ];
337
338        usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
339            return $b->getPriority() <=> $a->getPriority();
340        } );
341
342        foreach ( $this->filterGroups as $group ) {
343            $groupOutput = $group->getJsData();
344            if ( $groupOutput !== null ) {
345                $output['messageKeys'] = array_merge(
346                    $output['messageKeys'],
347                    $groupOutput['messageKeys']
348                );
349
350                unset( $groupOutput['messageKeys'] );
351                $output['groups'][] = $groupOutput;
352            }
353        }
354
355        return $output;
356    }
357
358    /**
359     * Get the parameters which can be set via the subpage
360     *
361     * @return array<string,string> A map of the parameter name to its type,
362     *   which can be either "bool" or "string".
363     */
364    public function getSubpageParams() {
365        // URL parameters can be per-group, like 'userExpLevel',
366        // or per-filter, like 'hideminor'.
367        $params = [];
368        foreach ( $this->filterGroups as $filterGroup ) {
369            if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
370                $params[$filterGroup->getName()] = 'string';
371            } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
372                foreach ( $filterGroup->getFilters() as $filter ) {
373                    $params[$filter->getName()] = 'bool';
374                }
375            }
376        }
377        return $params;
378    }
379
380    /**
381     * Add any necessary CSS classes
382     *
383     * @param IContextSource $ctx Context source
384     * @param RecentChange $rc Recent changes object
385     * @param array &$classes Non-associative array of CSS class names; appended to if needed
386     */
387    public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
388        foreach ( $this->filterGroups as $groupName => $filterGroup ) {
389            foreach ( $filterGroup->getFilters() as $filterName => $filter ) {
390                // New system
391                if ( $rc->isHighlighted( "$groupName/$filterName" ) ) {
392                    $class = $filter->getCssClass();
393                    if ( $class !== null ) {
394                        $classes[] = $class;
395                    }
396                }
397                // Old system
398                $filter->applyCssClassIfNeeded( $ctx, $rc, $classes );
399            }
400        }
401    }
402
403}