Merge "RCFilters: Don't load JS or redirect when transcluding"
[lhc/web/wiklou.git] / includes / changes / ChangesListFilter.php
1 <?php
2 /**
3 * Represents a filter (used on ChangesListSpecialPage and descendants)
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @license GPL 2+
22 * @author Matthew Flaschen
23 */
24
25 /**
26 * Represents a filter (used on ChangesListSpecialPage and descendants)
27 *
28 * @since 1.29
29 */
30 abstract class ChangesListFilter {
31 /**
32 * Filter name
33 *
34 * @var string $name
35 */
36 protected $name;
37
38 /**
39 * CSS class suffix used for attribution, e.g. 'bot'.
40 *
41 * In this example, if bot actions are included in the result set, this CSS class
42 * will then be included in all bot-flagged actions.
43 *
44 * @var string|null $cssClassSuffix
45 */
46 protected $cssClassSuffix;
47
48 /**
49 * Callable that returns true if and only if a row is attributed to this filter
50 *
51 * @var callable $isRowApplicableCallable
52 */
53 protected $isRowApplicableCallable;
54
55 /**
56 * Group. ChangesListFilterGroup this belongs to
57 *
58 * @var ChangesListFilterGroup $group
59 */
60 protected $group;
61
62 /**
63 * i18n key of label for structured UI
64 *
65 * @var string $label
66 */
67 protected $label;
68
69 /**
70 * i18n key of description for structured UI
71 *
72 * @var string $description
73 */
74 protected $description;
75
76 /**
77 * Array of associative arrays with conflict information. See
78 * setUnidirectionalConflict
79 *
80 * @var array $conflictingGroups
81 */
82 protected $conflictingGroups = [];
83
84 /**
85 * Array of associative arrays with conflict information. See
86 * setUnidirectionalConflict
87 *
88 * @var array $conflictingFilters
89 */
90 protected $conflictingFilters = [];
91
92 /**
93 * Array of associative arrays with subset information
94 *
95 * @var array $subsetFilters
96 */
97 protected $subsetFilters = [];
98
99 /**
100 * Priority integer. Higher value means higher up in the group's filter list.
101 *
102 * @var string $priority
103 */
104 protected $priority;
105
106 /**
107 *
108 * @var string $defaultHighlightColor
109 */
110 protected $defaultHighlightColor;
111
112 const RESERVED_NAME_CHAR = '_';
113
114 /**
115 * Creates a new filter with the specified configuration, and registers it to the
116 * specified group.
117 *
118 * It infers which UI (it can be either or both) to display the filter on based on
119 * which messages are provided.
120 *
121 * If 'label' is provided, it will be displayed on the structured UI. Thus,
122 * 'label', 'description', and sub-class parameters are optional depending on which
123 * UI it's for.
124 *
125 * @param array $filterDefinition ChangesListFilter definition
126 * * $filterDefinition['name'] string Name of filter; use lowercase with no
127 * punctuation
128 * * $filterDefinition['cssClassSuffix'] string CSS class suffix, used to mark
129 * that a particular row belongs to this filter (when a row is included by the
130 * filter) (optional)
131 * * $filterDefinition['isRowApplicableCallable'] Callable taking two parameters, the
132 * IContextSource, and the RecentChange object for the row, and returning true if
133 * the row is attributed to this filter. The above CSS class will then be
134 * automatically added (optional, required if cssClassSuffix is used).
135 * * $filterDefinition['group'] ChangesListFilterGroup Group. Filter group this
136 * belongs to.
137 * * $filterDefinition['label'] string i18n key of label for structured UI.
138 * * $filterDefinition['description'] string i18n key of description for structured
139 * UI.
140 * * $filterDefinition['priority'] int Priority integer. Higher value means higher
141 * up in the group's filter list.
142 */
143 public function __construct( array $filterDefinition ) {
144 if ( isset( $filterDefinition['group'] ) ) {
145 $this->group = $filterDefinition['group'];
146 } else {
147 throw new MWException( 'You must use \'group\' to specify the ' .
148 'ChangesListFilterGroup this filter belongs to' );
149 }
150
151 if ( strpos( $filterDefinition['name'], self::RESERVED_NAME_CHAR ) !== false ) {
152 throw new MWException( 'Filter names may not contain \'' .
153 self::RESERVED_NAME_CHAR .
154 '\'. Use the naming convention: \'lowercase\''
155 );
156 }
157
158 if ( $this->group->getFilter( $filterDefinition['name'] ) ) {
159 throw new MWException( 'Two filters in a group cannot have the ' .
160 "same name: '{$filterDefinition['name']}'" );
161 }
162
163 $this->name = $filterDefinition['name'];
164
165 if ( isset( $filterDefinition['cssClassSuffix'] ) ) {
166 $this->cssClassSuffix = $filterDefinition['cssClassSuffix'];
167 $this->isRowApplicableCallable = $filterDefinition['isRowApplicableCallable'];
168 }
169
170 if ( isset( $filterDefinition['label'] ) ) {
171 $this->label = $filterDefinition['label'];
172 $this->description = $filterDefinition['description'];
173 }
174
175 $this->priority = $filterDefinition['priority'];
176
177 $this->group->registerFilter( $this );
178 }
179
180 /**
181 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
182 *
183 * WARNING: This means there is a conflict when both things are *shown*
184 * (not filtered out), even for the hide-based filters. So e.g. conflicting with
185 * 'hideanons' means there is a conflict if only anonymous users are *shown*.
186 *
187 * @param ChangesListFilterGroup|ChangesListFilter $other Other
188 * ChangesListFilterGroup or ChangesListFilter
189 * @param string $globalKey i18n key for top-level conflict message
190 * @param string $forwardKey i18n key for conflict message in this
191 * direction (when in UI context of $this object)
192 * @param string $backwardKey i18n key for conflict message in reverse
193 * direction (when in UI context of $other object)
194 */
195 public function conflictsWith( $other, $globalKey, $forwardKey, $backwardKey ) {
196 if ( $globalKey === null || $forwardKey === null || $backwardKey === null ) {
197 throw new MWException( 'All messages must be specified' );
198 }
199
200 $this->setUnidirectionalConflict(
201 $other,
202 $globalKey,
203 $forwardKey
204 );
205
206 $other->setUnidirectionalConflict(
207 $this,
208 $globalKey,
209 $backwardKey
210 );
211 }
212
213 /**
214 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
215 * this object.
216 *
217 * Internal use ONLY.
218 *
219 * @param ChangesListFilterGroup|ChangesListFilter $other Other
220 * ChangesListFilterGroup or ChangesListFilter
221 * @param string $globalDescription i18n key for top-level conflict message
222 * @param string $contextDescription i18n key for conflict message in this
223 * direction (when in UI context of $this object)
224 */
225 public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
226 if ( $other instanceof ChangesListFilterGroup ) {
227 $this->conflictingGroups[] = [
228 'group' => $other->getName(),
229 'groupObject' => $other,
230 'globalDescription' => $globalDescription,
231 'contextDescription' => $contextDescription,
232 ];
233 } elseif ( $other instanceof ChangesListFilter ) {
234 $this->conflictingFilters[] = [
235 'group' => $other->getGroup()->getName(),
236 'filter' => $other->getName(),
237 'filterObject' => $other,
238 'globalDescription' => $globalDescription,
239 'contextDescription' => $contextDescription,
240 ];
241 } else {
242 throw new MWException( 'You can only pass in a ChangesListFilterGroup or a ChangesListFilter' );
243 }
244 }
245
246 /**
247 * Marks that the current instance is (also) a superset of the filter passed in.
248 * This can be called more than once.
249 *
250 * This means that anything in the results for the other filter is also in the
251 * results for this one.
252 *
253 * @param ChangesListFilter $other The filter the current instance is a superset of
254 */
255 public function setAsSupersetOf( ChangesListFilter $other ) {
256 if ( $other->getGroup() !== $this->getGroup() ) {
257 throw new MWException( 'Supersets can only be defined for filters in the same group' );
258 }
259
260 $this->subsetFilters[] = [
261 // It's always the same group, but this makes the representation
262 // more consistent with conflicts.
263 'group' => $other->getGroup()->getName(),
264 'filter' => $other->getName(),
265 ];
266 }
267
268 /**
269 * @return string Name, e.g. hideanons
270 */
271 public function getName() {
272 return $this->name;
273 }
274
275 /**
276 * @return ChangesListFilterGroup Group this belongs to
277 */
278 public function getGroup() {
279 return $this->group;
280 }
281
282 /**
283 * @return string i18n key of label for structured UI
284 */
285 public function getLabel() {
286 return $this->label;
287 }
288
289 /**
290 * @return string i18n key of description for structured UI
291 */
292 public function getDescription() {
293 return $this->description;
294 }
295
296 /**
297 * Checks whether the filter should display on the unstructured UI
298 *
299 * @return bool Whether to display
300 */
301 abstract public function displaysOnUnstructuredUi();
302
303 /**
304 * Checks whether the filter should display on the structured UI
305 * This refers to the exact filter. See also isFeatureAvailableOnStructuredUi.
306 *
307 * @return bool Whether to display
308 */
309 public function displaysOnStructuredUi() {
310 return $this->label !== null;
311 }
312
313 /**
314 * Checks whether an equivalent feature for this filter is available on the
315 * structured UI.
316 *
317 * This can either be the exact filter, or a new filter that replaces it.
318 * @return bool
319 */
320 public function isFeatureAvailableOnStructuredUi() {
321 return $this->displaysOnStructuredUi();
322 }
323
324 /**
325 * @return int Priority. Higher value means higher up in the group list
326 */
327 public function getPriority() {
328 return $this->priority;
329 }
330
331 /**
332 * Gets the CSS class
333 *
334 * @return string|null CSS class, or null if not defined
335 */
336 protected function getCssClass() {
337 if ( $this->cssClassSuffix !== null ) {
338 return ChangesList::CSS_CLASS_PREFIX . $this->cssClassSuffix;
339 } else {
340 return null;
341 }
342 }
343
344 /**
345 * Add CSS class if needed
346 *
347 * @param IContextSource $ctx Context source
348 * @param RecentChange $rc Recent changes object
349 * @param array &$classes Non-associative array of CSS class names; appended to if needed
350 */
351 public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
352 if ( $this->isRowApplicableCallable === null ) {
353 return;
354 }
355
356 if ( call_user_func( $this->isRowApplicableCallable, $ctx, $rc ) ) {
357 $classes[] = $this->getCssClass();
358 }
359 }
360
361 /**
362 * Gets the JS data required by the front-end of the structured UI
363 *
364 * @return array Associative array Data required by the front-end. messageKeys is
365 * a special top-level value, with the value being an array of the message keys to
366 * send to the client.
367 */
368 public function getJsData() {
369 $output = [
370 'name' => $this->getName(),
371 'label' => $this->getLabel(),
372 'description' => $this->getDescription(),
373 'cssClass' => $this->getCssClass(),
374 'priority' => $this->priority,
375 'subset' => $this->subsetFilters,
376 'conflicts' => [],
377 'defaultHighlightColor' => $this->defaultHighlightColor
378 ];
379
380 $output['messageKeys'] = [
381 $this->getLabel(),
382 $this->getDescription(),
383 ];
384
385 $conflicts = array_merge(
386 $this->conflictingGroups,
387 $this->conflictingFilters
388 );
389
390 foreach ( $conflicts as $conflictInfo ) {
391 unset( $conflictInfo['filterObject'] );
392 unset( $conflictInfo['groupObject'] );
393 $output['conflicts'][] = $conflictInfo;
394 array_push(
395 $output['messageKeys'],
396 $conflictInfo['globalDescription'],
397 $conflictInfo['contextDescription']
398 );
399 }
400
401 return $output;
402 }
403
404 /**
405 * Checks whether this filter is selected in the provided options
406 *
407 * @param FormOptions $opts
408 * @return bool
409 */
410 abstract public function isSelected( FormOptions $opts );
411
412 /**
413 * Get groups conflicting with this filter
414 *
415 * @return ChangesListFilterGroup[]
416 */
417 public function getConflictingGroups() {
418 return array_map(
419 function ( $conflictDesc ) {
420 return $conflictDesc[ 'groupObject' ];
421 },
422 $this->conflictingGroups
423 );
424 }
425
426 /**
427 * Get filters conflicting with this filter
428 *
429 * @return ChangesListFilter[]
430 */
431 public function getConflictingFilters() {
432 return array_map(
433 function ( $conflictDesc ) {
434 return $conflictDesc[ 'filterObject' ];
435 },
436 $this->conflictingFilters
437 );
438 }
439
440 /**
441 * Check if the conflict with a group is currently "active"
442 *
443 * @param ChangesListFilterGroup $group
444 * @param FormOptions $opts
445 * @return bool
446 */
447 public function activelyInConflictWithGroup( ChangesListFilterGroup $group, FormOptions $opts ) {
448 if ( $group->anySelected( $opts ) && $this->isSelected( $opts ) ) {
449 /** @var ChangesListFilter $siblingFilter */
450 foreach ( $this->getSiblings() as $siblingFilter ) {
451 if ( $siblingFilter->isSelected( $opts ) && !$siblingFilter->hasConflictWithGroup( $group ) ) {
452 return false;
453 }
454 }
455 return true;
456 }
457 return false;
458 }
459
460 private function hasConflictWithGroup( ChangesListFilterGroup $group ) {
461 return in_array( $group, $this->getConflictingGroups() );
462 }
463
464 /**
465 * Check if the conflict with a filter is currently "active"
466 *
467 * @param ChangesListFilter $filter
468 * @param FormOptions $opts
469 * @return bool
470 */
471 public function activelyInConflictWithFilter( ChangesListFilter $filter, FormOptions $opts ) {
472 if ( $this->isSelected( $opts ) && $filter->isSelected( $opts ) ) {
473 /** @var ChangesListFilter $siblingFilter */
474 foreach ( $this->getSiblings() as $siblingFilter ) {
475 if (
476 $siblingFilter->isSelected( $opts ) &&
477 !$siblingFilter->hasConflictWithFilter( $filter )
478 ) {
479 return false;
480 }
481 }
482 return true;
483 }
484 return false;
485 }
486
487 private function hasConflictWithFilter( ChangesListFilter $filter ) {
488 return in_array( $filter, $this->getConflictingFilters() );
489 }
490
491 /**
492 * Get filters in the same group
493 *
494 * @return ChangesListFilter[]
495 */
496 protected function getSiblings() {
497 return array_filter(
498 $this->getGroup()->getFilters(),
499 function ( $filter ) {
500 return $filter !== $this;
501 }
502 );
503 }
504
505 /**
506 * @param string $defaultHighlightColor
507 */
508 public function setDefaultHighlightColor( $defaultHighlightColor ) {
509 $this->defaultHighlightColor = $defaultHighlightColor;
510 }
511 }