Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.75% covered (danger)
32.75%
75 / 229
15.79% covered (danger)
15.79%
3 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
PendingChangesPager
32.75% covered (danger)
32.75%
75 / 229
15.79% covered (danger)
15.79%
3 / 19
940.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 setLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatRow
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
72.46% covered (warning)
72.46%
50 / 69
0.00% covered (danger)
0.00%
0 / 1
16.53
 getIndexField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doBatchLookups
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldNames
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 formatValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStartBody
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableHeader
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 buildHeaderCaption
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getPendingCount
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableElement
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableHeaderCells
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
240
 getEndBody
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableCaption
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Pager\TablePager;
6use Wikimedia\Rdbms\RawSQLExpression;
7
8/**
9 * Query to list out outdated reviewed pages
10 */
11class PendingChangesPager extends TablePager {
12
13    private PendingChanges $mForm;
14    private ?string $category;
15    /** @var int|int[] */
16    private $namespace;
17    private ?string $size;
18    private bool $watched;
19    private bool $stable;
20    private ?string $tagFilter;
21    // Don't get too expensive
22    private const PAGE_LIMIT = 100;
23
24    /**
25     * The unique sort fields for the sort options for unique paginate
26     */
27    private const INDEX_FIELDS = [
28        'fp_pending_since' => [ 'fp_pending_since' ],
29    ];
30
31    /**
32     * @param PendingChanges $form
33     * @param int|null $namespace
34     * @param string $category
35     * @param int|null $size
36     * @param bool $watched
37     * @param bool $stable
38     * @param ?string $tagFilter
39     */
40    public function __construct( $form, $namespace, string $category = '',
41        ?int $size = null, bool $watched = false, bool $stable = false, ?string $tagFilter = ''
42    ) {
43        $this->mForm = $form;
44        # Must be a content page...
45        if ( $namespace !== null ) {
46            $namespace = (int)$namespace;
47        }
48        # Sanity check
49        if ( $namespace === null || !FlaggedRevs::isReviewNamespace( $namespace ) ) {
50            $namespace = FlaggedRevs::getReviewNamespaces();
51        }
52        $this->namespace = $namespace;
53        $this->category = $category ? str_replace( ' ', '_', $category ) : null;
54        $this->tagFilter = $tagFilter ? str_replace( ' ', '_', $tagFilter ) : null;
55        $this->size = $size;
56        $this->watched = $watched;
57        $this->stable = $stable && !FlaggedRevs::isStableShownByDefault()
58            && !FlaggedRevs::useOnlyIfProtected();
59
60        parent::__construct();
61        # Don't get too expensive
62        $this->mLimitsShown = [ 20, 50, 100 ];
63        $this->setLimit( $this->mLimit ); // apply max limit
64    }
65
66    /**
67     * @inheritDoc
68     */
69    public function setLimit( $limit ) {
70        $this->mLimit = min( $limit, self::PAGE_LIMIT );
71    }
72
73    /**
74     * @inheritDoc
75     */
76    public function formatRow( $row ): string {
77        return $this->mForm->formatRow( $row );
78    }
79
80    /**
81     * @inheritDoc
82     */
83    public function getDefaultQuery(): array {
84        $query = parent::getDefaultQuery();
85        $query['category'] = $this->category;
86        $query['tagFilter'] = $this->tagFilter;
87        return $query;
88    }
89
90    /**
91     * @inheritDoc
92     */
93    public function getQueryInfo(): array {
94        $tables = [ 'page', 'revision', 'flaggedpages' ];
95        $fields = [
96            'page_namespace',
97            'page_title',
98            'page_len',
99            'rev_len',
100            'page_latest',
101            'stable' => 'fp_stable',
102            'quality' => 'fp_quality',
103            'pending_since' => 'fp_pending_since'
104        ];
105        $conds = [ $this->mDb->expr( 'fp_pending_since', '!=', null ) ];
106        $joinConds = [
107            'page' => [ 'JOIN', 'page_id=fp_page_id' ],
108            'revision' => [ 'JOIN', 'rev_id=fp_stable' ],
109        ];
110
111        # Filter by pages configured to be stable
112        if ( $this->stable ) {
113            $tables[] = 'flaggedpage_config';
114            $joinConds['flaggedpage_config'] = [ 'JOIN', 'fpc_page_id=fp_page_id' ];
115            $conds['fpc_override'] = 1;
116        }
117        # Filter by category
118        if ( $this->category != '' ) {
119            $tables[] = 'categorylinks';
120            $tables[] = 'linktarget';
121            $joinConds['categorylinks'] = [ 'JOIN', 'cl_from=fp_page_id' ];
122            $joinConds['linktarget'] = [ 'JOIN', 'lt_id=cl_target_id' ];
123            $conds['lt_title'] = $this->category;
124            $conds['lt_namespace'] = NS_CATEGORY;
125        }
126        # Index field for sorting
127        $this->mIndexField = 'fp_pending_since';
128        $fields[] = $this->mIndexField; // Pager needs this
129        # Filter namespace
130        if ( $this->namespace !== null ) {
131            $conds['page_namespace'] = $this->namespace;
132        }
133        # Filter by watchlist
134        if ( $this->watched ) {
135            $uid = $this->getUser()->getId();
136            if ( $uid ) {
137                $tables[] = 'watchlist';
138                $conds['wl_user'] = $uid;
139                $joinConds['watchlist'] = [ 'JOIN', [
140                    'wl_namespace=page_namespace',
141                    'wl_title=page_title'
142                ] ];
143            }
144        }
145        # Filter by bytes changed
146        if ( $this->size !== null && $this->size >= 0 ) {
147            $conds[] = new RawSQLExpression(
148                "(GREATEST(page_len, rev_len) - LEAST(page_len, rev_len)) <= " . intval( $this->size )
149            );
150        }
151        # Filter by tag
152        if ( $this->tagFilter !== null && $this->tagFilter !== '' ) {
153            $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
154            if ( !$changeTagsStore->canViewTag( $this->tagFilter, $this->getAuthority() ) ) {
155                $conds[] = '1=0';
156            } else {
157                $tables[] = 'change_tag';
158                $tables[] = 'change_tag_def';
159                $joinConds['change_tag'] = [ 'JOIN', 'ct_rev_id=rev_id' ];
160                $joinConds['change_tag_def'] = [ 'JOIN', 'ct_tag_id=ctd_id' ];
161                $conds['ctd_name'] = $this->tagFilter;
162            }
163        }
164        # Don't display pages with expired protection (T350527)
165        if ( FlaggedRevs::useOnlyIfProtected() ) {
166            $tables[] = 'flaggedpage_config';
167            $joinConds['flaggedpage_config'] = [ 'JOIN', 'fpc_page_id=fp_page_id' ];
168            $conds[] = new RawSQLExpression( $this->mDb->buildComparison( '>',
169                    [ 'fpc_expiry' => $this->mDb->timestamp() ] ) . ' OR fpc_expiry = "infinity"'
170            );
171        }
172        # Set sorting options
173        $sortField = $this->getRequest()->getVal( 'sort', 'fp_pending_since' );
174        $sortOrder = $this->getRequest()->getVal( 'asc' ) ? 'ASC' : 'DESC';
175        $options = [ 'ORDER BY' => "$sortField $sortOrder" ];
176        # Return query information
177        return [
178            'tables' => $tables,
179            'fields' => $fields,
180            'conds' => $conds,
181            'options' => $options,
182            'join_conds' => $joinConds,
183        ];
184    }
185
186    /**
187     * @inheritDoc
188     */
189    public function getIndexField() {
190        return $this->mIndexField;
191    }
192
193    /**
194     * @inheritDoc
195     */
196    protected function doBatchLookups() {
197        $this->mResult->seek( 0 );
198        $lb = MediaWikiServices::getInstance()->getLinkBatchFactory();
199        $batch = $lb->newLinkBatch();
200        foreach ( $this->mResult as $row ) {
201            $batch->add( $row->page_namespace, $row->page_title );
202        }
203        $batch->execute();
204    }
205
206    /**
207     * @inheritDoc
208     * @since 1.43
209     */
210    public function getDefaultSort(): string {
211        return 'fp_pending_since';
212    }
213
214    /**
215     * @inheritDoc
216     * @since 1.43
217     */
218    protected function getFieldNames(): array {
219        $fields = [
220            'page_title' => 'pendingchanges-table-page',
221            'review' => 'pendingchanges-table-review',
222            'rev_len' => 'pendingchanges-table-size',
223            'fp_pending_since' => 'pendingchanges-table-pending-since',
224        ];
225
226        if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
227            $fields['watching'] = 'pendingchanges-table-watching';
228        }
229
230        return $fields;
231    }
232
233    /**
234     * @inheritDoc
235     * @since 1.43
236     */
237    public function formatValue( $name, $value ): ?string {
238        return htmlspecialchars( $value );
239    }
240
241    /**
242     * @inheritDoc
243     * @since 1.43
244     */
245    protected function isFieldSortable( $field ): bool {
246        return isset( self::INDEX_FIELDS[$field] );
247    }
248
249    /**
250     * Builds and returns the start body HTML for the table.
251     *
252     * @return string HTML
253     * @since 1.43
254     */
255    protected function getStartBody(): string {
256        return Html::openElement( 'div', [ 'class' => 'cdx-table mw-fr-pending-changes-table' ] ) .
257            $this->buildTableHeader() .
258            Html::openElement( 'div', [ 'class' => 'cdx-table__table-wrapper' ] ) .
259            $this->buildTableElement();
260    }
261
262    /**
263     * Builds and returns the table header HTML.
264     *
265     * @return string HTML
266     */
267    private function buildTableHeader(): string {
268        $headerCaption = $this->buildHeaderCaption();
269        $headerContent = $this->buildTableCaption( 'cdx-table__header__header-content' );
270
271        return Html::rawElement(
272            'div',
273            [ 'class' => 'cdx-table__header' ],
274            $headerCaption . $headerContent
275        );
276    }
277
278    /**
279     * Builds and returns the header caption HTML.
280     *
281     * @return string HTML
282     */
283    private function buildHeaderCaption(): string {
284        return Html::rawElement(
285            'div',
286            [ 'class' => 'cdx-table__header__caption', 'aria-hidden' => 'true' ],
287            $this->msg( 'pendingchanges-table-caption' )->escaped()
288        );
289    }
290
291    /**
292     * Retrieves the count of pending pages.
293     *
294     * @return int The count of pending pages.
295     */
296    private function getPendingCount(): int {
297        return $this->mDb->newSelectQueryBuilder()
298            ->select( '*' )
299            ->from( 'flaggedpages' )
300            ->join( 'page', null, 'fp_page_id = page_id' )
301            ->where( $this->mDb->expr( 'fp_pending_since', '!=', null ) )
302            ->andWhere( [ 'page_namespace' => FlaggedRevs::getReviewNamespaces() ] )
303            ->caller( __METHOD__ )
304            ->fetchRowCount();
305    }
306
307    /**
308     * Builds and returns the table element HTML.
309     *
310     * @return string HTML
311     */
312    private function buildTableElement(): string {
313        $caption = Html::element( 'caption', [], $this->msg( 'pendingchanges-table-caption' )->text() );
314        $thead = $this->buildTableHeaderCells();
315
316        return Html::openElement( 'table', [ 'class' => 'cdx-table__table cdx-table__table--borders-vertical' ] ) .
317            $caption .
318            $thead .
319            Html::openElement( 'tbody' );
320    }
321
322    /**
323     * Builds and returns the table header cells HTML.
324     *
325     * @return string HTML
326     */
327    private function buildTableHeaderCells(): string {
328        $fields = $this->getFieldNames();
329        $headerCells = '';
330
331        foreach ( $fields as $field => $labelKey ) {
332            $class = ( $field === 'review' || $field === 'history' ) ? 'cdx-table__table__cell--align-center' : '';
333
334            if ( $field === 'history' ) {
335                $headerCells .= Html::rawElement(
336                    'th',
337                    [ 'scope' => 'col', 'class' => $class ],
338                    Html::rawElement(
339                        'span',
340                        [ 'class' => 'fr-cdx-icon-clock', 'aria-hidden' => 'true' ]
341                    )
342                );
343            } elseif ( $this->isFieldSortable( $field ) ) {
344                $isCurrentSortField = ( $this->mSort === $field );
345                $currentAsc = $this->getRequest()->getVal( 'asc', '1' );
346
347                $newSortAsc = $isCurrentSortField && $currentAsc === '1' ? '' : '1';
348                $newSortDesc = $isCurrentSortField && $currentAsc === '1' ? '1' : '';
349
350                $ariaSort = 'none';
351                if ( $isCurrentSortField ) {
352                    $ariaSort = $currentAsc === '1' ? 'ascending' : 'descending';
353                }
354
355                $iconClass = 'fr-cdx-icon-sort-vertical';
356                if ( $isCurrentSortField ) {
357                    $iconClass = $currentAsc === '1' ? 'fr-icon-asc' : 'fr-icon-desc';
358                }
359
360                $currentParams = $this->getRequest()->getValues();
361                unset( $currentParams['title'], $currentParams['sort'], $currentParams['asc'], $currentParams['desc'] );
362                $currentParams['sort'] = $field;
363                $currentParams['asc'] = $newSortAsc;
364                $currentParams['desc'] = $newSortDesc;
365
366                $href = $this->getTitle()->getLocalURL( $currentParams );
367
368                $headerCells .= Html::rawElement(
369                    'th',
370                    [
371                        'scope' => 'col',
372                        'class' => 'cdx-table__table__cell--has-sort ' . $class,
373                        'aria-sort' => $ariaSort,
374                    ],
375                    Html::rawElement(
376                        'a',
377                        [ 'href' => $href ],
378                        Html::rawElement(
379                            'button',
380                            [
381                                'class' => 'cdx-table__table__sort-button',
382                                'aria-selected' => $isCurrentSortField ? 'true' : 'false'
383                            ],
384                            $this->msg( $labelKey )->escaped() . ' ' .
385                            Html::rawElement(
386                                'span',
387                                [ 'class' => 'cdx-icon cdx-icon--small cdx-table__table__sort-icon ' .
388                                    $iconClass, 'aria-hidden' => 'true' ]
389                            )
390                        )
391                    )
392                );
393            } else {
394                $headerCells .= Html::rawElement(
395                    'th',
396                    [ 'scope' => 'col', 'class' => $class ],
397                    Html::rawElement(
398                        'span',
399                        [ 'class' => 'cdx-table__th-content' ],
400                        $this->msg( $labelKey )->escaped()
401                    )
402                );
403            }
404        }
405
406        return Html::rawElement(
407            'thead',
408            [],
409            Html::rawElement(
410                'tr',
411                [],
412                $headerCells
413            )
414        );
415    }
416
417    /**
418     * Builds and returns the end body HTML for the table.
419     *
420     * @return string HTML
421     * @since 1.43
422     */
423    protected function getEndBody(): string {
424        return Html::closeElement( 'tbody' ) .
425            Html::closeElement( 'table' ) .
426            Html::closeElement( 'div' ) .
427            $this->buildTableCaption( 'cdx-table__footer' ) .
428            Html::closeElement( 'div' );
429    }
430
431    /**
432     * Builds and returns the table caption, currently used both in
433     * the header and the footer.
434     *
435     * @param string $class The class to use for the returning element
436     * @return string HTML
437     */
438    private function buildTableCaption( string $class ): string {
439        $pendingCount = $this->getPendingCount();
440        $formattedCount = $this->getLanguage()->formatNum( $pendingCount );
441        $chip = Html::element( 'strong', [ 'class' => 'cdx-info-chip' ], $formattedCount );
442        $message = $this->msg( 'pendingchanges-table-footer' )
443            ->rawParams( $chip )
444            ->numParams( $pendingCount )
445            ->escaped();
446
447        return Html::rawElement(
448            'div',
449            [ 'class' => $class ],
450            Html::rawElement( 'span', [], $message )
451        );
452    }
453}