Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.28% covered (danger)
5.28%
40 / 758
7.69% covered (danger)
7.69%
4 / 52
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlaggablePageView
5.28% covered (danger)
5.28%
40 / 758
7.69% covered (danger)
7.69%
4 / 52
73755.60
0.00% covered (danger)
0.00%
0 / 1
 getInstanceCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 newFromTitle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 singleton
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 __clone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clear
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 diffRevRecordsAreSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showingStable
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 useSimpleUI
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getPageViewStabilityModeForUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isPageViewOrDiff
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isPageView
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 displayTag
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 addStatusIndicator
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
132
 setPageContent
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
156
 addTagNoticeIfApplicable
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 determineRequestedRevision
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 setRobotPolicy
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 makeParserOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 showDraftVersion
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
240
 showStableVersion
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
272
 showUnreviewedVersion
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getTopDiffToggle
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 addToHistView
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getEditNotices
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
272
 pendingEditNoticeMessage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 stabilityLogNotice
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 addToNoSuchSection
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 addToCategoryView
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 addReviewForm
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
182
 addStabilizationLink
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 setActionTabs
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 setViewTabs
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
156
 addDraftTab
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
182
 pageWriteOpRequested
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getOldIDFromRequest
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setPendingNotice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addToDiffView
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
272
 buildDiffHeaderItems
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 diffLinkAndMarkers
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 diffToStableLink
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 diffReviewMarkers
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getDiffRevMsgAndClass
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 setViewFlags
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
90
 isDiffToStable
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 injectPostEditURLParams
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 changeSaveButton
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 editWillRequireReview
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 editWillBeAutoreviewed
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 addReviewCheck
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 getBaseRevId
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getAltBaseRevId
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3use MediaWiki\Context\ContextSource;
4use MediaWiki\Context\RequestContext;
5use MediaWiki\EditPage\EditPage;
6use MediaWiki\HookContainer\HookRunner;
7use MediaWiki\Html\Html;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Message\Message;
10use MediaWiki\Output\OutputPage;
11use MediaWiki\Page\Article;
12use MediaWiki\Page\CacheKeyHelper;
13use MediaWiki\Page\PageIdentity;
14use MediaWiki\Parser\ParserOptions;
15use MediaWiki\Parser\ParserOutput;
16use MediaWiki\Request\WebRequest;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Skin\Skin;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\Title\Title;
21use MediaWiki\User\User;
22use MediaWiki\User\UserIdentity;
23use OOUI\ButtonInputWidget;
24use Wikimedia\Rdbms\IDBAccessObject;
25
26/**
27 * Class representing a web view of a MediaWiki page
28 */
29class FlaggablePageView extends ContextSource {
30    private static ?MapCacheLRU $instances = null;
31
32    private OutputPage $out;
33    private FlaggableWikiPage $article;
34    /** @var RevisionRecord[]|null Array of `old` and `new` RevisionsRecords for diffs */
35    private ?array $diffRevRecords = null;
36    private bool $isReviewableDiff = false;
37    private bool $isDiffFromStable = false;
38    private bool $isMultiPageDiff = false;
39    private string $reviewNotice = '';
40    private string $diffNoticeBox = '';
41    private string $diffIncChangeBox = '';
42    private ?RevisionRecord $reviewFormRevRecord = null;
43    /**
44     * The stable revision.
45     */
46    private ?FlaggedRevision $srev = null;
47
48    /**
49     * The flagged revision being viewed.
50     */
51    private ?FlaggedRevision $frev = null;
52
53    /**
54     * @return MapCacheLRU
55     */
56    private static function getInstanceCache(): MapCacheLRU {
57        if ( !self::$instances ) {
58            self::$instances = new MapCacheLRU( 10 );
59        }
60        return self::$instances;
61    }
62
63    /**
64     * Get a FlaggableWikiPage for a given title
65     *
66     * @param PageIdentity $title
67     *
68     * @return self
69     */
70    public static function newFromTitle( PageIdentity $title ): FlaggablePageView {
71        $cache = self::getInstanceCache();
72        $key = CacheKeyHelper::getKeyForPage( $title );
73        $view = $cache->get( $key );
74        if ( !$view ) {
75            $view = new self( $title );
76            $cache->set( $key, $view );
77        }
78        return $view;
79    }
80
81    /**
82     * Get the FlaggablePageView for this request
83     *
84     * @deprecated Use ::newFromTitle() instead
85     * @return self
86     */
87    public static function singleton(): FlaggablePageView {
88        return self::newFromTitle( RequestContext::getMain()->getTitle() );
89    }
90
91    /**
92     * @param Title|PageIdentity $title
93     */
94    private function __construct( PageIdentity $title ) {
95        // Title is injected (a step up from everything being global), but
96        // the rest is still implicitly uses RequestContext::getMain()
97        // via parent class ContextSource::getContext().
98        // TODO: Inject $context and call setContext() here.
99
100        if ( !$title->canExist() ) {
101            throw new InvalidArgumentException( 'FlaggablePageView needs a proper page' );
102        }
103        $this->article = FlaggableWikiPage::getTitleInstance( $title );
104        $this->out = $this->getOutput(); // convenience
105    }
106
107    private function __clone() {
108    }
109
110    /**
111     * Clear the FlaggablePageView for this request.
112     * Only needed when page redirection changes the environment.
113     */
114    public function clear(): void {
115        self::$instances = null;
116    }
117
118    /**
119     * Check if the old and new diff revs are set for this page view
120     */
121    public function diffRevRecordsAreSet(): bool {
122        return (bool)$this->diffRevRecords;
123    }
124
125    /**
126     * Assuming that the current request is a page view (see isPageView()),
127     * check if a stable version exists and should be displayed.
128     */
129    public function showingStable(): bool {
130        $request = $this->getRequest();
131
132        $canShowStable = (
133            // Page is reviewable and has a stable version
134            $this->article->getStableRev() &&
135            // No parameters requesting a different version of the page
136            !$request->getCheck( 'oldid' )
137        );
138        if ( !$canShowStable ) {
139            return false;
140        }
141
142        // Check if a stable or unstable version is explicitly requested (?stable=1 or ?stable=0).
143        $stableQuery = $request->getIntOrNull( 'stable' );
144        if ( $stableQuery !== null ) {
145            return $stableQuery === 1;
146        }
147
148        // Otherwise follow site/page config and user preferences.
149        $reqUser = $this->getUser();
150        $defaultForUser = $this->getPageViewStabilityModeForUser( $reqUser );
151        return (
152            // User is not configured to prefer current versions
153            $defaultForUser !== FR_SHOW_STABLE_NEVER &&
154            // User explicitly prefers stable versions of pages
155            (
156                $defaultForUser === FR_SHOW_STABLE_ALWAYS ||
157                // Check if the stable version overrides the draft
158                $this->article->getStabilitySettings()['override']
159            )
160        );
161    }
162
163    /**
164     * Should this be using a simple icon-based UI?
165     * Check the user's preferences first, using the site settings as the default.
166     */
167    private function useSimpleUI(): bool {
168        $default = (int)$this->getConfig()->get( 'SimpleFlaggedRevsUI' );
169        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
170        return (bool)$userOptionsLookup->getOption(
171            $this->getUser(),
172            'flaggedrevssimpleui',
173            $default
174        );
175    }
176
177    /**
178     * What version of pages should this user see by default?
179     *
180     * @param UserIdentity $user The user to get the stability mode for.
181     * @return int One of the FR_SHOW_STABLE_* constants
182     */
183    private function getPageViewStabilityModeForUser( UserIdentity $user ): int {
184        $services = MediaWikiServices::getInstance();
185
186        # Check user preferences (e.g. "show stable version by default?")
187        $userOptionsLookup = $services->getUserOptionsLookup();
188        $preference = (int)$userOptionsLookup->getOption( $user, 'flaggedrevsstable' );
189        if ( $preference === FR_SHOW_STABLE_ALWAYS || $preference === FR_SHOW_STABLE_NEVER ) {
190            return $preference;
191        }
192
193        $userIdentityUtils = $services->getUserIdentityUtils();
194
195        return $userIdentityUtils->isNamed( $user ) ? FR_SHOW_STABLE_NEVER : FR_SHOW_STABLE_DEFAULT;
196    }
197
198    /**
199     * Is this a view page action (including diffs)?
200     */
201    private function isPageViewOrDiff(): bool {
202        $action = $this->getActionName();
203        return $action === 'view' || $action === 'render';
204    }
205
206    /**
207     * Is this a view page action (not including diffs)?
208     */
209    private function isPageView(): bool {
210        $request = $this->getRequest();
211        return $this->isPageViewOrDiff()
212            && $request->getVal( 'diff' ) === null;
213    }
214
215    /**
216     * Output review notice
217     */
218    public function displayTag(): void {
219        // Sanity check that this is a reviewable page
220        if ( $this->article->isReviewable() && $this->reviewNotice ) {
221            $this->out->addSubtitle( $this->reviewNotice );
222        }
223    }
224
225    /**
226     * Adds a visual indicator to the page view based on the review status of the current page revision.
227     *
228     * This indicator helps users quickly identify whether the current revision is stable, a draft,
229     * or unchecked. The indicator is displayed using an appropriate icon and message at the top
230     * of the page, near the title.
231     *
232     * @return void
233     */
234    public function addStatusIndicator(): void {
235        if ( $this->getSkin()->getSkinName() === 'minerva' ) {
236            return;
237        }
238
239        if ( !$this->article->isReviewable() ) {
240            return;
241        }
242
243        // Determine the requested revision type
244        $requestedRevision = $this->determineRequestedRevision();
245
246        // Determine the message key, icon class, and indicator ID based on the requested revision type
247        $statusMessageKey = '';
248        $iconClass = '';
249        $indicatorId = 'mw-fr-revision-toggle'; // Default ID for the indicator
250
251        switch ( $requestedRevision ) {
252            case 'stable':
253                $statusMessageKey = 'revreview-quick-basic-same-title';
254                $iconClass = 'cdx-fr-css-icon-review--status--stable';
255                break;
256            case 'draft':
257                $statusMessageKey = 'revreview-draft-indicator-title';
258                $iconClass = 'cdx-fr-css-icon-review--status--pending';
259                break;
260            case 'unreviewed':
261                if ( !$this->out->isPrintable() ) {
262                    $statusMessageKey = $this->useSimpleUI() ? 'revreview-quick-none' : 'revreview-noflagged';
263                    $iconClass = 'cdx-fr-css-icon-review--status--unchecked';
264                }
265                break;
266            case 'invalid':
267            case 'old':
268                break;
269        }
270
271        // Only proceed if a valid status message key was determined
272        if ( $statusMessageKey ) {
273            // Prepare the attributes for the indicator element
274            $attributes = [
275                'name' => 'fr-review-status',
276                'class' => 'mw-fr-review-status-indicator',
277                'id' => $indicatorId,
278            ];
279
280            // Generate the HTML for the indicator
281            $indicatorHtml = Html::rawElement(
282                'indicator',
283                $attributes,
284                Html::element( 'span', [ 'class' => $iconClass ] ) . $this->msg( $statusMessageKey )->parse()
285            );
286
287            // Add the indicator to the output page
288            $this->out->setIndicators( [ 'indicator-fr-review-status' => $indicatorHtml ] );
289        }
290    }
291
292    /**
293     * Determines what page content to display, prioritizing the most recent stable version if
294     * $wgFlaggedRevsOverride is set to true.
295     *
296     * Handles regular page views (?action=view) only. Does not handle oldids or diffs. VisualEditor
297     * publishes also trigger this behaviour.
298     *
299     * This method replaces the current page view with the last stable version if conditions allow.
300     * It determines the type of revision requested by the user (e.g., 'stable', 'draft', 'unreviewed'),
301     * and adjusts the page content accordingly. Depending on the revision type, it may display tags,
302     * notices, and a review form.
303     *
304     * The method also controls whether the parser cache should be used and whether the parser output
305     * is completed.
306     *
307     * @param bool|ParserOutput|null &$outputDone Indicates whether the parser output is completed.
308     * @param bool &$useParserCache Controls whether the parser cache should be used for this page view.
309     *
310     * @return void
311     */
312    public function setPageContent( &$outputDone, &$useParserCache ): void {
313        $request = $this->getRequest();
314
315        // Only proceed if this is a page view without an oldid parameter, and the page exists and is reviewable
316        if ( !$this->isPageView() || $request->getVal( 'oldid' ) || !$this->article->exists() ||
317            !$this->article->isReviewable() ) {
318            return;
319        }
320
321        // Initialize $tag as an empty string
322        $tag = '';
323
324        // Determine the requested revision type
325        $requestedRevision = $this->determineRequestedRevision();
326
327        switch ( $requestedRevision ) {
328            // "Stable" means that a reviewed version of the page is being displayed. This can happen
329            // if the top revision has been marked reviewed, or if $wgFlaggedRevsOverride is set to
330            // true and a non-reviewer is viewing a page with unreviewed edits. In the latter case,
331            // the unreviewed edits will be hidden and replaced with the "stable", reviewed version.
332            case 'stable':
333                $outputDone = $this->showStableVersion( $this->srev, $tag );
334                $tagTypeClass = $this->article->stableVersionIsSynced() ? 'mw-fr-stable-synced' :
335                    'mw-fr-stable-not-synced';
336                $useParserCache = false;
337                break;
338
339            // "Draft" means that a reviewer is viewing a page with some unreviewed edits. Unreviewed
340            // edits are being displayed. If $wgFlaggedRevsOverride is set to true, unreviewed edits
341            // are only displayed to reviewers.
342            case 'draft':
343                $this->showDraftVersion( $this->srev, $tag );
344                $tagTypeClass = $this->article->stableVersionIsSynced() ? 'mw-fr-draft-synced' :
345                    'mw-fr-draft-not-synced';
346                break;
347
348            // A new article that has never been reviewed. No revisions will be hidden regardless of
349            // settings. We don't have a "stable", reviewed revision yet, so we have to show an
350            // unreviewed revision.
351            case 'unreviewed':
352            default:
353                $outputDone = $this->showUnreviewedVersion( $tag );
354                $tagTypeClass = $this->article->stableVersionIsSynced() ? 'mw-fr-stable-unreviewed' :
355                    'mw-fr-stable-not-unreviewed';
356                break;
357        }
358
359        $this->addTagNoticeIfApplicable( $tag, $tagTypeClass );
360    }
361
362    /**
363     * Add the tag notice if applicable.
364     *
365     * @param string $tag The tag message.
366     * @param string $tagTypeClass The CSS class for the tag type.
367     */
368    private function addTagNoticeIfApplicable( string $tag, string $tagTypeClass ): void {
369        if ( $tag !== '' ) {
370            $notice = Html::openElement( 'div', [ 'id' => 'mw-fr-revision-messages' ] );
371            if ( $this->useSimpleUI() ) {
372                $this->addStatusIndicator();
373                $notice .= $tag;
374            } else {
375                $cssClasses = "mw-fr-basic $tagTypeClass plainlinks noprint";
376                $notice .= FlaggedRevsHTML::addMessageBox( 'block', $tag, [
377                    'class' => $cssClasses,
378                ] );
379            }
380            $notice .= Html::closeElement( 'div' );
381            $this->reviewNotice .= $notice;
382        }
383    }
384
385    /**
386     * Determines the type of revision requested based on the current request.
387     *
388     * This method determines whether to show a stable, old reviewed, draft, or unreviewed version of
389     * the page. If no specific revision is requested, it falls back on the user's preferences and
390     * site configuration to decide which version to show.
391     *
392     * The method updates the stable and flagged revision properties (`$srev` and `$frev`) accordingly.
393     *
394     * @return string The type of revision requested: 'invalid', 'old', 'stable', 'draft', or 'unreviewed'.
395     */
396    private function determineRequestedRevision(): string {
397        $this->srev = $this->article->getStableRev();
398        $this->frev = $this->srev;
399
400        // Determine whether to show the draft or unreviewed version
401        if ( $this->frev ) {
402            if ( $this->showingStable() || $this->article->stableVersionIsSynced() ) {
403                return 'stable';
404            }
405            return 'draft';
406        } else {
407            return 'unreviewed';
408        }
409    }
410
411    /**
412     * If the page has a stable version and it shows by default,
413     * tell search crawlers to index only that version of the page.
414     * Also index the draft as well if they are synced (bug 27173).
415     */
416    public function setRobotPolicy(): void {
417        $request = $this->getRequest();
418        if ( $this->article->getStableRev() && $this->article->isStableShownByDefault() ) {
419            if ( $this->isPageView() && $this->showingStable() ) {
420                return; // stable version - index this
421            } elseif ( $this->out->getRevisionId() == $this->article->getStable()
422                && $this->article->stableVersionIsSynced()
423            ) {
424                return; // draft that is synced with the stable version - index this
425            }
426            $this->out->setRobotPolicy( 'noindex,nofollow' ); // don't index this version
427        }
428    }
429
430    /**
431     * @param User $reqUser
432     * @return ParserOptions
433     */
434    private function makeParserOptions( User $reqUser ): ParserOptions {
435        $parserOptions = $this->article->makeParserOptions( $reqUser );
436        # T349037: The ArticleParserOptions hook should be broadened to take
437        # a WikiPage (aka $this->article) instead of an Article.  But for now
438        # fake the Article.
439        $article = Article::newFromWikiPage( $this->article, RequestContext::getMain() );
440        # Allow extensions to vary parser options used for article rendering,
441        # in the same way Article does
442        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
443            ->onArticleParserOptions( $article, $parserOptions );
444
445        return $parserOptions;
446    }
447
448    /**
449     * Displays the draft version of a page.
450     *
451     * This method outputs the draft version of a page, which may differ from the stable version.
452     * It adds a notice indicating that the page is pending review and optionally displays a diff
453     * between the stable and draft versions. If the stable and draft versions are synchronized,
454     * the method does not display a diff. It also adds a "your edit will pending" notice for users
455     * who have made unreviewed edits, especially if the user lacks review rights.
456     *
457     * The method also adjusts the tag that is used for the review box/bar info.
458     *
459     * @param FlaggedRevision $srev The stable revision.
460     * @param string &$tag Reference to the variable holding the review box/bar info.
461     *
462     * @return void
463     */
464    private function showDraftVersion( FlaggedRevision $srev, string &$tag ): void {
465        $request = $this->getRequest();
466        $reqUser = $this->getUser();
467        if ( $this->out->isPrintable() ) {
468            return; // all this function does is add notices; don't show them
469        }
470        $time = $this->getLanguage()->date( $srev->getTimestamp(), true );
471        # Get stable version sync status
472        $synced = $this->article->stableVersionIsSynced();
473        if ( $synced ) { // draft == stable
474            $diffToggle = ''; // no diff to show
475        } else { // draft != stable
476            # The user may want the diff (via prefs)
477            $diffToggle = $this->getTopDiffToggle( $srev );
478            if ( $diffToggle != '' ) {
479                $diffToggle = " $diffToggle";
480            }
481            # Make sure there is always a notice bar when viewing the draft.
482            if ( $this->useSimpleUI() ) { // we already one for detailed UI
483                $this->setPendingNotice( $srev, $diffToggle );
484            }
485        }
486        # Give a "your edit is pending" notice to newer users if
487        # an unreviewed edit was completed...
488        $pm = MediaWikiServices::getInstance()->getPermissionManager();
489        if ( $request->getVal( 'shownotice' )
490            && $this->article->getUserText( RevisionRecord::RAW ) == $reqUser->getName()
491            && $this->article->revsArePending()
492            && !$pm->userHasRight( $reqUser, 'review' )
493        ) {
494            $revsSince = $this->article->getPendingRevCount();
495            $pending = $this->msg( 'revreview-edited', $srev->getRevId() )
496                ->numParams( $revsSince )->parse();
497            $anchor = $request->getVal( 'fromsection' );
498            if ( $anchor != null ) {
499                // Hack: reverse some of the Sanitizer::escapeId() encoding
500                $section = urldecode( str_replace( // bug 35661
501                    [ ':', '.' ], [ '%3A', '%' ], $anchor
502                ) );
503                $section = str_replace( '_', ' ', $section ); // prettify
504                $pending .= $this->msg( 'revreview-edited-section', $anchor, $section )
505                    ->parseAsBlock();
506            }
507        # Notice should always use subtitle
508            $this->reviewNotice = Html::openElement( 'div', [
509                    'id' => 'mw-fr-reviewnotice',
510                    'class' => 'cdx-message cdx-message--block cdx-message--notice
511                    flaggedrevs_preview plainlinks noprint',
512                ] )
513                . Html::element( 'span', [ 'class' => 'cdx-message__icon' ] )
514                . Html::rawElement( 'div', [ 'class' => 'cdx-message__content' ], $pending )
515                . Html::closeElement( 'div' );
516        # Otherwise, construct some tagging info for non-printable outputs.
517        # Also, if low profile UI is enabled and the page is synced, skip the tag.
518        # Note: the "your edit is pending" notice has all this info, so we never add both.
519        } elseif ( !( $this->article->lowProfileUI() && $synced ) ) {
520            $revsSince = $this->article->getPendingRevCount();
521            // Simple icon-based UI
522            if ( $this->useSimpleUI() ) {
523                $revisionId = $srev->getRevId();
524                $tag .= FlaggedRevsHTML::reviewDialog( $srev, $revisionId, $revsSince, 'draft', $synced );
525            // Standard UI
526            } else {
527                if ( $synced ) {
528                    $msg = 'revreview-basic-same';
529                } else {
530                    $msg = !$revsSince ? 'revreview-newest-basic-i' : 'revreview-newest-basic';
531                }
532                $msgHTML = $this->msg( $msg, $srev->getRevId(), $time )
533                    ->numParams( $revsSince )->parse();
534                $tag .= $msgHTML . $diffToggle;
535            }
536        }
537    }
538
539    /**
540     * Displays the stable version of a page.
541     *
542     * This method outputs the stable version of a page, which is the version that has been flagged as reviewed.
543     * It generates and caches the `ParserOutput` for the stable version, if not already cached. If the stable
544     * version is synchronized with the current draft, it skips the diff display. The method also adds relevant
545     * tags and notices based on the stability and synchronization status.
546     *
547     * The method ensures that only the stable version or a synced draft is indexed by search engines.
548     *
549     * @param FlaggedRevision $srev The stable revision.
550     * @param string &$tag Reference to the variable holding the review box/bar info.
551     *
552     * @return ?ParserOutput The generated ParserOutput for the stable version, or null if generation fails.
553     */
554    private function showStableVersion( FlaggedRevision $srev, string &$tag ): ?ParserOutput {
555        $reqUser = $this->getUser();
556        $time = $this->getLanguage()->date( $srev->getTimestamp(), true );
557        # Set display revision ID
558        $this->out->setRevisionId( $srev->getRevId() );
559        $synced = $this->article->stableVersionIsSynced();
560        # Construct some tagging
561        if (
562            !$this->out->isPrintable() &&
563            !( $this->article->lowProfileUI() && $synced )
564        ) {
565            $revsSince = $this->article->getPendingRevCount();
566            // Simple icon-based UI
567            if ( $this->useSimpleUI() ) {
568                # For protection based configs, show lock only if it's not redundant.
569                $revisionId = $srev->getRevId();
570                $tag = FlaggedRevsHTML::reviewDialog(
571                    $srev,
572                    $revisionId,
573                    $revsSince,
574                    'stable',
575                    $synced
576                );
577            // Standard UI
578            } else {
579                if ( $synced ) {
580                    $msg = 'revreview-basic-same';
581                } else {
582                    $msg = !$revsSince ? 'revreview-basic-i' : 'revreview-basic';
583                }
584                $tag = $this->msg( $msg, $srev->getRevId(), $time )
585                    ->numParams( $revsSince )->parse();
586            }
587        }
588
589        // TODO: Rewrite to use ParserOutputAccess
590        $parserOptions = $this->makeParserOptions( $reqUser );
591        $stableParserCache = FlaggedRevs::getParserCacheInstance( $parserOptions );
592        // Check the stable version cache for the parser output
593        $parserOut = $stableParserCache->get( $this->article, $parserOptions );
594
595        if ( !$parserOut ) {
596            if ( FlaggedRevs::inclusionSetting() == FR_INCLUDES_CURRENT && $synced ) {
597                # Stable and draft version are identical; check the draft version cache
598                $draftParserCache = MediaWikiServices::getInstance()->getParserCache();
599                $parserOut = $draftParserCache->get( $this->article, $parserOptions );
600            }
601
602            if ( !$parserOut ) {
603                # Regenerate the parser output, debouncing parse requests via PoolCounter
604                $status = FlaggedRevs::parseStableRevisionPooled( $srev, $parserOptions );
605                if ( !$status->isGood() ) {
606                    $this->out->disableClientCache();
607                    $this->out->setRobotPolicy( 'noindex,nofollow' );
608                    $statusFormatter = MediaWikiServices::getInstance()
609                        ->getFormatterFactory()
610                        ->getStatusFormatter( $this->getContext() );
611                    $errorText = $statusFormatter->getMessage( $status );
612                    $this->out->addHTML(
613                        Html::errorBox( $this->out->parseAsContent( $errorText ) )
614                    );
615                    return null;
616                }
617                $parserOut = $status->getValue();
618            }
619
620            if ( $parserOut instanceof ParserOutput ) {
621                # Update the stable version cache
622                $stableParserCache->save( $parserOut, $this->article, $parserOptions );
623            }
624        }
625
626        if ( !$parserOut ) {
627            $this->out->disableClientCache();
628            $this->out->setRobotPolicy( 'noindex,nofollow' );
629
630            $this->out->addWikiMsg(
631                'missing-article',
632                $this->article->getTitle()->getPrefixedText(),
633                $this->msg( 'missingarticle-rev', $srev->getRevId() )->plain()
634            );
635            return null;
636        }
637
638        # Add the parser output to the page view
639        $pm = MediaWikiServices::getInstance()->getPermissionManager();
640        $poOptions = [
641            // (T391788) This should always be used for full page views
642            'includeDebugInfo' => true,
643        ];
644        if ( $this->out->isPrintable() ||
645            !$pm->quickUserCan( 'edit', $reqUser, $this->article->getTitle() )
646        ) {
647            $poOptions['enableSectionEditLinks'] = false;
648        }
649
650        $this->out->addParserOutput( $parserOut, $parserOptions, $poOptions );
651        return $parserOut;
652    }
653
654    /**
655     * Displays a page with no reviewed revisions. Will display the most recent revision.
656     *
657     * This method handles the display of an unreviewed version of the page, showing the appropriate
658     * UI elements based on user preferences (simple or detailed). It sets the appropriate tag and
659     * tag type class to indicate the page's unreviewed status. The method generates a `ParserOutput`
660     * for the unreviewed version and adds it to the output page.
661     *
662     * The method is used when a page has no reviewed revisions in its history. In other words, it
663     * is used for newly created pages by non-reviewers.
664     *
665     * @param string &$tag Reference to the variable holding the review box/bar info.
666     *
667     * @return ParserOutput|bool The generated ParserOutput for the unreviewed version, or null if none is available.
668     */
669    private function showUnreviewedVersion( string &$tag ) {
670        $reqUser = $this->getUser();
671
672        if ( $this->useSimpleUI() ) {
673            $this->addStatusIndicator();
674            $this->out->addHTML( FlaggedRevsHTML::reviewDialog( null, 0, 0, 'unreviewed' ) );
675        } else {
676            $tag = $this->msg( 'revreview-noflagged' )->parse();
677        }
678
679        // Generate the ParserOutput for the unreviewed version
680        $parserOptions = $this->makeParserOptions( $reqUser );
681        $parserOut = $this->article->getParserOutput( $parserOptions );
682
683        // Set the output revision ID so that the "Permanent link" link works. T384778
684        // (T394381) Note that there is a tiny delay between publishing in VisualEditor
685        // and a Revision ID being available, which affects the page reload right after
686        // VisualEditor saves an edit; if so, skip.
687        $rev = $this->article->getRevisionRecord();
688        if ( $rev ) {
689            $this->out->setRevisionId( $rev->getId() );
690        }
691
692        // Add the ParserOutput to the output page
693        if ( $parserOut ) {
694            $this->out->addParserOutput( $parserOut, $parserOptions, [
695                // (T391788) This should always be used for full page views
696                'includeDebugInfo' => true,
697            ] );
698        }
699
700        return $parserOut;
701    }
702
703    /**
704     * Get a toggle for a collapsible diff-to-stable to add to the review notice as needed
705     * @param FlaggedRevision $srev stable version
706     * @return string|false the html line (either "" or "<diff toggle>")
707     */
708    private function getTopDiffToggle( FlaggedRevision $srev ) {
709        $reqUser = $this->getUser();
710        if ( !MediaWikiServices::getInstance()->getUserOptionsLookup()
711            ->getBoolOption( $reqUser, 'flaggedrevsviewdiffs' )
712        ) {
713            return false; // nothing to do here
714        }
715        # Diff should only show for the draft
716        $oldid = $this->getOldIDFromRequest();
717        $latest = $this->article->getLatest();
718        if ( $oldid && $oldid != $latest ) {
719            return false; // not viewing the draft
720        }
721        $revsSince = $this->article->getPendingRevCount();
722        if ( !$revsSince ) {
723            return false; // no pending changes
724        }
725
726        $title = $this->article->getTitle(); // convenience
727        if ( $srev->getRevId() !== $latest ) {
728            $nEdits = $revsSince - 1; // full diff-to-stable, no need for query
729            if ( $nEdits ) {
730                $limit = 100;
731                try {
732                    $latestRevObj = MediaWikiServices::getInstance()
733                        ->getRevisionLookup()
734                        ->getRevisionById( $latest );
735                    $users = MediaWikiServices::getInstance()
736                        ->getRevisionStore()
737                        ->getAuthorsBetween(
738                            $title->getArticleID(),
739                            $srev->getRevisionRecord(),
740                            $latestRevObj,
741                            null,
742                            $limit
743                        );
744                    $nUsers = count( $users );
745                } catch ( InvalidArgumentException ) {
746                    $nUsers = 0;
747                }
748                $multiNotice = DifferenceEngine::intermediateEditsMsg( $nEdits, $nUsers, $limit );
749            } else {
750                $multiNotice = '';
751            }
752            $this->isDiffFromStable = true; // alter default review form tags
753            // @phan-suppress-next-line SecurityCheck-DoubleEscaped multiNotice is used in a-tag
754            return FlaggedRevsHTML::diffToggle( $title, $srev->getRevId(), $latest, $multiNotice );
755        }
756
757        return '';
758    }
759
760    /**
761     * Adds stable version tags to page when viewing history
762     */
763    public function addToHistView(): void {
764        # Add a notice if there are pending edits...
765        $srev = $this->article->getStableRev();
766        if ( $srev && $this->article->revsArePending() ) {
767            $revsSince = $this->article->getPendingRevCount();
768            $noticeContent = $this->pendingEditNoticeMessage( $srev, $revsSince )->parse();
769            $tag = FlaggedRevsHTML::addMessageBox( 'block', $noticeContent, [
770                'id' => 'mw-fr-revision-tag-edit',
771                'class' => 'flaggedrevs_notice plainlinks'
772            ] );
773            $this->out->addHTML( $tag );
774        }
775    }
776
777    /**
778     * @param Title $title
779     * @param int $oldid
780     * @param string[] &$notices
781     */
782    public function getEditNotices( Title $title, int $oldid, array &$notices ): void {
783        if ( !$this->article->isReviewable() ) {
784            return;
785        }
786        // HACK fake EditPage
787        $editPage = new EditPage( new Article( $title, $oldid ) );
788        $editPage->oldid = $oldid;
789        $reqUser = $this->getUser();
790
791        $lines = [];
792
793        $log = $this->stabilityLogNotice();
794        if ( $log ) {
795            $lines[] = $log;
796        } elseif ( $this->editWillRequireReview( $editPage ) ) {
797            $lines[] = $this->msg( 'revreview-editnotice' )->parseAsBlock();
798        }
799        $frev = $this->article->getStableRev();
800        if ( $frev && $this->article->revsArePending() ) {
801            $revsSince = $this->article->getPendingRevCount();
802            $pendingMsg = $this->pendingEditNoticeMessage( $frev, $revsSince );
803            $lines[] = $pendingMsg->parseAsBlock();
804        }
805        $latestId = $this->article->getLatest();
806        $revId  = $oldid ?: $latestId;
807        if ( $frev && $frev->getRevId() < $latestId // changes were made
808            && MediaWikiServices::getInstance()->getUserOptionsLookup()
809                ->getBoolOption( $reqUser, 'flaggedrevseditdiffs' ) // not disabled via prefs
810            && $revId === $latestId // only for current rev
811        ) {
812            $lines[] = '<p>' . $this->msg( 'review-edit-diff' )->parse() . ' ' .
813                FlaggedRevsHTML::diffToggle( $this->article->getTitle(), $frev->getRevId(), $revId ) . '</p>';
814        }
815
816        $srev = $this->article->getStableRev();
817        $revsSince = $this->article->getPendingRevCount();
818
819        if ( $frev && $this->article->onlyTemplatesPending() && $revsSince === 0 && $srev ) {
820            $time = $this->getLanguage()->userTimeAndDate( $srev->getTimestamp(), $this->getUser() );
821            $lines[] = '<p>' . $this->msg( 'revreview-newest-basic-i',
822                    $srev->getRevId(), $time )->numParams( $revsSince )->parse() . '</p>';
823        }
824
825        if ( $lines ) {
826            $lineMessages = FlaggedRevsHTML::addMessageBox( 'block', implode( "\n", $lines ) );
827
828            $notices['flaggedrevs_editnotice'] = Html::rawElement( 'div', [
829                'class' => 'mw-fr-edit-messages',
830            ], $lineMessages );
831        }
832    }
833
834    /**
835     * Creates "stable rev reviewed on"/"x pending edits" message
836     */
837    private function pendingEditNoticeMessage( FlaggedRevision $frev, int $revsSince ): Message {
838        $time = $this->getLanguage()->date( $frev->getTimestamp(), true );
839        # Add message text for pending edits
840        return $this->msg( 'revreview-pending-basic', $frev->getRevId(), $time )->numParams( $revsSince );
841    }
842
843    private function stabilityLogNotice(): string {
844        if ( $this->article->isPageLocked() ) {
845            $msg = 'revreview-locked';
846        } elseif ( $this->article->isPageUnlocked() ) {
847            $msg = 'revreview-unlocked';
848        } else {
849            return '';
850        }
851        $s = $this->msg( $msg )->parseAsBlock();
852        return $s . FlaggedRevsHTML::stabilityLogExcerpt( $this->article->getTitle() );
853    }
854
855    public function addToNoSuchSection( string &$s ): void {
856        $srev = $this->article->getStableRev();
857        # Add notice for users that may have clicked "edit" for a
858        # section in the stable version that isn't in the draft.
859        if ( $srev && $this->article->revsArePending() ) {
860            $revsSince = $this->article->getPendingRevCount();
861            if ( $revsSince ) {
862                $s .= "<div class='flaggedrevs_editnotice plainlinks'>" .
863                    $this->msg( 'revreview-pending-nosection',
864                        $srev->getRevId() )->numParams( $revsSince )->parse() . "</div>";
865            }
866        }
867    }
868
869    /**
870     * Add unreviewed pages links
871     */
872    public function addToCategoryView(): void {
873        $reqUser = $this->getUser();
874        $pm = MediaWikiServices::getInstance()->getPermissionManager();
875        if ( !$pm->userHasRight( $reqUser, 'review' ) ) {
876            return;
877        }
878
879        if ( !FlaggedRevs::useOnlyIfProtected() ) {
880            # Add links to lists of unreviewed pages and pending changes in this category
881            $category = $this->article->getTitle()->getText();
882            $this->out->addSubtitle(
883                Html::rawElement(
884                    'span',
885                    [ 'class' => 'plainlinks', 'id' => 'mw-fr-category-oldreviewed' ],
886                    $this->msg( 'flaggedrevs-categoryview', urlencode( $category ) )->parse()
887                )
888            );
889        }
890    }
891
892    /**
893     * Add review form to pages when necessary on a regular page view (action=view).
894     * If $output is an OutputPage then this prepends the form onto it.
895     * If $output is a string then this appends the review form to it.
896     * @param string|OutputPage &$output
897     */
898    public function addReviewForm( &$output ): void {
899        if ( $this->out->isPrintable() ) {
900            // Must be on non-printable output
901            return;
902        }
903
904        # User must have review rights
905        $reqUser = $this->getUser();
906        if ( !MediaWikiServices::getInstance()->getPermissionManager()
907            ->userHasRight( $reqUser, 'review' )
908        ) {
909            return;
910        }
911        # Page must exist and be reviewable
912        if ( !$this->article->exists() || !$this->article->isReviewable() ) {
913            return;
914        }
915        # Must be a page view action...
916        if ( !$this->isPageViewOrDiff() ) {
917            return;
918        }
919        // Determine the revision to be reviewed, either from the current output or fallback to
920        // the latest revision for unchecked pages
921        $revisionId = $this->out->getRevisionId() ?: $this->article->getLatest();
922        $revRecord = $this->reviewFormRevRecord ?: MediaWikiServices::getInstance()
923            ->getRevisionLookup()
924            ->getRevisionById( $revisionId );
925
926        # Build the review form as needed
927        if ( $revRecord && ( !$this->diffRevRecords || $this->isReviewableDiff ) ) {
928            $form = new RevisionReviewFormUI(
929                $this->getContext(),
930                $this->article,
931                $revRecord
932            );
933            # Default tags and existence of "reject" button depend on context
934            if ( $this->diffRevRecords ) {
935                $oldRevRecord = $this->diffRevRecords['old'];
936                $form->setDiffPriorRevRecord( $oldRevRecord );
937            }
938            # Review notice box goes in top of form
939            $form->setTopNotice( $this->diffNoticeBox );
940            $form->setBottomNotice( $this->diffIncChangeBox );
941
942            [ $html, ] = $form->getHtml();
943            # Diff action: place the form at the top of the page
944            if ( $output instanceof OutputPage ) {
945                $output->prependHTML( $html );
946            # View action: place the form at the bottom of the page
947            } else {
948                $output .= $html;
949            }
950        }
951    }
952
953    /**
954     * Add link to stable version setting to protection form
955     */
956    public function addStabilizationLink(): void {
957        $request = $this->getRequest();
958        if ( FlaggedRevs::useOnlyIfProtected() ) {
959            // Simple custom levels set for action=protect
960            return;
961        }
962        # Check only if the title is reviewable
963        if ( !FlaggedRevs::inReviewNamespace( $this->article ) ) {
964            return;
965        }
966        $action = $request->getVal( 'action', 'view' );
967        if ( $action == 'protect' || $action == 'unprotect' ) {
968            $title = SpecialPage::getTitleFor( 'Stabilization' );
969            # Give a link to the page to configure the stable version
970            $frev = $this->article->getStableRev();
971            if ( !$frev ) {
972                $msg = 'revreview-visibility-nostable';
973            } elseif ( $frev->getRevId() == $this->article->getLatest() ) {
974                $msg = 'revreview-visibility-synced';
975            } else {
976                $msg = 'revreview-visibility-outdated';
977            }
978            $this->out->prependHTML( "<span class='revreview-visibility $msg plainlinks'>" .
979                $this->msg( $msg, $title->getPrefixedText() )->parse() . '</span>' );
980        }
981    }
982
983    /**
984     * Modify an array of action links, as used by SkinTemplateNavigation and
985     * SkinTemplateTabs, to include flagged revs UI elements
986     *
987     * @param array &$actions
988     */
989    public function setActionTabs( array &$actions ): void {
990        $reqUser = $this->getUser();
991
992        if ( FlaggedRevs::useOnlyIfProtected() ) {
993            return; // simple custom levels set for action=protect
994        }
995
996        if ( !FlaggedRevs::inReviewNamespace( $this->article ) ) {
997            return; // Only reviewable pages need these tabs
998        }
999
1000        // Check if we should show a stabilization tab
1001        $pm = MediaWikiServices::getInstance()->getPermissionManager();
1002        if (
1003            !$this->article->getTitle()->isTalkPage() &&
1004            !isset( $actions['protect'] ) &&
1005            !isset( $actions['unprotect'] ) &&
1006            $pm->userHasRight( $reqUser, 'stablesettings' ) &&
1007            $this->article->exists()
1008        ) {
1009            $stableTitle = SpecialPage::getTitleFor( 'Stabilization' );
1010            // Add the tab
1011            $actions['default'] = [
1012                'class' => false,
1013                'text' => $this->msg( 'stabilization-tab' )->text(),
1014                'href' => $stableTitle->getLocalURL( 'page=' . $this->article->getTitle()->getPrefixedURL() )
1015            ];
1016        }
1017    }
1018
1019    /**
1020     * Modify an array of tab links to include flagged revs UI elements
1021     * @param Skin $skin
1022     * @param array[] &$views
1023     */
1024    public function setViewTabs( Skin $skin, array &$views ): void {
1025        if ( !FlaggedRevs::inReviewNamespace( $this->article ) ) {
1026            // Short-circuit for non-reviewable pages
1027            return;
1028        }
1029        # Hack for bug 16734 (some actions update and view all at once)
1030        if ( $this->pageWriteOpRequested() &&
1031            MediaWikiServices::getInstance()->getDBLoadBalancer()->hasOrMadeRecentPrimaryChanges()
1032        ) {
1033            # Tabs need to reflect the new stable version so users actually
1034            # see the results of their action (i.e. "delete"/"rollback")
1035            $this->article->loadPageData( IDBAccessObject::READ_LATEST );
1036        }
1037        $srev = $this->article->getStableRev();
1038        if ( !$srev ) {
1039            // No stable revision exists
1040            return;
1041        }
1042        $synced = $this->article->stableVersionIsSynced();
1043        $pendingEdits = !$synced && $this->article->isStableShownByDefault();
1044        // Set the edit tab names as needed...
1045        if ( $pendingEdits && $this->isPageView() && $this->showingStable() ) {
1046            // bug 31489; direct user to current
1047            if ( isset( $views['edit'] ) ) {
1048                $views['edit']['href'] = $skin->getTitle()->getFullURL( 'action=edit' );
1049            }
1050            if ( isset( $views['viewsource'] ) ) {
1051                $views['viewsource']['href'] = $skin->getTitle()->getFullURL( 'action=edit' );
1052            }
1053            // Instruct alternative editors like VisualEditor to load the latest ("current")
1054            // revision for editing, rather than the one from 'wgRevisionId'
1055            $skin->getOutput()->addJsConfigVars( 'wgEditLatestRevision', true );
1056        }
1057        # Add "pending changes" tab if the page is not synced
1058        if ( !$synced ) {
1059            $this->addDraftTab( $views, $srev );
1060        }
1061    }
1062
1063    /**
1064     * Add "pending changes" tab and set tab selection CSS
1065     * @param array[] &$views
1066     * @param FlaggedRevision $srev
1067     */
1068    private function addDraftTab( array &$views, FlaggedRevision $srev ): void {
1069        $request = $this->getRequest();
1070        $title = $this->article->getTitle(); // convenience
1071        $tabs = [
1072            'read' => [ // view stable
1073                'text'  => '', // unused
1074                'href'  => $title->getLocalURL( 'stable=1' ),
1075                'class' => ''
1076            ],
1077            'draft' => [ // view draft
1078                'text'  => $this->msg( 'revreview-current' )->text(),
1079                'href'  => $title->getLocalURL( 'stable=0&redirect=no' ),
1080                'class' => 'collapsible'
1081            ],
1082        ];
1083        // Set tab selection CSS
1084        if ( ( $this->isPageView() && $this->showingStable() ) || $request->getVal( 'stableid' ) ) {
1085            // We are looking a the stable version or an old reviewed one
1086            $tabs['read']['class'] = 'selected';
1087        } elseif ( $this->isPageViewOrDiff() ) {
1088            $ts = null;
1089            if ( $this->out->getRevisionId() ) { // @TODO: avoid same query in Skin.php
1090                if ( $this->out->getRevisionId() == $this->article->getLatest() ) {
1091                    $ts = $this->article->getTimestamp(); // skip query
1092                } else {
1093                    $ts = MediaWikiServices::getInstance()
1094                        ->getRevisionLookup()
1095                        ->getTimestampFromId( $this->out->getRevisionId() );
1096                }
1097            }
1098            // Are we looking at a pending revision?
1099            if ( $ts > $srev->getRevTimestamp() ) { // bug 15515
1100                $tabs['draft']['class'] .= ' selected';
1101            // Are there *just* pending template changes.
1102            } elseif ( $this->article->onlyTemplatesPending()
1103                && $this->out->getRevisionId() == $this->article->getStable()
1104            ) {
1105                $tabs['draft']['class'] .= ' selected';
1106            // Otherwise, fallback to regular tab behavior
1107            } else {
1108                $tabs['read']['class'] = 'selected';
1109            }
1110        }
1111        $newViews = [];
1112        // Rebuild tabs array
1113        $previousTab = null;
1114        foreach ( $views as $tabAction => $data ) {
1115            // The 'view' tab. Make it go to the stable version...
1116            if ( $tabAction == 'view' ) {
1117                // 'view' for content page; make it go to the stable version
1118                $newViews[$tabAction]['text'] = $data['text']; // keep tab name
1119                $newViews[$tabAction]['href'] = $tabs['read']['href'];
1120                $newViews[$tabAction]['class'] = $tabs['read']['class'];
1121            // All other tabs...
1122            } else {
1123                if ( $previousTab == 'view' ) {
1124                    $newViews['current'] = $tabs['draft'];
1125                }
1126                $newViews[$tabAction] = $data;
1127            }
1128            $previousTab = $tabAction;
1129        }
1130        // Replaces old tabs with new tabs
1131        $views = $newViews;
1132    }
1133
1134    /**
1135     * Check if a flaggedrevs relevant write op was done this page view
1136     */
1137    private function pageWriteOpRequested(): bool {
1138        $request = $this->getRequest();
1139        # Hack for bug 16734 (some actions update and view all at once)
1140        $action = $request->getVal( 'action' );
1141        return $action === 'rollback' ||
1142            ( $action === 'delete' && $request->wasPosted() );
1143    }
1144
1145    private function getOldIDFromRequest(): int {
1146        $article = Article::newFromWikiPage( $this->article, RequestContext::getMain() );
1147        return $article->getOldIDFromRequest();
1148    }
1149
1150    /**
1151     * Adds a notice saying that this revision is pending review
1152     *
1153     * @param FlaggedRevision $srev The stable version
1154     * @param string $diffToggle either "" or " <diff toggle><diff div>"
1155     */
1156    private function setPendingNotice( FlaggedRevision $srev, string $diffToggle = '' ): void {
1157        $time = $this->getLanguage()->date( $srev->getTimestamp(), true );
1158        $revsSince = $this->article->getPendingRevCount();
1159        $msg = !$revsSince ? 'revreview-newest-basic-i' : 'revreview-newest-basic';
1160        # Add bar msg to the top of the page...
1161        $msgHTML = $this->msg( $msg, $srev->getRevId(), $time )->numParams( $revsSince )->parse();
1162
1163        if ( !$this->useSimpleUI() ) {
1164            $this->reviewNotice .= FlaggedRevsHTML::addMessageBox( 'block', $msgHTML . $diffToggle );
1165        }
1166    }
1167
1168    /**
1169     * When viewing a diff:
1170     * (a) Add the review form to the top of the page
1171     * (b) Mark off which versions are checked or not
1172     * (c) When comparing the stable revision to the current:
1173     *   (i)  Show a tag with some explanation for the diff
1174     */
1175    public function addToDiffView( ?RevisionRecord $oldRevRecord, ?RevisionRecord $newRevRecord ): void {
1176        $pm = MediaWikiServices::getInstance()->getPermissionManager();
1177        $request = $this->getRequest();
1178        $reqUser = $this->getUser();
1179        # Exempt printer-friendly output
1180        if ( $this->out->isPrintable() ) {
1181            return;
1182        # Multi-page diffs are useless and misbehave (bug 19327). Sanity check $newRevRecord.
1183        } elseif ( $this->isMultiPageDiff || !$newRevRecord ) {
1184            return;
1185        # Page must be reviewable.
1186        } elseif ( !$this->article->isReviewable() ) {
1187            return;
1188        }
1189        $srev = $this->article->getStableRev();
1190        if ( $srev && $this->isReviewableDiff ) {
1191            $this->reviewFormRevRecord = $newRevRecord;
1192        }
1193        # Check if this is a diff-to-stable. If so:
1194        # (a) prompt reviewers to review the changes
1195        if ( $srev
1196            && $this->isDiffFromStable
1197            && !$this->article->stableVersionIsSynced() // pending changes
1198        ) {
1199            # If there are pending revs, notify the user...
1200            if ( $this->article->revsArePending() ) {
1201                # If the user can review then prompt them to review them...
1202                if ( $pm->userHasRight( $reqUser, 'review' ) ) {
1203                    // Reviewer just edited...
1204                    if ( $request->getInt( 'shownotice' )
1205                        && $newRevRecord->isCurrent()
1206                        && $newRevRecord->getUser( RevisionRecord::RAW )
1207                            ->equals( $reqUser )
1208                    ) {
1209                        $title = $this->article->getTitle(); // convenience
1210                        // @TODO: make diff class cache this
1211                        $n = MediaWikiServices::getInstance()
1212                            ->getRevisionStore()
1213                            ->countRevisionsBetween(
1214                                $title->getArticleID(),
1215                                $oldRevRecord,
1216                                $newRevRecord
1217                            );
1218                        if ( $n ) {
1219                            $msg = 'revreview-update-edited-prev'; // previous pending edits
1220                        } else {
1221                            $msg = 'revreview-update-edited'; // just couldn't autoreview
1222                        }
1223                    // All other cases...
1224                    } else {
1225                        $msg = 'revreview-update'; // generic "please review" notice...
1226                    }
1227                    // add as part of form
1228                    $this->diffNoticeBox = $this->msg( $msg )->parseAsBlock();
1229                }
1230            }
1231        }
1232        # Add a link to diff from stable to current as needed.
1233        # Show review status of the diff revision(s). Uses a <table>.
1234        $this->out->addHTML(
1235            '<div id="mw-fr-diff-headeritems">' .
1236            self::diffLinkAndMarkers(
1237                $this->article,
1238                $oldRevRecord,
1239                $newRevRecord
1240            ) .
1241            '</div>'
1242        );
1243    }
1244
1245    /**
1246     * get new diff header items for in-place page review
1247     */
1248    public static function buildDiffHeaderItems( int $oldid, int $newid ): string {
1249        $revLookup = MediaWikiServices::getInstance()->getRevisionLookup();
1250        $newRevRecord = $revLookup->getRevisionById( $newid );
1251        if ( $newRevRecord && $newRevRecord->getPageAsLinkTarget() ) {
1252            $oldRevRecord = $revLookup->getRevisionById( $oldid );
1253            $fa = FlaggableWikiPage::getTitleInstance(
1254                Title::newFromLinkTarget( $newRevRecord->getPageAsLinkTarget() )
1255            );
1256            return self::diffLinkAndMarkers( $fa, $oldRevRecord, $newRevRecord );
1257        }
1258        return '';
1259    }
1260
1261    /**
1262     * (a) Add a link to diff from stable to current as needed
1263     * (b) Show review status of the diff revision(s). Uses a <table>.
1264     * Note: used by ajax function to rebuild diff page
1265     */
1266    private static function diffLinkAndMarkers(
1267        FlaggableWikiPage $article,
1268        ?RevisionRecord $oldRevRecord,
1269        ?RevisionRecord $newRevRecord
1270    ): string {
1271        $s = '<form id="mw-fr-diff-dataform">';
1272        $s .= Html::hidden( 'oldid', $oldRevRecord ? $oldRevRecord->getId() : 0 );
1273        $s .= Html::hidden( 'newid', $newRevRecord ? $newRevRecord->getId() : 0 );
1274        $s .= "</form>\n";
1275        if ( $newRevRecord && $oldRevRecord ) { // sanity check
1276            $s .= self::diffToStableLink( $article, $oldRevRecord, $newRevRecord );
1277            $s .= self::diffReviewMarkers( $article, $oldRevRecord, $newRevRecord );
1278        }
1279        return $s;
1280    }
1281
1282    /**
1283     * Add a link to diff-to-stable for reviewable pages
1284     */
1285    private static function diffToStableLink(
1286        FlaggableWikiPage $article,
1287        RevisionRecord $oldRevRecord,
1288        RevisionRecord $newRevRecord
1289    ): string {
1290        $srev = $article->getStableRev();
1291        if ( !$srev ) {
1292            return ''; // nothing to do
1293        }
1294        $review = '';
1295        # Is this already the full diff-to-stable?
1296        $fullStableDiff = $newRevRecord->isCurrent()
1297            && self::isDiffToStable(
1298                $srev,
1299                $oldRevRecord,
1300                $newRevRecord
1301            );
1302        # Make a link to the full diff-to-stable if:
1303        # (a) Actual revs are pending and (b) We are not viewing the full diff-to-stable
1304        if ( $article->revsArePending() && !$fullStableDiff ) {
1305            $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
1306            $reviewLink = $linkRenderer->makeKnownLink(
1307                $article->getTitle(),
1308                wfMessage( 'review-diff2stable' )->text(),
1309                [],
1310                [ 'oldid' => $srev->getRevId(), 'diff' => 'cur' ]
1311            );
1312            $reviewWrapped = wfMessage( 'parentheses' )->rawParams( $reviewLink )->escaped();
1313            $review = "<div class='fr-diff-to-stable' style='text-align: center;'>$reviewWrapped</div>";
1314        }
1315        return $review;
1316    }
1317
1318    /**
1319     * Add [checked version] and such to left and right side of diff
1320     */
1321    private static function diffReviewMarkers(
1322        FlaggableWikiPage $article,
1323        ?RevisionRecord $oldRevRecord,
1324        ?RevisionRecord $newRevRecord
1325    ): string {
1326        $table = '';
1327        $srev = $article->getStableRev();
1328        # Diff between two revisions
1329        if ( $oldRevRecord && $newRevRecord ) {
1330            [ $msg, $class ] = self::getDiffRevMsgAndClass( $oldRevRecord, $srev );
1331            $table .= "<table class='fr-diff-ratings'><tr>";
1332            $table .= "<td style='text-align: center; width: 50%;'>";
1333            // @todo i18n FIXME: Hard coded brackets
1334            $table .= "<span class='$class'>[" .
1335                wfMessage( $msg )->escaped() . "]</span>";
1336
1337            [ $msg, $class ] = self::getDiffRevMsgAndClass( $newRevRecord, $srev );
1338            $table .= "</td><td style='text-align: center; width: 50%;'>";
1339            // @todo i18n FIXME: Hard coded brackets
1340            $table .= "<span class='$class'>[" .
1341                wfMessage( $msg )->escaped() . "]</span>";
1342
1343            $table .= "</td></tr></table>\n";
1344        # New page "diffs" - just one rev
1345        } elseif ( $newRevRecord ) {
1346            [ $msg, $class ] = self::getDiffRevMsgAndClass( $newRevRecord, $srev );
1347            $table .= "<table class='fr-diff-ratings'>";
1348            $table .= "<tr><td style='text-align: center;'><span class='$class'>";
1349            // @todo i18n FIXME: Hard coded brackets
1350            $table .= '[' . wfMessage( $msg )->escaped() . ']';
1351            $table .= "</span></td></tr></table>\n";
1352        }
1353        return $table;
1354    }
1355
1356    /**
1357     * @return string[]
1358     */
1359    private static function getDiffRevMsgAndClass(
1360        RevisionRecord $revRecord, ?FlaggedRevision $srev
1361    ): array {
1362        $checked = FlaggedRevision::revIsFlagged( $revRecord->getId() );
1363        if ( $checked ) {
1364            $msg = 'revreview-hist-basic';
1365        } else {
1366            $msg = ( $srev && $revRecord->getTimestamp() > $srev->getRevTimestamp() ) ? // bug 15515
1367                'revreview-hist-pending' :
1368                'revreview-hist-draft';
1369        }
1370        return [ $msg, $checked ? 'flaggedrevs-color-1' : 'flaggedrevs-color-0' ];
1371    }
1372
1373    /**
1374     * Set $this->isDiffFromStable and $this->isMultiPageDiff fields
1375     */
1376    public function setViewFlags(
1377        DifferenceEngine $diff,
1378        ?RevisionRecord $oldRevRecord,
1379        ?RevisionRecord $newRevRecord
1380    ): void {
1381        // We only want valid diffs that actually make sense...
1382        if ( !( $newRevRecord
1383            && $oldRevRecord
1384            && $newRevRecord->getTimestamp() >= $oldRevRecord->getTimestamp() )
1385        ) {
1386            return;
1387        }
1388
1389        // Is this a diff between two pages?
1390        if ( $newRevRecord->getPageId() != $oldRevRecord->getPageId() ) {
1391            $this->isMultiPageDiff = true;
1392        // Is there a stable version?
1393        } elseif ( $this->article->isReviewable() ) {
1394            $srev = $this->article->getStableRev();
1395            // Is this a diff of a draft rev against the stable rev?
1396            if ( self::isDiffToStable(
1397                $srev,
1398                $oldRevRecord,
1399                $newRevRecord
1400            ) ) {
1401                $this->isDiffFromStable = true;
1402                $this->isReviewableDiff = true;
1403            // Is this a diff of a draft rev against a reviewed rev?
1404            } elseif (
1405                FlaggedRevision::newFromTitle(
1406                    $diff->getTitle(),
1407                    $oldRevRecord->getId()
1408                ) ||
1409                FlaggedRevision::newFromTitle(
1410                    $diff->getTitle(),
1411                    $newRevRecord->getId()
1412                )
1413            ) {
1414                $this->isReviewableDiff = true;
1415            }
1416        }
1417
1418        $this->diffRevRecords = [
1419            'old' => $oldRevRecord,
1420            'new' => $newRevRecord
1421        ];
1422    }
1423
1424    /**
1425     * Is a diff from $oldRev to $newRev a diff-to-stable?
1426     */
1427    private static function isDiffToStable(
1428        ?FlaggedRevision $srev,
1429        ?RevisionRecord $oldRevRecord,
1430        ?RevisionRecord $newRevRecord
1431    ): bool {
1432        return ( $srev
1433            && $oldRevRecord
1434            && $newRevRecord
1435            && $oldRevRecord->getPageId() === $newRevRecord->getPageId() // no multipage diffs
1436            && $oldRevRecord->getId() == $srev->getRevId()
1437            && $newRevRecord->getTimestamp() >= $oldRevRecord->getTimestamp() // no backwards diffs
1438        );
1439    }
1440
1441    /**
1442     * Redirect users out to review the changes to the stable version.
1443     * Only for people who can review and for pages that have a stable version.
1444     */
1445    public function injectPostEditURLParams( string &$sectionAnchor, string &$extraQuery ): void {
1446        $reqUser = $this->getUser();
1447        $this->article->loadPageData( IDBAccessObject::READ_LATEST );
1448        # Get the stable version from the primary DB
1449        $frev = $this->article->getStableRev();
1450        if ( !$frev ) {
1451            // Only for pages with stable versions
1452            return;
1453        }
1454
1455        $params = [];
1456        $pm = MediaWikiServices::getInstance()->getPermissionManager();
1457        // If the edit was not autoreviewed, and the user can actually make a
1458        // new stable version, then go to the diff...
1459        if ( $this->article->revsArePending() && $frev->userCanSetTag( $reqUser ) ) {
1460            $params += [ 'oldid' => $frev->getRevId(), 'diff' => 'cur', 'shownotice' => 1 ];
1461        // ...otherwise, go to the draft revision after completing an edit.
1462        // This allows for users to immediately see their changes. Even if the stable
1463        // and draft page match, we can avoid a parse due to FR_INCLUDES_STABLE.
1464        } else {
1465            $params += [ 'stable' => 0 ];
1466            // Show a notice at the top of the page for non-reviewers...
1467            if ( $this->article->revsArePending()
1468                && $this->article->isStableShownByDefault()
1469                && !$pm->userHasRight( $reqUser, 'review' )
1470            ) {
1471                $params += [ 'shownotice' => 1 ];
1472                if ( $sectionAnchor ) {
1473                    // Pass a section parameter in the URL as needed to add a link to
1474                    // the "your changes are pending" box on the top of the page...
1475                    $params += [ 'fromsection' => substr( $sectionAnchor, 1 ) ]; // strip #
1476                    $sectionAnchor = ''; // go to the top of the page to see notice
1477                }
1478            }
1479        }
1480        if ( $extraQuery !== '' ) {
1481            $extraQuery .= '&';
1482        }
1483        $extraQuery .= wfArrayToCgi( $params ); // note: EditPage will add initial "&"
1484    }
1485
1486    /**
1487     * If submitting the edit will leave it pending, then change the button text
1488     * Note: interacts with 'review pending changes' checkbox
1489     * @param EditPage $editPage
1490     * @param ButtonInputWidget[] $buttons
1491     */
1492    public function changeSaveButton( EditPage $editPage, array $buttons ): void {
1493        if ( !$this->editWillRequireReview( $editPage ) ) {
1494            // Edit will go live or be reviewed on save
1495            return;
1496        }
1497        if ( isset( $buttons['save'] ) ) {
1498            $buttonLabel = $this->msg( 'revreview-submitedit' )->text();
1499            $buttons['save']->setLabel( $buttonLabel );
1500            $buttonTitle = $this->msg( 'revreview-submitedit-title' )->text();
1501            $buttons['save']->setTitle( $buttonTitle );
1502        }
1503    }
1504
1505    /**
1506     * If this edit will not go live on submit (accounting for wpReviewEdit)
1507     */
1508    private function editWillRequireReview( EditPage $editPage ): bool {
1509        $request = $this->getRequest(); // convenience
1510        $title = $this->article->getTitle(); // convenience
1511        if ( !$this->article->editsRequireReview() || $this->editWillBeAutoreviewed( $editPage ) ) {
1512            return false; // edit will go live immediately
1513        } elseif ( $request->getCheck( 'wpReviewEdit' ) &&
1514            MediaWikiServices::getInstance()->getPermissionManager()
1515                ->userCan( 'review', $this->getUser(), $title )
1516        ) {
1517            return false; // edit checked off to be reviewed on save
1518        }
1519        return true; // edit needs review
1520    }
1521
1522    /**
1523     * If this edit will be auto-reviewed on submit
1524     * Note: checking wpReviewEdit does not count as auto-reviewed
1525     */
1526    private function editWillBeAutoreviewed( EditPage $editPage ): bool {
1527        $title = $this->article->getTitle(); // convenience
1528        if ( !$this->article->isReviewable() ) {
1529            return false;
1530        }
1531        if ( MediaWikiServices::getInstance()->getPermissionManager()
1532            ->quickUserCan( 'autoreview', $this->getUser(), $title )
1533        ) {
1534            if ( FlaggedRevs::autoReviewNewPages() && !$this->article->exists() ) {
1535                return true; // edit will be autoreviewed
1536            }
1537
1538            $baseRevId = self::getBaseRevId( $editPage, $this->getRequest() );
1539            $baseRevId2 = self::getAltBaseRevId( $editPage, $this->getRequest() );
1540            $baseFRev = FlaggedRevision::newFromTitle( $title, $baseRevId );
1541            if ( !$baseFRev && $baseRevId2 ) {
1542                $baseFRev = FlaggedRevision::newFromTitle( $title, $baseRevId2 );
1543            }
1544
1545            if ( $baseFRev ) {
1546                return true; // edit will be autoreviewed
1547            }
1548        }
1549        return false; // edit won't be autoreviewed
1550    }
1551
1552    /**
1553     * Add a "review pending changes" checkbox to the edit form iff:
1554     * (a) there are currently any revisions pending (bug 16713)
1555     * (b) this is an unreviewed page (bug 23970)
1556     */
1557    public function addReviewCheck( EditPage $editPage, array &$checkboxes ): void {
1558        $request = $this->getRequest();
1559        $title = $this->article->getTitle(); // convenience
1560        if ( !$this->article->isReviewable() ||
1561            !MediaWikiServices::getInstance()->getPermissionManager()
1562                ->userCan( 'review', $this->getUser(), $title )
1563        ) {
1564            // Not needed
1565            return;
1566        } elseif ( $this->editWillBeAutoreviewed( $editPage ) ) {
1567            // Edit will be auto-reviewed
1568            return;
1569        }
1570        if ( self::getBaseRevId( $editPage, $request ) == $this->article->getLatest() ) {
1571            # For pages with either no stable version, or an outdated one, let
1572            # the user decide if he/she wants it reviewed on the spot. One might
1573            # do this if he/she just saw the diff-to-stable and *then* decided to edit.
1574            # Note: check not shown when editing old revisions, which is confusing.
1575            $name = 'wpReviewEdit';
1576            $options = [
1577                'id' => $name,
1578                'default' => $request->getCheck( $name ),
1579                'legacy-name' => 'reviewed',
1580            ];
1581            // For reviewed pages...
1582            if ( $this->article->getStable() ) {
1583                // For pending changes...
1584                if ( $this->article->revsArePending() ) {
1585                    $n = $this->article->getPendingRevCount();
1586                    $options['title-message'] = 'revreview-check-flag-p-title';
1587                    $options['label-message'] = $this->msg( 'revreview-check-flag-p' )
1588                        ->numParams( $n );
1589                // For just the user's changes...
1590                } else {
1591                    $options['title-message'] = 'revreview-check-flag-y-title';
1592                    $options['label-message'] = 'revreview-check-flag-y';
1593                }
1594            // For unreviewed pages...
1595            } else {
1596                $options['title-message'] = 'revreview-check-flag-u-title';
1597                $options['label-message'] = 'revreview-check-flag-u';
1598            }
1599            $checkboxes[$name] = $options;
1600        }
1601    }
1602
1603    /**
1604     * Guess the rev ID the text of this form is based off
1605     */
1606    private static function getBaseRevId( EditPage $editPage, WebRequest $request ): int {
1607        if ( $editPage->isConflict ) {
1608            return 0; // throw away these values (bug 33481)
1609        }
1610
1611        $article = $editPage->getArticle(); // convenience
1612        $latestId = $article->getPage()->getLatest(); // current rev
1613        # Undoing edits...
1614        if ( $request->getIntOrNull( 'wpUndidRevision' ) ?? $request->getIntOrNull( 'undo' ) ) {
1615            $revId = $latestId; // current rev is the base rev
1616            # Other edits...
1617        } else {
1618            # If we are editing via oldid=X, then use that rev ID.
1619            $revId = $article->getOldID();
1620        }
1621        # Zero oldid => draft revision
1622        return $revId ?: $latestId;
1623    }
1624
1625    /**
1626     * Guess the alternative rev ID the text of this form is based off.
1627     * When undoing the top X edits, the base can be though of as either
1628     * the current or the edit X edits prior to the latest.
1629     */
1630    private static function getAltBaseRevId( EditPage $editPage, WebRequest $request ): int {
1631        if ( $editPage->isConflict ) {
1632            return 0; // throw away these values (bug 33481)
1633        }
1634
1635        $article = $editPage->getArticle(); // convenience
1636        $latestId = $article->getPage()->getLatest(); // current rev
1637        $undo = $request->getIntOrNull( 'wpUndidRevision' ) ?? $request->getIntOrNull( 'undo' );
1638        # Undoing consecutive top edits...
1639        if ( $undo && $undo === $latestId ) {
1640            # Treat this like a revert to a base revision.
1641            # We are undoing all edits *after* some rev ID (undoafter).
1642            # If undoafter is not given, then it is the previous rev ID.
1643            $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
1644            $revision = $revisionLookup->getRevisionById( $latestId );
1645            $previousRevision = $revision ? $revisionLookup->getPreviousRevision( $revision ) : null;
1646            $altBaseRevId = $request->getInt( 'wpUndoAfter', $request->getInt( 'undoafter',
1647                $previousRevision ? $previousRevision->getId() : null
1648            ) );
1649        } else {
1650            $altBaseRevId = 0;
1651        }
1652
1653        return $altBaseRevId;
1654    }
1655}