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