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