Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.14% covered (warning)
50.14%
179 / 357
25.00% covered (danger)
25.00%
8 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditWatchlist
50.14% covered (warning)
50.14%
179 / 357
25.00% covered (danger)
25.00%
8 / 32
1609.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultPager
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
75.56% covered (warning)
75.56%
34 / 45
0.00% covered (danger)
0.00%
0 / 1
15.47
 createWatchlistFilterForm
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 executeViewEditWatchlist
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 handleEditWatchlistFormSubmission
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
8.12
 unwatchItemsFromFormSubmission
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 assignLabelsFromFormSubmission
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 unassignLabelsFromFormSubmission
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getTitlesAndLabelsFromRequest
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 displayFormSubmitSuccessMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 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
 appendTitlesToString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 titlesAsHtmlListString
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 getWatchlistFull
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
12.99
 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 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getExpandedTargets
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
3.01
 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
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use LogicException;
10use MediaWiki\Cache\GenderCache;
11use MediaWiki\Deferred\DeferredUpdates;
12use MediaWiki\Exception\UserNotLoggedIn;
13use MediaWiki\Html\Html;
14use MediaWiki\HTMLForm\HTMLForm;
15use MediaWiki\HTMLForm\OOUIHTMLForm;
16use MediaWiki\MainConfigNames;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\LinkBatchFactory;
19use MediaWiki\Page\PageReference;
20use MediaWiki\Page\PageReferenceValue;
21use MediaWiki\Page\WikiPageFactory;
22use MediaWiki\Request\WebRequest;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\SpecialPage\UnlistedSpecialPage;
25use MediaWiki\Specials\Pager\EditWatchlistPager;
26use MediaWiki\Title\MalformedTitleException;
27use MediaWiki\Title\NamespaceInfo;
28use MediaWiki\Title\Title;
29use MediaWiki\Title\TitleParser;
30use MediaWiki\Watchlist\WatchedItemStoreInterface;
31use MediaWiki\Watchlist\WatchlistLabelStore;
32use MediaWiki\Watchlist\WatchlistManager;
33use MediaWiki\Watchlist\WatchlistSpecialPage;
34use Wikimedia\Codex\Component\HtmlSnippet;
35use Wikimedia\Codex\Localization\MediaWikiLocalization;
36use Wikimedia\Codex\Utility\Codex;
37
38/**
39 * Users can edit their watchlist via this page.
40 *
41 * @ingroup SpecialPage
42 * @ingroup Watchlist
43 * @author Rob Church <robchur@gmail.com>
44 */
45class SpecialEditWatchlist extends UnlistedSpecialPage {
46
47    use WatchlistSpecialPage;
48
49    /**
50     * Editing modes. EDIT_CLEAR is no longer used; the "Clear" link scared people
51     * too much. Now it's passed on to the raw editor, from which it's very easy to clear.
52     */
53    public const EDIT_CLEAR = 1;
54    public const EDIT_RAW = 2;
55    public const EDIT = 3;
56    public const VIEW = 4;
57
58    public const WL_ITEM_CHECKBOX_NAME = 'wpTitles';
59
60    /** @var string|null */
61    protected $successMessage;
62
63    /** @var array[] */
64    private $badItems = [];
65
66    private TitleParser $titleParser;
67    private WatchedItemStoreInterface $watchedItemStore;
68    private WatchlistLabelStore $watchlistLabelStore;
69    private GenderCache $genderCache;
70    protected LinkBatchFactory $linkBatchFactory;
71    private NamespaceInfo $nsInfo;
72    private WikiPageFactory $wikiPageFactory;
73    private WatchlistManager $watchlistManager;
74    protected EditWatchlistPager $pager;
75
76    public function __construct(
77        ?WatchedItemStoreInterface $watchedItemStore = null,
78        ?WatchlistLabelStore $watchlistLabelStore = null,
79        ?TitleParser $titleParser = null,
80        ?GenderCache $genderCache = null,
81        ?LinkBatchFactory $linkBatchFactory = null,
82        ?NamespaceInfo $nsInfo = null,
83        ?WikiPageFactory $wikiPageFactory = null,
84        ?WatchlistManager $watchlistManager = null,
85    ) {
86        parent::__construct( 'EditWatchlist' );
87        // This class is extended and therefor fallback to global state - T266065
88        $services = MediaWikiServices::getInstance();
89        $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore();
90        $this->watchlistLabelStore = $watchlistLabelStore ?? $services->getWatchlistLabelStore();
91        $this->titleParser = $titleParser ?? $services->getTitleParser();
92        $this->genderCache = $genderCache ?? $services->getGenderCache();
93        $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
94        $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
95        $this->wikiPageFactory = $wikiPageFactory ?? $services->getWikiPageFactory();
96        $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
97        $this->pager = $this->getDefaultPager(
98            (bool)$services->getMainConfig()->get( MainConfigNames::WatchlistExpiry )
99        );
100    }
101
102    /** @inheritDoc */
103    public function getRestriction(): string {
104        return 'editmywatchlist';
105    }
106
107    private function getDefaultPager( bool $expiryEnabled ): EditWatchlistPager {
108        return new EditWatchlistPager(
109            $this->getContext(),
110            $this->getPageTitle(),
111            $this->watchedItemStore,
112            $this->nsInfo,
113            $this->linkBatchFactory,
114            $this->getHookRunner(),
115            $expiryEnabled,
116        );
117    }
118
119    /** @inheritDoc */
120    public function doesWrites() {
121        return true;
122    }
123
124    /**
125     * Main execution point
126     *
127     * @param string|null $mode
128     */
129    public function execute( $mode ) {
130        $this->setHeaders();
131
132        $user = $this->getUser();
133        if ( !$user->isRegistered()
134            || ( $user->isTemp() && !$user->isAllowed( 'editmywatchlist' ) )
135        ) {
136            throw new UserNotLoggedIn( 'watchlistanontext' );
137        }
138
139        $out = $this->getOutput();
140
141        $this->checkPermissions();
142        $this->checkReadOnly();
143
144        $this->outputHeader();
145        $out->addModuleStyles( [
146            'mediawiki.interface.helpers.styles',
147            'mediawiki.special.watchlistedit.styles',
148        ] );
149        $out->addModules( [ 'mediawiki.special.watchlistedit' ] );
150        if ( $this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
151            $watchlistLabels = array_map(
152                static fn ( $label ) => [ 'id' => $label->getId(), 'name' => $label->getName() ],
153                $this->watchlistLabelStore->loadAllForUser( $this->getUser() )
154            );
155            $out->addJsConfigVars( 'watchlistLabels', $watchlistLabels );
156            if ( !$watchlistLabels ) {
157                $out->addJsConfigVars(
158                    'SpecialWatchlistLabelsTitle',
159                    SpecialPage::getTitleFor( 'WatchlistLabels' )->getFullText()
160                );
161            }
162        }
163
164        $mode = self::getMode( $this->getRequest(), $mode, self::EDIT );
165        $this->currentMode = $mode;
166        $this->outputSubtitle();
167
168        switch ( $mode ) {
169            case self::VIEW:
170                $title = SpecialPage::getTitleFor( 'Watchlist' );
171                $out->redirect( $title->getLocalURL() );
172                break;
173            case self::EDIT_RAW:
174                $out->setPageTitleMsg( $this->msg( 'watchlistedit-raw-title' ) );
175                $form = $this->getRawForm();
176                if ( $form->show() ) {
177                    $out->addHTML( $this->successMessage );
178                    $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
179                }
180                break;
181            case self::EDIT_CLEAR:
182                $out->setPageTitleMsg( $this->msg( 'watchlistedit-clear-title' ) );
183                $form = $this->getClearForm();
184                if ( $form->show() ) {
185                    $out->addHTML( $this->successMessage );
186                    $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
187                }
188                break;
189
190            case self::EDIT:
191            default:
192                $this->executeViewEditWatchlist();
193                break;
194        }
195    }
196
197    /**
198     * @return HTMLForm
199     */
200    private function createWatchlistFilterForm(): HTMLForm {
201        $filterFormDescriptor = [
202            'search' => [
203                'type' => 'text',
204                'name' => 'search',
205                'id' => 'watchlist-search',
206                'label-message' => 'watchlistedit-search-label',
207                'placeholder-message' => 'watchlistedit-search-placeholder',
208                'default' => '',
209            ],
210            'namespace' => [
211                'type' => 'namespaceselect',
212                'name' => 'namespace',
213                'id' => 'namespace-selector',
214                'label-message' => 'namespace',
215                'all' => '',
216                'default' => '',
217                'include' => array_merge( array_values( $this->nsInfo->getSubjectNamespaces() ) ),
218            ],
219        ];
220        $filterForm = HTMLForm::factory( 'codex', $filterFormDescriptor, $this->getContext() );
221        $filterForm
222            ->setMethod( 'get' )
223            ->setId( 'watchlist-filter-form' )
224            ->setTitle( $this->getPageTitle() )
225            ->setSubmitTextMsg( 'allpagessubmit' );
226        if ( $this->getRequest()->getInt( 'limit' ) ) {
227            $filterForm->addHiddenField( 'limit', $this->getRequest()->getInt( 'limit' ) );
228        }
229        return $filterForm->prepareForm();
230    }
231
232    /**
233     * Executes an edit mode for the watchlist view, from which you can manage your watchlist
234     */
235    protected function executeViewEditWatchlist() {
236        $output = $this->getOutput();
237        $output->setPageTitleMsg( $this->msg( 'watchlistedit-normal-title' ) );
238
239        if ( $this->getRequest()->wasPosted() ) {
240            $this->handleEditWatchlistFormSubmission();
241        }
242
243        $this->createWatchlistFilterForm()->displayForm( '' );
244        $output->addHTML( $this->pager->getBody() );
245    }
246
247    /**
248     * @return void
249     */
250    private function handleEditWatchlistFormSubmission(): void {
251        if ( ( new HTMLForm( [], $this->getContext() ) )->requestIsAuthorized() ) {
252            $action = $this->getRequest()->getText( 'watchlistlabels-action', 'unwatch' );
253            switch ( $action ) {
254                case 'assign':
255                    $this->assignLabelsFromFormSubmission();
256                    break;
257                case 'unassign':
258                    $this->unassignLabelsFromFormSubmission();
259                    break;
260                case 'unwatch':
261                    $this->unwatchItemsFromFormSubmission();
262            }
263
264        }
265    }
266
267    private function unwatchItemsFromFormSubmission() {
268        $removed = [];
269        $titles = $this->getRequest()->getArray( self::WL_ITEM_CHECKBOX_NAME );
270        if ( is_array( $titles ) ) {
271            $this->unwatchTitles( $titles );
272            $removed = array_merge( $removed, $titles );
273        }
274        if ( count( $removed ) > 0 ) {
275            $successMessage = Html::rawElement(
276                'div', [],
277                $this->msg( 'watchlistedit-normal-done' )
278                    ->numParams( count( $removed ) )->parse()
279            );
280            $successMessage .= $this->titlesAsHtmlListString( $removed );
281            $this->displayFormSubmitSuccessMessage( $successMessage );
282        }
283    }
284
285    private function assignLabelsFromFormSubmission() {
286        $titlesAndLabels = $this->getTitlesAndLabelsFromRequest();
287        if ( $titlesAndLabels !== null ) {
288            $this->watchedItemStore->addLabels(
289                $this->getUser(), $titlesAndLabels["titles"], $titlesAndLabels["labels"]
290            );
291            $this->displayFormSubmitSuccessMessage(
292                $this->msg( 'watchlistlabels-assign-labels-done' )
293                    // Halve title count because both subject and talk pages are labelled.
294                    ->numParams( count( $titlesAndLabels["labels"] ), count( $titlesAndLabels["titles"] ) / 2 )->parse()
295            );
296        }
297    }
298
299    private function unassignLabelsFromFormSubmission() {
300        $titlesAndLabels = $this->getTitlesAndLabelsFromRequest();
301        if ( $titlesAndLabels !== null ) {
302            $this->watchedItemStore->removeLabels(
303                $this->getUser(), $titlesAndLabels["titles"], $titlesAndLabels["labels"]
304            );
305            $this->displayFormSubmitSuccessMessage(
306                $this->msg( 'watchlistlabels-unassign-labels-done' )
307                    // Halve title count because both subject and talk pages are labelled.
308                    ->numParams( count( $titlesAndLabels["labels"] ), count( $titlesAndLabels["titles"] ) / 2 )->parse()
309            );
310        }
311    }
312
313    private function getTitlesAndLabelsFromRequest(): ?array {
314        $titleStrings = $this->getRequest()->getArray( self::WL_ITEM_CHECKBOX_NAME );
315        $labels = $this->getRequest()->getArray( 'watchlistlabels' );
316        if ( is_array( $titleStrings ) && count( $titleStrings ) > 0 && is_array( $labels ) && count( $labels ) > 0 ) {
317            $titles = $this->getExpandedTargets( $titleStrings );
318            if ( count( $titles ) > 0 ) {
319                return [ "titles" => $titles, "labels" => $labels ];
320            }
321        }
322        return null;
323    }
324
325    private function displayFormSubmitSuccessMessage( string $successMessage ) {
326        $output = $this->getOutput();
327        $msgHtml = ( new Codex( new MediaWikiLocalization( $this->getContext() ) ) )->message()
328            ->setType( 'success' )
329            ->setContentHtml( new HtmlSnippet( $successMessage ) )
330            ->build()
331            ->getHtml();
332        $output->addHTML( $msgHtml );
333    }
334
335    /**
336     * Return an array of subpages that this special page will accept.
337     *
338     * @see also SpecialWatchlist::getSubpagesForPrefixSearch
339     * @return string[] subpages
340     */
341    public function getSubpagesForPrefixSearch() {
342        // SpecialWatchlist uses SpecialEditWatchlist::getMode, so new types should be added
343        // here and there - no 'edit' here, because that the default for this page
344        return [
345            'clear',
346            'raw',
347        ];
348    }
349
350    /**
351     * Extract a list of titles from a blob of text, returning
352     * (prefixed) strings; unwatchable titles are ignored
353     *
354     * @param string $list
355     * @return string[]
356     */
357    private function extractTitles( $list ) {
358        $list = explode( "\n", trim( $list ) );
359
360        $titles = [];
361
362        foreach ( $list as $text ) {
363            $text = trim( $text );
364            if ( $text !== '' ) {
365                $title = Title::newFromText( $text );
366                if ( $title instanceof Title && $this->watchlistManager->isWatchable( $title ) ) {
367                    $titles[] = $title;
368                }
369            }
370        }
371
372        $this->genderCache->doTitlesArray( $titles );
373
374        $list = [];
375        /** @var Title $title */
376        foreach ( $titles as $title ) {
377            $list[] = $title->getPrefixedText();
378        }
379
380        return array_unique( $list );
381    }
382
383    /**
384     * @param array $data
385     * @return bool
386     */
387    private function submitRaw( $data ) {
388        $wanted = $this->extractTitles( $data['Titles'] );
389        $current = $this->getWatchlistFull();
390
391        if ( count( $wanted ) > 0 ) {
392            $toWatch = array_diff( $wanted, $current );
393            $toUnwatch = array_diff( $current, $wanted );
394            if ( !$toWatch && !$toUnwatch ) {
395                return false;
396            }
397
398            $this->watchTitles( $toWatch );
399            $this->unwatchTitles( $toUnwatch );
400            $this->getUser()->invalidateCache();
401            $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse();
402
403            if ( $toWatch ) {
404                $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-added' )
405                    ->numParams( count( $toWatch ) )->parse();
406                $this->appendTitlesToString( $toWatch, $this->successMessage );
407            }
408
409            if ( $toUnwatch ) {
410                $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' )
411                    ->numParams( count( $toUnwatch ) )->parse();
412                $this->appendTitlesToString( $toUnwatch, $this->successMessage );
413            }
414        } else {
415            if ( !$current ) {
416                return false;
417            }
418
419            $this->clearUserWatchedItems( 'raw' );
420            $this->appendTitlesToString( $current, $this->successMessage );
421        }
422
423        return true;
424    }
425
426    /**
427     * Handler for the clear form submission
428     *
429     * @return bool
430     */
431    private function submitClear(): bool {
432        $this->clearUserWatchedItems( 'clear' );
433        return true;
434    }
435
436    /**
437     * Makes a decision about using the JobQueue or not for clearing a users watchlist.
438     * Also displays the appropriate messages to the user based on that decision.
439     *
440     * @param string $messageFor 'raw' or 'clear'. Only used when JobQueue is not used.
441     */
442    private function clearUserWatchedItems( string $messageFor ): void {
443        if ( $this->watchedItemStore->mustClearWatchedItemsUsingJobQueue( $this->getUser() ) ) {
444            $this->clearUserWatchedItemsUsingJobQueue();
445        } else {
446            $this->clearUserWatchedItemsNow( $messageFor );
447        }
448    }
449
450    /**
451     * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue
452     *
453     * @param string $messageFor 'raw' or 'clear'
454     */
455    private function clearUserWatchedItemsNow( string $messageFor ): void {
456        $current = $this->getWatchlistFull();
457        if ( !$this->watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) {
458            throw new LogicException(
459                __METHOD__ . ' should only be called when able to clear synchronously'
460            );
461        }
462        // Messages used: watchlistedit-clear-done, watchlistedit-raw-done
463        $this->successMessage = $this->msg( 'watchlistedit-' . $messageFor . '-done' )->parse();
464        // Messages used: watchlistedit-clear-removed, watchlistedit-raw-removed
465        $this->successMessage .= ' ' . $this->msg( 'watchlistedit-' . $messageFor . '-removed' )
466                ->numParams( count( $current ) )->parse();
467        $this->getUser()->invalidateCache();
468        $this->appendTitlesToString( $current, $this->successMessage );
469    }
470
471    /**
472     * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue
473     */
474    private function clearUserWatchedItemsUsingJobQueue(): void {
475        $this->watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->getUser() );
476        $this->successMessage = $this->msg( 'watchlistedit-clear-jobqueue' )->parse();
477    }
478
479    /**
480     * Transform a list of titles to a html list, and append to the passed-by-ref input string
481     *
482     * @param array $titles Array of strings, or Title objects
483     * @param string &$string
484     */
485    private function appendTitlesToString( array $titles, string &$string ) {
486        $string .= $this->titlesAsHtmlListString( $titles ) . "\n";
487    }
488
489    /**
490     * Transform a list of linked titles to a html list
491     *
492     * @param array $titles Array of strings, or Title objects
493     */
494    private function titlesAsHtmlListString( array $titles ): string {
495        if ( count( $titles ) >= 100 ) {
496            return $this->msg( 'watchlistedit-too-many' )->parse();
497        }
498        $talk = $this->msg( 'talkpagelinktext' )->text();
499        // Do a batch existence check
500        $batch = $this->linkBatchFactory->newLinkBatch();
501        foreach ( $titles as $title ) {
502            if ( !$title instanceof Title ) {
503                $title = Title::newFromText( $title );
504            }
505
506            if ( $title instanceof Title ) {
507                $batch->addObj( $title );
508                $batch->addObj( $title->getTalkPage() );
509            }
510        }
511
512        $batch->execute();
513
514        // Print out the list
515        $string = "<ul>\n";
516
517        $linkRenderer = $this->getLinkRenderer();
518        foreach ( $titles as $title ) {
519            if ( !$title instanceof Title ) {
520                $title = Title::newFromText( $title );
521            }
522
523            if ( $title instanceof Title ) {
524                $string .= '<li>' .
525                    $linkRenderer->makeLink( $title ) . ' ' .
526                    $this->msg( 'parentheses' )->rawParams(
527                        $linkRenderer->makeLink( $title->getTalkPage(), $talk )
528                    )->escaped() .
529                    "</li>\n";
530            }
531        }
532
533        $string .= "</ul>\n";
534        return $string;
535    }
536
537    /**
538     * Prepare a list of ALL titles on a user's watchlist (excluding talk pages)
539     * and return an array of (prefixed) strings
540     *
541     * @return array
542     */
543    private function getWatchlistFull(): array {
544        $list = [];
545        $watchedItems = $this->watchedItemStore->getWatchedItemsForUser(
546            $this->getUser(),
547            [ 'forWrite' => $this->getRequest()->wasPosted() ]
548        );
549        if ( $watchedItems ) {
550            /** @var Title[] $titles */
551            $titles = [];
552            foreach ( $watchedItems as $watchedItem ) {
553                $namespace = $watchedItem->getTarget()->getNamespace();
554                $dbKey = $watchedItem->getTarget()->getDBkey();
555                $title = Title::makeTitleSafe( $namespace, $dbKey );
556
557                if ( $this->checkTitle( $title, $namespace, $dbKey )
558                    && !$title->isTalkPage()
559                ) {
560                    $titles[] = $title;
561                }
562            }
563            $this->genderCache->doTitlesArray( $titles );
564            foreach ( $titles as $title ) {
565                $list[] = $title->getPrefixedText();
566            }
567        }
568        $this->cleanupWatchlist();
569        return $list;
570    }
571
572    /**
573     * Validates watchlist entry
574     *
575     * @param Title $title
576     * @param int $namespace
577     * @param string $dbKey
578     * @return bool Whether this item is valid
579     */
580    private function checkTitle( $title, $namespace, $dbKey ) {
581        if ( $title
582            && ( $title->isExternal()
583                || $title->getNamespace() < 0
584            )
585        ) {
586            $title = false; // unrecoverable
587        }
588
589        if ( !$title
590            || $title->getNamespace() != $namespace
591            || $title->getDBkey() != $dbKey
592        ) {
593            $this->badItems[] = [ $title, $namespace, $dbKey ];
594        }
595
596        return (bool)$title;
597    }
598
599    /**
600     * Attempts to clean up broken items
601     */
602    private function cleanupWatchlist() {
603        if ( $this->badItems === [] ) {
604            return; // nothing to do
605        }
606
607        $user = $this->getUser();
608        $badItems = $this->badItems;
609        DeferredUpdates::addCallableUpdate( function () use ( $user, $badItems ) {
610            foreach ( $badItems as [ $title, $namespace, $dbKey ] ) {
611                $action = $title ? 'cleaning up' : 'deleting';
612                wfDebug( "User {$user->getName()} has broken watchlist item " .
613                    "ns($namespace):$dbKey, $action." );
614
615                // NOTE: We *know* that the title is invalid. TitleValue may refuse instantiation.
616                // XXX: We may need an InvalidTitleValue class that allows instantiation of
617                //      known bad title values.
618                $this->watchedItemStore->removeWatch( $user, Title::makeTitle( (int)$namespace, $dbKey ) );
619                // Can't just do an UPDATE instead of DELETE/INSERT due to unique index
620                if ( $title ) {
621                    $this->watchlistManager->addWatch( $user, $title );
622                }
623            }
624        } );
625    }
626
627    /**
628     * Add a list of targets to a user's watchlist
629     *
630     * @param string[] $targets
631     */
632    private function watchTitles( array $targets ): void {
633        if ( $targets &&
634            $this->watchedItemStore->addWatchBatchForUser(
635                $this->getUser(), $this->getExpandedTargets( $targets )
636            )
637        ) {
638            $this->runWatchUnwatchCompleteHook( 'Watch', $targets );
639        }
640    }
641
642    /**
643     * Remove a list of titles from a user's watchlist
644     *
645     * $titles can be an array of strings or Title objects; the former
646     * is preferred, since Titles are very memory-heavy
647     *
648     * @param string[] $targets
649     */
650    private function unwatchTitles( array $targets ): void {
651        if ( $targets &&
652            $this->watchedItemStore->removeWatchBatchForUser(
653                $this->getUser(), $this->getExpandedTargets( $targets )
654            )
655        ) {
656            $this->runWatchUnwatchCompleteHook( 'Unwatch', $targets );
657        }
658    }
659
660    /**
661     * @param string $action
662     *   Can be "Watch" or "Unwatch"
663     * @param string[] $targets
664     */
665    private function runWatchUnwatchCompleteHook( string $action, array $targets ): void {
666        foreach ( $targets as $target ) {
667            $title = Title::newFromText( $target );
668            $page = $this->wikiPageFactory->newFromTitle( $title );
669            $user = $this->getUser();
670            if ( $action === 'Watch' ) {
671                $this->getHookRunner()->onWatchArticleComplete( $user, $page );
672            } else {
673                $this->getHookRunner()->onUnwatchArticleComplete( $user, $page );
674            }
675        }
676    }
677
678    /**
679     * @param string[] $targets
680     * @return PageReference[]
681     */
682    private function getExpandedTargets( array $targets ) {
683        $expandedTargets = [];
684        foreach ( $targets as $target ) {
685            try {
686                $target = $this->titleParser->parseTitle( $target, NS_MAIN );
687            } catch ( MalformedTitleException ) {
688                continue;
689            }
690
691            $ns = $target->getNamespace();
692            $dbKey = $target->getDBkey();
693            $expandedTargets[] =
694                PageReferenceValue::localReference(
695                    $this->nsInfo->getSubject( $ns ),
696                    $dbKey
697                );
698            $expandedTargets[] =
699                PageReferenceValue::localReference(
700                    $this->nsInfo->getTalk( $ns ),
701                    $dbKey
702                );
703        }
704        return $expandedTargets;
705    }
706
707    /**
708     * Get a form for editing the watchlist in "raw" mode
709     *
710     * @return HTMLForm
711     */
712    protected function getRawForm() {
713        $titles = implode( "\n", $this->getWatchlistFull() );
714        $fields = [
715            'Titles' => [
716                'type' => 'textarea',
717                'label-message' => 'watchlistedit-raw-titles',
718                'default' => $titles,
719            ],
720        ];
721        $form = new OOUIHTMLForm( $fields, $this->getContext() );
722        $form->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage
723        $form->setSubmitTextMsg( 'watchlistedit-raw-submit' );
724        # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit'
725        $form->setSubmitTooltip( 'watchlistedit-raw-submit' );
726        $form->setWrapperLegendMsg( 'watchlistedit-raw-legend' );
727        $form->addHeaderHtml( $this->msg( 'watchlistedit-raw-explain' )->parse() );
728        $form->setSubmitCallback( $this->submitRaw( ... ) );
729
730        return $form;
731    }
732
733    /**
734     * Get a form for clearing the watchlist
735     *
736     * @return HTMLForm
737     */
738    protected function getClearForm() {
739        $form = new OOUIHTMLForm( [], $this->getContext() );
740        $form->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage
741        $form->setSubmitTextMsg( 'watchlistedit-clear-submit' );
742        # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit'
743        $form->setSubmitTooltip( 'watchlistedit-clear-submit' );
744        $form->setWrapperLegendMsg( 'watchlistedit-clear-legend' );
745        $form->addHeaderHtml( $this->msg( 'watchlistedit-clear-explain' )->parse() );
746        $form->setSubmitCallback( $this->submitClear( ... ) );
747        $form->setSubmitDestructive();
748
749        return $form;
750    }
751
752    /**
753     * Determine whether we are editing the watchlist, and if so, what
754     * kind of editing operation
755     *
756     * @param WebRequest $request
757     * @param string|null $par
758     * @param int|false $defaultValue to use if not known.
759     * @return int|false
760     */
761    public static function getMode( $request, $par, $defaultValue = false ) {
762        $mode = strtolower( $request->getRawVal( 'action' ) ?? $par ?? '' );
763
764        switch ( $mode ) {
765            case 'view':
766                return self::VIEW;
767            case 'clear':
768            case self::EDIT_CLEAR:
769                return self::EDIT_CLEAR;
770            case 'raw':
771            case self::EDIT_RAW:
772                return self::EDIT_RAW;
773            case 'edit':
774            case self::EDIT:
775                return self::EDIT;
776            default:
777                return $defaultValue;
778        }
779    }
780}
781
782// @codeCoverageIgnoreStart
783/** @deprecated class alias since 1.41 */
784class_alias( SpecialEditWatchlist::class, 'SpecialEditWatchlist' );
785// @codeCoverageIgnoreEnd