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