Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.26% covered (danger)
33.26%
144 / 433
13.33% covered (danger)
13.33%
4 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditWatchlist
33.33% covered (danger)
33.33%
144 / 432
13.33% covered (danger)
13.33%
4 / 30
4459.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
81.58% covered (warning)
81.58%
31 / 38
0.00% covered (danger)
0.00%
0 / 1
11.76
 outputSubtitle
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getAssociatedNavigationLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getShortDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 executeViewEditWatchlist
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
4.13
 getSubpagesForPrefixSearch
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 extractTitles
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 submitRaw
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 submitClear
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 clearUserWatchedItems
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 clearUserWatchedItemsNow
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 clearUserWatchedItemsUsingJobQueue
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 showTitles
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 getWatchlist
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
12.99
 getWatchlistInfo
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
4.70
 checkTitle
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 cleanupWatchlist
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
20.15
 watchTitles
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 unwatchTitles
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 runWatchUnwatchCompleteHook
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getExpandedTargets
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 submitNormal
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getNormalForm
28.81% covered (danger)
28.81%
17 / 59
0.00% covered (danger)
0.00%
0 / 1
46.07
 buildRemoveLine
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
56
 getRawForm
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getClearForm
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMode
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
9.29
 buildTools
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
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
21namespace MediaWiki\Specials;
22
23use EditWatchlistCheckboxSeriesField;
24use EditWatchlistNormalHTMLForm;
25use LogicException;
26use MediaWiki\Cache\GenderCache;
27use MediaWiki\Cache\LinkBatchFactory;
28use MediaWiki\Deferred\DeferredUpdates;
29use MediaWiki\Html\Html;
30use MediaWiki\HTMLForm\HTMLForm;
31use MediaWiki\HTMLForm\OOUIHTMLForm;
32use MediaWiki\Linker\LinkRenderer;
33use MediaWiki\Linker\LinkTarget;
34use MediaWiki\MainConfigNames;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Page\WikiPageFactory;
37use MediaWiki\Parser\Parser;
38use MediaWiki\Parser\ParserOutput;
39use MediaWiki\Parser\ParserOutputFlags;
40use MediaWiki\Request\WebRequest;
41use MediaWiki\SpecialPage\SpecialPage;
42use MediaWiki\SpecialPage\UnlistedSpecialPage;
43use MediaWiki\Status\Status;
44use MediaWiki\Title\MalformedTitleException;
45use MediaWiki\Title\NamespaceInfo;
46use MediaWiki\Title\Title;
47use MediaWiki\Title\TitleParser;
48use MediaWiki\Title\TitleValue;
49use MediaWiki\Watchlist\WatchedItemStore;
50use MediaWiki\Watchlist\WatchedItemStoreInterface;
51use MediaWiki\Watchlist\WatchlistManager;
52use UserNotLoggedIn;
53use Wikimedia\Parsoid\Core\SectionMetadata;
54use 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 */
63class 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 */
957class_alias( SpecialEditWatchlist::class, 'SpecialEditWatchlist' );