Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.12% covered (warning)
86.12%
856 / 994
43.48% covered (danger)
43.48%
30 / 69
CRAP
0.00% covered (danger)
0.00%
0 / 1
DifferenceEngine
86.12% covered (warning)
86.12%
856 / 994
43.48% covered (danger)
43.48%
30 / 69
572.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 getSlotDiffRenderers
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 markAsSlotDiffRenderer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSlotContents
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 loadSingleSlot
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 addRevisionLoadError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionLoadErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasNewRevisionLoadError
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 setReducedLineNumbers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDiffLang
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wasCacheHit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOldid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNewid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOldRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getNewRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deletedLink
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 deletedIdMarker
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 showMissingRevision
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 hasDeletedRevision
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPermissionErrors
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 hasSuppressedRevision
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getUserEditCount
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getUserRoles
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getUserMetaData
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 isUserAllowedToSeeRevisions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 shouldBeHiddenFromUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 showDiffPage
93.66% covered (success)
93.66%
192 / 205
0.00% covered (danger)
0.00%
0 / 1
42.45
 showTablePrefixes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 markPatrolledLink
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 getMarkPatrolledLinkInfo
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 revisionDeleteLink
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderNewRevision
73.58% covered (warning)
73.58%
39 / 53
0.00% covered (danger)
0.00%
0 / 1
11.84
 showDiff
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 showDiffStyle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getDiff
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 incrementStats
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getDiffBody
84.75% covered (warning)
84.75%
50 / 59
0.00% covered (danger)
0.00%
0 / 1
26.04
 getDiffBodyForRole
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getSlotHeader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getSlotError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDiffBodyCacheKeyParams
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getExtraCacheKeys
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 setSlotDiffOptions
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 setExtraQueryParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateContentDiffBody
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 generateTextDiffBody
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 getEngine
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 debug
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 getDebugString
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 localiseDiff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localiseLineNumbers
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getMultiNotice
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
21
 intermediateEditsMsg
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
3.85
 userCanEdit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionHeader
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
7
 addHeader
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
8
 setContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setRevisions
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 setTextLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mapDiffPrevNext
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 loadRevisionIds
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 loadRevisionData
81.82% covered (warning)
81.82%
45 / 55
0.00% covered (danger)
0.00%
0 / 1
19.95
 loadText
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
9.16
 loadNewText
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getTextDiffer
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getSupportedFormats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTextDiffFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * User interface for the difference engine.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup DifferenceEngine
22 */
23
24use MediaWiki\CommentFormatter\CommentFormatter;
25use MediaWiki\Content\IContentHandlerFactory;
26use MediaWiki\Context\ContextSource;
27use MediaWiki\Context\IContextSource;
28use MediaWiki\Debug\DeprecationHelper;
29use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
30use MediaWiki\HookContainer\HookRunner;
31use MediaWiki\Html\Html;
32use MediaWiki\Linker\Linker;
33use MediaWiki\Linker\LinkRenderer;
34use MediaWiki\MainConfigNames;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Message\Message;
37use MediaWiki\Page\ParserOutputAccess;
38use MediaWiki\Page\WikiPageFactory;
39use MediaWiki\Permissions\Authority;
40use MediaWiki\Permissions\PermissionStatus;
41use MediaWiki\Revision\ArchivedRevisionLookup;
42use MediaWiki\Revision\BadRevisionException;
43use MediaWiki\Revision\RevisionRecord;
44use MediaWiki\Revision\RevisionStore;
45use MediaWiki\Revision\SlotRecord;
46use MediaWiki\SpecialPage\SpecialPage;
47use MediaWiki\Storage\NameTableAccessException;
48use MediaWiki\Title\Title;
49use MediaWiki\User\Options\UserOptionsLookup;
50use MediaWiki\User\UserEditTracker;
51use MediaWiki\User\UserGroupManager;
52use MediaWiki\User\UserGroupMembership;
53use MediaWiki\User\UserIdentity;
54use MediaWiki\User\UserIdentityUtils;
55use Wikimedia\Rdbms\IConnectionProvider;
56
57/**
58 * DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
59 * This includes interpreting URL parameters, retrieving revision data, checking access permissions,
60 * selecting and invoking the diff generator class for the individual slots, doing post-processing
61 * on the generated diff, adding the rest of the HTML (such as headers) and writing the whole thing
62 * to OutputPage.
63 *
64 * DifferenceEngine can be subclassed by extensions, by customizing
65 * ContentHandler::createDifferenceEngine; the content handler will be selected based on the
66 * content model of the main slot (of the new revision, when the two are different).
67 * That might change after PageTypeHandler gets introduced.
68 *
69 * In the past, the class was also used for slot-level diff generation, and extensions might still
70 * subclass it and add such functionality. When that is the case (specifically, when a
71 * ContentHandler returns a standard SlotDiffRenderer but a nonstandard DifferenceEngine)
72 * DifferenceEngineSlotDiffRenderer will be used to convert the old behavior into the new one.
73 *
74 * @ingroup DifferenceEngine
75 *
76 * @todo This class is huge and poorly defined. It should be split into a controller responsible
77 * for interpreting query parameters, retrieving data and checking permissions; and a HTML renderer.
78 */
79class DifferenceEngine extends ContextSource {
80
81    use DeprecationHelper;
82
83    /**
84     * Constant to indicate diff cache compatibility.
85     * Bump this when changing the diff formatting in a way that
86     * fixes important bugs or such to force cached diff views to
87     * clear.
88     */
89    private const DIFF_VERSION = '1.41';
90
91    /**
92     * Revision ID for the old revision. 0 for the revision previous to $mNewid, false
93     * if the diff does not have an old revision (e.g. 'oldid=<first revision of page>&diff=prev'),
94     * or the revision does not exist, null if the revision is unsaved.
95     * @var int|false|null
96     */
97    protected $mOldid;
98
99    /**
100     * Revision ID for the new revision. 0 for the last revision of the current page
101     * (as defined by the request context), false if the revision does not exist, null
102     * if it is unsaved, or an alias such as 'next'.
103     * @var int|string|false|null
104     */
105    protected $mNewid;
106
107    /**
108     * Old revision (left pane).
109     * Allowed to be an unsaved revision, unlikely that's ever needed though.
110     * False when the old revision does not exist; this can happen when using
111     * diff=prev on the first revision. Null when the revision should exist but
112     * doesn't (e.g. load failure); loadRevisionData() will return false in that
113     * case. Also null until lazy-loaded. Ignored completely when isContentOverridden
114     * is set.
115     * @var RevisionRecord|null|false
116     */
117    private $mOldRevisionRecord;
118
119    /**
120     * New revision (right pane).
121     * Note that this might be an unsaved revision (e.g. for edit preview).
122     * Null in case of load failure; diff methods will just return an error message in that case,
123     * and loadRevisionData() will return false. Also null until lazy-loaded. Ignored completely
124     * when isContentOverridden is set.
125     * @var RevisionRecord|null
126     */
127    private $mNewRevisionRecord;
128
129    /**
130     * Title of old revision or null if the old revision does not exist or does not belong to a page.
131     * @var Title|null
132     */
133    protected $mOldPage;
134
135    /**
136     * Title of new revision or null if the new revision does not exist or does not belong to a page.
137     * @var Title|null
138     */
139    protected $mNewPage;
140
141    /**
142     * Change tags of old revision or null if it does not exist / is not saved.
143     * @var string|false|null
144     */
145    private $mOldTags;
146
147    /**
148     * Change tags of new revision or null if it does not exist / is not saved.
149     * @var string|null
150     */
151    private $mNewTags;
152
153    /**
154     * @var Content|null
155     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
156     *   This property is set to the content of the main slot, but not actually used for the main diff.
157     */
158    private $mOldContent;
159
160    /**
161     * @var Content|null
162     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
163     *   This property is set to the content of the main slot, but not actually used for the main diff.
164     */
165    private $mNewContent;
166
167    /** @var Language */
168    protected $mDiffLang;
169
170    /** @var bool Have the revisions IDs been loaded */
171    private $mRevisionsIdsLoaded = false;
172
173    /** @var bool Have the revisions been loaded */
174    protected $mRevisionsLoaded = false;
175
176    /** @var int How many text blobs have been loaded, 0, 1 or 2? */
177    protected $mTextLoaded = 0;
178
179    /**
180     * Was the content overridden via setContent()?
181     * If the content was overridden, most internal state (e.g. mOldid or mOldRev) should be ignored
182     * and only mOldContent and mNewContent is reliable.
183     * (Note that setRevisions() does not set this flag as in that case all properties are
184     * overridden and remain consistent with each other, so no special handling is needed.)
185     * @var bool
186     */
187    protected $isContentOverridden = false;
188
189    /** @var bool Was the diff fetched from cache? */
190    protected $mCacheHit = false;
191
192    /** @var string|null Cache key if the diff was fetched from cache */
193    private $cacheHitKey = null;
194
195    /**
196     * Set this to true to add debug info to the HTML output.
197     * Warning: this may cause RSS readers to spuriously mark articles as "new"
198     * (T22601)
199     * @var bool
200     */
201    public $enableDebugComment = false;
202
203    /** @var bool If true, line X is not displayed when X is 1, for example
204     *    to increase readability and conserve space with many small diffs.
205     */
206    protected $mReducedLineNumbers = false;
207
208    /** @var string Link to action=markpatrolled */
209    protected $mMarkPatrolledLink = null;
210
211    /** @var bool Show rev_deleted content if allowed */
212    protected $unhide = false;
213
214    /** @var bool Refresh the diff cache */
215    protected $mRefreshCache = false;
216
217    /** @var SlotDiffRenderer[]|null DifferenceEngine classes for the slots, keyed by role name. */
218    protected $slotDiffRenderers = null;
219
220    /**
221     * Temporary hack for B/C while slot diff related methods of DifferenceEngine are being
222     * deprecated. When true, we are inside a DifferenceEngineSlotDiffRenderer and
223     * $slotDiffRenderers should not be used.
224     * @var bool
225     */
226    protected $isSlotDiffRenderer = false;
227
228    /**
229     * A set of options that will be passed to the SlotDiffRenderer upon creation
230     * @var array
231     */
232    private $slotDiffOptions = [];
233
234    /**
235     * Extra query parameters to be appended to diff page links
236     */
237    private $extraQueryParams = [];
238
239    /** @var ManifoldTextDiffer|null */
240    private $textDiffer;
241
242    protected LinkRenderer $linkRenderer;
243    private IContentHandlerFactory $contentHandlerFactory;
244    private RevisionStore $revisionStore;
245    private ArchivedRevisionLookup $archivedRevisionLookup;
246    private HookRunner $hookRunner;
247    private WikiPageFactory $wikiPageFactory;
248    private UserOptionsLookup $userOptionsLookup;
249    private CommentFormatter $commentFormatter;
250    private IConnectionProvider $dbProvider;
251    private UserGroupManager $userGroupManager;
252    private UserEditTracker $userEditTracker;
253    private UserIdentityUtils $userIdentityUtils;
254
255    /** @var Message[] */
256    private $revisionLoadErrors = [];
257
258    /**
259     * @param IContextSource|null $context Context to use, anything else will be ignored
260     * @param int $old Old ID we want to show and diff with.
261     * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
262     * @param int $rcid Deprecated, no longer used!
263     * @param bool $refreshCache If set, refreshes the diff cache
264     * @param bool $unhide If set, allow viewing deleted revs
265     */
266    public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
267        $refreshCache = false, $unhide = false
268    ) {
269        if ( $context instanceof IContextSource ) {
270            $this->setContext( $context );
271        }
272
273        wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
274
275        $this->mOldid = $old;
276        $this->mNewid = $new;
277        $this->mRefreshCache = $refreshCache;
278        $this->unhide = $unhide;
279
280        $services = MediaWikiServices::getInstance();
281        $this->linkRenderer = $services->getLinkRenderer();
282        $this->contentHandlerFactory = $services->getContentHandlerFactory();
283        $this->revisionStore = $services->getRevisionStore();
284        $this->archivedRevisionLookup = $services->getArchivedRevisionLookup();
285        $this->hookRunner = new HookRunner( $services->getHookContainer() );
286        $this->wikiPageFactory = $services->getWikiPageFactory();
287        $this->userOptionsLookup = $services->getUserOptionsLookup();
288        $this->commentFormatter = $services->getCommentFormatter();
289        $this->dbProvider = $services->getConnectionProvider();
290        $this->userGroupManager = $services->getUserGroupManager();
291        $this->userEditTracker = $services->getUserEditTracker();
292        $this->userIdentityUtils = $services->getUserIdentityUtils();
293    }
294
295    /**
296     * @return SlotDiffRenderer[] Diff renderers for each slot, keyed by role name.
297     *   Includes slots only present in one of the revisions. Does not include slots
298     *   for which content is identical in the two revisions.
299     */
300    protected function getSlotDiffRenderers() {
301        if ( $this->isSlotDiffRenderer ) {
302            throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
303        }
304
305        if ( $this->slotDiffRenderers === null ) {
306            if ( !$this->loadRevisionData() ) {
307                return [];
308            }
309
310            $slotContents = $this->getSlotContents();
311            $this->slotDiffRenderers = [];
312            foreach ( $slotContents as $role => $contents ) {
313                if ( $contents['new'] && $contents['old']
314                    && $contents['new']->equals( $contents['old'] )
315                ) {
316                    // Do not produce a diff of identical content
317                    continue;
318                }
319                $handler = ( $contents['new'] ?: $contents['old'] )->getContentHandler();
320                $this->slotDiffRenderers[$role] = $handler->getSlotDiffRenderer(
321                    $this->getContext(),
322                    $this->slotDiffOptions + [
323                        'contentLanguage' => $this->getDiffLang()->getCode(),
324                        'textDiffer' => $this->getTextDiffer()
325                    ]
326                );
327            }
328        }
329
330        return $this->slotDiffRenderers;
331    }
332
333    /**
334     * Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
335     * This is used in legacy mode when the DifferenceEngine is wrapped in a
336     * DifferenceEngineSlotDiffRenderer.
337     * @internal For use by DifferenceEngineSlotDiffRenderer only.
338     */
339    public function markAsSlotDiffRenderer() {
340        $this->isSlotDiffRenderer = true;
341    }
342
343    /**
344     * Get the old and new content objects for all slots.
345     * This method does not do any permission checks.
346     * @return (Content|null)[][] [ role => [ 'old' => Content|null, 'new' => Content|null ], ... ]
347     */
348    protected function getSlotContents() {
349        if ( $this->isContentOverridden ) {
350            return [
351                SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
352            ];
353        } elseif ( !$this->loadRevisionData() ) {
354            return [];
355        }
356
357        $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
358        $oldSlots = $this->mOldRevisionRecord ?
359            $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
360            [];
361        // The order here will determine the visual order of the diff. The current logic is
362        // slots of the new revision first in natural order, then deleted ones. This is ad hoc
363        // and should not be relied on - in the future we may want the ordering to depend
364        // on the page type.
365        $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
366
367        $slots = [];
368        foreach ( $roles as $role ) {
369            $slots[$role] = [
370                'old' => $this->loadSingleSlot(
371                    $oldSlots[$role] ?? null,
372                    'old'
373                ),
374                'new' => $this->loadSingleSlot(
375                    $newSlots[$role] ?? null,
376                    'new'
377                )
378            ];
379        }
380        // move main slot to front
381        if ( isset( $slots[SlotRecord::MAIN] ) ) {
382            $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
383        }
384        return $slots;
385    }
386
387    /**
388     * Load the content of a single slot record
389     *
390     * @param SlotRecord|null $slot
391     * @param string $which "new" or "old"
392     * @return Content|null
393     */
394    private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
395        if ( !$slot ) {
396            return null;
397        }
398        try {
399            return $slot->getContent();
400        } catch ( BadRevisionException $e ) {
401            $this->addRevisionLoadError( $which );
402            return null;
403        }
404    }
405
406    /**
407     * Set a message to show as a notice at the top of the page
408     *
409     * @param string $which "new" or "old"
410     */
411    private function addRevisionLoadError( $which ) {
412        $this->revisionLoadErrors[] = $this->msg( $which === 'new'
413            ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
414        );
415    }
416
417    /**
418     * If errors were encountered while loading the revision contents, this
419     * will return an array of Messages describing the errors.
420     *
421     * @return Message[]
422     */
423    public function getRevisionLoadErrors() {
424        return $this->revisionLoadErrors;
425    }
426
427    /**
428     * Determine whether there was an error loading the new revision
429     * @return bool
430     */
431    private function hasNewRevisionLoadError() {
432        foreach ( $this->revisionLoadErrors as $error ) {
433            if ( $error->getKey() === 'difference-bad-new-revision' ) {
434                return true;
435            }
436        }
437        return false;
438    }
439
440    /** @inheritDoc */
441    public function getTitle() {
442        // T202454 avoid errors when there is no title
443        return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
444    }
445
446    /**
447     * Set reduced line numbers mode.
448     * When set, line X is not displayed when X is 1, for example to increase readability and
449     * conserve space with many small diffs.
450     * @param bool $value
451     */
452    public function setReducedLineNumbers( $value = true ) {
453        $this->mReducedLineNumbers = $value;
454    }
455
456    /**
457     * Get the language in which the diff text is written
458     *
459     * @return Language
460     */
461    public function getDiffLang() {
462        # Default language in which the diff text is written.
463        $this->mDiffLang ??= $this->getDefaultLanguage();
464        return $this->mDiffLang;
465    }
466
467    /**
468     * Get the language to use if none has been set by setTextLanguage().
469     * Wikibase overrides this to use the user language.
470     *
471     * @return Language
472     */
473    protected function getDefaultLanguage() {
474        return $this->getTitle()->getPageLanguage();
475    }
476
477    /**
478     * @return bool
479     */
480    public function wasCacheHit() {
481        return $this->mCacheHit;
482    }
483
484    /**
485     * Get the ID of old revision (left pane) of the diff. 0 for the revision
486     * previous to getNewid(), false if the old revision does not exist, null
487     * if it's unsaved.
488     * To get a real revision ID instead of 0, call loadRevisionData() first.
489     * @return int|false|null
490     */
491    public function getOldid() {
492        $this->loadRevisionIds();
493
494        return $this->mOldid;
495    }
496
497    /**
498     * Get the ID of new revision (right pane) of the diff. 0 for the current revision,
499     * false if the new revision does not exist, null if it's unsaved.
500     * To get a real revision ID instead of 0, call loadRevisionData() first.
501     * @return int|false|null
502     */
503    public function getNewid() {
504        $this->loadRevisionIds();
505
506        return $this->mNewid;
507    }
508
509    /**
510     * Get the left side of the diff.
511     * Could be null when the first revision of the page is diffed to 'prev' (or in the case of
512     * load failure).
513     * @return RevisionRecord|null
514     */
515    public function getOldRevision() {
516        return $this->mOldRevisionRecord ?: null;
517    }
518
519    /**
520     * Get the right side of the diff.
521     * Should not be null but can still happen in the case of load failure.
522     * @return RevisionRecord|null
523     */
524    public function getNewRevision() {
525        return $this->mNewRevisionRecord;
526    }
527
528    /**
529     * Look up a special:Undelete link to the given deleted revision id,
530     * as a workaround for being unable to load deleted diffs in currently.
531     *
532     * @param int $id Revision ID
533     *
534     * @return string|bool Link HTML or false
535     */
536    public function deletedLink( $id ) {
537        if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
538            $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
539            if ( $revRecord ) {
540                $title = Title::newFromPageIdentity( $revRecord->getPage() );
541
542                return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
543                    'target' => $title->getPrefixedText(),
544                    'timestamp' => $revRecord->getTimestamp()
545                ] );
546            }
547        }
548
549        return false;
550    }
551
552    /**
553     * Build a wikitext link toward a deleted revision, if viewable.
554     *
555     * @param int $id Revision ID
556     *
557     * @return string Wikitext fragment
558     */
559    public function deletedIdMarker( $id ) {
560        $link = $this->deletedLink( $id );
561        if ( $link ) {
562            return "[$link $id]";
563        } else {
564            return (string)$id;
565        }
566    }
567
568    private function showMissingRevision() {
569        $out = $this->getOutput();
570
571        $missing = [];
572        if ( $this->mOldid && ( !$this->mOldRevisionRecord || !$this->mOldContent ) ) {
573            $missing[] = $this->deletedIdMarker( $this->mOldid );
574        }
575        if ( !$this->mNewRevisionRecord || !$this->mNewContent ) {
576            $missing[] = $this->deletedIdMarker( $this->mNewid );
577        }
578
579        $out->setPageTitleMsg( $this->msg( 'errorpagetitle' ) );
580        $msg = $this->msg( 'difference-missing-revision' )
581            ->params( $this->getLanguage()->listToText( $missing ) )
582            ->numParams( count( $missing ) )
583            ->parseAsBlock();
584        $out->addHTML( $msg );
585    }
586
587    /**
588     * Checks whether one of the given Revisions was deleted
589     *
590     * @return bool
591     */
592    public function hasDeletedRevision() {
593        $this->loadRevisionData();
594        return (
595                $this->mNewRevisionRecord &&
596                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
597            ) ||
598            (
599                $this->mOldRevisionRecord &&
600                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
601            );
602    }
603
604    /**
605     * Get the permission errors associated with the revisions for the current diff.
606     *
607     * @param Authority $performer
608     * @return array[] Array of arrays of the arguments to wfMessage to explain permissions problems.
609     */
610    public function getPermissionErrors( Authority $performer ) {
611        $this->loadRevisionData();
612        $permStatus = PermissionStatus::newEmpty();
613        if ( $this->mNewPage ) {
614            $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
615        }
616        if ( $this->mOldPage ) {
617            $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
618        }
619        return $permStatus->toLegacyErrorArray();
620    }
621
622    /**
623     * Checks whether one of the given Revisions was suppressed
624     *
625     * @return bool
626     */
627    public function hasSuppressedRevision() {
628        return $this->hasDeletedRevision() && (
629            ( $this->mOldRevisionRecord &&
630                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
631            ( $this->mNewRevisionRecord &&
632                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
633        );
634    }
635
636    /**
637     * Renders user associated edit count
638     *
639     * @param UserIdentity $user
640     * @return string
641     */
642    private function getUserEditCount( $user ): string {
643        $editCount = $this->userEditTracker->getUserEditCount( $user );
644        if ( $editCount === null ) {
645            return '';
646        }
647
648        return Html::rawElement( 'div', [
649            'class' => 'mw-diff-usereditcount',
650        ],
651            $this->msg(
652                'diff-user-edits',
653                $this->getLanguage()->formatNum( $editCount )
654            )->parse()
655        );
656    }
657
658    /**
659     * Renders user roles
660     *
661     * @param UserIdentity $user
662     * @return string
663     */
664    private function getUserRoles( UserIdentity $user ) {
665        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
666            return '';
667        }
668        $userGroups = $this->userGroupManager->getUserGroups( $user );
669        $userGroupLinks = [];
670        foreach ( $userGroups as $group ) {
671            $userGroupLinks[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
672        }
673        return Html::rawElement( 'div', [
674            'class' => 'mw-diff-userroles',
675        ], $this->getLanguage()->commaList( $userGroupLinks ) );
676    }
677
678    /**
679     * Renders user associated meta data
680     *
681     * @param UserIdentity|null $user
682     * @return string
683     */
684    private function getUserMetaData( ?UserIdentity $user ) {
685        if ( !$user ) {
686            return '';
687        }
688        return Html::rawElement( 'div', [
689            'class' => 'mw-diff-usermetadata',
690        ], $this->getUserRoles( $user ) . $this->getUserEditCount( $user ) );
691    }
692
693    /**
694     * Checks whether the current user has permission for accessing the revisions of the diff.
695     * Note that this does not check whether the user has permission to view the page, it only
696     * checks revdelete permissions.
697     *
698     * It is the caller's responsibility to call
699     * $this->getUserPermissionErrors or similar checks.
700     *
701     * @param Authority $performer
702     * @return bool
703     */
704    public function isUserAllowedToSeeRevisions( Authority $performer ) {
705        $this->loadRevisionData();
706
707        if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
708            RevisionRecord::DELETED_TEXT,
709            $performer
710        ) ) {
711            return false;
712        }
713
714        // $this->mNewRev will only be falsy if a loading error occurred
715        // (in which case the user is allowed to see).
716        return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
717            RevisionRecord::DELETED_TEXT,
718            $performer
719        );
720    }
721
722    /**
723     * Checks whether the diff should be hidden from the current user
724     * This is based on whether the user is allowed to see it and has specifically asked to see it.
725     *
726     * @param Authority $performer
727     * @return bool
728     */
729    public function shouldBeHiddenFromUser( Authority $performer ) {
730        return $this->hasDeletedRevision() && ( !$this->unhide ||
731            !$this->isUserAllowedToSeeRevisions( $performer ) );
732    }
733
734    /**
735     * @param bool $diffOnly
736     */
737    public function showDiffPage( $diffOnly = false ) {
738        # Allow frames except in certain special cases
739        $out = $this->getOutput();
740        $out->setPreventClickjacking( false );
741        $out->setRobotPolicy( 'noindex,nofollow' );
742
743        // Allow extensions to add any extra output here
744        $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
745
746        if ( !$this->loadRevisionData() ) {
747            if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
748                $this->showMissingRevision();
749            }
750            return;
751        }
752
753        $user = $this->getUser();
754        $permErrors = $this->getPermissionErrors( $this->getAuthority() );
755        if ( $permErrors ) {
756            throw new PermissionsError( 'read', $permErrors );
757        }
758
759        $rollback = '';
760
761        $query = $this->extraQueryParams;
762        # Carry over 'diffonly' param via navigation links
763        if ( $diffOnly != MediaWikiServices::getInstance()
764            ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
765        ) {
766            $query['diffonly'] = $diffOnly;
767        }
768        # Cascade unhide param in links for easy deletion browsing
769        if ( $this->unhide ) {
770            $query['unhide'] = 1;
771        }
772
773        # Check if one of the revisions is deleted/suppressed
774        $deleted = $this->hasDeletedRevision();
775        $suppressed = $this->hasSuppressedRevision();
776        $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
777
778        $revisionTools = [];
779        $breadCrumbs = '';
780
781        # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
782        # a diff between a version V and its previous version V' AND the version V
783        # is the first version of that article. In that case, V' does not exist.
784        if ( $this->mOldRevisionRecord === false ) {
785            if ( $this->mNewPage ) {
786                $out->setPageTitleMsg(
787                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
788                );
789            }
790            $samePage = true;
791            $oldHeader = '';
792            // Allow extensions to change the $oldHeader variable
793            $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
794        } else {
795            $this->hookRunner->onDifferenceEngineViewHeader( $this );
796
797            if ( !$this->mOldPage || !$this->mNewPage ) {
798                // XXX say something to the user?
799                $samePage = false;
800            } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
801                $out->setPageTitleMsg(
802                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
803                );
804                $samePage = true;
805            } else {
806                $out->setPageTitleMsg( $this->msg( 'difference-title-multipage' )->plaintextParams(
807                    $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
808                $out->addSubtitle( $this->msg( 'difference-multipage' ) );
809                $samePage = false;
810            }
811
812            if ( $samePage && $this->mNewPage &&
813                $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
814            ) {
815                if ( $this->mNewRevisionRecord->isCurrent() &&
816                    $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
817                ) {
818                    $rollbackLink = Linker::generateRollback(
819                        $this->mNewRevisionRecord,
820                        $this->getContext(),
821                        [ 'noBrackets' ]
822                    );
823                    if ( $rollbackLink ) {
824                        $out->setPreventClickjacking( true );
825                        $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
826                    }
827                }
828
829                if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
830                    $this->userCanEdit( $this->mNewRevisionRecord )
831                ) {
832                    $undoLink = $this->linkRenderer->makeKnownLink(
833                        $this->mNewPage,
834                        $this->msg( 'editundo' )->text(),
835                        [ 'title' => Linker::titleAttrib( 'undo' ) ],
836                        [
837                            'action' => 'edit',
838                            'undoafter' => $this->mOldid,
839                            'undo' => $this->mNewid
840                        ]
841                    );
842                    $revisionTools['mw-diff-undo'] = $undoLink;
843                }
844            }
845            # Make "previous revision link"
846            $hasPrevious = $samePage && $this->mOldPage &&
847                $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
848            if ( $hasPrevious ) {
849                $prevlinkQuery = [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query;
850                $prevlink = $this->linkRenderer->makeKnownLink(
851                    $this->mOldPage,
852                    $this->msg( 'previousdiff' )->text(),
853                    [ 'id' => 'differences-prevlink' ],
854                    $prevlinkQuery
855                );
856                $breadCrumbs .= $this->linkRenderer->makeKnownLink(
857                    $this->mOldPage,
858                    $this->msg( 'previousdiff' )->text(),
859                    [
860                        'class' => 'mw-diff-revision-history-link-previous'
861                    ],
862                    $prevlinkQuery
863                );
864            } else {
865                $prevlink = "\u{00A0}";
866            }
867
868            if ( $this->mOldRevisionRecord->isMinor() ) {
869                $oldminor = ChangesList::flag( 'minor' );
870            } else {
871                $oldminor = '';
872            }
873
874            $oldRevRecord = $this->mOldRevisionRecord;
875
876            $ldel = $this->revisionDeleteLink( $oldRevRecord );
877            $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
878            $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
879            $oldRevComment = $this->commentFormatter
880                ->formatRevision(
881                    $oldRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
882                );
883
884            if ( $oldRevComment === '' ) {
885                $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
886                $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
887            }
888
889            $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
890                '<div id="mw-diff-otitle2">' .
891                Linker::revUserTools( $oldRevRecord, !$this->unhide ) .
892                $this->getUserMetaData( $oldRevRecord->getUser() ) .
893                '</div>' .
894                '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
895                '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
896                '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
897
898            // Allow extensions to change the $oldHeader variable
899            $this->hookRunner->onDifferenceEngineOldHeader(
900                $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
901        }
902
903        $out->addJsConfigVars( [
904            'wgDiffOldId' => $this->mOldid,
905            'wgDiffNewId' => $this->mNewid,
906        ] );
907
908        # Make "next revision link"
909        # Skip next link on the top revision
910        if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
911            $nextlinkQuery = [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query;
912            $nextlink = $this->linkRenderer->makeKnownLink(
913                $this->mNewPage,
914                $this->msg( 'nextdiff' )->text(),
915                [ 'id' => 'differences-nextlink' ],
916                $nextlinkQuery
917            );
918            $breadCrumbs .= $this->linkRenderer->makeKnownLink(
919                $this->mNewPage,
920                $this->msg( 'nextdiff' )->text(),
921                [
922                    'class' => 'mw-diff-revision-history-link-next'
923                ],
924                $nextlinkQuery
925            );
926        } else {
927            $nextlink = "\u{00A0}";
928        }
929
930        if ( $this->mNewRevisionRecord->isMinor() ) {
931            $newminor = ChangesList::flag( 'minor' );
932        } else {
933            $newminor = '';
934        }
935
936        # Handle RevisionDelete links...
937        $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
938
939        # Allow extensions to define their own revision tools
940        $this->hookRunner->onDiffTools(
941            $this->mNewRevisionRecord,
942            $revisionTools,
943            $this->mOldRevisionRecord ?: null,
944            $user
945        );
946
947        $formattedRevisionTools = [];
948        // Put each one in parentheses (poor man's button)
949        foreach ( $revisionTools as $key => $tool ) {
950            $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
951            $element = Html::rawElement(
952                'span',
953                [ 'class' => $toolClass ],
954                $tool
955            );
956            $formattedRevisionTools[] = $element;
957        }
958
959        $newRevRecord = $this->mNewRevisionRecord;
960
961        $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
962            ' ' . implode( ' ', $formattedRevisionTools );
963        $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
964        $newRevComment = $this->commentFormatter->formatRevision(
965            $newRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
966        );
967
968        if ( $newRevComment === '' ) {
969            $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
970            $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
971        }
972
973        $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
974            '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
975            $rollback .
976            $this->getUserMetaData( $newRevRecord->getUser() ) .
977            '</div>' .
978            '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
979            '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
980            '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
981
982        // Allow extensions to change the $newHeader variable
983        $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
984            $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
985            $rdel, $this->unhide );
986
987        $out->addHTML(
988            Html::rawElement( 'div', [
989                'class' => 'mw-diff-revision-history-links'
990            ], $breadCrumbs )
991        );
992        # If the diff cannot be shown due to a deleted revision, then output
993        # the diff header and links to unhide (if available)...
994        if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
995            $this->showDiffStyle();
996            $multi = $this->getMultiNotice();
997            $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
998            if ( !$allowed ) {
999                # Give explanation for why revision is not visible
1000                $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
1001            } else {
1002                # Give explanation and add a link to view the diff...
1003                $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
1004                $msg = [
1005                    $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
1006                    $this->getTitle()->getFullURL( $query )
1007                ];
1008            }
1009            $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
1010        # Otherwise, output a regular diff...
1011        } else {
1012            # Add deletion notice if the user is viewing deleted content
1013            $notice = '';
1014            if ( $deleted ) {
1015                $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
1016                $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
1017            }
1018
1019            # Add an error if the content can't be loaded
1020            $this->getSlotContents();
1021            foreach ( $this->getRevisionLoadErrors() as $msg ) {
1022                $notice .= Html::warningBox( $msg->parse() );
1023            }
1024
1025            // Check if inline switcher will be needed
1026            if ( $this->getTextDiffer()->hasFormat( 'inline' ) ) {
1027                $out->enableOOUI();
1028            }
1029
1030            $this->showTablePrefixes();
1031            $this->showDiff( $oldHeader, $newHeader, $notice );
1032            if ( !$diffOnly ) {
1033                $this->renderNewRevision();
1034            }
1035
1036            // Allow extensions to optionally not show the final patrolled link
1037            if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1038                # Add redundant patrol link on bottom...
1039                $out->addHTML( $this->markPatrolledLink() );
1040            }
1041        }
1042    }
1043
1044    /**
1045     * Add table prefixes
1046     */
1047    private function showTablePrefixes() {
1048        $parts = [];
1049        foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1050            $parts += $slotDiffRenderer->getTablePrefix( $this->getContext(), $this->mNewPage );
1051        }
1052        ksort( $parts );
1053        if ( count( array_filter( $parts ) ) > 0 ) {