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