Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
80 / 90
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchAction
88.89% covered (warning)
88.89%
80 / 90
85.71% covered (warning)
85.71%
12 / 14
43.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requiresUnblock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onSubmit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 checkCanExecute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usesOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormFields
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
2.59
 getExpiryOptions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getExpiryOptionsFromMessage
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 alterForm
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 onSuccess
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 doesWrites
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Performs the watch actions on a page
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
18 *
19 * @file
20 * @ingroup Actions
21 */
22
23use MediaWiki\Context\IContextSource;
24use MediaWiki\HTMLForm\HTMLForm;
25use MediaWiki\MainConfigNames;
26use MediaWiki\Status\Status;
27use MediaWiki\User\User;
28use MediaWiki\Watchlist\WatchedItem;
29use MediaWiki\Watchlist\WatchedItemStore;
30use MediaWiki\Watchlist\WatchlistManager;
31use MediaWiki\Xml\XmlSelect;
32use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
33
34/**
35 * Page addition to a user's watchlist
36 *
37 * @ingroup Actions
38 */
39class WatchAction extends FormAction {
40
41    /** @var bool The value of the $wgWatchlistExpiry configuration variable. */
42    protected $watchlistExpiry;
43
44    /** @var string */
45    protected $expiryFormFieldName = 'expiry';
46
47    /** @var false|WatchedItem */
48    protected $watchedItem = false;
49
50    private WatchlistManager $watchlistManager;
51
52    /**
53     * Only public since 1.21
54     *
55     * @param Article $article
56     * @param IContextSource $context
57     * @param WatchlistManager $watchlistManager
58     * @param WatchedItemStore $watchedItemStore
59     */
60    public function __construct(
61        Article $article,
62        IContextSource $context,
63        WatchlistManager $watchlistManager,
64        WatchedItemStore $watchedItemStore
65    ) {
66        parent::__construct( $article, $context );
67        $this->watchlistExpiry = $this->getContext()->getConfig()->get( MainConfigNames::WatchlistExpiry );
68        if ( $this->watchlistExpiry ) {
69            // The watchedItem is only used in this action's form if $wgWatchlistExpiry is enabled.
70            $this->watchedItem = $watchedItemStore->getWatchedItem(
71                $this->getUser(),
72                $this->getTitle()
73            );
74        }
75        $this->watchlistManager = $watchlistManager;
76    }
77
78    public function getName() {
79        return 'watch';
80    }
81
82    public function requiresUnblock() {
83        return false;
84    }
85
86    protected function getDescription() {
87        return '';
88    }
89
90    public function onSubmit( $data ) {
91        // Even though we're never unwatching here, use WatchlistManager::setWatch()
92        // because it also checks for changed expiry.
93        $result = $this->watchlistManager->setWatch(
94            true,
95            $this->getAuthority(),
96            $this->getTitle(),
97            $this->getRequest()->getVal( 'wp' . $this->expiryFormFieldName )
98        );
99
100        return Status::wrap( $result );
101    }
102
103    /**
104     * @throws UserNotLoggedIn
105     * @throws PermissionsError
106     * @throws ReadOnlyError
107     * @throws UserBlockedError
108     */
109    protected function checkCanExecute( User $user ) {
110        if ( !$user->isRegistered()
111            || ( $user->isTemp() && !$user->isAllowed( 'editmywatchlist' ) )
112        ) {
113            throw new UserNotLoggedIn( 'watchlistanontext', 'watchnologin' );
114        }
115
116        parent::checkCanExecute( $user );
117    }
118
119    public function getRestriction() {
120        return 'editmywatchlist';
121    }
122
123    protected function usesOOUI() {
124        return true;
125    }
126
127    protected function getFormFields() {
128        // If watchlist expiry is not enabled, return a simple confirmation message.
129        if ( !$this->watchlistExpiry ) {
130            return [
131                'intro' => [
132                    'type' => 'info',
133                    'raw' => true,
134                    'default' => $this->msg( 'confirm-watch-top' )->parse(),
135                ],
136            ];
137        }
138
139        // Otherwise, use a select-list of expiries.
140        $expiryOptions = static::getExpiryOptions( $this->getContext(), $this->watchedItem );
141        return [
142            $this->expiryFormFieldName => [
143                'type' => 'select',
144                'label-message' => 'confirm-watch-label',
145                'options' => $expiryOptions['options'],
146                'default' => $expiryOptions['default'],
147            ]
148        ];
149    }
150
151    /**
152     * Get options and default for a watchlist expiry select list. If an expiry time is provided, it
153     * will be added to the top of the list as 'x days left'.
154     *
155     * @since 1.35
156     * @todo Move this somewhere better when it's being used in more than just this action.
157     *
158     * @param MessageLocalizer $msgLocalizer
159     * @param WatchedItem|false $watchedItem
160     *
161     * @return mixed[] With keys `options` (string[]) and `default` (string).
162     */
163    public static function getExpiryOptions( MessageLocalizer $msgLocalizer, $watchedItem ) {
164        $expiryOptions = self::getExpiryOptionsFromMessage( $msgLocalizer );
165        $default = in_array( 'infinite', $expiryOptions )
166            ? 'infinite'
167            : current( $expiryOptions );
168        if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() ) {
169            // If it's already being temporarily watched,
170            // add the existing expiry as the default option in the dropdown.
171            $default = $watchedItem->getExpiry( TS_ISO_8601 );
172            $daysLeft = $watchedItem->getExpiryInDaysText( $msgLocalizer, true );
173            $expiryOptions = array_merge( [ $daysLeft => $default ], $expiryOptions );
174        }
175        return [
176            'options' => $expiryOptions,
177            'default' => $default,
178        ];
179    }
180
181    /**
182     * Parse expiry options message. Fallback to english options
183     * if translated options are invalid or broken
184     *
185     * @param MessageLocalizer $msgLocalizer
186     * @param string|null $lang
187     * @return string[]
188     */
189    private static function getExpiryOptionsFromMessage(
190        MessageLocalizer $msgLocalizer, ?string $lang = null
191    ): array {
192        $expiryOptionsMsg = $msgLocalizer->msg( 'watchlist-expiry-options' );
193        $optionsText = !$lang ? $expiryOptionsMsg->text() : $expiryOptionsMsg->inLanguage( $lang )->text();
194        $options = XmlSelect::parseOptionsMessage(
195            $optionsText
196        );
197
198        $expiryOptions = [];
199        foreach ( $options as $label => $value ) {
200            if ( strtotime( $value ) || wfIsInfinity( $value ) ) {
201                $expiryOptions[$label] = $value;
202            }
203        }
204
205        // If message options is invalid try to recover by returning
206        // english options (T267611)
207        if ( !$expiryOptions && $expiryOptionsMsg->getLanguage()->getCode() !== 'en' ) {
208            return self::getExpiryOptionsFromMessage( $msgLocalizer, 'en' );
209        }
210
211        return $expiryOptions;
212    }
213
214    protected function alterForm( HTMLForm $form ) {
215        $msg = $this->watchlistExpiry && $this->watchedItem ? 'updatewatchlist' : 'addwatch';
216        $form->setWrapperLegendMsg( $msg );
217        $submitMsg = $this->watchlistExpiry ? 'confirm-watch-button-expiry' : 'confirm-watch-button';
218        $form->setSubmitTextMsg( $submitMsg );
219        $form->setTokenSalt( 'watch' );
220    }
221
222    /**
223     * Show one of 8 possible success messages.
224     * The messages are:
225     * 1. addedwatchtext
226     * 2. addedwatchtext-talk
227     * 3. addedwatchindefinitelytext
228     * 4. addedwatchindefinitelytext-talk
229     * 5. addedwatchexpirytext
230     * 6. addedwatchexpirytext-talk
231     * 7. addedwatchexpiryhours
232     * 8. addedwatchexpiryhours-talk
233     */
234    public function onSuccess() {
235        $msgKey = $this->getTitle()->isTalkPage() ? 'addedwatchtext-talk' : 'addedwatchtext';
236        $expiryLabel = null;
237        $submittedExpiry = $this->getContext()->getRequest()->getText( 'wp' . $this->expiryFormFieldName );
238        if ( $submittedExpiry ) {
239            // We can't use $this->watcheditem to get the expiry because it's not been saved at this
240            // point in the request and so its values are those from before saving.
241            $expiry = ExpiryDef::normalizeExpiry( $submittedExpiry, TS_ISO_8601 );
242
243            // If the expiry label isn't one of the predefined ones in the dropdown, calculate 'x days'.
244            $expiryDays = WatchedItem::calculateExpiryInDays( $expiry );
245            $defaultLabels = static::getExpiryOptions( $this->getContext(), false )['options'];
246            $localizedExpiry = array_search( $submittedExpiry, $defaultLabels );
247            $expiryLabel = $expiryDays && $localizedExpiry === false
248                ? $this->getContext()->msg( 'days', $expiryDays )->text()
249                : $localizedExpiry;
250
251            // Determine which message to use, depending on whether this is a talk page or not
252            // and whether an expiry was selected.
253            $isTalk = $this->getTitle()->isTalkPage();
254            if ( wfIsInfinity( $expiry ) ) {
255                $msgKey = $isTalk ? 'addedwatchindefinitelytext-talk' : 'addedwatchindefinitelytext';
256            } elseif ( $expiryDays > 0 ) {
257                $msgKey = $isTalk ? 'addedwatchexpirytext-talk' : 'addedwatchexpirytext';
258            } elseif ( $expiryDays < 1 ) {
259                $msgKey = $isTalk ? 'addedwatchexpiryhours-talk' : 'addedwatchexpiryhours';
260            }
261        }
262        $this->getOutput()->addWikiMsg( $msgKey, $this->getTitle()->getPrefixedText(), $expiryLabel );
263    }
264
265    public function doesWrites() {
266        return true;
267    }
268}