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