Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 299
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 / 299
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 / 75
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\ChannelFeed;
6use MediaWiki\Feed\FeedItem;
7use MediaWiki\Feed\FeedUtils;
8use MediaWiki\Html\Html;
9use MediaWiki\MainConfigNames;
10use MediaWiki\RecentChanges\ChangesList;
11use MediaWiki\Revision\RevisionLookup;
12use MediaWiki\SpecialPage\IncludableSpecialPage;
13use MediaWiki\SpecialPage\SpecialPageFactory;
14use MediaWiki\Title\NamespaceInfo;
15use MediaWiki\Title\Title;
16
17class PendingChanges extends IncludableSpecialPage {
18
19    private ?PendingChangesPager $pager = null;
20    private int $currentUnixTS;
21    private ?int $namespace;
22    private string $category;
23    private ?string $tagFilter;
24    private ?int $size;
25    private bool $watched;
26
27    public function __construct(
28        private readonly NamespaceInfo $namespaceInfo,
29        private readonly RevisionLookup $revisionLookup,
30        private readonly SpecialPageFactory $specialPageFactory,
31    ) {
32        parent::__construct( 'PendingChanges' );
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->getContext(), $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        /** @var ChannelFeed $feed */
307        $this->pager->mLimit = min( $this->getConfig()->get( MainConfigNames::FeedLimit ), $this->pager->mLimit );
308
309        $feed->outputHeader( $this->getOutput() );
310        if ( $this->pager->getNumRows() > 0 ) {
311            foreach ( $this->pager->mResult as $row ) {
312                $feed->outputItem( $this->feedItem( $row ), $this->getOutput() );
313            }
314        }
315        $feed->outputFooter( $this->getOutput() );
316    }
317
318    private function feedTitle(): string {
319        $languageCode = $this->getConfig()->get( MainConfigNames::LanguageCode );
320        $sitename = $this->getConfig()->get( MainConfigNames::Sitename );
321
322        $page = $this->specialPageFactory->getPage( 'PendingChanges' );
323        $desc = $page->getDescription();
324        return "$sitename - $desc [$languageCode]";
325    }
326
327    /**
328     * @param stdClass $row
329     * @return FeedItem|null
330     * @throws MWException
331     */
332    private function feedItem( stdClass $row ): ?FeedItem {
333        $title = Title::makeTitle( $row->page_namespace, $row->page_title );
334        if ( !$title ) {
335            return null;
336        }
337
338        $date = $row->pending_since;
339        $comments = $this->namespaceInfo->getTalkPage( $title );
340        $curRevRecord = $this->revisionLookup->getRevisionByTitle( $title );
341        $currentComment = $curRevRecord->getComment() ? $curRevRecord->getComment()->text : '';
342        $currentUserText = $curRevRecord->getUser() ? $curRevRecord->getUser()->getName() : '';
343        return new FeedItem(
344            $title->getPrefixedText(),
345            FeedUtils::formatDiffRow2(
346                $title,
347                $row->stable,
348                $curRevRecord->getId(),
349                $row->pending_since,
350                $currentComment
351            ),
352            $title->getFullURL(),
353            $date,
354            $currentUserText,
355            Title::newFromLinkTarget( $comments )->getPrefixedText()
356        );
357    }
358
359    public function formatRow( stdClass $row ): string {
360        $css = '';
361        $title = Title::newFromRow( $row );
362        $size = ChangesList::showCharacterDifference(
363            $row->rev_len, $row->page_len, $this->getContext()
364        );
365        # Page links...
366        $linkRenderer = $this->getLinkRenderer();
367
368        $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : [];
369
370        $link = $linkRenderer->makeKnownLink(
371            $title,
372            null,
373            [ 'class' => 'mw-fr-pending-changes-page-title' ],
374            $query
375        );
376        $linkArr = [];
377        $linkArr[] = $linkRenderer->makeKnownLink(
378            $title,
379            $this->msg( 'hist' )->text(),
380            [ 'class' => 'mw-fr-pending-changes-page-history' ],
381            [ 'action' => 'history' ]
382        );
383        if ( $this->getAuthority()->isAllowed( 'edit' ) ) {
384            $linkArr[] = $linkRenderer->makeKnownLink(
385                $title,
386                $this->msg( 'editlink' )->text(),
387                [ 'class' => 'mw-fr-pending-changes-page-edit' ],
388                [ 'action' => 'edit' ]
389            );
390        }
391        if ( $this->getAuthority()->isAllowed( 'delete' ) ) {
392            $linkArr[] = $linkRenderer->makeKnownLink(
393                $title,
394                $this->msg( 'tags-delete' )->text(),
395                [ 'class' => 'mw-fr-pending-changes-page-delete' ],
396                [ 'action' => 'delete' ]
397            );
398        }
399        $links = $this->msg( 'parentheses' )->rawParams( $this->getLanguage()
400            ->pipeList( $linkArr ) )->escaped();
401        $review = Html::rawElement(
402            'a',
403            [
404                'class' => 'cdx-docs-link',
405                'href' => $title->getFullURL( [ 'diff' => 'cur', 'oldid' => $row->stable ] )
406            ],
407            $this->msg( 'pendingchanges-diff' )->escaped()
408        );
409        # Is anybody watching?
410        // Only show information to users with the `unwatchedpages` who could find this
411        // information elsewhere anyway, T281065
412        if ( !$this->including() && $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
413            $uw = FRUserActivity::numUsersWatchingPage( $title );
414            $watching = ' ';
415            $watching .= $uw
416                ? $this->getWatchingFormatted( $uw )
417                : $this->msg( 'pendingchanges-unwatched' )->escaped();
418        } else {
419            $uw = -1;
420            $watching = '';
421        }
422        # Get how long the first unreviewed edit has been waiting...
423        if ( $row->pending_since ) {
424            $firstPendingTime = (int)wfTimestamp( TS_UNIX, $row->pending_since );
425            $hours = ( $this->currentUnixTS - $firstPendingTime ) / 3600;
426            $days = round( $hours / 24 );
427            if ( $days >= 3 ) {
428                $age = $this->msg( 'pendingchanges-days' )->numParams( $days )->text();
429            } elseif ( $hours >= 1 ) {
430                $age = $this->msg( 'pendingchanges-hours' )->numParams( round( $hours ) )->text();
431            } else {
432                $age = $this->msg( 'pendingchanges-recent' )->text(); // hot off the press :)
433            }
434            $age = Html::element( 'span', [], $age );
435            // Oh-noes!
436            $class = $this->getLineClass( $uw );
437            $css = $class ? " $class" : "";
438        } else {
439            $age = "";
440        }
441        $watchingColumn = $watching ? "<td>$watching</td>" : '';
442        return (
443            "<tr class='$css'>
444                <td>$link $links</td>
445                <td class='cdx-table__table__cell--align-center'>$review</td>
446                <td>$size</td>
447                <td>$age</td>
448                $watchingColumn
449            </tr>"
450        );
451    }
452
453    /**
454     * @param int $numUsersWatching Number of users or -1 when not allowed to see the number
455     * @return string
456     */
457    private function getLineClass( int $numUsersWatching ): string {
458        return $numUsersWatching == 0 ? 'fr-unreviewed-unwatched' : '';
459    }
460
461    /**
462     * @return string
463     */
464    protected function getGroupName(): string {
465        return 'quality';
466    }
467
468    /**
469     * Get formatted text for the watching value
470     *
471     * @param int $watching
472     * @return string
473     * @since 1.43
474     */
475    public function getWatchingFormatted( int $watching ): string {
476        return $watching > 0
477            ? Html::element( 'span', [], $this->getLanguage()->formatNum( $watching ) )
478            : Html::rawElement(
479                'div',
480                [ 'class' => 'cdx-info-chip' ],
481                Html::element( 'span', [ 'class' => 'cdx-info-chip--text' ],
482                    $this->msg( 'pendingchanges-unwatched' )->text() )
483            );
484    }
485}