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