* (bug 32505) Incorrect spacing on messages for Special:EditWatchlist/raw results
[lhc/web/wiklou.git] / includes / specials / SpecialEditWatchlist.php
1 <?php
2
3 /**
4 * Provides the UI through which users can perform editing
5 * operations on their watchlist
6 *
7 * @ingroup Watchlist
8 * @author Rob Church <robchur@gmail.com>
9 */
10 class SpecialEditWatchlist extends UnlistedSpecialPage {
11
12 /**
13 * Editing modes
14 */
15 const EDIT_CLEAR = 1;
16 const EDIT_RAW = 2;
17 const EDIT_NORMAL = 3;
18
19 protected $successMessage;
20
21 protected $toc;
22
23 public function __construct(){
24 parent::__construct( 'EditWatchlist' );
25 }
26
27 /**
28 * Main execution point
29 *
30 * @param $mode int
31 */
32 public function execute( $mode ) {
33 $this->setHeaders();
34
35 $out = $this->getOutput();
36
37 # Anons don't get a watchlist
38 if( $this->getUser()->isAnon() ) {
39 $out->setPageTitle( $this->msg( 'watchnologin' ) );
40 $llink = Linker::linkKnown(
41 SpecialPage::getTitleFor( 'Userlogin' ),
42 wfMsgHtml( 'loginreqlink' ),
43 array(),
44 array( 'returnto' => $this->getTitle()->getPrefixedText() )
45 );
46 $out->addHTML( wfMessage( 'watchlistanontext' )->rawParams( $llink )->parse() );
47 return;
48 }
49
50 $this->checkPermissions();
51
52 $this->outputHeader();
53
54 $out->addSubtitle( $this->msg( 'watchlistfor2', $this->getUser()->getName()
55 )->rawParams( SpecialEditWatchlist::buildTools( null ) ) );
56
57 # B/C: $mode used to be waaay down the parameter list, and the first parameter
58 # was $wgUser
59 if( $mode instanceof User ){
60 $args = func_get_args();
61 if( count( $args >= 4 ) ){
62 $mode = $args[3];
63 }
64 }
65 $mode = self::getMode( $this->getRequest(), $mode );
66
67 switch( $mode ) {
68 case self::EDIT_CLEAR:
69 // The "Clear" link scared people too much.
70 // Pass on to the raw editor, from which it's very easy to clear.
71
72 case self::EDIT_RAW:
73 $out->setPageTitle( $this->msg( 'watchlistedit-raw-title' ) );
74 $form = $this->getRawForm();
75 if( $form->show() ){
76 $out->addHTML( $this->successMessage );
77 $out->returnToMain();
78 }
79 break;
80
81 case self::EDIT_NORMAL:
82 default:
83 $out->setPageTitle( $this->msg( 'watchlistedit-normal-title' ) );
84 $form = $this->getNormalForm();
85 if( $form->show() ){
86 $out->addHTML( $this->successMessage );
87 $out->returnToMain();
88 } elseif ( $this->toc !== false ) {
89 $out->prependHTML( $this->toc );
90 }
91 break;
92 }
93 }
94
95 /**
96 * Extract a list of titles from a blob of text, returning
97 * (prefixed) strings; unwatchable titles are ignored
98 *
99 * @param $list String
100 * @return array
101 */
102 private function extractTitles( $list ) {
103 $titles = array();
104 $list = explode( "\n", trim( $list ) );
105 if( !is_array( $list ) ) {
106 return array();
107 }
108 foreach( $list as $text ) {
109 $text = trim( $text );
110 if( strlen( $text ) > 0 ) {
111 $title = Title::newFromText( $text );
112 if( $title instanceof Title && $title->isWatchable() ) {
113 $titles[] = $title->getPrefixedText();
114 }
115 }
116 }
117 return array_unique( $titles );
118 }
119
120 public function submitRaw( $data ){
121 $wanted = $this->extractTitles( $data['Titles'] );
122 $current = $this->getWatchlist();
123
124 if( count( $wanted ) > 0 ) {
125 $toWatch = array_diff( $wanted, $current );
126 $toUnwatch = array_diff( $current, $wanted );
127 $this->watchTitles( $toWatch );
128 $this->unwatchTitles( $toUnwatch );
129 $this->getUser()->invalidateCache();
130
131 if( count( $toWatch ) > 0 || count( $toUnwatch ) > 0 ){
132 $this->successMessage = wfMessage( 'watchlistedit-raw-done' )->parse();
133 } else {
134 return false;
135 }
136
137 if( count( $toWatch ) > 0 ) {
138 $this->successMessage .= ' ' . wfMessage(
139 'watchlistedit-raw-added',
140 $this->getLang()->formatNum( count( $toWatch ) )
141 );
142 $this->showTitles( $toWatch, $this->successMessage );
143 }
144
145 if( count( $toUnwatch ) > 0 ) {
146 $this->successMessage .= ' ' . wfMessage(
147 'watchlistedit-raw-removed',
148 $this->getLang()->formatNum( count( $toUnwatch ) )
149 );
150 $this->showTitles( $toUnwatch, $this->successMessage );
151 }
152 } else {
153 $this->clearWatchlist();
154 $this->getUser()->invalidateCache();
155 $this->successMessage .= wfMessage(
156 'watchlistedit-raw-removed',
157 $this->getLang()->formatNum( count( $current ) )
158 );
159 $this->showTitles( $current, $this->successMessage );
160 }
161 return true;
162 }
163
164 /**
165 * Print out a list of linked titles
166 *
167 * $titles can be an array of strings or Title objects; the former
168 * is preferred, since Titles are very memory-heavy
169 *
170 * @param $titles array of strings, or Title objects
171 * @param $output String
172 */
173 private function showTitles( $titles, &$output ) {
174 $talk = wfMsgHtml( 'talkpagelinktext' );
175 // Do a batch existence check
176 $batch = new LinkBatch();
177 foreach( $titles as $title ) {
178 if( !$title instanceof Title ) {
179 $title = Title::newFromText( $title );
180 }
181 if( $title instanceof Title ) {
182 $batch->addObj( $title );
183 $batch->addObj( $title->getTalkPage() );
184 }
185 }
186 $batch->execute();
187 // Print out the list
188 $output .= "<ul>\n";
189 foreach( $titles as $title ) {
190 if( !$title instanceof Title ) {
191 $title = Title::newFromText( $title );
192 }
193 if( $title instanceof Title ) {
194 $output .= "<li>"
195 . Linker::link( $title )
196 . ' (' . Linker::link( $title->getTalkPage(), $talk )
197 . ")</li>\n";
198 }
199 }
200 $output .= "</ul>\n";
201 }
202
203 /**
204 * Prepare a list of titles on a user's watchlist (excluding talk pages)
205 * and return an array of (prefixed) strings
206 *
207 * @return array
208 */
209 private function getWatchlist() {
210 $list = array();
211 $dbr = wfGetDB( DB_MASTER );
212 $res = $dbr->select(
213 'watchlist',
214 '*',
215 array(
216 'wl_user' => $this->getUser()->getId(),
217 ),
218 __METHOD__
219 );
220 if( $res->numRows() > 0 ) {
221 foreach ( $res as $row ) {
222 $title = Title::makeTitleSafe( $row->wl_namespace, $row->wl_title );
223 if( $title instanceof Title && !$title->isTalkPage() )
224 $list[] = $title->getPrefixedText();
225 }
226 $res->free();
227 }
228 return $list;
229 }
230
231 /**
232 * Get a list of titles on a user's watchlist, excluding talk pages,
233 * and return as a two-dimensional array with namespace and title.
234 *
235 * @return array
236 */
237 private function getWatchlistInfo() {
238 $titles = array();
239 $dbr = wfGetDB( DB_MASTER );
240
241 $res = $dbr->select(
242 array( 'watchlist' ),
243 array( 'wl_namespace', 'wl_title' ),
244 array( 'wl_user' => $this->getUser()->getId() ),
245 __METHOD__,
246 array( 'ORDER BY' => 'wl_namespace, wl_title' )
247 );
248
249 $lb = new LinkBatch();
250 foreach ( $res as $row ) {
251 $lb->add( $row->wl_namespace, $row->wl_title );
252 if ( !MWNamespace::isTalk( $row->wl_namespace ) ) {
253 $titles[$row->wl_namespace][$row->wl_title] = 1;
254 }
255 }
256
257 $lb->execute();
258 return $titles;
259 }
260
261 /**
262 * Remove all titles from a user's watchlist
263 */
264 private function clearWatchlist() {
265 $dbw = wfGetDB( DB_MASTER );
266 $dbw->delete(
267 'watchlist',
268 array( 'wl_user' => $this->getUser()->getId() ),
269 __METHOD__
270 );
271 }
272
273 /**
274 * Add a list of titles to a user's watchlist
275 *
276 * $titles can be an array of strings or Title objects; the former
277 * is preferred, since Titles are very memory-heavy
278 *
279 * @param $titles Array of strings, or Title objects
280 */
281 private function watchTitles( $titles ) {
282 $dbw = wfGetDB( DB_MASTER );
283 $rows = array();
284 foreach( $titles as $title ) {
285 if( !$title instanceof Title ) {
286 $title = Title::newFromText( $title );
287 }
288 if( $title instanceof Title ) {
289 $rows[] = array(
290 'wl_user' => $this->getUser()->getId(),
291 'wl_namespace' => ( $title->getNamespace() & ~1 ),
292 'wl_title' => $title->getDBkey(),
293 'wl_notificationtimestamp' => null,
294 );
295 $rows[] = array(
296 'wl_user' => $this->getUser()->getId(),
297 'wl_namespace' => ( $title->getNamespace() | 1 ),
298 'wl_title' => $title->getDBkey(),
299 'wl_notificationtimestamp' => null,
300 );
301 }
302 }
303 $dbw->insert( 'watchlist', $rows, __METHOD__, 'IGNORE' );
304 }
305
306 /**
307 * Remove a list of titles from a user's watchlist
308 *
309 * $titles can be an array of strings or Title objects; the former
310 * is preferred, since Titles are very memory-heavy
311 *
312 * @param $titles Array of strings, or Title objects
313 */
314 private function unwatchTitles( $titles ) {
315 $dbw = wfGetDB( DB_MASTER );
316 foreach( $titles as $title ) {
317 if( !$title instanceof Title ) {
318 $title = Title::newFromText( $title );
319 }
320 if( $title instanceof Title ) {
321 $dbw->delete(
322 'watchlist',
323 array(
324 'wl_user' => $this->getUser()->getId(),
325 'wl_namespace' => ( $title->getNamespace() & ~1 ),
326 'wl_title' => $title->getDBkey(),
327 ),
328 __METHOD__
329 );
330 $dbw->delete(
331 'watchlist',
332 array(
333 'wl_user' => $this->getUser()->getId(),
334 'wl_namespace' => ( $title->getNamespace() | 1 ),
335 'wl_title' => $title->getDBkey(),
336 ),
337 __METHOD__
338 );
339 $article = new Article( $title, 0 );
340 wfRunHooks( 'UnwatchArticleComplete', array( $this->getUser(), &$article ) );
341 }
342 }
343 }
344
345 public function submitNormal( $data ) {
346 $removed = array();
347
348 foreach( $data as $titles ) {
349 $this->unwatchTitles( $titles );
350 $removed += $titles;
351 }
352
353 if( count( $removed ) > 0 ) {
354 $this->successMessage = wfMessage(
355 'watchlistedit-normal-done',
356 $this->getLang()->formatNum( count( $removed ) )
357 );
358 $this->showTitles( $removed, $this->successMessage );
359 return true;
360 } else {
361 return false;
362 }
363 }
364
365 /**
366 * Get the standard watchlist editing form
367 *
368 * @return HTMLForm
369 */
370 protected function getNormalForm(){
371 global $wgContLang;
372
373 $fields = array();
374 $count = 0;
375
376 $haveInvalidNamespaces = false;
377 foreach( $this->getWatchlistInfo() as $namespace => $pages ){
378 if ( $namespace < 0 ) {
379 $haveInvalidNamespaces = true;
380 continue;
381 }
382
383 $fields['TitlesNs'.$namespace] = array(
384 'class' => 'EditWatchlistCheckboxSeriesField',
385 'options' => array(),
386 'section' => "ns$namespace",
387 );
388
389 foreach( array_keys( $pages ) as $dbkey ){
390 $title = Title::makeTitleSafe( $namespace, $dbkey );
391 $text = $this->buildRemoveLine( $title );
392 $fields['TitlesNs'.$namespace]['options'][$text] = $title->getEscapedText();
393 $count++;
394 }
395 }
396 if ( $haveInvalidNamespaces ) {
397 wfDebug( "User {$this->getContext()->getUser()->getId()} has invalid watchlist entries, cleaning up...\n" );
398 $this->getContext()->getUser()->cleanupWatchlist();
399 }
400
401 if ( count( $fields ) > 1 && $count > 30 ) {
402 $this->toc = Linker::tocIndent();
403 $tocLength = 0;
404 foreach( $fields as $key => $data ) {
405
406 # strip out the 'ns' prefix from the section name:
407 $ns = substr( $data['section'], 2 );
408
409 $nsText = ($ns == NS_MAIN)
410 ? wfMsgHtml( 'blanknamespace' )
411 : htmlspecialchars( $wgContLang->getFormattedNsText( $ns ) );
412 $this->toc .= Linker::tocLine( "mw-htmlform-{$data['section']}", $nsText,
413 $this->getLang()->formatNum( ++$tocLength ), 1 ) . Linker::tocLineEnd();
414 }
415 $this->toc = Linker::tocList( $this->toc );
416 } else {
417 $this->toc = false;
418 }
419
420 $form = new EditWatchlistNormalHTMLForm( $fields, $this->getContext() );
421 $form->setTitle( $this->getTitle() );
422 $form->setSubmitText( wfMessage( 'watchlistedit-normal-submit' )->text() );
423 $form->setWrapperLegend( wfMessage( 'watchlistedit-normal-legend' )->text() );
424 $form->addHeaderText( wfMessage( 'watchlistedit-normal-explain' )->parse() );
425 $form->setSubmitCallback( array( $this, 'submitNormal' ) );
426 return $form;
427 }
428
429 /**
430 * Build the label for a checkbox, with a link to the title, and various additional bits
431 *
432 * @param $title Title
433 * @return string
434 */
435 private function buildRemoveLine( $title ) {
436 $link = Linker::link( $title );
437 if( $title->isRedirect() ) {
438 // Linker already makes class mw-redirect, so this is redundant
439 $link = '<span class="watchlistredir">' . $link . '</span>';
440 }
441 $tools[] = Linker::link( $title->getTalkPage(), wfMsgHtml( 'talkpagelinktext' ) );
442 if( $title->exists() ) {
443 $tools[] = Linker::linkKnown(
444 $title,
445 wfMsgHtml( 'history_short' ),
446 array(),
447 array( 'action' => 'history' )
448 );
449 }
450 if( $title->getNamespace() == NS_USER && !$title->isSubpage() ) {
451 $tools[] = Linker::linkKnown(
452 SpecialPage::getTitleFor( 'Contributions', $title->getText() ),
453 wfMsgHtml( 'contributions' )
454 );
455 }
456
457 wfRunHooks( 'WatchlistEditorBuildRemoveLine', array( &$tools, $title, $title->isRedirect(), $this->getSkin() ) );
458
459 return $link . " (" . $this->getLang()->pipeList( $tools ) . ")";
460 }
461
462 /**
463 * Get a form for editing the watchlist in "raw" mode
464 *
465 * @return HTMLForm
466 */
467 protected function getRawForm(){
468 $titles = implode( $this->getWatchlist(), "\n" );
469 $fields = array(
470 'Titles' => array(
471 'type' => 'textarea',
472 'label-message' => 'watchlistedit-raw-titles',
473 'default' => $titles,
474 ),
475 );
476 $form = new HTMLForm( $fields, $this->getContext() );
477 $form->setTitle( $this->getTitle( 'raw' ) );
478 $form->setSubmitText( wfMessage( 'watchlistedit-raw-submit' )->text() );
479 $form->setWrapperLegend( wfMessage( 'watchlistedit-raw-legend' )->text() );
480 $form->addHeaderText( wfMessage( 'watchlistedit-raw-explain' )->parse() );
481 $form->setSubmitCallback( array( $this, 'submitRaw' ) );
482 return $form;
483 }
484
485 /**
486 * Determine whether we are editing the watchlist, and if so, what
487 * kind of editing operation
488 *
489 * @param $request WebRequest
490 * @param $par mixed
491 * @return int
492 */
493 public static function getMode( $request, $par ) {
494 $mode = strtolower( $request->getVal( 'action', $par ) );
495 switch( $mode ) {
496 case 'clear':
497 case self::EDIT_CLEAR:
498 return self::EDIT_CLEAR;
499
500 case 'raw':
501 case self::EDIT_RAW:
502 return self::EDIT_RAW;
503
504 case 'edit':
505 case self::EDIT_NORMAL:
506 return self::EDIT_NORMAL;
507
508 default:
509 return false;
510 }
511 }
512
513 /**
514 * Build a set of links for convenient navigation
515 * between watchlist viewing and editing modes
516 *
517 * @param $unused Unused
518 * @return string
519 */
520 public static function buildTools( $unused ) {
521 global $wgLang;
522
523 $tools = array();
524 $modes = array(
525 'view' => array( 'Watchlist', false ),
526 'edit' => array( 'EditWatchlist', false ),
527 'raw' => array( 'EditWatchlist', 'raw' ),
528 );
529 foreach( $modes as $mode => $arr ) {
530 // can use messages 'watchlisttools-view', 'watchlisttools-edit', 'watchlisttools-raw'
531 $tools[] = Linker::linkKnown(
532 SpecialPage::getTitleFor( $arr[0], $arr[1] ),
533 wfMsgHtml( "watchlisttools-{$mode}" )
534 );
535 }
536 return Html::rawElement( 'span',
537 array( 'class' => 'mw-watchlist-toollinks' ),
538 wfMsg( 'parentheses', $wgLang->pipeList( $tools ) ) );
539 }
540 }
541
542 # B/C since 1.18
543 class WatchlistEditor extends SpecialEditWatchlist {}
544
545 /**
546 * Extend HTMLForm purely so we can have a more sane way of getting the section headers
547 */
548 class EditWatchlistNormalHTMLForm extends HTMLForm {
549 public function getLegend( $namespace ){
550 $namespace = substr( $namespace, 2 );
551 return $namespace == NS_MAIN
552 ? wfMsgHtml( 'blanknamespace' )
553 : htmlspecialchars( $this->getContext()->getLang()->getFormattedNsText( $namespace ) );
554 }
555 }
556
557 class EditWatchlistCheckboxSeriesField extends HTMLMultiSelectField {
558 /**
559 * HTMLMultiSelectField throws validation errors if we get input data
560 * that doesn't match the data set in the form setup. This causes
561 * problems if something gets removed from the watchlist while the
562 * form is open (bug 32126), but we know that invalid items will
563 * be harmless so we can override it here.
564 *
565 * @param $value String the value the field was submitted with
566 * @param $alldata Array the data collected from the form
567 * @return Mixed Bool true on success, or String error to display.
568 */
569 function validate( $value, $alldata ) {
570 // Need to call into grandparent to be a good citizen. :)
571 return HTMLFormField::validate( $value, $alldata );
572 }
573 }