Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
33.26% |
144 / 433 |
|
13.33% |
4 / 30 |
CRAP | |
0.00% |
0 / 1 |
SpecialEditWatchlist | |
33.33% |
144 / 432 |
|
13.33% |
4 / 30 |
4459.07 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
81.58% |
31 / 38 |
|
0.00% |
0 / 1 |
11.76 | |||
outputSubtitle | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
getAssociatedNavigationLinks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getShortDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
executeViewEditWatchlist | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
4.13 | |||
getSubpagesForPrefixSearch | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
extractTitles | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
submitRaw | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
56 | |||
submitClear | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
clearUserWatchedItems | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
clearUserWatchedItemsNow | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
clearUserWatchedItemsUsingJobQueue | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
showTitles | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
72 | |||
getWatchlist | |
42.11% |
8 / 19 |
|
0.00% |
0 / 1 |
12.99 | |||
getWatchlistInfo | |
64.71% |
11 / 17 |
|
0.00% |
0 / 1 |
4.70 | |||
checkTitle | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
56 | |||
cleanupWatchlist | |
15.38% |
2 / 13 |
|
0.00% |
0 / 1 |
20.15 | |||
watchTitles | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
unwatchTitles | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
runWatchUnwatchCompleteHook | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getExpandedTargets | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
submitNormal | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
getNormalForm | |
28.81% |
17 / 59 |
|
0.00% |
0 / 1 |
46.07 | |||
buildRemoveLine | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
56 | |||
getRawForm | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
getClearForm | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getMode | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
9.29 | |||
buildTools | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Specials; |
22 | |
23 | use EditWatchlistCheckboxSeriesField; |
24 | use EditWatchlistNormalHTMLForm; |
25 | use LogicException; |
26 | use MediaWiki\Cache\GenderCache; |
27 | use MediaWiki\Cache\LinkBatchFactory; |
28 | use MediaWiki\Deferred\DeferredUpdates; |
29 | use MediaWiki\Html\Html; |
30 | use MediaWiki\HTMLForm\HTMLForm; |
31 | use MediaWiki\HTMLForm\OOUIHTMLForm; |
32 | use MediaWiki\Linker\LinkRenderer; |
33 | use MediaWiki\Linker\LinkTarget; |
34 | use MediaWiki\MainConfigNames; |
35 | use MediaWiki\MediaWikiServices; |
36 | use MediaWiki\Page\WikiPageFactory; |
37 | use MediaWiki\Parser\Parser; |
38 | use MediaWiki\Parser\ParserOutput; |
39 | use MediaWiki\Parser\ParserOutputFlags; |
40 | use MediaWiki\Request\WebRequest; |
41 | use MediaWiki\SpecialPage\SpecialPage; |
42 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
43 | use MediaWiki\Status\Status; |
44 | use MediaWiki\Title\MalformedTitleException; |
45 | use MediaWiki\Title\NamespaceInfo; |
46 | use MediaWiki\Title\Title; |
47 | use MediaWiki\Title\TitleParser; |
48 | use MediaWiki\Title\TitleValue; |
49 | use MediaWiki\Watchlist\WatchedItemStore; |
50 | use MediaWiki\Watchlist\WatchedItemStoreInterface; |
51 | use MediaWiki\Watchlist\WatchlistManager; |
52 | use UserNotLoggedIn; |
53 | use Wikimedia\Parsoid\Core\SectionMetadata; |
54 | use Wikimedia\Parsoid\Core\TOCData; |
55 | |
56 | /** |
57 | * Users can edit their watchlist via this page. |
58 | * |
59 | * @ingroup SpecialPage |
60 | * @ingroup Watchlist |
61 | * @author Rob Church <robchur@gmail.com> |
62 | */ |
63 | class SpecialEditWatchlist extends UnlistedSpecialPage { |
64 | /** |
65 | * Editing modes. EDIT_CLEAR is no longer used; the "Clear" link scared people |
66 | * too much. Now it's passed on to the raw editor, from which it's very easy to clear. |
67 | */ |
68 | public const EDIT_CLEAR = 1; |
69 | public const EDIT_RAW = 2; |
70 | public const EDIT_NORMAL = 3; |
71 | public const VIEW = 4; |
72 | |
73 | /** @var string|null */ |
74 | protected $successMessage; |
75 | |
76 | /** @var TOCData */ |
77 | protected $tocData; |
78 | |
79 | /** @var array[] */ |
80 | private $badItems = []; |
81 | |
82 | private TitleParser $titleParser; |
83 | private WatchedItemStoreInterface $watchedItemStore; |
84 | private GenderCache $genderCache; |
85 | private LinkBatchFactory $linkBatchFactory; |
86 | private NamespaceInfo $nsInfo; |
87 | private WikiPageFactory $wikiPageFactory; |
88 | private WatchlistManager $watchlistManager; |
89 | |
90 | /** @var int|false where the value is one of the EDIT_ prefixed constants (e.g. EDIT_NORMAL) */ |
91 | private $currentMode; |
92 | |
93 | /** |
94 | * @param WatchedItemStoreInterface|null $watchedItemStore |
95 | * @param TitleParser|null $titleParser |
96 | * @param GenderCache|null $genderCache |
97 | * @param LinkBatchFactory|null $linkBatchFactory |
98 | * @param NamespaceInfo|null $nsInfo |
99 | * @param WikiPageFactory|null $wikiPageFactory |
100 | * @param WatchlistManager|null $watchlistManager |
101 | */ |
102 | public function __construct( |
103 | ?WatchedItemStoreInterface $watchedItemStore = null, |
104 | ?TitleParser $titleParser = null, |
105 | ?GenderCache $genderCache = null, |
106 | ?LinkBatchFactory $linkBatchFactory = null, |
107 | ?NamespaceInfo $nsInfo = null, |
108 | ?WikiPageFactory $wikiPageFactory = null, |
109 | ?WatchlistManager $watchlistManager = null |
110 | ) { |
111 | parent::__construct( 'EditWatchlist', 'editmywatchlist' ); |
112 | // This class is extended and therefor fallback to global state - T266065 |
113 | $services = MediaWikiServices::getInstance(); |
114 | $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore(); |
115 | $this->titleParser = $titleParser ?? $services->getTitleParser(); |
116 | $this->genderCache = $genderCache ?? $services->getGenderCache(); |
117 | $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory(); |
118 | $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo(); |
119 | $this->wikiPageFactory = $wikiPageFactory ?? $services->getWikiPageFactory(); |
120 | $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager(); |
121 | } |
122 | |
123 | public function doesWrites() { |
124 | return true; |
125 | } |
126 | |
127 | /** |
128 | * Main execution point |
129 | * |
130 | * @param string|null $mode |
131 | */ |
132 | public function execute( $mode ) { |
133 | $this->setHeaders(); |
134 | |
135 | $user = $this->getUser(); |
136 | if ( !$user->isRegistered() |
137 | || ( $user->isTemp() && !$user->isAllowed( 'editmywatchlist' ) ) |
138 | ) { |
139 | throw new UserNotLoggedIn( 'watchlistanontext' ); |
140 | } |
141 | |
142 | $out = $this->getOutput(); |
143 | |
144 | $this->checkPermissions(); |
145 | $this->checkReadOnly(); |
146 | |
147 | $this->outputHeader(); |
148 | $out->addModuleStyles( [ |
149 | 'mediawiki.interface.helpers.styles', |
150 | 'mediawiki.special' |
151 | ] ); |
152 | $out->addModules( [ 'mediawiki.special.watchlist' ] ); |
153 | |
154 | $mode = self::getMode( $this->getRequest(), $mode, self::EDIT_NORMAL ); |
155 | $this->currentMode = $mode; |
156 | $this->outputSubtitle(); |
157 | |
158 | switch ( $mode ) { |
159 | case self::VIEW: |
160 | $title = SpecialPage::getTitleFor( 'Watchlist' ); |
161 | $out->redirect( $title->getLocalURL() ); |
162 | break; |
163 | case self::EDIT_RAW: |
164 | $out->setPageTitleMsg( $this->msg( 'watchlistedit-raw-title' ) ); |
165 | $form = $this->getRawForm(); |
166 | if ( $form->show() ) { |
167 | $out->addHTML( $this->successMessage ); |
168 | $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); |
169 | } |
170 | break; |
171 | case self::EDIT_CLEAR: |
172 | $out->setPageTitleMsg( $this->msg( 'watchlistedit-clear-title' ) ); |
173 | $form = $this->getClearForm(); |
174 | if ( $form->show() ) { |
175 | $out->addHTML( $this->successMessage ); |
176 | $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); |
177 | } |
178 | break; |
179 | |
180 | case self::EDIT_NORMAL: |
181 | default: |
182 | $this->executeViewEditWatchlist(); |
183 | break; |
184 | } |
185 | } |
186 | |
187 | /** |
188 | * Renders a subheader on the watchlist page. |
189 | */ |
190 | protected function outputSubtitle() { |
191 | $out = $this->getOutput(); |
192 | $skin = $this->getSkin(); |
193 | // For legacy skins render the tabs in the subtitle |
194 | $subpageSubtitle = $skin->supportsMenu( 'associated-pages' ) ? '' : |
195 | ' ' . |
196 | self::buildTools( |
197 | null, |
198 | $this->getLinkRenderer(), |
199 | $this->currentMode |
200 | ); |
201 | $out->addSubtitle( |
202 | Html::element( |
203 | 'span', |
204 | [ |
205 | 'class' => 'mw-watchlist-owner' |
206 | ], |
207 | // Previously the watchlistfor2 message took 2 parameters. |
208 | // It now only takes 1 so empty string is passed. |
209 | // Empty string parameter can be removed when all messages |
210 | // are updated to not use $2 |
211 | $this->msg( 'watchlistfor2', $this->getUser()->getName(), '' )->text() |
212 | ) . $subpageSubtitle |
213 | ); |
214 | } |
215 | |
216 | /** |
217 | * @inheritDoc |
218 | */ |
219 | public function getAssociatedNavigationLinks() { |
220 | return SpecialWatchlist::WATCHLIST_TAB_PATHS; |
221 | } |
222 | |
223 | /** |
224 | * @inheritDoc |
225 | */ |
226 | public function getShortDescription( string $path = '' ): string { |
227 | return SpecialWatchlist::getShortDescriptionHelper( $this, $path ); |
228 | } |
229 | |
230 | /** |
231 | * Executes an edit mode for the watchlist view, from which you can manage your watchlist |
232 | */ |
233 | protected function executeViewEditWatchlist() { |
234 | $out = $this->getOutput(); |
235 | $out->setPageTitleMsg( $this->msg( 'watchlistedit-normal-title' ) ); |
236 | |
237 | $form = $this->getNormalForm(); |
238 | $form->prepareForm(); |
239 | |
240 | $result = $form->tryAuthorizedSubmit(); |
241 | if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { |
242 | $out->addHTML( $this->successMessage ); |
243 | $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); |
244 | return; |
245 | } |
246 | |
247 | $pout = new ParserOutput; |
248 | $pout->setTOCData( $this->tocData ); |
249 | $pout->setOutputFlag( ParserOutputFlags::SHOW_TOC ); |
250 | $pout->setRawText( Parser::TOC_PLACEHOLDER ); |
251 | $out->addParserOutput( $pout ); |
252 | |
253 | $form->displayForm( $result ); |
254 | } |
255 | |
256 | /** |
257 | * Return an array of subpages that this special page will accept. |
258 | * |
259 | * @see also SpecialWatchlist::getSubpagesForPrefixSearch |
260 | * @return string[] subpages |
261 | */ |
262 | public function getSubpagesForPrefixSearch() { |
263 | // SpecialWatchlist uses SpecialEditWatchlist::getMode, so new types should be added |
264 | // here and there - no 'edit' here, because that the default for this page |
265 | return [ |
266 | 'clear', |
267 | 'raw', |
268 | ]; |
269 | } |
270 | |
271 | /** |
272 | * Extract a list of titles from a blob of text, returning |
273 | * (prefixed) strings; unwatchable titles are ignored |
274 | * |
275 | * @param string $list |
276 | * @return array |
277 | */ |
278 | private function extractTitles( $list ) { |
279 | $list = explode( "\n", trim( $list ) ); |
280 | |
281 | $titles = []; |
282 | |
283 | foreach ( $list as $text ) { |
284 | $text = trim( $text ); |
285 | if ( strlen( $text ) > 0 ) { |
286 | $title = Title::newFromText( $text ); |
287 | if ( $title instanceof Title && $this->watchlistManager->isWatchable( $title ) ) { |
288 | $titles[] = $title; |
289 | } |
290 | } |
291 | } |
292 | |
293 | $this->genderCache->doTitlesArray( $titles ); |
294 | |
295 | $list = []; |
296 | /** @var Title $title */ |
297 | foreach ( $titles as $title ) { |
298 | $list[] = $title->getPrefixedText(); |
299 | } |
300 | |
301 | return array_unique( $list ); |
302 | } |
303 | |
304 | public function submitRaw( $data ) { |
305 | $wanted = $this->extractTitles( $data['Titles'] ); |
306 | $current = $this->getWatchlist(); |
307 | |
308 | if ( count( $wanted ) > 0 ) { |
309 | $toWatch = array_diff( $wanted, $current ); |
310 | $toUnwatch = array_diff( $current, $wanted ); |
311 | if ( !$toWatch && !$toUnwatch ) { |
312 | return false; |
313 | } |
314 | |
315 | $this->watchTitles( $toWatch ); |
316 | $this->unwatchTitles( $toUnwatch ); |
317 | $this->getUser()->invalidateCache(); |
318 | $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse(); |
319 | |
320 | if ( $toWatch ) { |
321 | $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-added' ) |
322 | ->numParams( count( $toWatch ) )->parse(); |
323 | $this->showTitles( $toWatch, $this->successMessage ); |
324 | } |
325 | |
326 | if ( $toUnwatch ) { |
327 | $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' ) |
328 | ->numParams( count( $toUnwatch ) )->parse(); |
329 | $this->showTitles( $toUnwatch, $this->successMessage ); |
330 | } |
331 | } else { |
332 | if ( !$current ) { |
333 | return false; |
334 | } |
335 | |
336 | $this->clearUserWatchedItems( 'raw' ); |
337 | $this->showTitles( $current, $this->successMessage ); |
338 | } |
339 | |
340 | return true; |
341 | } |
342 | |
343 | /** |
344 | * Handler for the clear form submission |
345 | * |
346 | * @param array $data |
347 | * @return bool |
348 | */ |
349 | public function submitClear( $data ): bool { |
350 | $this->clearUserWatchedItems( 'clear' ); |
351 | return true; |
352 | } |
353 | |
354 | /** |
355 | * Makes a decision about using the JobQueue or not for clearing a users watchlist. |
356 | * Also displays the appropriate messages to the user based on that decision. |
357 | * |
358 | * @param string $messageFor 'raw' or 'clear'. Only used when JobQueue is not used. |
359 | */ |
360 | private function clearUserWatchedItems( string $messageFor ): void { |
361 | if ( $this->watchedItemStore->mustClearWatchedItemsUsingJobQueue( $this->getUser() ) ) { |
362 | $this->clearUserWatchedItemsUsingJobQueue(); |
363 | } else { |
364 | $this->clearUserWatchedItemsNow( $messageFor ); |
365 | } |
366 | } |
367 | |
368 | /** |
369 | * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue |
370 | * |
371 | * @param string $messageFor 'raw' or 'clear' |
372 | */ |
373 | private function clearUserWatchedItemsNow( string $messageFor ): void { |
374 | $current = $this->getWatchlist(); |
375 | if ( !$this->watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) { |
376 | throw new LogicException( |
377 | __METHOD__ . ' should only be called when able to clear synchronously' |
378 | ); |
379 | } |
380 | // Messages used: watchlistedit-clear-done, watchlistedit-raw-done |
381 | $this->successMessage = $this->msg( 'watchlistedit-' . $messageFor . '-done' )->parse(); |
382 | // Messages used: watchlistedit-clear-removed, watchlistedit-raw-removed |
383 | $this->successMessage .= ' ' . $this->msg( 'watchlistedit-' . $messageFor . '-removed' ) |
384 | ->numParams( count( $current ) )->parse(); |
385 | $this->getUser()->invalidateCache(); |
386 | $this->showTitles( $current, $this->successMessage ); |
387 | } |
388 | |
389 | /** |
390 | * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue |
391 | */ |
392 | private function clearUserWatchedItemsUsingJobQueue(): void { |
393 | $this->watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->getUser() ); |
394 | $this->successMessage = $this->msg( 'watchlistedit-clear-jobqueue' )->parse(); |
395 | } |
396 | |
397 | /** |
398 | * Print out a list of linked titles |
399 | * |
400 | * $titles can be an array of strings or Title objects; the former |
401 | * is preferred, since Titles are very memory-heavy |
402 | * |
403 | * @param array $titles Array of strings, or Title objects |
404 | * @param string &$output |
405 | */ |
406 | private function showTitles( $titles, &$output ) { |
407 | $talk = $this->msg( 'talkpagelinktext' )->text(); |
408 | // Do a batch existence check |
409 | $batch = $this->linkBatchFactory->newLinkBatch(); |
410 | if ( count( $titles ) >= 100 ) { |
411 | $output = $this->msg( 'watchlistedit-too-many' )->parse(); |
412 | return; |
413 | } |
414 | foreach ( $titles as $title ) { |
415 | if ( !$title instanceof Title ) { |
416 | $title = Title::newFromText( $title ); |
417 | } |
418 | |
419 | if ( $title instanceof Title ) { |
420 | $batch->addObj( $title ); |
421 | $batch->addObj( $title->getTalkPage() ); |
422 | } |
423 | } |
424 | |
425 | $batch->execute(); |
426 | |
427 | // Print out the list |
428 | $output .= "<ul>\n"; |
429 | |
430 | $linkRenderer = $this->getLinkRenderer(); |
431 | foreach ( $titles as $title ) { |
432 | if ( !$title instanceof Title ) { |
433 | $title = Title::newFromText( $title ); |
434 | } |
435 | |
436 | if ( $title instanceof Title ) { |
437 | $output .= '<li>' . |
438 | $linkRenderer->makeLink( $title ) . ' ' . |
439 | $this->msg( 'parentheses' )->rawParams( |
440 | $linkRenderer->makeLink( $title->getTalkPage(), $talk ) |
441 | )->escaped() . |
442 | "</li>\n"; |
443 | } |
444 | } |
445 | |
446 | $output .= "</ul>\n"; |
447 | } |
448 | |
449 | /** |
450 | * Prepare a list of titles on a user's watchlist (excluding talk pages) |
451 | * and return an array of (prefixed) strings |
452 | * |
453 | * @return array |
454 | */ |
455 | private function getWatchlist() { |
456 | $list = []; |
457 | |
458 | $watchedItems = $this->watchedItemStore->getWatchedItemsForUser( |
459 | $this->getUser(), |
460 | [ 'forWrite' => $this->getRequest()->wasPosted() ] |
461 | ); |
462 | |
463 | if ( $watchedItems ) { |
464 | /** @var Title[] $titles */ |
465 | $titles = []; |
466 | foreach ( $watchedItems as $watchedItem ) { |
467 | $namespace = $watchedItem->getTarget()->getNamespace(); |
468 | $dbKey = $watchedItem->getTarget()->getDBkey(); |
469 | $title = Title::makeTitleSafe( $namespace, $dbKey ); |
470 | |
471 | if ( $this->checkTitle( $title, $namespace, $dbKey ) |
472 | && !$title->isTalkPage() |
473 | ) { |
474 | $titles[] = $title; |
475 | } |
476 | } |
477 | |
478 | $this->genderCache->doTitlesArray( $titles ); |
479 | |
480 | foreach ( $titles as $title ) { |
481 | $list[] = $title->getPrefixedText(); |
482 | } |
483 | } |
484 | |
485 | $this->cleanupWatchlist(); |
486 | |
487 | return $list; |
488 | } |
489 | |
490 | /** |
491 | * Get a list of titles on a user's watchlist, excluding talk pages, |
492 | * and return as a two-dimensional array with namespace and title. |
493 | * |
494 | * @return array |
495 | */ |
496 | protected function getWatchlistInfo() { |
497 | $titles = []; |
498 | $options = [ 'sort' => WatchedItemStore::SORT_ASC ]; |
499 | |
500 | if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) { |
501 | $options[ 'sortByExpiry'] = true; |
502 | } |
503 | |
504 | $watchedItems = $this->watchedItemStore->getWatchedItemsForUser( |
505 | $this->getUser(), $options |
506 | ); |
507 | |
508 | $lb = $this->linkBatchFactory->newLinkBatch(); |
509 | $context = $this->getContext(); |
510 | |
511 | foreach ( $watchedItems as $watchedItem ) { |
512 | $namespace = $watchedItem->getTarget()->getNamespace(); |
513 | $dbKey = $watchedItem->getTarget()->getDBkey(); |
514 | $lb->add( $namespace, $dbKey ); |
515 | if ( !$this->nsInfo->isTalk( $namespace ) ) { |
516 | $titles[$namespace][$dbKey] = $watchedItem->getExpiryInDaysText( $context ); |
517 | } |
518 | } |
519 | |
520 | $lb->execute(); |
521 | |
522 | return $titles; |
523 | } |
524 | |
525 | /** |
526 | * Validates watchlist entry |
527 | * |
528 | * @param Title $title |
529 | * @param int $namespace |
530 | * @param string $dbKey |
531 | * @return bool Whether this item is valid |
532 | */ |
533 | private function checkTitle( $title, $namespace, $dbKey ) { |
534 | if ( $title |
535 | && ( $title->isExternal() |
536 | || $title->getNamespace() < 0 |
537 | ) |
538 | ) { |
539 | $title = false; // unrecoverable |
540 | } |
541 | |
542 | if ( !$title |
543 | || $title->getNamespace() != $namespace |
544 | || $title->getDBkey() != $dbKey |
545 | ) { |
546 | $this->badItems[] = [ $title, $namespace, $dbKey ]; |
547 | } |
548 | |
549 | return (bool)$title; |
550 | } |
551 | |
552 | /** |
553 | * Attempts to clean up broken items |
554 | */ |
555 | private function cleanupWatchlist() { |
556 | if ( $this->badItems === [] ) { |
557 | return; // nothing to do |
558 | } |
559 | |
560 | $user = $this->getUser(); |
561 | $badItems = $this->badItems; |
562 | DeferredUpdates::addCallableUpdate( function () use ( $user, $badItems ) { |
563 | foreach ( $badItems as [ $title, $namespace, $dbKey ] ) { |
564 | $action = $title ? 'cleaning up' : 'deleting'; |
565 | wfDebug( "User {$user->getName()} has broken watchlist item " . |
566 | "ns($namespace):$dbKey, $action." ); |
567 | |
568 | // NOTE: We *know* that the title is invalid. TitleValue may refuse instantiation. |
569 | // XXX: We may need an InvalidTitleValue class that allows instantiation of |
570 | // known bad title values. |
571 | $this->watchedItemStore->removeWatch( $user, Title::makeTitle( (int)$namespace, $dbKey ) ); |
572 | // Can't just do an UPDATE instead of DELETE/INSERT due to unique index |
573 | if ( $title ) { |
574 | $this->watchlistManager->addWatch( $user, $title ); |
575 | } |
576 | } |
577 | } ); |
578 | } |
579 | |
580 | /** |
581 | * Add a list of targets to a user's watchlist |
582 | * |
583 | * @param string[]|LinkTarget[] $targets |
584 | */ |
585 | private function watchTitles( array $targets ): void { |
586 | if ( $targets && |
587 | $this->watchedItemStore->addWatchBatchForUser( |
588 | $this->getUser(), $this->getExpandedTargets( $targets ) |
589 | ) |
590 | ) { |
591 | $this->runWatchUnwatchCompleteHook( 'Watch', $targets ); |
592 | } |
593 | } |
594 | |
595 | /** |
596 | * Remove a list of titles from a user's watchlist |
597 | * |
598 | * $titles can be an array of strings or Title objects; the former |
599 | * is preferred, since Titles are very memory-heavy |
600 | * |
601 | * @param string[]|LinkTarget[] $targets |
602 | */ |
603 | private function unwatchTitles( array $targets ): void { |
604 | if ( $targets && |
605 | $this->watchedItemStore->removeWatchBatchForUser( |
606 | $this->getUser(), $this->getExpandedTargets( $targets ) |
607 | ) |
608 | ) { |
609 | $this->runWatchUnwatchCompleteHook( 'Unwatch', $targets ); |
610 | } |
611 | } |
612 | |
613 | /** |
614 | * @param string $action |
615 | * Can be "Watch" or "Unwatch" |
616 | * @param string[]|LinkTarget[] $targets |
617 | */ |
618 | private function runWatchUnwatchCompleteHook( string $action, array $targets ): void { |
619 | foreach ( $targets as $target ) { |
620 | $title = $target instanceof LinkTarget ? |
621 | Title::newFromLinkTarget( $target ) : |
622 | Title::newFromText( $target ); |
623 | $page = $this->wikiPageFactory->newFromTitle( $title ); |
624 | $user = $this->getUser(); |
625 | if ( $action === 'Watch' ) { |
626 | $this->getHookRunner()->onWatchArticleComplete( $user, $page ); |
627 | } else { |
628 | $this->getHookRunner()->onUnwatchArticleComplete( $user, $page ); |
629 | } |
630 | } |
631 | } |
632 | |
633 | /** |
634 | * @param string[]|LinkTarget[] $targets |
635 | * @return TitleValue[] |
636 | */ |
637 | private function getExpandedTargets( array $targets ) { |
638 | $expandedTargets = []; |
639 | foreach ( $targets as $target ) { |
640 | if ( !$target instanceof LinkTarget ) { |
641 | try { |
642 | $target = $this->titleParser->parseTitle( $target, NS_MAIN ); |
643 | } catch ( MalformedTitleException $e ) { |
644 | continue; |
645 | } |
646 | } |
647 | |
648 | $ns = $target->getNamespace(); |
649 | $dbKey = $target->getDBkey(); |
650 | $expandedTargets[] = |
651 | new TitleValue( $this->nsInfo->getSubject( $ns ), $dbKey ); |
652 | $expandedTargets[] = |
653 | new TitleValue( $this->nsInfo->getTalk( $ns ), $dbKey ); |
654 | } |
655 | return $expandedTargets; |
656 | } |
657 | |
658 | public function submitNormal( $data ) { |
659 | $removed = []; |
660 | |
661 | foreach ( $data as $titles ) { |
662 | // ignore the 'check all' checkbox, which is a boolean value |
663 | if ( is_array( $titles ) ) { |
664 | $this->unwatchTitles( $titles ); |
665 | $removed = array_merge( $removed, $titles ); |
666 | } |
667 | } |
668 | |
669 | if ( count( $removed ) > 0 ) { |
670 | $this->successMessage = $this->msg( 'watchlistedit-normal-done' |
671 | )->numParams( count( $removed ) )->parse(); |
672 | $this->showTitles( $removed, $this->successMessage ); |
673 | |
674 | return true; |
675 | } else { |
676 | return false; |
677 | } |
678 | } |
679 | |
680 | /** |
681 | * Get the standard watchlist editing form |
682 | * |
683 | * @return HTMLForm |
684 | */ |
685 | protected function getNormalForm() { |
686 | $fields = []; |
687 | $count = 0; |
688 | |
689 | // Allow subscribers to manipulate the list of watched pages (or use it |
690 | // to preload lots of details at once) |
691 | $watchlistInfo = $this->getWatchlistInfo(); |
692 | $this->getHookRunner()->onWatchlistEditorBeforeFormRender( $watchlistInfo ); |
693 | |
694 | foreach ( $watchlistInfo as $namespace => $pages ) { |
695 | $options = []; |
696 | foreach ( $pages as $dbkey => $expiryDaysText ) { |
697 | $title = Title::makeTitleSafe( $namespace, $dbkey ); |
698 | |
699 | if ( $this->checkTitle( $title, $namespace, $dbkey ) ) { |
700 | $text = $this->buildRemoveLine( $title, $expiryDaysText ); |
701 | $options[$text] = $title->getPrefixedText(); |
702 | $count++; |
703 | } |
704 | } |
705 | |
706 | // checkTitle can filter some options out, avoid empty sections |
707 | if ( count( $options ) > 0 ) { |
708 | // add a checkbox to select all entries in namespace |
709 | $fields['CheckAllNs' . $namespace] = [ |
710 | 'cssclass' => 'mw-watchlistedit-checkall', |
711 | 'type' => 'check', |
712 | 'section' => "ns$namespace", |
713 | 'label' => $this->msg( 'watchlistedit-normal-check-all' )->text() |
714 | ]; |
715 | |
716 | $fields['TitlesNs' . $namespace] = [ |
717 | 'cssclass' => 'mw-watchlistedit-check', |
718 | 'class' => EditWatchlistCheckboxSeriesField::class, |
719 | 'options' => $options, |
720 | 'section' => "ns$namespace", |
721 | ]; |
722 | } |
723 | } |
724 | $this->cleanupWatchlist(); |
725 | |
726 | $this->tocData = new TOCData(); |
727 | if ( count( $fields ) > 1 && $count > 30 ) { |
728 | $tocLength = 0; |
729 | $contLang = $this->getContentLanguage(); |
730 | foreach ( $fields as $key => $data ) { |
731 | // ignore the 'check all' field |
732 | if ( str_starts_with( $key, 'CheckAllNs' ) ) { |
733 | continue; |
734 | } |
735 | # strip out the 'ns' prefix from the section name: |
736 | $ns = (int)substr( $data['section'], 2 ); |
737 | $nsText = ( $ns === NS_MAIN ) |
738 | ? $this->msg( 'blanknamespace' )->text() |
739 | : $contLang->getFormattedNsText( $ns ); |
740 | $anchor = "editwatchlist-{$data['section']}"; |
741 | ++$tocLength; |
742 | $this->tocData->addSection( new SectionMetadata( |
743 | 1, |
744 | // This is supposed to be the heading level, e.g. 2 for a <h2> tag, |
745 | // but this page uses <legend> tags for the headings, so use a fake value |
746 | 99, |
747 | htmlspecialchars( $nsText ), |
748 | $this->getLanguage()->formatNum( $tocLength ), |
749 | (string)$tocLength, |
750 | null, |
751 | null, |
752 | $anchor, |
753 | $anchor |
754 | ) ); |
755 | } |
756 | } |
757 | |
758 | $form = new EditWatchlistNormalHTMLForm( $fields, $this->getContext() ); |
759 | $form->setTitle( $this->getPageTitle() ); // Remove subpage |
760 | $form->setSubmitTextMsg( 'watchlistedit-normal-submit' ); |
761 | $form->setSubmitDestructive(); |
762 | # Used message keys: |
763 | # 'accesskey-watchlistedit-normal-submit', 'tooltip-watchlistedit-normal-submit' |
764 | $form->setSubmitTooltip( 'watchlistedit-normal-submit' ); |
765 | $form->setWrapperLegendMsg( 'watchlistedit-normal-legend' ); |
766 | $form->addHeaderHtml( $this->msg( 'watchlistedit-normal-explain' )->parse() ); |
767 | $form->setSubmitCallback( [ $this, 'submitNormal' ] ); |
768 | |
769 | return $form; |
770 | } |
771 | |
772 | /** |
773 | * Build the label for a checkbox, with a link to the title, and various additional bits |
774 | * |
775 | * @param Title $title |
776 | * @param string $expiryDaysText message shows the number of days a title has remaining in a user's watchlist. |
777 | * If this param is not empty then include a message that states the time remaining in a watchlist. |
778 | * @return string |
779 | */ |
780 | private function buildRemoveLine( $title, string $expiryDaysText = '' ): string { |
781 | $linkRenderer = $this->getLinkRenderer(); |
782 | $link = $linkRenderer->makeLink( $title ); |
783 | |
784 | $tools = []; |
785 | $tools['talk'] = $linkRenderer->makeLink( |
786 | $title->getTalkPage(), |
787 | $this->msg( 'talkpagelinktext' )->text() |
788 | ); |
789 | |
790 | if ( $title->exists() ) { |
791 | $tools['history'] = $linkRenderer->makeKnownLink( |
792 | $title, |
793 | $this->msg( 'history_small' )->text(), |
794 | [], |
795 | [ 'action' => 'history' ] |
796 | ); |
797 | } |
798 | |
799 | if ( $title->getNamespace() === NS_USER && !$title->isSubpage() ) { |
800 | $tools['contributions'] = $linkRenderer->makeKnownLink( |
801 | SpecialPage::getTitleFor( 'Contributions', $title->getText() ), |
802 | $this->msg( 'contribslink' )->text() |
803 | ); |
804 | } |
805 | |
806 | $this->getHookRunner()->onWatchlistEditorBuildRemoveLine( |
807 | $tools, $title, $title->isRedirect(), $this->getSkin(), $link ); |
808 | |
809 | if ( $title->isRedirect() ) { |
810 | // Linker already makes class mw-redirect, so this is redundant |
811 | $link = '<span class="watchlistredir">' . $link . '</span>'; |
812 | } |
813 | |
814 | $watchlistExpiringMessage = ''; |
815 | if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && $expiryDaysText ) { |
816 | $watchlistExpiringMessage = Html::element( |
817 | 'span', |
818 | [ 'class' => 'mw-watchlistexpiry-msg' ], |
819 | $expiryDaysText |
820 | ); |
821 | } |
822 | |
823 | return $link . ' ' . Html::openElement( 'span', [ 'class' => 'mw-changeslist-links' ] ) . |
824 | implode( |
825 | '', |
826 | array_map( static function ( $tool ) { |
827 | return Html::rawElement( 'span', [], $tool ); |
828 | }, $tools ) |
829 | ) . |
830 | Html::closeElement( 'span' ) . |
831 | $watchlistExpiringMessage; |
832 | } |
833 | |
834 | /** |
835 | * Get a form for editing the watchlist in "raw" mode |
836 | * |
837 | * @return HTMLForm |
838 | */ |
839 | protected function getRawForm() { |
840 | $titles = implode( "\n", $this->getWatchlist() ); |
841 | $fields = [ |
842 | 'Titles' => [ |
843 | 'type' => 'textarea', |
844 | 'label-message' => 'watchlistedit-raw-titles', |
845 | 'default' => $titles, |
846 | ], |
847 | ]; |
848 | $form = new OOUIHTMLForm( $fields, $this->getContext() ); |
849 | $form->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage |
850 | $form->setSubmitTextMsg( 'watchlistedit-raw-submit' ); |
851 | # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit' |
852 | $form->setSubmitTooltip( 'watchlistedit-raw-submit' ); |
853 | $form->setWrapperLegendMsg( 'watchlistedit-raw-legend' ); |
854 | $form->addHeaderHtml( $this->msg( 'watchlistedit-raw-explain' )->parse() ); |
855 | $form->setSubmitCallback( [ $this, 'submitRaw' ] ); |
856 | |
857 | return $form; |
858 | } |
859 | |
860 | /** |
861 | * Get a form for clearing the watchlist |
862 | * |
863 | * @return HTMLForm |
864 | */ |
865 | protected function getClearForm() { |
866 | $form = new OOUIHTMLForm( [], $this->getContext() ); |
867 | $form->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage |
868 | $form->setSubmitTextMsg( 'watchlistedit-clear-submit' ); |
869 | # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit' |
870 | $form->setSubmitTooltip( 'watchlistedit-clear-submit' ); |
871 | $form->setWrapperLegendMsg( 'watchlistedit-clear-legend' ); |
872 | $form->addHeaderHtml( $this->msg( 'watchlistedit-clear-explain' )->parse() ); |
873 | $form->setSubmitCallback( [ $this, 'submitClear' ] ); |
874 | $form->setSubmitDestructive(); |
875 | |
876 | return $form; |
877 | } |
878 | |
879 | /** |
880 | * Determine whether we are editing the watchlist, and if so, what |
881 | * kind of editing operation |
882 | * |
883 | * @param WebRequest $request |
884 | * @param string|null $par |
885 | * @param int|false $defaultValue to use if not known. |
886 | * @return int|false |
887 | */ |
888 | public static function getMode( $request, $par, $defaultValue = false ) { |
889 | $mode = strtolower( $request->getRawVal( 'action' ) ?? $par ?? '' ); |
890 | |
891 | switch ( $mode ) { |
892 | case 'view': |
893 | return self::VIEW; |
894 | case 'clear': |
895 | case self::EDIT_CLEAR: |
896 | return self::EDIT_CLEAR; |
897 | case 'raw': |
898 | case self::EDIT_RAW: |
899 | return self::EDIT_RAW; |
900 | case 'edit': |
901 | case self::EDIT_NORMAL: |
902 | return self::EDIT_NORMAL; |
903 | default: |
904 | return $defaultValue; |
905 | } |
906 | } |
907 | |
908 | /** |
909 | * Build a set of links for convenient navigation |
910 | * between watchlist viewing and editing modes |
911 | * |
912 | * @param mixed $unused |
913 | * @param LinkRenderer|null $linkRenderer |
914 | * @param int|false $selectedMode result of self::getMode |
915 | * @return string |
916 | */ |
917 | public static function buildTools( $unused, ?LinkRenderer $linkRenderer = null, $selectedMode = false ) { |
918 | if ( !$linkRenderer ) { |
919 | $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
920 | } |
921 | |
922 | $tools = []; |
923 | $modes = [ |
924 | 'view' => [ 'Watchlist', false, false ], |
925 | 'edit' => [ 'EditWatchlist', false, self::EDIT_NORMAL ], |
926 | 'raw' => [ 'EditWatchlist', 'raw', self::EDIT_RAW ], |
927 | 'clear' => [ 'EditWatchlist', 'clear', self::EDIT_CLEAR ], |
928 | ]; |
929 | |
930 | foreach ( $modes as $mode => $arr ) { |
931 | // can use messages 'watchlisttools-view', 'watchlisttools-edit', 'watchlisttools-raw' |
932 | $link = $linkRenderer->makeKnownLink( |
933 | SpecialPage::getTitleFor( $arr[0], $arr[1] ), |
934 | wfMessage( "watchlisttools-{$mode}" )->text() |
935 | ); |
936 | $isSelected = $selectedMode === $arr[2]; |
937 | $classes = [ |
938 | 'mw-watchlist-toollink', |
939 | 'mw-watchlist-toollink-' . $mode, |
940 | $isSelected ? 'mw-watchlist-toollink-active' : |
941 | 'mw-watchlist-toollink-inactive' |
942 | ]; |
943 | $tools[] = Html::rawElement( 'span', [ |
944 | 'class' => $classes, |
945 | ], $link ); |
946 | } |
947 | |
948 | return Html::rawElement( |
949 | 'span', |
950 | [ 'class' => 'mw-watchlist-toollinks mw-changeslist-links' ], |
951 | implode( '', $tools ) |
952 | ); |
953 | } |
954 | } |
955 | |
956 | /** @deprecated class alias since 1.41 */ |
957 | class_alias( SpecialEditWatchlist::class, 'SpecialEditWatchlist' ); |