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