58 private $badItems = [];
64 private $watchedItemStore;
70 private $linkBatchFactory;
76 private $wikiPageFactory;
79 private $watchlistManager;
102 parent::__construct(
'EditWatchlist',
'editmywatchlist' );
104 $services = MediaWikiServices::getInstance();
105 $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore();
106 $this->titleParser = $titleParser ?? $services->getTitleParser();
107 $this->genderCache = $genderCache ?? $services->getGenderCache();
108 $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
109 $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
110 $this->wikiPageFactory = $wikiPageFactory ?? $services->getWikiPageFactory();
111 $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
126 # Anons don't get a watchlist
135 $out->addModuleStyles( [
136 'mediawiki.interface.helpers.styles',
141 $this->currentMode = $mode;
146 $out->setPageTitle( $this->
msg(
'watchlistedit-raw-title' ) );
148 if ( $form->show() ) {
149 $out->addHTML( $this->successMessage );
154 $out->setPageTitle( $this->
msg(
'watchlistedit-clear-title' ) );
156 if ( $form->show() ) {
157 $out->addHTML( $this->successMessage );
178 'class' =>
'mw-watchlist-owner'
199 $out->setPageTitle( $this->
msg(
'watchlistedit-normal-title' ) );
201 if ( $form->show() ) {
202 $out->addHTML( $this->successMessage );
204 } elseif ( $this->toc !==
false ) {
205 $out->prependHTML( $this->toc );
231 private function extractTitles( $list ) {
232 $list = explode(
"\n", trim( $list ) );
236 foreach ( $list as $text ) {
237 $text = trim( $text );
238 if ( strlen( $text ) > 0 ) {
239 $title = Title::newFromText( $text );
240 if (
$title instanceof
Title && $this->watchlistManager->isWatchable(
$title ) ) {
246 $this->genderCache->doTitlesArray( $titles );
250 foreach ( $titles as
$title ) {
251 $list[] =
$title->getPrefixedText();
254 return array_unique( $list );
258 $wanted = $this->extractTitles( $data[
'Titles'] );
259 $current = $this->getWatchlist();
261 if ( count( $wanted ) > 0 ) {
262 $toWatch = array_diff( $wanted, $current );
263 $toUnwatch = array_diff( $current, $wanted );
264 $this->watchTitles( $toWatch );
265 $this->unwatchTitles( $toUnwatch );
266 $this->
getUser()->invalidateCache();
268 if ( count( $toWatch ) > 0 || count( $toUnwatch ) > 0 ) {
269 $this->successMessage = $this->
msg(
'watchlistedit-raw-done' )->parse();
274 if ( count( $toWatch ) > 0 ) {
275 $this->successMessage .=
' ' . $this->
msg(
'watchlistedit-raw-added' )
276 ->numParams( count( $toWatch ) )->parse();
277 $this->showTitles( $toWatch, $this->successMessage );
280 if ( count( $toUnwatch ) > 0 ) {
281 $this->successMessage .=
' ' . $this->
msg(
'watchlistedit-raw-removed' )
282 ->numParams( count( $toUnwatch ) )->parse();
283 $this->showTitles( $toUnwatch, $this->successMessage );
287 if ( count( $current ) === 0 ) {
291 $this->clearUserWatchedItems(
'raw' );
292 $this->showTitles( $current, $this->successMessage );
305 $this->clearUserWatchedItems(
'clear' );
315 private function clearUserWatchedItems(
string $messageFor ): void {
316 if ( $this->watchedItemStore->mustClearWatchedItemsUsingJobQueue( $this->
getUser() ) ) {
317 $this->clearUserWatchedItemsUsingJobQueue();
319 $this->clearUserWatchedItemsNow( $messageFor );
328 private function clearUserWatchedItemsNow(
string $messageFor ): void {
329 $current = $this->getWatchlist();
330 if ( !$this->watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) {
331 throw new LogicException(
332 __METHOD__ .
' should only be called when able to clear synchronously'
335 $this->successMessage = $this->msg(
'watchlistedit-' . $messageFor .
'-done' )->parse();
336 $this->successMessage .=
' ' . $this->msg(
'watchlistedit-' . $messageFor .
'-removed' )
337 ->numParams( count( $current ) )->parse();
338 $this->
getUser()->invalidateCache();
339 $this->showTitles( $current, $this->successMessage );
345 private function clearUserWatchedItemsUsingJobQueue(): void {
346 $this->watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->
getUser() );
347 $this->successMessage = $this->msg(
'watchlistedit-clear-jobqueue' )->parse();
359 private function showTitles( $titles, &$output ) {
360 $talk = $this->msg(
'talkpagelinktext' )->text();
362 $batch = $this->linkBatchFactory->newLinkBatch();
363 if ( count( $titles ) >= 100 ) {
364 $output = $this->msg(
'watchlistedit-too-many' )->parse();
367 foreach ( $titles as
$title ) {
374 $batch->addObj(
$title->getTalkPage() );
383 $linkRenderer = $this->getLinkRenderer();
384 foreach ( $titles as
$title ) {
391 $linkRenderer->makeLink(
$title ) .
' ' .
392 $this->msg(
'parentheses' )->rawParams(
393 $linkRenderer->makeLink(
$title->getTalkPage(), $talk )
399 $output .=
"</ul>\n";
408 private function getWatchlist() {
411 $watchedItems = $this->watchedItemStore->getWatchedItemsForUser(
413 [
'forWrite' => $this->getRequest()->wasPosted() ]
416 if ( $watchedItems ) {
419 foreach ( $watchedItems as $watchedItem ) {
420 $namespace = $watchedItem->getTarget()->getNamespace();
421 $dbKey = $watchedItem->getTarget()->getDBkey();
424 if ( $this->checkTitle(
$title, $namespace, $dbKey )
431 $this->genderCache->doTitlesArray( $titles );
433 foreach ( $titles as
$title ) {
434 $list[] =
$title->getPrefixedText();
438 $this->cleanupWatchlist();
453 if ( $this->getConfig()->
get( MainConfigNames::WatchlistExpiry ) ) {
454 $options[
'sortByExpiry'] =
true;
457 $watchedItems = $this->watchedItemStore->getWatchedItemsForUser(
461 $lb = $this->linkBatchFactory->newLinkBatch();
464 foreach ( $watchedItems as $watchedItem ) {
465 $namespace = $watchedItem->getTarget()->getNamespace();
466 $dbKey = $watchedItem->getTarget()->getDBkey();
467 $lb->add( $namespace, $dbKey );
468 if ( !$this->nsInfo->isTalk( $namespace ) ) {
469 $titles[$namespace][$dbKey] = $watchedItem->getExpiryInDaysText( $context );
486 private function checkTitle(
$title, $namespace, $dbKey ) {
489 ||
$title->getNamespace() < 0
496 ||
$title->getNamespace() != $namespace
497 ||
$title->getDBkey() != $dbKey
499 $this->badItems[] = [
$title, $namespace, $dbKey ];
508 private function cleanupWatchlist() {
509 if ( $this->badItems === [] ) {
514 $badItems = $this->badItems;
516 foreach ( $badItems as [
$title, $namespace, $dbKey ] ) {
517 $action =
$title ?
'cleaning up' :
'deleting';
518 wfDebug(
"User {$user->getName()} has broken watchlist item " .
519 "ns($namespace):$dbKey, $action." );
524 $this->watchedItemStore->removeWatch( $user, Title::makeTitle( (
int)$namespace, $dbKey ) );
527 $this->watchlistManager->addWatch( $user,
$title );
541 private function watchTitles( array $targets ) {
542 return $this->watchedItemStore->addWatchBatchForUser(
543 $this->
getUser(), $this->getExpandedTargets( $targets )
544 ) && $this->runWatchUnwatchCompleteHook(
'Watch', $targets );
559 private function unwatchTitles( array $targets ) {
560 return $this->watchedItemStore->removeWatchBatchForUser(
561 $this->
getUser(), $this->getExpandedTargets( $targets )
562 ) && $this->runWatchUnwatchCompleteHook(
'Unwatch', $targets );
573 private function runWatchUnwatchCompleteHook( $action, $targets ) {
574 foreach ( $targets as $target ) {
577 Title::newFromText( $target );
578 $page = $this->wikiPageFactory->newFromTitle(
$title );
580 if ( $action ===
'Watch' ) {
581 $this->getHookRunner()->onWatchArticleComplete( $user, $page );
583 $this->getHookRunner()->onUnwatchArticleComplete( $user, $page );
593 private function getExpandedTargets( array $targets ) {
594 $expandedTargets = [];
595 foreach ( $targets as $target ) {
598 $target = $this->titleParser->parseTitle( $target,
NS_MAIN );
604 $ns = $target->getNamespace();
605 $dbKey = $target->getDBkey();
607 new TitleValue( $this->nsInfo->getSubject( $ns ), $dbKey );
609 new TitleValue( $this->nsInfo->getTalk( $ns ), $dbKey );
611 return $expandedTargets;
617 foreach ( $data as $titles ) {
618 $this->unwatchTitles( $titles );
619 $removed = array_merge( $removed, $titles );
622 if ( count( $removed ) > 0 ) {
623 $this->successMessage = $this->msg(
'watchlistedit-normal-done'
624 )->numParams( count( $removed ) )->parse();
625 $this->showTitles( $removed, $this->successMessage );
644 $watchlistInfo = $this->getWatchlistInfo();
645 $this->getHookRunner()->onWatchlistEditorBeforeFormRender( $watchlistInfo );
647 foreach ( $watchlistInfo as $namespace => $pages ) {
649 foreach ( $pages as $dbkey => $expiryDaysText ) {
650 $title = Title::makeTitleSafe( $namespace, $dbkey );
652 if ( $this->checkTitle(
$title, $namespace, $dbkey ) ) {
653 $text = $this->buildRemoveLine(
$title, $expiryDaysText );
654 $options[$text] =
$title->getPrefixedText();
660 if ( count( $options ) > 0 ) {
661 $fields[
'TitlesNs' . $namespace] = [
662 'class' => EditWatchlistCheckboxSeriesField::class,
663 'options' => $options,
664 'section' =>
"ns$namespace",
668 $this->cleanupWatchlist();
670 if ( count( $fields ) > 1 && $count > 30 ) {
673 $contLang = $this->getContentLanguage();
675 foreach ( $fields as $data ) {
676 # strip out the 'ns' prefix from the section name:
677 $ns = (int)substr( $data[
'section'], 2 );
680 ? $this->msg(
'blanknamespace' )->escaped()
681 : htmlspecialchars( $contLang->getFormattedNsText( $ns ) );
682 $this->toc .=
Linker::tocLine(
"editwatchlist-{$data['section']}", $nsText,
692 $form->setTitle( $this->getPageTitle() );
693 $form->setSubmitTextMsg(
'watchlistedit-normal-submit' );
694 $form->setSubmitDestructive();
696 # 'accesskey-watchlistedit-normal-submit', 'tooltip-watchlistedit-normal-submit'
697 $form->setSubmitTooltip(
'watchlistedit-normal-submit' );
698 $form->setWrapperLegendMsg(
'watchlistedit-normal-legend' );
699 $form->addHeaderText( $this->msg(
'watchlistedit-normal-explain' )->parse() );
700 $form->setSubmitCallback( [ $this,
'submitNormal' ] );
713 private function buildRemoveLine(
$title,
string $expiryDaysText =
'' ): string {
714 $linkRenderer = $this->getLinkRenderer();
715 $link = $linkRenderer->makeLink(
$title );
718 $tools[
'talk'] = $linkRenderer->makeLink(
720 $this->msg(
'talkpagelinktext' )->text()
724 $tools[
'history'] = $linkRenderer->makeKnownLink(
726 $this->msg(
'history_small' )->text(),
728 [
'action' =>
'history' ]
733 $tools[
'contributions'] = $linkRenderer->makeKnownLink(
735 $this->msg(
'contribslink' )->text()
739 $this->getHookRunner()->onWatchlistEditorBuildRemoveLine(
740 $tools,
$title,
$title->isRedirect(), $this->getSkin(), $link );
742 if (
$title->isRedirect() ) {
744 $link =
'<span class="watchlistredir">' . $link .
'</span>';
747 $watchlistExpiringMessage =
'';
748 if ( $this->getConfig()->
get( MainConfigNames::WatchlistExpiry ) && $expiryDaysText ) {
751 [
'class' =>
'mw-watchlistexpiry-msg' ],
756 return $link .
' ' .
Html::openElement(
'span', [
'class' =>
'mw-changeslist-links' ] ) .
759 array_map(
static function ( $tool ) {
764 $watchlistExpiringMessage;
773 $titles = implode(
"\n", $this->getWatchlist() );
776 'type' =>
'textarea',
777 'label-message' =>
'watchlistedit-raw-titles',
778 'default' => $titles,
782 $form->setTitle( $this->getPageTitle(
'raw' ) );
783 $form->setSubmitTextMsg(
'watchlistedit-raw-submit' );
784 # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit'
785 $form->setSubmitTooltip(
'watchlistedit-raw-submit' );
786 $form->setWrapperLegendMsg(
'watchlistedit-raw-legend' );
787 $form->addHeaderText( $this->msg(
'watchlistedit-raw-explain' )->parse() );
788 $form->setSubmitCallback( [ $this,
'submitRaw' ] );
800 $form->setTitle( $this->getPageTitle(
'clear' ) );
801 $form->setSubmitTextMsg(
'watchlistedit-clear-submit' );
802 # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit'
803 $form->setSubmitTooltip(
'watchlistedit-clear-submit' );
804 $form->setWrapperLegendMsg(
'watchlistedit-clear-legend' );
805 $form->addHeaderText( $this->msg(
'watchlistedit-clear-explain' )->parse() );
806 $form->setSubmitCallback( [ $this,
'submitClear' ] );
807 $form->setSubmitDestructive();
821 public static function getMode( $request, $par, $defaultValue =
false ) {
822 $mode = strtolower( $request->getRawVal(
'action', $par ??
'' ) );
826 case self::EDIT_CLEAR:
827 return self::EDIT_CLEAR;
830 return self::EDIT_RAW;
832 case self::EDIT_NORMAL:
833 return self::EDIT_NORMAL;
835 return $defaultValue;
854 if ( !$linkRenderer ) {
855 $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
860 'view' => [
'Watchlist',
false, false ],
861 'edit' => [
'EditWatchlist',
false, self::EDIT_NORMAL ],
862 'raw' => [
'EditWatchlist',
'raw', self::EDIT_RAW ],
863 'clear' => [
'EditWatchlist',
'clear', self::EDIT_CLEAR ],
866 foreach ( $modes as $mode => $arr ) {
868 $link = $linkRenderer->makeKnownLink(
870 wfMessage(
"watchlisttools-{$mode}" )->text()
872 $isSelected = $selectedMode === $arr[2];
874 'mw-watchlist-toollink',
875 'mw-watchlist-toollink-' . $mode,
876 $isSelected ?
'mw-watchlist-toollink-active' :
877 'mw-watchlist-toollink-inactive'
879 $tools[] = Html::rawElement(
'span', [
884 return Html::rawElement(
886 [
'class' =>
'mw-watchlist-toollinks mw-changeslist-links' ],
887 implode(
'', $tools )
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
if(!defined( 'MW_NO_SESSION') &&! $wgCommandLineMode $wgLang
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Caches user genders when needed to use correct namespace aliases.
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
static closeElement( $element)
Returns "</$element>".
Base class for language-specific code.
static tocLine( $anchor, $tocline, $tocnumber, $level, $sectionIndex=false)
parameter level defines if we are on an indentation level
static tocIndent()
Add another level to the Table of Contents.
static tocList( $toc, Language $lang=null)
Wraps the TOC in a div with ARIA navigation role and provides the hide/collapse JavaScript.
static tocLineEnd()
End a Table Of Contents line.
A class containing constants representing the names of configuration variables.
Service for creating WikiPage objects.
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Provides the UI through which users can perform editing operations on their watchlist.
static buildTools( $lang, LinkRenderer $linkRenderer=null, $selectedMode=false)
Build a set of links for convenient navigation between watchlist viewing and editing modes.
getRawForm()
Get a form for editing the watchlist in "raw" mode.
getNormalForm()
Get the standard watchlist editing form.
doesWrites()
Indicates whether this special page may perform database writes.
executeViewEditWatchlist()
Executes an edit mode for the watchlist view, from which you can manage your watchlist.
getSubpagesForPrefixSearch()
Return an array of subpages that this special page will accept.
getWatchlistInfo()
Get a list of titles on a user's watchlist, excluding talk pages, and return as a two-dimensional arr...
getClearForm()
Get a form for clearing the watchlist.
const EDIT_CLEAR
Editing modes.
execute( $mode)
Main execution point.
__construct(WatchedItemStoreInterface $watchedItemStore=null, TitleParser $titleParser=null, GenderCache $genderCache=null, LinkBatchFactory $linkBatchFactory=null, NamespaceInfo $nsInfo=null, WikiPageFactory $wikiPageFactory=null, WatchlistManager $watchlistManager=null)
outputSubtitle()
Renders a subheader on the watchlist page.
submitClear( $data)
Handler for the clear form submission.
static getMode( $request, $par, $defaultValue=false)
Determine whether we are editing the watchlist, and if so, what kind of editing operation.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
getName()
Get the name of this Special Page.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
checkPermissions()
Checks if userCanExecute, and if not throws a PermissionsError.
requireNamedUser( $reasonMsg='exception-nologin-text', $titleMsg='exception-nologin')
If the user is not logged in or is a temporary user, throws UserNotLoggedIn.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getRequest()
Get the WebRequest being used for this instance.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
getLanguage()
Shortcut to get user's language.
Represents a page (or page fragment) title within MediaWiki.
Represents a title within MediaWiki.
static newFromLinkTarget(LinkTarget $linkTarget, $forceClone='')
Returns a Title given a LinkTarget.
static newFromText( $text, $defaultNamespace=NS_MAIN)
Create a new Title from text, such as what one would find in a link.
static makeTitleSafe( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Shortcut to construct a special page which is unlisted by default.
A title parser service for MediaWiki.
if(!isset( $args[0])) $lang