Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 297
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PendingChanges
0.00% covered (danger)
0.00%
0 / 297
0.00% covered (danger)
0.00%
0 / 14
2550
0.00% covered (danger)
0.00%
0 / 1
 __construct
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 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 setSyndicated
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 showForm
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
6
 getLimitSelector
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 showPageList
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 parseParams
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 feed
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 feedTitle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 feedItem
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 formatRow
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
156
 getLineClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWatchingFormatted
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3use MediaWiki\Config\ConfigException;
4use MediaWiki\Exception\MWException;
5use MediaWiki\Feed\FeedItem;
6use MediaWiki\Feed\FeedUtils;
7use MediaWiki\Html\Html;
8use MediaWiki\MainConfigNames;
9use MediaWiki\RecentChanges\ChangesList;
10use MediaWiki\Revision\RevisionLookup;
11use MediaWiki\SpecialPage\IncludableSpecialPage;
12use MediaWiki\SpecialPage\SpecialPageFactory;
13use MediaWiki\Title\NamespaceInfo;
14use MediaWiki\Title\Title;
15
16class PendingChanges extends IncludableSpecialPage {
17
18    private ?PendingChangesPager $pager = null;
19    private int $currentUnixTS;
20    private ?int $namespace;
21    private string $category;
22    private ?string $tagFilter;
23    private ?int $size;
24    private bool $watched;
25
26    public function __construct(
27        private readonly NamespaceInfo $namespaceInfo,
28        private readonly RevisionLookup $revisionLookup,
29        private readonly SpecialPageFactory $specialPageFactory,
30    ) {
31        parent::__construct( 'PendingChanges' );
32    }
33
34    /**
35     * @inheritDoc
36     * @throws ConfigException
37     */
38    public function execute( $subPage ) {
39        $request = $this->getRequest();
40
41        $this->setHeaders();
42        $this->addHelpLink( 'Help:Extension:FlaggedRevs' );
43        $this->currentUnixTS = (int)wfTimestamp();
44
45        $this->namespace = $request->getIntOrNull( 'namespace' );
46        $this->tagFilter = $request->getVal( 'tagFilter' );
47        $category = trim( $request->getVal( 'category', '' ) );
48        $catTitle = Title::makeTitleSafe( NS_CATEGORY, $category );
49        $this->category = $catTitle === null ? '' : $catTitle->getText();
50        $this->size = $request->getIntOrNull( 'size' );
51        $this->watched = $request->getCheck( 'watched' );
52        $stable = $request->getCheck( 'stable' );
53        $feedType = $request->getVal( 'feed' );
54        $limit = $request->getInt( 'limit', 50 );
55
56        $incLimit = 0;
57        if ( $this->including() && $subPage !== null ) {
58            $incLimit = $this->parseParams( $subPage ); // apply non-URL params
59        }
60
61        $this->pager = new PendingChangesPager( $this, $this->namespace,
62            $this->category, $this->size, $this->watched, $stable, $this->tagFilter );
63        $this->pager->setLimit( $limit );
64
65        # Output appropriate format...
66        if ( $feedType != null ) {
67            $this->feed( $feedType );
68        } else {
69            if ( $this->including() ) {
70                if ( $incLimit ) { // limit provided
71                    $this->pager->setLimit( $incLimit ); // apply non-URL limit
72                }
73            } else {
74                $this->setSyndicated();
75                $this->showForm();
76            }
77            $this->showPageList();
78        }
79    }
80
81    private function setSyndicated() {
82        $request = $this->getRequest();
83        $queryParams = [
84            'namespace' => $request->getIntOrNull( 'namespace' ),
85            'category'  => $request->getVal( 'category' ),
86        ];
87        $this->getOutput()->setSyndicated();
88        $this->getOutput()->setFeedAppendQuery( wfArrayToCgi( $queryParams ) );
89    }
90
91    private function showForm() {
92        $form = Html::openElement( 'form', [
93                'name' => 'pendingchanges',
94                'action' => $this->getConfig()->get( MainConfigNames::Script ),
95                'method' => 'get',
96                'class' => 'mw-fr-form-container'
97            ] ) . "\n";
98
99        $form .= Html::openElement( 'fieldset', [ 'class' => 'cdx-field' ] ) . "\n";
100
101        $form .= Html::openElement( 'legend', [ 'class' => 'cdx-label' ] ) . "\n";
102        $form .= Html::rawElement( 'span', [ 'class' => 'cdx-label__label' ],
103            Html::element( 'span', [ 'class' => 'cdx-label__label__text' ],
104                $this->msg( 'pendingchanges-legend' )->text() )
105        );
106
107        # Explanatory text
108        $form .= Html::rawElement( 'span', [ 'class' => 'cdx-label__description' ],
109            $this->msg( 'pendingchanges-list' )->params(
110                $this->getLanguage()->formatNum( $this->pager->getNumRows() )
111            )->parse()
112        );
113
114        $form .= Html::closeElement( 'legend' ) . "\n";
115
116        $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . "\n";
117
118        $form .= Html::openElement( 'div', [ 'class' => 'cdx-field__control' ] ) . "\n";
119
120        if ( count( FlaggedRevs::getReviewNamespaces() ) > 1 ) {
121            $form .= Html::rawElement(
122                'div',
123                [ 'class' => 'cdx-field__item' ],
124                FlaggedRevsHTML::getNamespaceMenu( $this->namespace, '' )
125            );
126        }
127
128        $form .= Html::rawElement(
129            'div',
130            [ 'class' => 'cdx-field__item' ],
131            FlaggedRevsHTML::getEditTagFilterMenu( $this->tagFilter )
132        );
133
134        $form .= Html::rawElement(
135            'div',
136            [ 'class' => 'cdx-field__item' ],
137            $this->getLimitSelector( $this->pager->mLimit )
138        );
139
140        $form .= Html::rawElement(
141                'div',
142                [ 'class' => 'cdx-field__item' ],
143                Html::label( $this->msg( 'pendingchanges-category' )->text(), 'wpCategory',
144                    [ 'class' => 'cdx-label__label' ] ) .
145                Html::input( 'category', $this->category, 'text', [
146                    'id' => 'wpCategory',
147                    'class' => 'cdx-text-input__input'
148                ] )
149            ) . "\n";
150
151        $form .= Html::rawElement(
152                'div',
153                [ 'class' => 'cdx-field__item' ],
154                Html::label( $this->msg( 'pendingchanges-size' )->text(), 'wpSize',
155                    [ 'class' => 'cdx-label__label' ] ) .
156                Html::input( 'size', (string)$this->size, 'number', [
157                    'id' => 'wpSize',
158                    'class' => 'cdx-text-input__input'
159                ] )
160            ) . "\n";
161
162        $form .= Html::closeElement( 'div' ) . "\n";
163
164        $form .= Html::rawElement(
165                'div',
166                [ 'class' => 'cdx-field__control' ],
167                Html::rawElement( 'span', [ 'class' => 'cdx-checkbox cdx-checkbox--inline' ],
168                    Html::check( 'watched', $this->watched, [
169                        'id' => 'wpWatched',
170                        'class' => 'cdx-checkbox__input'
171                    ] ) .
172                    Html::rawElement( 'span', [ 'class' => 'cdx-checkbox__icon' ] ) .
173                    Html::rawElement(
174                        'div',
175                        [ 'class' => 'cdx-checkbox__label cdx-label' ],
176                        Html::label( $this->msg( 'pendingchanges-onwatchlist' )->text(), 'wpWatched',
177                            [ 'class' => 'cdx-label__label' ] )
178                    )
179                )
180            ) . "\n";
181
182        $form .= Html::rawElement(
183                'div',
184                [ 'class' => 'cdx-field__control' ],
185                Html::submitButton( $this->msg( 'pendingchanges-filter-submit-button-text' )->text(), [
186                    'class' => 'cdx-button cdx-button--action-progressive'
187                ] )
188            ) . "\n";
189
190        $form .= Html::closeElement( 'fieldset' ) . "\n";
191        $form .= Html::closeElement( 'form' ) . "\n";
192
193        $this->getOutput()->addHTML( $form );
194    }
195
196    /**
197     * Get a selector for limit options
198     *
199     * @param int $selected The currently selected limit
200     */
201    private function getLimitSelector( int $selected = 20 ): string {
202        $s = Html::rawElement( 'div', [ 'class' => 'cdx-field__item' ],
203            Html::rawElement( 'div', [ 'class' => 'cdx-label' ],
204                Html::label(
205                    $this->msg( 'pendingchanges-limit' )->text(),
206                    'wpLimit',
207                    [ 'class' => 'cdx-label__label' ]
208                )
209            )
210        );
211
212        $options = [ 20, 50, 100 ];
213        $selectOptions = '';
214        foreach ( $options as $option ) {
215            $selectOptions .= Html::element( 'option', [
216                'value' => $option,
217                'selected' => $selected == $option
218            ], $this->getLanguage()->formatNum( $option ) );
219        }
220
221        $s .= Html::rawElement( 'select', [
222            'name' => 'limit',
223            'id' => 'wpLimit',
224            'class' => 'cdx-select'
225        ], $selectOptions );
226
227        return $s;
228    }
229
230    private function showPageList() {
231        $out = $this->getOutput();
232
233        if ( !$this->pager->getNumRows() ) {
234            $out->addWikiMsg( 'pendingchanges-none' );
235            return;
236        }
237
238        // To style output of ChangesList::showCharacterDifference
239        $out->addModuleStyles( 'mediawiki.special.changeslist' );
240        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
241
242        if ( $this->including() ) {
243            // If this list is transcluded...
244            $out->addHTML( $this->pager->getBody() );
245        } else {
246            // Viewing the list normally...
247            $navigationBar = $this->pager->getNavigationBar();
248            $out->addHTML( $navigationBar );
249            $out->addHTML( $this->pager->getBody() );
250            $out->addHTML( $navigationBar );
251        }
252    }
253
254    /**
255     * Set pager parameters from $subPage, return pager limit
256     * @param string $subPage
257     * @return bool|int
258     */
259    private function parseParams( string $subPage ) {
260        $bits = preg_split( '/\s*,\s*/', trim( $subPage ) );
261        $limit = false;
262        foreach ( $bits as $bit ) {
263            if ( is_numeric( $bit ) ) {
264                $limit = intval( $bit );
265            }
266            $m = [];
267            if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
268                $limit = intval( $m[1] );
269            }
270            if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
271                $ns = $this->getLanguage()->getNsIndex( $m[1] );
272                if ( $ns !== false ) {
273                    $this->namespace = $ns;
274                }
275            }
276            if ( preg_match( '/^category=(.+)$/', $bit, $m ) ) {
277                $this->category = $m[1];
278            }
279        }
280        return $limit;
281    }
282
283    /**
284     * Output a subscription feed listing recent edits to this page.
285     * @param string $type
286     * @throws ConfigException
287     */
288    private function feed( string $type ) {
289        if ( !$this->getConfig()->get( MainConfigNames::Feed ) ) {
290            $this->getOutput()->addWikiMsg( 'feed-unavailable' );
291            return;
292        }
293
294        $feedClasses = $this->getConfig()->get( MainConfigNames::FeedClasses );
295        if ( !isset( $feedClasses[$type] ) ) {
296            $this->getOutput()->addWikiMsg( 'feed-invalid' );
297            return;
298        }
299
300        $feed = new $feedClasses[$type](
301            $this->feedTitle(),
302            $this->msg( 'tagline' )->text(),
303            $this->getPageTitle()->getFullURL()
304        );
305        $this->pager->mLimit = min( $this->getConfig()->get( MainConfigNames::FeedLimit ), $this->pager->mLimit );
306
307        $feed->outHeader();
308        if ( $this->pager->getNumRows() > 0 ) {
309            foreach ( $this->pager->mResult as $row ) {
310                $feed->outItem( $this->feedItem( $row ) );
311            }
312        }
313        $feed->outFooter();
314    }
315
316    private function feedTitle(): string {
317        $languageCode = $this->getConfig()->get( MainConfigNames::LanguageCode );
318        $sitename = $this->getConfig()->get( MainConfigNames::Sitename );
319
320        $page = $this->specialPageFactory->getPage( 'PendingChanges' );
321        $desc = $page->getDescription();
322        return "$sitename - $desc [$languageCode]";
323    }
324
325    /**
326     * @param stdClass $row
327     * @return FeedItem|null
328     * @throws MWException
329     */
330    private function feedItem( stdClass $row ): ?FeedItem {
331        $title = Title::makeTitle( $row->page_namespace, $row->page_title );
332        if ( !$title ) {
333            return null;
334        }
335
336        $date = $row->pending_since;
337        $comments = $this->namespaceInfo->getTalkPage( $title );
338        $curRevRecord = $this->revisionLookup->getRevisionByTitle( $title );
339        $currentComment = $curRevRecord->getComment() ? $curRevRecord->getComment()->text : '';
340        $currentUserText = $curRevRecord->getUser() ? $curRevRecord->getUser()->getName() : '';
341        return new FeedItem(
342            $title->getPrefixedText(),
343            FeedUtils::formatDiffRow2(
344                $title,
345                $row->stable,
346                $curRevRecord->getId(),
347                $row->pending_since,
348                $currentComment
349            ),
350            $title->getFullURL(),
351            $date,
352            $currentUserText,
353            $comments
354        );
355    }
356
357    public function formatRow( stdClass $row ): string {
358        $css = '';
359        $title = Title::newFromRow( $row );
360        $size = ChangesList::showCharacterDifference( $row->rev_len, $row->page_len );
361        # Page links...
362        $linkRenderer = $this->getLinkRenderer();
363
364        $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : [];
365
366        $link = $linkRenderer->makeKnownLink(
367            $title,
368            null,
369            [ 'class' => 'mw-fr-pending-changes-page-title' ],
370            $query
371        );
372        $linkArr = [];
373        $linkArr[] = $linkRenderer->makeKnownLink(
374            $title,
375            $this->msg( 'hist' )->text(),
376            [ 'class' => 'mw-fr-pending-changes-page-history' ],
377            [ 'action' => 'history' ]
378        );
379        if ( $this->getAuthority()->isAllowed( 'edit' ) ) {
380            $linkArr[] = $linkRenderer->makeKnownLink(
381                $title,
382                $this->msg( 'editlink' )->text(),
383                [ 'class' => 'mw-fr-pending-changes-page-edit' ],
384                [ 'action' => 'edit' ]
385            );
386        }
387        if ( $this->getAuthority()->isAllowed( 'delete' ) ) {
388            $linkArr[] = $linkRenderer->makeKnownLink(
389                $title,
390                $this->msg( 'tags-delete' )->text(),
391                [ 'class' => 'mw-fr-pending-changes-page-delete' ],
392                [ 'action' => 'delete' ]
393            );
394        }
395        $links = $this->msg( 'parentheses' )->rawParams( $this->getLanguage()
396            ->pipeList( $linkArr ) )->escaped();
397        $review = Html::rawElement(
398            'a',
399            [
400                'class' => 'cdx-docs-link',
401                'href' => $title->getFullURL( [ 'diff' => 'cur', 'oldid' => $row->stable ] )
402            ],
403            $this->msg( 'pendingchanges-diff' )->escaped()
404        );
405        # Is anybody watching?
406        // Only show information to users with the `unwatchedpages` who could find this
407        // information elsewhere anyway, T281065
408        if ( !$this->including() && $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
409            $uw = FRUserActivity::numUsersWatchingPage( $title );
410            $watching = ' ';
411            $watching .= $uw
412                ? $this->getWatchingFormatted( $uw )
413                : $this->msg( 'pendingchanges-unwatched' )->escaped();
414        } else {
415            $uw = -1;
416            $watching = '';
417        }
418        # Get how long the first unreviewed edit has been waiting...
419        if ( $row->pending_since ) {
420            $firstPendingTime = (int)wfTimestamp( TS_UNIX, $row->pending_since );
421            $hours = ( $this->currentUnixTS - $firstPendingTime ) / 3600;
422            $days = round( $hours / 24 );
423            if ( $days >= 3 ) {
424                $age = $this->msg( 'pendingchanges-days' )->numParams( $days )->text();
425            } elseif ( $hours >= 1 ) {
426                $age = $this->msg( 'pendingchanges-hours' )->numParams( round( $hours ) )->text();
427            } else {
428                $age = $this->msg( 'pendingchanges-recent' )->text(); // hot off the press :)
429            }
430            $age = Html::element( 'span', [], $age );
431            // Oh-noes!
432            $class = $this->getLineClass( $uw );
433            $css = $class ? " $class" : "";
434        } else {
435            $age = "";
436        }
437        $watchingColumn = $watching ? "<td>$watching</td>" : '';
438        return (
439            "<tr class='$css'>
440                <td>$link $links</td>
441                <td class='cdx-table__table__cell--align-center'>$review</td>
442                <td>$size</td>
443                <td>$age</td>
444                $watchingColumn
445            </tr>"
446        );
447    }
448
449    /**
450     * @param int $numUsersWatching Number of users or -1 when not allowed to see the number
451     * @return string
452     */
453    private function getLineClass( int $numUsersWatching ): string {
454        return $numUsersWatching == 0 ? 'fr-unreviewed-unwatched' : '';
455    }
456
457    /**
458     * @return string
459     */
460    protected function getGroupName(): string {
461        return 'quality';
462    }
463
464    /**
465     * Get formatted text for the watching value
466     *
467     * @param int $watching
468     * @return string
469     * @since 1.43
470     */
471    public function getWatchingFormatted( int $watching ): string {
472        return $watching > 0
473            ? Html::element( 'span', [], $this->getLanguage()->formatNum( $watching ) )
474            : Html::rawElement(
475                'div',
476                [ 'class' => 'cdx-info-chip' ],
477                Html::element( 'span', [ 'class' => 'cdx-info-chip--text' ],
478                    $this->msg( 'pendingchanges-unwatched' )->text() )
479            );
480    }
481}