Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialEditWatchlist.php
blob56e0a52855629c74be421edfc515ef3ec633fd05
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Specials;
23 use EditWatchlistCheckboxSeriesField;
24 use EditWatchlistNormalHTMLForm;
25 use LogicException;
26 use MediaWiki\Cache\GenderCache;
27 use MediaWiki\Cache\LinkBatchFactory;
28 use MediaWiki\Deferred\DeferredUpdates;
29 use MediaWiki\Html\Html;
30 use MediaWiki\HTMLForm\HTMLForm;
31 use MediaWiki\HTMLForm\OOUIHTMLForm;
32 use MediaWiki\Linker\LinkRenderer;
33 use MediaWiki\Linker\LinkTarget;
34 use MediaWiki\MainConfigNames;
35 use MediaWiki\MediaWikiServices;
36 use MediaWiki\Page\WikiPageFactory;
37 use MediaWiki\Parser\Parser;
38 use MediaWiki\Parser\ParserOutput;
39 use MediaWiki\Parser\ParserOutputFlags;
40 use MediaWiki\Request\WebRequest;
41 use MediaWiki\SpecialPage\SpecialPage;
42 use MediaWiki\SpecialPage\UnlistedSpecialPage;
43 use MediaWiki\Status\Status;
44 use MediaWiki\Title\MalformedTitleException;
45 use MediaWiki\Title\NamespaceInfo;
46 use MediaWiki\Title\Title;
47 use MediaWiki\Title\TitleParser;
48 use MediaWiki\Title\TitleValue;
49 use MediaWiki\Watchlist\WatchedItemStore;
50 use MediaWiki\Watchlist\WatchedItemStoreInterface;
51 use MediaWiki\Watchlist\WatchlistManager;
52 use UserNotLoggedIn;
53 use Wikimedia\Parsoid\Core\SectionMetadata;
54 use Wikimedia\Parsoid\Core\TOCData;
56 /**
57 * Users can edit their watchlist via this page.
59 * @ingroup SpecialPage
60 * @ingroup Watchlist
61 * @author Rob Church <robchur@gmail.com>
63 class SpecialEditWatchlist extends UnlistedSpecialPage {
64 /**
65 * Editing modes. EDIT_CLEAR is no longer used; the "Clear" link scared people
66 * too much. Now it's passed on to the raw editor, from which it's very easy to clear.
68 public const EDIT_CLEAR = 1;
69 public const EDIT_RAW = 2;
70 public const EDIT_NORMAL = 3;
71 public const VIEW = 4;
73 /** @var string|null */
74 protected $successMessage;
76 /** @var TOCData */
77 protected $tocData;
79 /** @var array[] */
80 private $badItems = [];
82 private TitleParser $titleParser;
83 private WatchedItemStoreInterface $watchedItemStore;
84 private GenderCache $genderCache;
85 private LinkBatchFactory $linkBatchFactory;
86 private NamespaceInfo $nsInfo;
87 private WikiPageFactory $wikiPageFactory;
88 private WatchlistManager $watchlistManager;
90 /** @var int|false where the value is one of the EDIT_ prefixed constants (e.g. EDIT_NORMAL) */
91 private $currentMode;
93 /**
94 * @param WatchedItemStoreInterface|null $watchedItemStore
95 * @param TitleParser|null $titleParser
96 * @param GenderCache|null $genderCache
97 * @param LinkBatchFactory|null $linkBatchFactory
98 * @param NamespaceInfo|null $nsInfo
99 * @param WikiPageFactory|null $wikiPageFactory
100 * @param WatchlistManager|null $watchlistManager
102 public function __construct(
103 ?WatchedItemStoreInterface $watchedItemStore = null,
104 ?TitleParser $titleParser = null,
105 ?GenderCache $genderCache = null,
106 ?LinkBatchFactory $linkBatchFactory = null,
107 ?NamespaceInfo $nsInfo = null,
108 ?WikiPageFactory $wikiPageFactory = null,
109 ?WatchlistManager $watchlistManager = null
111 parent::__construct( 'EditWatchlist', 'editmywatchlist' );
112 // This class is extended and therefor fallback to global state - T266065
113 $services = MediaWikiServices::getInstance();
114 $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore();
115 $this->titleParser = $titleParser ?? $services->getTitleParser();
116 $this->genderCache = $genderCache ?? $services->getGenderCache();
117 $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
118 $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
119 $this->wikiPageFactory = $wikiPageFactory ?? $services->getWikiPageFactory();
120 $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
123 public function doesWrites() {
124 return true;
128 * Main execution point
130 * @param string|null $mode
132 public function execute( $mode ) {
133 $this->setHeaders();
135 $user = $this->getUser();
136 if ( !$user->isRegistered()
137 || ( $user->isTemp() && !$user->isAllowed( 'editmywatchlist' ) )
139 throw new UserNotLoggedIn( 'watchlistanontext' );
142 $out = $this->getOutput();
144 $this->checkPermissions();
145 $this->checkReadOnly();
147 $this->outputHeader();
148 $out->addModuleStyles( [
149 'mediawiki.interface.helpers.styles',
150 'mediawiki.special'
151 ] );
152 $out->addModules( [ 'mediawiki.special.watchlist' ] );
154 $mode = self::getMode( $this->getRequest(), $mode, self::EDIT_NORMAL );
155 $this->currentMode = $mode;
156 $this->outputSubtitle();
158 switch ( $mode ) {
159 case self::VIEW:
160 $title = SpecialPage::getTitleFor( 'Watchlist' );
161 $out->redirect( $title->getLocalURL() );
162 break;
163 case self::EDIT_RAW:
164 $out->setPageTitleMsg( $this->msg( 'watchlistedit-raw-title' ) );
165 $form = $this->getRawForm();
166 if ( $form->show() ) {
167 $out->addHTML( $this->successMessage );
168 $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
170 break;
171 case self::EDIT_CLEAR:
172 $out->setPageTitleMsg( $this->msg( 'watchlistedit-clear-title' ) );
173 $form = $this->getClearForm();
174 if ( $form->show() ) {
175 $out->addHTML( $this->successMessage );
176 $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
178 break;
180 case self::EDIT_NORMAL:
181 default:
182 $this->executeViewEditWatchlist();
183 break;
188 * Renders a subheader on the watchlist page.
190 protected function outputSubtitle() {
191 $out = $this->getOutput();
192 $skin = $this->getSkin();
193 // For legacy skins render the tabs in the subtitle
194 $subpageSubtitle = $skin->supportsMenu( 'associated-pages' ) ? '' :
195 ' ' .
196 self::buildTools(
197 null,
198 $this->getLinkRenderer(),
199 $this->currentMode
201 $out->addSubtitle(
202 Html::element(
203 'span',
205 'class' => 'mw-watchlist-owner'
207 // Previously the watchlistfor2 message took 2 parameters.
208 // It now only takes 1 so empty string is passed.
209 // Empty string parameter can be removed when all messages
210 // are updated to not use $2
211 $this->msg( 'watchlistfor2', $this->getUser()->getName(), '' )->text()
212 ) . $subpageSubtitle
217 * @inheritDoc
219 public function getAssociatedNavigationLinks() {
220 return SpecialWatchlist::WATCHLIST_TAB_PATHS;
224 * @inheritDoc
226 public function getShortDescription( string $path = '' ): string {
227 return SpecialWatchlist::getShortDescriptionHelper( $this, $path );
231 * Executes an edit mode for the watchlist view, from which you can manage your watchlist
233 protected function executeViewEditWatchlist() {
234 $out = $this->getOutput();
235 $out->setPageTitleMsg( $this->msg( 'watchlistedit-normal-title' ) );
237 $form = $this->getNormalForm();
238 $form->prepareForm();
240 $result = $form->tryAuthorizedSubmit();
241 if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
242 $out->addHTML( $this->successMessage );
243 $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
244 return;
247 $pout = new ParserOutput;
248 $pout->setTOCData( $this->tocData );
249 $pout->setOutputFlag( ParserOutputFlags::SHOW_TOC );
250 $pout->setRawText( Parser::TOC_PLACEHOLDER );
251 $out->addParserOutput( $pout );
253 $form->displayForm( $result );
257 * Return an array of subpages that this special page will accept.
259 * @see also SpecialWatchlist::getSubpagesForPrefixSearch
260 * @return string[] subpages
262 public function getSubpagesForPrefixSearch() {
263 // SpecialWatchlist uses SpecialEditWatchlist::getMode, so new types should be added
264 // here and there - no 'edit' here, because that the default for this page
265 return [
266 'clear',
267 'raw',
272 * Extract a list of titles from a blob of text, returning
273 * (prefixed) strings; unwatchable titles are ignored
275 * @param string $list
276 * @return array
278 private function extractTitles( $list ) {
279 $list = explode( "\n", trim( $list ) );
281 $titles = [];
283 foreach ( $list as $text ) {
284 $text = trim( $text );
285 if ( strlen( $text ) > 0 ) {
286 $title = Title::newFromText( $text );
287 if ( $title instanceof Title && $this->watchlistManager->isWatchable( $title ) ) {
288 $titles[] = $title;
293 $this->genderCache->doTitlesArray( $titles );
295 $list = [];
296 /** @var Title $title */
297 foreach ( $titles as $title ) {
298 $list[] = $title->getPrefixedText();
301 return array_unique( $list );
304 public function submitRaw( $data ) {
305 $wanted = $this->extractTitles( $data['Titles'] );
306 $current = $this->getWatchlist();
308 if ( count( $wanted ) > 0 ) {
309 $toWatch = array_diff( $wanted, $current );
310 $toUnwatch = array_diff( $current, $wanted );
311 if ( !$toWatch && !$toUnwatch ) {
312 return false;
315 $this->watchTitles( $toWatch );
316 $this->unwatchTitles( $toUnwatch );
317 $this->getUser()->invalidateCache();
318 $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse();
320 if ( $toWatch ) {
321 $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-added' )
322 ->numParams( count( $toWatch ) )->parse();
323 $this->showTitles( $toWatch, $this->successMessage );
326 if ( $toUnwatch ) {
327 $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' )
328 ->numParams( count( $toUnwatch ) )->parse();
329 $this->showTitles( $toUnwatch, $this->successMessage );
331 } else {
332 if ( !$current ) {
333 return false;
336 $this->clearUserWatchedItems( 'raw' );
337 $this->showTitles( $current, $this->successMessage );
340 return true;
344 * Handler for the clear form submission
346 * @param array $data
347 * @return bool
349 public function submitClear( $data ): bool {
350 $this->clearUserWatchedItems( 'clear' );
351 return true;
355 * Makes a decision about using the JobQueue or not for clearing a users watchlist.
356 * Also displays the appropriate messages to the user based on that decision.
358 * @param string $messageFor 'raw' or 'clear'. Only used when JobQueue is not used.
360 private function clearUserWatchedItems( string $messageFor ): void {
361 if ( $this->watchedItemStore->mustClearWatchedItemsUsingJobQueue( $this->getUser() ) ) {
362 $this->clearUserWatchedItemsUsingJobQueue();
363 } else {
364 $this->clearUserWatchedItemsNow( $messageFor );
369 * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue
371 * @param string $messageFor 'raw' or 'clear'
373 private function clearUserWatchedItemsNow( string $messageFor ): void {
374 $current = $this->getWatchlist();
375 if ( !$this->watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) {
376 throw new LogicException(
377 __METHOD__ . ' should only be called when able to clear synchronously'
380 // Messages used: watchlistedit-clear-done, watchlistedit-raw-done
381 $this->successMessage = $this->msg( 'watchlistedit-' . $messageFor . '-done' )->parse();
382 // Messages used: watchlistedit-clear-removed, watchlistedit-raw-removed
383 $this->successMessage .= ' ' . $this->msg( 'watchlistedit-' . $messageFor . '-removed' )
384 ->numParams( count( $current ) )->parse();
385 $this->getUser()->invalidateCache();
386 $this->showTitles( $current, $this->successMessage );
390 * You should call clearUserWatchedItems() instead to decide if this should use the JobQueue
392 private function clearUserWatchedItemsUsingJobQueue(): void {
393 $this->watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->getUser() );
394 $this->successMessage = $this->msg( 'watchlistedit-clear-jobqueue' )->parse();
398 * Print out a list of linked titles
400 * $titles can be an array of strings or Title objects; the former
401 * is preferred, since Titles are very memory-heavy
403 * @param array $titles Array of strings, or Title objects
404 * @param string &$output
406 private function showTitles( $titles, &$output ) {
407 $talk = $this->msg( 'talkpagelinktext' )->text();
408 // Do a batch existence check
409 $batch = $this->linkBatchFactory->newLinkBatch();
410 if ( count( $titles ) >= 100 ) {
411 $output = $this->msg( 'watchlistedit-too-many' )->parse();
412 return;
414 foreach ( $titles as $title ) {
415 if ( !$title instanceof Title ) {
416 $title = Title::newFromText( $title );
419 if ( $title instanceof Title ) {
420 $batch->addObj( $title );
421 $batch->addObj( $title->getTalkPage() );
425 $batch->execute();
427 // Print out the list
428 $output .= "<ul>\n";
430 $linkRenderer = $this->getLinkRenderer();
431 foreach ( $titles as $title ) {
432 if ( !$title instanceof Title ) {
433 $title = Title::newFromText( $title );
436 if ( $title instanceof Title ) {
437 $output .= '<li>' .
438 $linkRenderer->makeLink( $title ) . ' ' .
439 $this->msg( 'parentheses' )->rawParams(
440 $linkRenderer->makeLink( $title->getTalkPage(), $talk )
441 )->escaped() .
442 "</li>\n";
446 $output .= "</ul>\n";
450 * Prepare a list of titles on a user's watchlist (excluding talk pages)
451 * and return an array of (prefixed) strings
453 * @return array
455 private function getWatchlist() {
456 $list = [];
458 $watchedItems = $this->watchedItemStore->getWatchedItemsForUser(
459 $this->getUser(),
460 [ 'forWrite' => $this->getRequest()->wasPosted() ]
463 if ( $watchedItems ) {
464 /** @var Title[] $titles */
465 $titles = [];
466 foreach ( $watchedItems as $watchedItem ) {
467 $namespace = $watchedItem->getTarget()->getNamespace();
468 $dbKey = $watchedItem->getTarget()->getDBkey();
469 $title = Title::makeTitleSafe( $namespace, $dbKey );
471 if ( $this->checkTitle( $title, $namespace, $dbKey )
472 && !$title->isTalkPage()
474 $titles[] = $title;
478 $this->genderCache->doTitlesArray( $titles );
480 foreach ( $titles as $title ) {
481 $list[] = $title->getPrefixedText();
485 $this->cleanupWatchlist();
487 return $list;
491 * Get a list of titles on a user's watchlist, excluding talk pages,
492 * and return as a two-dimensional array with namespace and title.
494 * @return array
496 protected function getWatchlistInfo() {
497 $titles = [];
498 $options = [ 'sort' => WatchedItemStore::SORT_ASC ];
500 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
501 $options[ 'sortByExpiry'] = true;
504 $watchedItems = $this->watchedItemStore->getWatchedItemsForUser(
505 $this->getUser(), $options
508 $lb = $this->linkBatchFactory->newLinkBatch();
509 $context = $this->getContext();
511 foreach ( $watchedItems as $watchedItem ) {
512 $namespace = $watchedItem->getTarget()->getNamespace();
513 $dbKey = $watchedItem->getTarget()->getDBkey();
514 $lb->add( $namespace, $dbKey );
515 if ( !$this->nsInfo->isTalk( $namespace ) ) {
516 $titles[$namespace][$dbKey] = $watchedItem->getExpiryInDaysText( $context );
520 $lb->execute();
522 return $titles;
526 * Validates watchlist entry
528 * @param Title $title
529 * @param int $namespace
530 * @param string $dbKey
531 * @return bool Whether this item is valid
533 private function checkTitle( $title, $namespace, $dbKey ) {
534 if ( $title
535 && ( $title->isExternal()
536 || $title->getNamespace() < 0
539 $title = false; // unrecoverable
542 if ( !$title
543 || $title->getNamespace() != $namespace
544 || $title->getDBkey() != $dbKey
546 $this->badItems[] = [ $title, $namespace, $dbKey ];
549 return (bool)$title;
553 * Attempts to clean up broken items
555 private function cleanupWatchlist() {
556 if ( $this->badItems === [] ) {
557 return; // nothing to do
560 $user = $this->getUser();
561 $badItems = $this->badItems;
562 DeferredUpdates::addCallableUpdate( function () use ( $user, $badItems ) {
563 foreach ( $badItems as [ $title, $namespace, $dbKey ] ) {
564 $action = $title ? 'cleaning up' : 'deleting';
565 wfDebug( "User {$user->getName()} has broken watchlist item " .
566 "ns($namespace):$dbKey, $action." );
568 // NOTE: We *know* that the title is invalid. TitleValue may refuse instantiation.
569 // XXX: We may need an InvalidTitleValue class that allows instantiation of
570 // known bad title values.
571 $this->watchedItemStore->removeWatch( $user, Title::makeTitle( (int)$namespace, $dbKey ) );
572 // Can't just do an UPDATE instead of DELETE/INSERT due to unique index
573 if ( $title ) {
574 $this->watchlistManager->addWatch( $user, $title );
577 } );
581 * Add a list of targets to a user's watchlist
583 * @param string[]|LinkTarget[] $targets
585 private function watchTitles( array $targets ): void {
586 if ( $targets &&
587 $this->watchedItemStore->addWatchBatchForUser(
588 $this->getUser(), $this->getExpandedTargets( $targets )
591 $this->runWatchUnwatchCompleteHook( 'Watch', $targets );
596 * Remove a list of titles from a user's watchlist
598 * $titles can be an array of strings or Title objects; the former
599 * is preferred, since Titles are very memory-heavy
601 * @param string[]|LinkTarget[] $targets
603 private function unwatchTitles( array $targets ): void {
604 if ( $targets &&
605 $this->watchedItemStore->removeWatchBatchForUser(
606 $this->getUser(), $this->getExpandedTargets( $targets )
609 $this->runWatchUnwatchCompleteHook( 'Unwatch', $targets );
614 * @param string $action
615 * Can be "Watch" or "Unwatch"
616 * @param string[]|LinkTarget[] $targets
618 private function runWatchUnwatchCompleteHook( string $action, array $targets ): void {
619 foreach ( $targets as $target ) {
620 $title = $target instanceof LinkTarget ?
621 Title::newFromLinkTarget( $target ) :
622 Title::newFromText( $target );
623 $page = $this->wikiPageFactory->newFromTitle( $title );
624 $user = $this->getUser();
625 if ( $action === 'Watch' ) {
626 $this->getHookRunner()->onWatchArticleComplete( $user, $page );
627 } else {
628 $this->getHookRunner()->onUnwatchArticleComplete( $user, $page );
634 * @param string[]|LinkTarget[] $targets
635 * @return TitleValue[]
637 private function getExpandedTargets( array $targets ) {
638 $expandedTargets = [];
639 foreach ( $targets as $target ) {
640 if ( !$target instanceof LinkTarget ) {
641 try {
642 $target = $this->titleParser->parseTitle( $target, NS_MAIN );
643 } catch ( MalformedTitleException $e ) {
644 continue;
648 $ns = $target->getNamespace();
649 $dbKey = $target->getDBkey();
650 $expandedTargets[] =
651 new TitleValue( $this->nsInfo->getSubject( $ns ), $dbKey );
652 $expandedTargets[] =
653 new TitleValue( $this->nsInfo->getTalk( $ns ), $dbKey );
655 return $expandedTargets;
658 public function submitNormal( $data ) {
659 $removed = [];
661 foreach ( $data as $titles ) {
662 // ignore the 'check all' checkbox, which is a boolean value
663 if ( is_array( $titles ) ) {
664 $this->unwatchTitles( $titles );
665 $removed = array_merge( $removed, $titles );
669 if ( count( $removed ) > 0 ) {
670 $this->successMessage = $this->msg( 'watchlistedit-normal-done'
671 )->numParams( count( $removed ) )->parse();
672 $this->showTitles( $removed, $this->successMessage );
674 return true;
675 } else {
676 return false;
681 * Get the standard watchlist editing form
683 * @return HTMLForm
685 protected function getNormalForm() {
686 $fields = [];
687 $count = 0;
689 // Allow subscribers to manipulate the list of watched pages (or use it
690 // to preload lots of details at once)
691 $watchlistInfo = $this->getWatchlistInfo();
692 $this->getHookRunner()->onWatchlistEditorBeforeFormRender( $watchlistInfo );
694 foreach ( $watchlistInfo as $namespace => $pages ) {
695 $options = [];
696 foreach ( $pages as $dbkey => $expiryDaysText ) {
697 $title = Title::makeTitleSafe( $namespace, $dbkey );
699 if ( $this->checkTitle( $title, $namespace, $dbkey ) ) {
700 $text = $this->buildRemoveLine( $title, $expiryDaysText );
701 $options[$text] = $title->getPrefixedText();
702 $count++;
706 // checkTitle can filter some options out, avoid empty sections
707 if ( count( $options ) > 0 ) {
708 // add a checkbox to select all entries in namespace
709 $fields['CheckAllNs' . $namespace] = [
710 'cssclass' => 'mw-watchlistedit-checkall',
711 'type' => 'check',
712 'section' => "ns$namespace",
713 'label' => $this->msg( 'watchlistedit-normal-check-all' )->text()
716 $fields['TitlesNs' . $namespace] = [
717 'cssclass' => 'mw-watchlistedit-check',
718 'class' => EditWatchlistCheckboxSeriesField::class,
719 'options' => $options,
720 'section' => "ns$namespace",
724 $this->cleanupWatchlist();
726 $this->tocData = new TOCData();
727 if ( count( $fields ) > 1 && $count > 30 ) {
728 $tocLength = 0;
729 $contLang = $this->getContentLanguage();
730 foreach ( $fields as $key => $data ) {
731 // ignore the 'check all' field
732 if ( str_starts_with( $key, 'CheckAllNs' ) ) {
733 continue;
735 # strip out the 'ns' prefix from the section name:
736 $ns = (int)substr( $data['section'], 2 );
737 $nsText = ( $ns === NS_MAIN )
738 ? $this->msg( 'blanknamespace' )->text()
739 : $contLang->getFormattedNsText( $ns );
740 $anchor = "editwatchlist-{$data['section']}";
741 ++$tocLength;
742 $this->tocData->addSection( new SectionMetadata(
744 // This is supposed to be the heading level, e.g. 2 for a <h2> tag,
745 // but this page uses <legend> tags for the headings, so use a fake value
747 htmlspecialchars( $nsText ),
748 $this->getLanguage()->formatNum( $tocLength ),
749 (string)$tocLength,
750 null,
751 null,
752 $anchor,
753 $anchor
754 ) );
758 $form = new EditWatchlistNormalHTMLForm( $fields, $this->getContext() );
759 $form->setTitle( $this->getPageTitle() ); // Remove subpage
760 $form->setSubmitTextMsg( 'watchlistedit-normal-submit' );
761 $form->setSubmitDestructive();
762 # Used message keys:
763 # 'accesskey-watchlistedit-normal-submit', 'tooltip-watchlistedit-normal-submit'
764 $form->setSubmitTooltip( 'watchlistedit-normal-submit' );
765 $form->setWrapperLegendMsg( 'watchlistedit-normal-legend' );
766 $form->addHeaderHtml( $this->msg( 'watchlistedit-normal-explain' )->parse() );
767 $form->setSubmitCallback( [ $this, 'submitNormal' ] );
769 return $form;
773 * Build the label for a checkbox, with a link to the title, and various additional bits
775 * @param Title $title
776 * @param string $expiryDaysText message shows the number of days a title has remaining in a user's watchlist.
777 * If this param is not empty then include a message that states the time remaining in a watchlist.
778 * @return string
780 private function buildRemoveLine( $title, string $expiryDaysText = '' ): string {
781 $linkRenderer = $this->getLinkRenderer();
782 $link = $linkRenderer->makeLink( $title );
784 $tools = [];
785 $tools['talk'] = $linkRenderer->makeLink(
786 $title->getTalkPage(),
787 $this->msg( 'talkpagelinktext' )->text()
790 if ( $title->exists() ) {
791 $tools['history'] = $linkRenderer->makeKnownLink(
792 $title,
793 $this->msg( 'history_small' )->text(),
795 [ 'action' => 'history' ]
799 if ( $title->getNamespace() === NS_USER && !$title->isSubpage() ) {
800 $tools['contributions'] = $linkRenderer->makeKnownLink(
801 SpecialPage::getTitleFor( 'Contributions', $title->getText() ),
802 $this->msg( 'contribslink' )->text()
806 $this->getHookRunner()->onWatchlistEditorBuildRemoveLine(
807 $tools, $title, $title->isRedirect(), $this->getSkin(), $link );
809 if ( $title->isRedirect() ) {
810 // Linker already makes class mw-redirect, so this is redundant
811 $link = '<span class="watchlistredir">' . $link . '</span>';
814 $watchlistExpiringMessage = '';
815 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && $expiryDaysText ) {
816 $watchlistExpiringMessage = Html::element(
817 'span',
818 [ 'class' => 'mw-watchlistexpiry-msg' ],
819 $expiryDaysText
823 return $link . ' ' . Html::openElement( 'span', [ 'class' => 'mw-changeslist-links' ] ) .
824 implode(
826 array_map( static function ( $tool ) {
827 return Html::rawElement( 'span', [], $tool );
828 }, $tools )
830 Html::closeElement( 'span' ) .
831 $watchlistExpiringMessage;
835 * Get a form for editing the watchlist in "raw" mode
837 * @return HTMLForm
839 protected function getRawForm() {
840 $titles = implode( "\n", $this->getWatchlist() );
841 $fields = [
842 'Titles' => [
843 'type' => 'textarea',
844 'label-message' => 'watchlistedit-raw-titles',
845 'default' => $titles,
848 $form = new OOUIHTMLForm( $fields, $this->getContext() );
849 $form->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage
850 $form->setSubmitTextMsg( 'watchlistedit-raw-submit' );
851 # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit'
852 $form->setSubmitTooltip( 'watchlistedit-raw-submit' );
853 $form->setWrapperLegendMsg( 'watchlistedit-raw-legend' );
854 $form->addHeaderHtml( $this->msg( 'watchlistedit-raw-explain' )->parse() );
855 $form->setSubmitCallback( [ $this, 'submitRaw' ] );
857 return $form;
861 * Get a form for clearing the watchlist
863 * @return HTMLForm
865 protected function getClearForm() {
866 $form = new OOUIHTMLForm( [], $this->getContext() );
867 $form->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage
868 $form->setSubmitTextMsg( 'watchlistedit-clear-submit' );
869 # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit'
870 $form->setSubmitTooltip( 'watchlistedit-clear-submit' );
871 $form->setWrapperLegendMsg( 'watchlistedit-clear-legend' );
872 $form->addHeaderHtml( $this->msg( 'watchlistedit-clear-explain' )->parse() );
873 $form->setSubmitCallback( [ $this, 'submitClear' ] );
874 $form->setSubmitDestructive();
876 return $form;
880 * Determine whether we are editing the watchlist, and if so, what
881 * kind of editing operation
883 * @param WebRequest $request
884 * @param string|null $par
885 * @param int|false $defaultValue to use if not known.
886 * @return int|false
888 public static function getMode( $request, $par, $defaultValue = false ) {
889 $mode = strtolower( $request->getRawVal( 'action' ) ?? $par ?? '' );
891 switch ( $mode ) {
892 case 'view':
893 return self::VIEW;
894 case 'clear':
895 case self::EDIT_CLEAR:
896 return self::EDIT_CLEAR;
897 case 'raw':
898 case self::EDIT_RAW:
899 return self::EDIT_RAW;
900 case 'edit':
901 case self::EDIT_NORMAL:
902 return self::EDIT_NORMAL;
903 default:
904 return $defaultValue;
909 * Build a set of links for convenient navigation
910 * between watchlist viewing and editing modes
912 * @param mixed $unused
913 * @param LinkRenderer|null $linkRenderer
914 * @param int|false $selectedMode result of self::getMode
915 * @return string
917 public static function buildTools( $unused, ?LinkRenderer $linkRenderer = null, $selectedMode = false ) {
918 if ( !$linkRenderer ) {
919 $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
922 $tools = [];
923 $modes = [
924 'view' => [ 'Watchlist', false, false ],
925 'edit' => [ 'EditWatchlist', false, self::EDIT_NORMAL ],
926 'raw' => [ 'EditWatchlist', 'raw', self::EDIT_RAW ],
927 'clear' => [ 'EditWatchlist', 'clear', self::EDIT_CLEAR ],
930 foreach ( $modes as $mode => $arr ) {
931 // can use messages 'watchlisttools-view', 'watchlisttools-edit', 'watchlisttools-raw'
932 $link = $linkRenderer->makeKnownLink(
933 SpecialPage::getTitleFor( $arr[0], $arr[1] ),
934 wfMessage( "watchlisttools-{$mode}" )->text()
936 $isSelected = $selectedMode === $arr[2];
937 $classes = [
938 'mw-watchlist-toollink',
939 'mw-watchlist-toollink-' . $mode,
940 $isSelected ? 'mw-watchlist-toollink-active' :
941 'mw-watchlist-toollink-inactive'
943 $tools[] = Html::rawElement( 'span', [
944 'class' => $classes,
945 ], $link );
948 return Html::rawElement(
949 'span',
950 [ 'class' => 'mw-watchlist-toollinks mw-changeslist-links' ],
951 implode( '', $tools )
956 /** @deprecated class alias since 1.41 */
957 class_alias( SpecialEditWatchlist::class, 'SpecialEditWatchlist' );