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