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