Merge "mediawiki.requestIdleCallback: Re-enable use of native requestIdleCallback"
[lhc/web/wiklou.git] / includes / specialpage / ChangesListSpecialPage.php
1 <?php
2 /**
3 * Special page which uses a ChangesList to show query results.
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 * @ingroup SpecialPage
22 */
23 use MediaWiki\Logger\LoggerFactory;
24
25 /**
26 * Special page which uses a ChangesList to show query results.
27 * @todo Way too many public functions, most of them should be protected
28 *
29 * @ingroup SpecialPage
30 */
31 abstract class ChangesListSpecialPage extends SpecialPage {
32 /** @var string */
33 protected $rcSubpage;
34
35 /** @var FormOptions */
36 protected $rcOptions;
37
38 /** @var array */
39 protected $customFilters;
40
41 /**
42 * Main execution point
43 *
44 * @param string $subpage
45 */
46 public function execute( $subpage ) {
47 $this->rcSubpage = $subpage;
48
49 $this->setHeaders();
50 $this->outputHeader();
51 $this->addModules();
52
53 $rows = $this->getRows();
54 $opts = $this->getOptions();
55 if ( $rows === false ) {
56 if ( !$this->including() ) {
57 $this->doHeader( $opts, 0 );
58 $this->getOutput()->setStatusCode( 404 );
59 }
60
61 return;
62 }
63
64 $batch = new LinkBatch;
65 foreach ( $rows as $row ) {
66 $batch->add( NS_USER, $row->rc_user_text );
67 $batch->add( NS_USER_TALK, $row->rc_user_text );
68 $batch->add( $row->rc_namespace, $row->rc_title );
69 if ( $row->rc_source === RecentChange::SRC_LOG ) {
70 $formatter = LogFormatter::newFromRow( $row );
71 foreach ( $formatter->getPreloadTitles() as $title ) {
72 $batch->addObj( $title );
73 }
74 }
75 }
76 $batch->execute();
77
78 $this->webOutput( $rows, $opts );
79
80 $rows->free();
81
82 if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
83 // Clean up any bad page entries for titles showing up in RC
84 DeferredUpdates::addUpdate( new WANCacheReapUpdate(
85 $this->getDB(),
86 LoggerFactory::getInstance( 'objectcache' )
87 ) );
88 }
89 }
90
91 /**
92 * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
93 *
94 * @return bool|ResultWrapper Result or false
95 */
96 public function getRows() {
97 $opts = $this->getOptions();
98 $conds = $this->buildMainQueryConds( $opts );
99
100 return $this->doMainQuery( $conds, $opts );
101 }
102
103 /**
104 * Get the current FormOptions for this request
105 *
106 * @return FormOptions
107 */
108 public function getOptions() {
109 if ( $this->rcOptions === null ) {
110 $this->rcOptions = $this->setup( $this->rcSubpage );
111 }
112
113 return $this->rcOptions;
114 }
115
116 /**
117 * Create a FormOptions object with options as specified by the user
118 *
119 * @param array $parameters
120 *
121 * @return FormOptions
122 */
123 public function setup( $parameters ) {
124 $opts = $this->getDefaultOptions();
125 foreach ( $this->getCustomFilters() as $key => $params ) {
126 $opts->add( $key, $params['default'] );
127 }
128
129 $opts = $this->fetchOptionsFromRequest( $opts );
130
131 // Give precedence to subpage syntax
132 if ( $parameters !== null ) {
133 $this->parseParameters( $parameters, $opts );
134 }
135
136 $this->validateOptions( $opts );
137
138 return $opts;
139 }
140
141 /**
142 * Get a FormOptions object containing the default options. By default returns some basic options,
143 * you might want to not call parent method and discard them, or to override default values.
144 *
145 * @return FormOptions
146 */
147 public function getDefaultOptions() {
148 $config = $this->getConfig();
149 $opts = new FormOptions();
150
151 $opts->add( 'hideminor', false );
152 $opts->add( 'hidemajor', false );
153 $opts->add( 'hidebots', false );
154 $opts->add( 'hidehumans', false );
155 $opts->add( 'hideanons', false );
156 $opts->add( 'hideliu', false );
157 $opts->add( 'hidepatrolled', false );
158 $opts->add( 'hideunpatrolled', false );
159 $opts->add( 'hidemyself', false );
160 $opts->add( 'hidebyothers', false );
161
162 if ( $config->get( 'RCWatchCategoryMembership' ) ) {
163 $opts->add( 'hidecategorization', false );
164 }
165 $opts->add( 'hidepageedits', false );
166 $opts->add( 'hidenewpages', false );
167 $opts->add( 'hidelog', false );
168
169 $opts->add( 'namespace', '', FormOptions::INTNULL );
170 $opts->add( 'invert', false );
171 $opts->add( 'associated', false );
172
173 return $opts;
174 }
175
176 /**
177 * Get custom show/hide filters
178 *
179 * @return array Map of filter URL param names to properties (msg/default)
180 */
181 protected function getCustomFilters() {
182 if ( $this->customFilters === null ) {
183 $this->customFilters = [];
184 Hooks::run( 'ChangesListSpecialPageFilters', [ $this, &$this->customFilters ] );
185 }
186
187 return $this->customFilters;
188 }
189
190 /**
191 * Fetch values for a FormOptions object from the WebRequest associated with this instance.
192 *
193 * Intended for subclassing, e.g. to add a backwards-compatibility layer.
194 *
195 * @param FormOptions $opts
196 * @return FormOptions
197 */
198 protected function fetchOptionsFromRequest( $opts ) {
199 $opts->fetchValuesFromRequest( $this->getRequest() );
200
201 return $opts;
202 }
203
204 /**
205 * Process $par and put options found in $opts. Used when including the page.
206 *
207 * @param string $par
208 * @param FormOptions $opts
209 */
210 public function parseParameters( $par, FormOptions $opts ) {
211 // nothing by default
212 }
213
214 /**
215 * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
216 *
217 * @param FormOptions $opts
218 */
219 public function validateOptions( FormOptions $opts ) {
220 // nothing by default
221 }
222
223 /**
224 * Return an array of conditions depending of options set in $opts
225 *
226 * @param FormOptions $opts
227 * @return array
228 */
229 public function buildMainQueryConds( FormOptions $opts ) {
230 $dbr = $this->getDB();
231 $user = $this->getUser();
232 $conds = [];
233
234 // It makes no sense to hide both anons and logged-in users. When this occurs, try a guess on
235 // what the user meant and either show only bots or force anons to be shown.
236 $botsonly = false;
237 $hideanons = $opts['hideanons'];
238 if ( $opts['hideanons'] && $opts['hideliu'] ) {
239 if ( $opts['hidebots'] ) {
240 $hideanons = false;
241 } else {
242 $botsonly = true;
243 }
244 }
245
246 // Toggles
247 if ( $opts['hideminor'] ) {
248 $conds[] = 'rc_minor = 0';
249 }
250 if ( $opts['hidemajor'] ) {
251 $conds[] = 'rc_minor = 1';
252 }
253 if ( $opts['hidebots'] ) {
254 $conds['rc_bot'] = 0;
255 }
256 if ( $opts['hidehumans'] ) {
257 $conds[] = 'rc_bot = 1';
258 }
259 if ( $user->useRCPatrol() ) {
260 if ( $opts['hidepatrolled'] ) {
261 $conds[] = 'rc_patrolled = 0';
262 }
263 if ( $opts['hideunpatrolled'] ) {
264 $conds[] = 'rc_patrolled = 1';
265 }
266 }
267 if ( $botsonly ) {
268 $conds['rc_bot'] = 1;
269 } else {
270 if ( $opts['hideliu'] ) {
271 $conds[] = 'rc_user = 0';
272 }
273 if ( $hideanons ) {
274 $conds[] = 'rc_user != 0';
275 }
276 }
277
278 if ( $opts['hidemyself'] ) {
279 if ( $user->getId() ) {
280 $conds[] = 'rc_user != ' . $dbr->addQuotes( $user->getId() );
281 } else {
282 $conds[] = 'rc_user_text != ' . $dbr->addQuotes( $user->getName() );
283 }
284 }
285 if ( $opts['hidebyothers'] ) {
286 if ( $user->getId() ) {
287 $conds[] = 'rc_user = ' . $dbr->addQuotes( $user->getId() );
288 } else {
289 $conds[] = 'rc_user_text = ' . $dbr->addQuotes( $user->getName() );
290 }
291 }
292
293 if ( $this->getConfig()->get( 'RCWatchCategoryMembership' )
294 && $opts['hidecategorization'] === true
295 ) {
296 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
297 }
298 if ( $opts['hidepageedits'] ) {
299 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
300 }
301 if ( $opts['hidenewpages'] ) {
302 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
303 }
304 if ( $opts['hidelog'] ) {
305 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
306 }
307
308 // Namespace filtering
309 if ( $opts['namespace'] !== '' ) {
310 $selectedNS = $dbr->addQuotes( $opts['namespace'] );
311 $operator = $opts['invert'] ? '!=' : '=';
312 $boolean = $opts['invert'] ? 'AND' : 'OR';
313
314 // Namespace association (T4429)
315 if ( !$opts['associated'] ) {
316 $condition = "rc_namespace $operator $selectedNS";
317 } else {
318 // Also add the associated namespace
319 $associatedNS = $dbr->addQuotes(
320 MWNamespace::getAssociated( $opts['namespace'] )
321 );
322 $condition = "(rc_namespace $operator $selectedNS "
323 . $boolean
324 . " rc_namespace $operator $associatedNS)";
325 }
326
327 $conds[] = $condition;
328 }
329
330 return $conds;
331 }
332
333 /**
334 * Process the query
335 *
336 * @param array $conds
337 * @param FormOptions $opts
338 * @return bool|ResultWrapper Result or false
339 */
340 public function doMainQuery( $conds, $opts ) {
341 $tables = [ 'recentchanges' ];
342 $fields = RecentChange::selectFields();
343 $query_options = [];
344 $join_conds = [];
345
346 ChangeTags::modifyDisplayQuery(
347 $tables,
348 $fields,
349 $conds,
350 $join_conds,
351 $query_options,
352 ''
353 );
354
355 if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
356 $opts )
357 ) {
358 return false;
359 }
360
361 $dbr = $this->getDB();
362
363 return $dbr->select(
364 $tables,
365 $fields,
366 $conds,
367 __METHOD__,
368 $query_options,
369 $join_conds
370 );
371 }
372
373 protected function runMainQueryHook( &$tables, &$fields, &$conds,
374 &$query_options, &$join_conds, $opts
375 ) {
376 return Hooks::run(
377 'ChangesListSpecialPageQuery',
378 [ $this->getName(), &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts ]
379 );
380 }
381
382 /**
383 * Return a IDatabase object for reading
384 *
385 * @return IDatabase
386 */
387 protected function getDB() {
388 return wfGetDB( DB_REPLICA );
389 }
390
391 /**
392 * Send output to the OutputPage object, only called if not used feeds
393 *
394 * @param ResultWrapper $rows Database rows
395 * @param FormOptions $opts
396 */
397 public function webOutput( $rows, $opts ) {
398 if ( !$this->including() ) {
399 $this->outputFeedLinks();
400 $this->doHeader( $opts, $rows->numRows() );
401 }
402
403 $this->outputChangesList( $rows, $opts );
404 }
405
406 /**
407 * Output feed links.
408 */
409 public function outputFeedLinks() {
410 // nothing by default
411 }
412
413 /**
414 * Build and output the actual changes list.
415 *
416 * @param ResultWrapper $rows Database rows
417 * @param FormOptions $opts
418 */
419 abstract public function outputChangesList( $rows, $opts );
420
421 /**
422 * Set the text to be displayed above the changes
423 *
424 * @param FormOptions $opts
425 * @param int $numRows Number of rows in the result to show after this header
426 */
427 public function doHeader( $opts, $numRows ) {
428 $this->setTopText( $opts );
429
430 // @todo Lots of stuff should be done here.
431
432 $this->setBottomText( $opts );
433 }
434
435 /**
436 * Send the text to be displayed before the options. Should use $this->getOutput()->addWikiText()
437 * or similar methods to print the text.
438 *
439 * @param FormOptions $opts
440 */
441 public function setTopText( FormOptions $opts ) {
442 // nothing by default
443 }
444
445 /**
446 * Send the text to be displayed after the options. Should use $this->getOutput()->addWikiText()
447 * or similar methods to print the text.
448 *
449 * @param FormOptions $opts
450 */
451 public function setBottomText( FormOptions $opts ) {
452 // nothing by default
453 }
454
455 /**
456 * Get options to be displayed in a form
457 * @todo This should handle options returned by getDefaultOptions().
458 * @todo Not called by anything, should be called by something… doHeader() maybe?
459 *
460 * @param FormOptions $opts
461 * @return array
462 */
463 public function getExtraOptions( $opts ) {
464 return [];
465 }
466
467 /**
468 * Return the legend displayed within the fieldset
469 *
470 * @return string
471 */
472 public function makeLegend() {
473 $context = $this->getContext();
474 $user = $context->getUser();
475 # The legend showing what the letters and stuff mean
476 $legend = Html::openElement( 'dl' ) . "\n";
477 # Iterates through them and gets the messages for both letter and tooltip
478 $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
479 if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
480 unset( $legendItems['unpatrolled'] );
481 }
482 foreach ( $legendItems as $key => $item ) { # generate items of the legend
483 $label = isset( $item['legend'] ) ? $item['legend'] : $item['title'];
484 $letter = $item['letter'];
485 $cssClass = isset( $item['class'] ) ? $item['class'] : $key;
486
487 $legend .= Html::element( 'dt',
488 [ 'class' => $cssClass ], $context->msg( $letter )->text()
489 ) . "\n" .
490 Html::rawElement( 'dd',
491 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
492 $context->msg( $label )->parse()
493 ) . "\n";
494 }
495 # (+-123)
496 $legend .= Html::rawElement( 'dt',
497 [ 'class' => 'mw-plusminus-pos' ],
498 $context->msg( 'recentchanges-legend-plusminus' )->parse()
499 ) . "\n";
500 $legend .= Html::element(
501 'dd',
502 [ 'class' => 'mw-changeslist-legend-plusminus' ],
503 $context->msg( 'recentchanges-label-plusminus' )->text()
504 ) . "\n";
505 $legend .= Html::closeElement( 'dl' ) . "\n";
506
507 # Collapsibility
508 $legend =
509 '<div class="mw-changeslist-legend">' .
510 $context->msg( 'recentchanges-legend-heading' )->parse() .
511 '<div class="mw-collapsible-content">' . $legend . '</div>' .
512 '</div>';
513
514 return $legend;
515 }
516
517 /**
518 * Add page-specific modules.
519 */
520 protected function addModules() {
521 $out = $this->getOutput();
522 // Styles and behavior for the legend box (see makeLegend())
523 $out->addModuleStyles( [
524 'mediawiki.special.changeslist.legend',
525 'mediawiki.special.changeslist',
526 ] );
527 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
528 }
529
530 protected function getGroupName() {
531 return 'changes';
532 }
533
534 /**
535 * Get filters that can be rendered.
536 *
537 * Filters with 'msg' => false can be used to filter data but won't
538 * be presented as show/hide toggles in the UI. They are not returned
539 * by this function.
540 *
541 * @param array $allFilters Map of filter URL param names to properties (msg/default)
542 * @return array Map of filter URL param names to properties (msg/default)
543 */
544 protected function getRenderableCustomFilters( $allFilters ) {
545 return array_filter(
546 $allFilters,
547 function( $filter ) {
548 return isset( $filter['msg'] ) && ( $filter['msg'] !== false );
549 }
550 );
551 }
552 }