Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiEchoUnreadNotificationPages
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 7
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getFromLocal
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
306
 getUnreadNotificationPagesFromForeign
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 3
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
3namespace MediaWiki\Extension\Notifications\Api;
4
5use ApiQuery;
6use ApiQueryBase;
7use ApiUsageException;
8use MediaWiki\Extension\Notifications\DbFactory;
9use MediaWiki\Extension\Notifications\NotifUser;
10use MediaWiki\Extension\Notifications\Services;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\Page\PageRecord;
13use MediaWiki\Page\PageStore;
14use MediaWiki\Title\Title;
15use MediaWiki\Title\TitleFactory;
16use MediaWiki\WikiMap\WikiMap;
17use Wikimedia\ParamValidator\ParamValidator;
18use Wikimedia\ParamValidator\TypeDef\IntegerDef;
19use Wikimedia\Rdbms\SelectQueryBuilder;
20
21class ApiEchoUnreadNotificationPages extends ApiQueryBase {
22    use ApiCrossWiki;
23
24    /**
25     * @var bool
26     */
27    protected $crossWikiSummary = false;
28
29    /**
30     * @var PageStore
31     */
32    private $pageStore;
33
34    /**
35     * @var TitleFactory
36     */
37    private $titleFactory;
38
39    /**
40     * @param ApiQuery $query
41     * @param string $moduleName
42     * @param PageStore $pageStore
43     * @param TitleFactory $titleFactory
44     */
45    public function __construct( $query, $moduleName, PageStore $pageStore, TitleFactory $titleFactory ) {
46        parent::__construct( $query, $moduleName, 'unp' );
47        $this->pageStore = $pageStore;
48        $this->titleFactory = $titleFactory;
49    }
50
51    /**
52     * @throws ApiUsageException
53     */
54    public function execute() {
55        // To avoid API warning, register the parameter used to bust browser cache
56        $this->getMain()->getVal( '_' );
57
58        if ( !$this->getUser()->isRegistered() ) {
59            $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
60        }
61
62        $params = $this->extractRequestParams();
63
64        $result = [];
65        if ( in_array( WikiMap::getCurrentWikiId(), $this->getRequestedWikis() ) ) {
66            $result[WikiMap::getCurrentWikiId()] = $this->getFromLocal( $params['limit'], $params['grouppages'] );
67        }
68
69        if ( $this->getRequestedForeignWikis() ) {
70            $result += $this->getUnreadNotificationPagesFromForeign();
71        }
72
73        $apis = $this->getForeignNotifications()->getApiEndpoints( $this->getRequestedWikis() );
74        foreach ( $result as $wiki => $data ) {
75            $result[$wiki]['source'] = $apis[$wiki];
76            $result[$wiki]['pages'] = $data['pages'] ?: [];
77        }
78
79        $this->getResult()->addValue( 'query', $this->getModuleName(), $result );
80    }
81
82    /**
83     * @param int $limit
84     * @param bool $groupPages
85     * @return array
86     * @phan-return array{pages:array[],totalCount:int}
87     */
88    protected function getFromLocal( $limit, $groupPages ) {
89        $attributeManager = Services::getInstance()->getAttributeManager();
90        $enabledTypes = $attributeManager->getUserEnabledEvents( $this->getUser(), 'web' );
91
92        $dbr = DbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
93        $queryBuilder = $dbr->newSelectQueryBuilder()
94            ->select( [ 'event_page_id', 'count' => 'COUNT(*)' ] )
95            ->from( 'echo_event' )
96            ->join( 'echo_notification', null, 'notification_event = event_id' )
97            ->where( [
98                'notification_user' => $this->getUser()->getId(),
99                'notification_read_timestamp' => null,
100                'event_deleted' => 0,
101                'event_type' => $enabledTypes,
102            ] )
103            ->groupBy( 'event_page_id' )
104            ->caller( __METHOD__ );
105        // If $groupPages is true, we need to fetch all pages and apply the ORDER BY and LIMIT ourselves
106        // after grouping.
107        if ( !$groupPages ) {
108            $queryBuilder
109                ->orderBy( 'count', SelectQueryBuilder::SORT_DESC )
110                ->limit( $limit );
111        }
112        $rows = $queryBuilder->fetchResultSet();
113
114        $nullCount = 0;
115        $pageCounts = [];
116        foreach ( $rows as $row ) {
117            if ( $row->event_page_id !== null ) {
118                $pageCounts[(int)$row->event_page_id] = intval( $row->count );
119            } else {
120                $nullCount = intval( $row->count );
121            }
122        }
123
124        $titles = $this->pageStore
125            ->newSelectQueryBuilder()
126            ->wherePageIds( array_keys( $pageCounts ) )
127            ->caller( __METHOD__ )
128            ->fetchPageRecords();
129
130        $groupCounts = [];
131        /** @var PageRecord $title */
132        foreach ( $titles as $title ) {
133            $title = $this->titleFactory->castFromPageIdentity( $title );
134            if ( $groupPages ) {
135                // If $title is a talk page, add its count to its subject page's count
136                $pageName = $title->getSubjectPage()->getPrefixedText();
137            } else {
138                $pageName = $title->getPrefixedText();
139            }
140
141            $count = $pageCounts[$title->getArticleID()] ?? 0;
142            if ( isset( $groupCounts[$pageName] ) ) {
143                $groupCounts[$pageName] += $count;
144            } else {
145                $groupCounts[$pageName] = $count;
146            }
147        }
148
149        $userPageName = $this->getUser()->getUserPage()->getPrefixedText();
150        if ( $nullCount > 0 && $groupPages ) {
151            // Add the count for NULL (not associated with any page) to the count for the user page
152            if ( isset( $groupCounts[$userPageName] ) ) {
153                $groupCounts[$userPageName] += $nullCount;
154            } else {
155                $groupCounts[$userPageName] = $nullCount;
156            }
157        }
158
159        arsort( $groupCounts );
160        if ( $groupPages ) {
161            $groupCounts = array_slice( $groupCounts, 0, $limit );
162        }
163
164        $result = [];
165        foreach ( $groupCounts as $pageName => $count ) {
166            if ( $groupPages ) {
167                $title = Title::newFromText( $pageName );
168                $pages = [ $title->getSubjectPage()->getPrefixedText() ];
169                if ( $title->canHaveTalkPage() ) {
170                    $pages[] = $title->getTalkPage()->getPrefixedText();
171                }
172                if ( $pageName === $userPageName ) {
173                    $pages[] = null;
174                }
175                $pageDescription = [
176                    'ns' => $title->getNamespace(),
177                    'title' => $title->getPrefixedText(),
178                    'unprefixed' => $title->getText(),
179                    'pages' => $pages,
180                ];
181            } else {
182                $pageDescription = [ 'title' => $pageName ];
183            }
184            $result[] = $pageDescription + [
185                'count' => $count,
186            ];
187        }
188        if ( !$groupPages && $nullCount > 0 ) {
189            $result[] = [
190                'title' => null,
191                'count' => $nullCount,
192            ];
193        }
194
195        return [
196            'pages' => $result,
197            'totalCount' => NotifUser::newFromUser( $this->getUser() )->getLocalNotificationCount(),
198        ];
199    }
200
201    /**
202     * @return array[]
203     */
204    protected function getUnreadNotificationPagesFromForeign() {
205        $result = [];
206        foreach ( $this->getFromForeign() as $wiki => $data ) {
207            if ( isset( $data['query'][$this->getModuleName()][$wiki] ) ) {
208                $result[$wiki] = $data['query'][$this->getModuleName()][$wiki];
209            } else {
210                # Usually an error or it is some malformed response
211                # T273479
212                LoggerFactory::getInstance( 'Echo' )->warning(
213                    __METHOD__ . ': Unexpected API response from {wiki}',
214                    [
215                        'wiki' => $wiki,
216                        'data' => $data,
217                    ]
218                );
219            }
220        }
221
222        return $result;
223    }
224
225    /**
226     * @return array[]
227     */
228    public function getAllowedParams() {
229        $maxUpdateCount = $this->getConfig()->get( 'EchoMaxUpdateCount' );
230
231        return $this->getCrossWikiParams() + [
232            'grouppages' => [
233                ParamValidator::PARAM_TYPE => 'boolean',
234                ParamValidator::PARAM_DEFAULT => false,
235            ],
236            'limit' => [
237                ParamValidator::PARAM_TYPE => 'limit',
238                ParamValidator::PARAM_DEFAULT => 10,
239                IntegerDef::PARAM_MIN => 1,
240                IntegerDef::PARAM_MAX => $maxUpdateCount,
241                IntegerDef::PARAM_MAX2 => $maxUpdateCount,
242            ],
243            // there is no `offset` or `continue` value: the set of possible
244            // notifications is small enough to allow fetching all of them at
245            // once, and any sort of fetching would be unreliable because
246            // they're sorted based on count of notifications, which could
247            // change in between requests
248        ];
249    }
250
251    /**
252     * @see ApiBase::getExamplesMessages()
253     * @return string[]
254     */
255    protected function getExamplesMessages() {
256        return [
257            'action=query&meta=unreadnotificationpages' => 'apihelp-query+unreadnotificationpages-example-1',
258        ];
259    }
260
261    public function getHelpUrls() {
262        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
263    }
264}