Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiFeedWatchlist
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 8
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomPrinter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
272
 createFeedItem
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
210
 getWatchlistModule
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
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 along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\Feed\FeedItem;
24use MediaWiki\MainConfigNames;
25use MediaWiki\Request\FauxRequest;
26use MediaWiki\SpecialPage\SpecialPage;
27use MediaWiki\Title\Title;
28use Wikimedia\ParamValidator\ParamValidator;
29use Wikimedia\ParamValidator\TypeDef\IntegerDef;
30
31/**
32 * This action allows users to get their watchlist items in RSS/Atom formats.
33 * When executed, it performs a nested call to the API to get the needed data,
34 * and formats it in a proper format.
35 *
36 * @ingroup API
37 */
38class ApiFeedWatchlist extends ApiBase {
39
40    private $watchlistModule = null;
41    private $linkToSections = false;
42
43    private ParserFactory $parserFactory;
44
45    /**
46     * @param ApiMain $main
47     * @param string $action
48     * @param ParserFactory $parserFactory
49     */
50    public function __construct(
51        ApiMain $main,
52        $action,
53        ParserFactory $parserFactory
54    ) {
55        parent::__construct( $main, $action );
56        $this->parserFactory = $parserFactory;
57    }
58
59    /**
60     * This module uses a custom feed wrapper printer.
61     *
62     * @return ApiFormatFeedWrapper
63     */
64    public function getCustomPrinter() {
65        return new ApiFormatFeedWrapper( $this->getMain() );
66    }
67
68    /**
69     * Make a nested call to the API to request watchlist items in the last $hours.
70     * Wrap the result as an RSS/Atom feed.
71     */
72    public function execute() {
73        $config = $this->getConfig();
74        $feedClasses = $config->get( MainConfigNames::FeedClasses );
75        $params = [];
76        $feedItems = [];
77        try {
78            $params = $this->extractRequestParams();
79
80            if ( !$config->get( MainConfigNames::Feed ) ) {
81                $this->dieWithError( 'feed-unavailable' );
82            }
83
84            if ( !isset( $feedClasses[$params['feedformat']] ) ) {
85                $this->dieWithError( 'feed-invalid' );
86            }
87
88            // limit to the number of hours going from now back
89            $endTime = wfTimestamp( TS_MW, time() - (int)$params['hours'] * 60 * 60 );
90
91            // Prepare parameters for nested request
92            $fauxReqArr = [
93                'action' => 'query',
94                'meta' => 'siteinfo',
95                'siprop' => 'general',
96                'list' => 'watchlist',
97                'wlprop' => 'title|user|comment|timestamp|ids|loginfo',
98                'wldir' => 'older', // reverse order - from newest to oldest
99                'wlend' => $endTime, // stop at this time
100                'wllimit' => min( 50, $this->getConfig()->get( MainConfigNames::FeedLimit ) )
101            ];
102
103            if ( $params['wlowner'] !== null ) {
104                $fauxReqArr['wlowner'] = $params['wlowner'];
105            }
106            if ( $params['wltoken'] !== null ) {
107                $fauxReqArr['wltoken'] = $params['wltoken'];
108            }
109            if ( $params['wlexcludeuser'] !== null ) {
110                $fauxReqArr['wlexcludeuser'] = $params['wlexcludeuser'];
111            }
112            if ( $params['wlshow'] !== null ) {
113                $fauxReqArr['wlshow'] = $params['wlshow'];
114            }
115            if ( $params['wltype'] !== null ) {
116                $fauxReqArr['wltype'] = $params['wltype'];
117            }
118
119            // Support linking directly to sections when possible
120            // (possible only if section name is present in comment)
121            if ( $params['linktosections'] ) {
122                $this->linkToSections = true;
123            }
124
125            // Check for 'allrev' parameter, and if found, show all revisions to each page on wl.
126            if ( $params['allrev'] ) {
127                $fauxReqArr['wlallrev'] = '';
128            }
129
130            $fauxReq = new FauxRequest( $fauxReqArr );
131
132            $module = new ApiMain( $fauxReq );
133            $module->execute();
134
135            $data = $module->getResult()->getResultData( [ 'query', 'watchlist' ] );
136            foreach ( (array)$data as $key => $info ) {
137                if ( ApiResult::isMetadataKey( $key ) ) {
138                    continue;
139                }
140                $feedItem = $this->createFeedItem( $info );
141                if ( $feedItem ) {
142                    $feedItems[] = $feedItem;
143                }
144            }
145
146            $msg = $this->msg( 'watchlist' )->inContentLanguage()->text();
147
148            $feedTitle = $this->getConfig()->get( MainConfigNames::Sitename ) . ' - ' . $msg .
149                ' [' . $this->getConfig()->get( MainConfigNames::LanguageCode ) . ']';
150            $feedUrl = SpecialPage::getTitleFor( 'Watchlist' )->getFullURL();
151
152            $feed = new $feedClasses[$params['feedformat']] (
153                $feedTitle,
154                htmlspecialchars( $msg ),
155                $feedUrl
156            );
157
158            ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
159        } catch ( Exception $e ) {
160            // Error results should not be cached
161            $this->getMain()->setCacheMaxAge( 0 );
162
163            // @todo FIXME: Localise  brackets
164            $feedTitle = $this->getConfig()->get( MainConfigNames::Sitename ) . ' - Error - ' .
165                $this->msg( 'watchlist' )->inContentLanguage()->text() .
166                ' [' . $this->getConfig()->get( MainConfigNames::LanguageCode ) . ']';
167            $feedUrl = SpecialPage::getTitleFor( 'Watchlist' )->getFullURL();
168
169            $feedFormat = $params['feedformat'] ?? 'rss';
170            $msg = $this->msg( 'watchlist' )->inContentLanguage()->escaped();
171            $feed = new $feedClasses[$feedFormat] ( $feedTitle, $msg, $feedUrl );
172
173            if ( $e instanceof ApiUsageException ) {
174                foreach ( $e->getStatusValue()->getErrors() as $error ) {
175                    // @phan-suppress-next-line PhanUndeclaredMethod
176                    $msg = ApiMessage::create( $error )
177                        ->inLanguage( $this->getLanguage() );
178                    $errorTitle = $this->msg( 'api-feed-error-title', $msg->getApiCode() );
179                    $errorText = $msg->text();
180                    $feedItems[] = new FeedItem( $errorTitle, $errorText, '', '', '' );
181                }
182            } else {
183                // Something is seriously wrong
184                $errorCode = 'internal_api_error';
185                $errorTitle = $this->msg( 'api-feed-error-title', $errorCode );
186                $errorText = $e->getMessage();
187                $feedItems[] = new FeedItem( $errorTitle, $errorText, '', '', '' );
188            }
189
190            ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
191        }
192    }
193
194    /**
195     * @param array $info
196     * @return FeedItem|null
197     */
198    private function createFeedItem( $info ) {
199        if ( !isset( $info['title'] ) ) {
200            // Probably a revdeled log entry, skip it.
201            return null;
202        }
203
204        $titleStr = $info['title'];
205        $title = Title::newFromText( $titleStr );
206        $curidParam = [];
207        if ( !$title || $title->isExternal() ) {
208            // Probably a formerly-valid title that's now conflicting with an
209            // interwiki prefix or the like.
210            if ( isset( $info['pageid'] ) ) {
211                $title = Title::newFromID( $info['pageid'] );
212                $curidParam = [ 'curid' => $info['pageid'] ];
213            }
214            if ( !$title || $title->isExternal() ) {
215                return null;
216            }
217        }
218        if ( isset( $info['revid'] ) ) {
219            if ( $info['revid'] === 0 && isset( $info['logid'] ) ) {
220                $logTitle = Title::makeTitle( NS_SPECIAL, 'Log' );
221                $titleUrl = $logTitle->getFullURL( [ 'logid' => $info['logid'] ] );
222            } else {
223                $titleUrl = $title->getFullURL( [ 'diff' => $info['revid'] ] );
224            }
225        } else {
226            $titleUrl = $title->getFullURL( $curidParam );
227        }
228        $comment = $info['comment'] ?? null;
229
230        // Create an anchor to section.
231        // The anchor won't work for sections that have dupes on page
232        // as there's no way to strip that info from ApiWatchlist (apparently?).
233        // RegExp in the line below is equal to MediaWiki\CommentFormatter\CommentParser::doSectionLinks().
234        if ( $this->linkToSections && $comment !== null &&
235            preg_match( '!(.*)/\*\s*(.*?)\s*\*/(.*)!', $comment, $matches )
236        ) {
237            $titleUrl .= $this->parserFactory->getMainInstance()->guessSectionNameFromWikiText( $matches[ 2 ] );
238        }
239
240        $timestamp = $info['timestamp'];
241
242        if ( isset( $info['user'] ) ) {
243            $user = $info['user'];
244            $completeText = "$comment ($user)";
245        } else {
246            $user = '';
247            $completeText = (string)$comment;
248        }
249
250        return new FeedItem( $titleStr, $completeText, $titleUrl, $timestamp, $user );
251    }
252
253    private function getWatchlistModule() {
254        $this->watchlistModule ??= $this->getMain()->getModuleManager()->getModule( 'query' )
255            ->getModuleManager()->getModule( 'watchlist' );
256
257        return $this->watchlistModule;
258    }
259
260    public function getAllowedParams( $flags = 0 ) {
261        $feedFormatNames = array_keys( $this->getConfig()->get( MainConfigNames::FeedClasses ) );
262        $ret = [
263            'feedformat' => [
264                ParamValidator::PARAM_DEFAULT => 'rss',
265                ParamValidator::PARAM_TYPE => $feedFormatNames
266            ],
267            'hours' => [
268                ParamValidator::PARAM_DEFAULT => 24,
269                ParamValidator::PARAM_TYPE => 'integer',
270                IntegerDef::PARAM_MIN => 1,
271                IntegerDef::PARAM_MAX => 72,
272            ],
273            'linktosections' => false,
274        ];
275
276        $copyParams = [
277            'allrev' => 'allrev',
278            'owner' => 'wlowner',
279            'token' => 'wltoken',
280            'show' => 'wlshow',
281            'type' => 'wltype',
282            'excludeuser' => 'wlexcludeuser',
283        ];
284        // @phan-suppress-next-line PhanParamTooMany
285        $wlparams = $this->getWatchlistModule()->getAllowedParams( $flags );
286        foreach ( $copyParams as $from => $to ) {
287            $p = $wlparams[$from];
288            if ( !is_array( $p ) ) {
289                $p = [ ParamValidator::PARAM_DEFAULT => $p ];
290            }
291            if ( !isset( $p[ApiBase::PARAM_HELP_MSG] ) ) {
292                $p[ApiBase::PARAM_HELP_MSG] = "apihelp-query+watchlist-param-$from";
293            }
294            if ( isset( $p[ParamValidator::PARAM_TYPE] ) && is_array( $p[ParamValidator::PARAM_TYPE] ) &&
295                isset( $p[ApiBase::PARAM_HELP_MSG_PER_VALUE] )
296            ) {
297                foreach ( $p[ParamValidator::PARAM_TYPE] as $v ) {
298                    if ( !isset( $p[ApiBase::PARAM_HELP_MSG_PER_VALUE][$v] ) ) {
299                        $p[ApiBase::PARAM_HELP_MSG_PER_VALUE][$v] = "apihelp-query+watchlist-paramvalue-$from-$v";
300                    }
301                }
302            }
303            $ret[$to] = $p;
304        }
305
306        return $ret;
307    }
308
309    protected function getExamplesMessages() {
310        return [
311            'action=feedwatchlist'
312                => 'apihelp-feedwatchlist-example-default',
313            'action=feedwatchlist&allrev=&hours=6'
314                => 'apihelp-feedwatchlist-example-all6hrs',
315        ];
316    }
317
318    public function getHelpUrls() {
319        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Watchlist_feed';
320    }
321}