Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.85% covered (warning)
83.85%
914 / 1090
41.43% covered (danger)
41.43%
29 / 70
CRAP
0.00% covered (danger)
0.00%
0 / 1
DifferenceEngine
83.93% covered (warning)
83.93%
914 / 1089
41.43% covered (danger)
41.43%
29 / 70
759.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 getSlotDiffRenderers
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
11.31
 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
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 hasDeletedRevision
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 authorizeView
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.78% covered (success)
93.78%
211 / 225
0.00% covered (danger)
0.00%
0 / 1
43.45
 showTablePrefixes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 markPatrolledLink
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getMarkPatrolledLinkInfo
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
 revisionDeleteLink
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderNewRevision
77.36% covered (warning)
77.36%
41 / 53
0.00% covered (danger)
0.00%
0 / 1
13.67
 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%
4 / 4
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
42.86% covered (danger)
42.86%
21 / 49
0.00% covered (danger)
0.00%
0 / 1
103.29
 intermediateEditsMsg
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 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
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
8
 setContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setRevisions
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.02
 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
80.39% covered (warning)
80.39%
41 / 51
0.00% covered (danger)
0.00%
0 / 1
21.72
 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
 getMobileFooter
98.61% covered (success)
98.61%
71 / 72
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2/**
3 * User interface for the difference engine.
4 *
5 * @license GPL-2.0-or-later
6 *
7 * @file
8 * @ingroup DifferenceEngine
9 */
10
11namespace MediaWiki\Diff;
12
13use BadMethodCallException;
14use Exception;
15use InvalidArgumentException;
16use LogicException;
17use MediaWiki\ChangeTags\ChangeTagsFormatter;
18use MediaWiki\CommentFormatter\CommentFormatter;
19use MediaWiki\Content\Content;
20use MediaWiki\Content\IContentHandlerFactory;
21use MediaWiki\Context\ContextSource;
22use MediaWiki\Context\IContextSource;
23use MediaWiki\Debug\DeprecationHelper;
24use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
25use MediaWiki\Exception\PermissionsError;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\Html\Html;
28use MediaWiki\Language\Language;
29use MediaWiki\Linker\Linker;
30use MediaWiki\Linker\LinkRenderer;
31use MediaWiki\MainConfigNames;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Message\Message;
34use MediaWiki\Page\Article;
35use MediaWiki\Page\ParserOutputAccess;
36use MediaWiki\Page\WikiPageFactory;
37use MediaWiki\Permissions\Authority;
38use MediaWiki\Permissions\PermissionStatus;
39use MediaWiki\RecentChanges\ChangesList;
40use MediaWiki\RecentChanges\RecentChange;
41use MediaWiki\RecentChanges\RecentChangeLookup;
42use MediaWiki\Revision\ArchivedRevisionLookup;
43use MediaWiki\Revision\BadRevisionException;
44use MediaWiki\Revision\RevisionRecord;
45use MediaWiki\Revision\RevisionStore;
46use MediaWiki\Revision\SlotRecord;
47use MediaWiki\SpecialPage\SpecialPage;
48use MediaWiki\Storage\NameTableAccessException;
49use MediaWiki\Title\Title;
50use MediaWiki\User\Options\UserOptionsLookup;
51use MediaWiki\User\UserEditTracker;
52use MediaWiki\User\UserGroupManager;
53use MediaWiki\User\UserGroupMembership;
54use MediaWiki\User\UserIdentity;
55use MediaWiki\User\UserIdentityUtils;
56use Wikimedia\Rdbms\IConnectionProvider;
57use Wikimedia\Timestamp\ConvertibleTimestamp;
58use Wikimedia\Timestamp\TimestampFormat as TS;
59
60/**
61 * DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
62 * This includes interpreting URL parameters, retrieving revision data, checking access permissions,
63 * selecting and invoking the diff generator class for the individual slots, doing post-processing
64 * on the generated diff, adding the rest of the HTML (such as headers) and writing the whole thing
65 * to OutputPage.
66 *
67 * DifferenceEngine can be subclassed by extensions, by customizing
68 * ContentHandler::createDifferenceEngine; the content handler will be selected based on the
69 * content model of the main slot (of the new revision, when the two are different).
70 * That might change after PageTypeHandler gets introduced.
71 *
72 * In the past, the class was also used for slot-level diff generation, and extensions might still
73 * subclass it and add such functionality. When that is the case (specifically, when a
74 * ContentHandler returns a standard SlotDiffRenderer but a nonstandard DifferenceEngine)
75 * DifferenceEngineSlotDiffRenderer will be used to convert the old behavior into the new one.
76 *
77 * @ingroup DifferenceEngine
78 *
79 * @todo This class is huge and poorly defined. It should be split into a controller responsible
80 * for interpreting query parameters, retrieving data and checking permissions; and a HTML renderer.
81 */
82class DifferenceEngine extends ContextSource {
83
84    use DeprecationHelper;
85
86    /**
87     * Constant to indicate diff cache compatibility.
88     * Bump this when changing the diff formatting in a way that
89     * fixes important bugs or such to force cached diff views to
90     * clear.
91     */
92    private const DIFF_VERSION = '1.41';
93
94    /**
95     * Revision ID for the old revision. 0 for the revision previous to $mNewid, false
96     * if the diff does not have an old revision (e.g. 'oldid=<first revision of page>&diff=prev'),
97     * or the revision does not exist, null if the revision is unsaved.
98     * @var int|false|null
99     */
100    protected $mOldid;
101
102    /**
103     * Revision ID for the new revision. 0 for the last revision of the current page
104     * (as defined by the request context), false if the revision does not exist, null
105     * if it is unsaved, or an alias such as 'next'.
106     * @var int|string|false|null
107     */
108    protected $mNewid;
109
110    /**
111     * Old revision (left pane).
112     * Allowed to be an unsaved revision, unlikely that's ever needed though.
113     * False when the old revision does not exist; this can happen when using
114     * diff=prev on the first revision. Null when the revision should exist but
115     * doesn't (e.g. load failure); loadRevisionData() will return false in that
116     * case. Also null until lazy-loaded. Ignored completely when isContentOverridden
117     * is set.
118     * @var RevisionRecord|null|false
119     */
120    private $mOldRevisionRecord;
121
122    /**
123     * New revision (right pane).
124     * Note that this might be an unsaved revision (e.g. for edit preview).
125     * Null in case of load failure; diff methods will just return an error message in that case,
126     * and loadRevisionData() will return false. Also null until lazy-loaded. Ignored completely
127     * when isContentOverridden is set.
128     * @var RevisionRecord|null
129     */
130    private $mNewRevisionRecord;
131
132    /**
133     * Title of old revision or null if the old revision does not exist or does not belong to a page.
134     * @var Title|null
135     */
136    protected $mOldPage;
137
138    /**
139     * Title of new revision or null if the new revision does not exist or does not belong to a page.
140     * @var Title|null
141     */
142    protected $mNewPage;
143
144    /**
145     * Change tags of old revision or null if it does not exist / is not saved.
146     * @var string|false|null
147     */
148    private $mOldTags;
149
150    /**
151     * Change tags of new revision or null if it does not exist / is not saved.
152     * @var string|null
153     */
154    private $mNewTags;
155
156    /**
157     * @var Content|null
158     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
159     *   This property is set to the content of the main slot, but not actually used for the main diff.
160     */
161    private $mOldContent;
162
163    /**
164     * @var Content|null
165     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
166     *   This property is set to the content of the main slot, but not actually used for the main diff.
167     */
168    private $mNewContent;
169
170    /** @var Language */
171    protected $mDiffLang;
172
173    /** @var bool Have the revisions IDs been loaded */
174    private $mRevisionsIdsLoaded = false;
175
176    /** @var bool Have the revisions been loaded */
177    protected $mRevisionsLoaded = false;
178
179    /** @var int How many text blobs have been loaded, 0, 1 or 2? */
180    protected $mTextLoaded = 0;
181
182    /**
183     * Was the content overridden via setContent()?
184     * If the content was overridden, most internal state (e.g. mOldid or mOldRev) should be ignored
185     * and only mOldContent and mNewContent is reliable.
186     * (Note that setRevisions() does not set this flag as in that case all properties are
187     * overridden and remain consistent with each other, so no special handling is needed.)
188     * @var bool
189     */
190    protected $isContentOverridden = false;
191
192    /** @var bool Was the diff fetched from cache? */
193    protected $mCacheHit = false;
194
195    /** @var string|null Cache key if the diff was fetched from cache */
196    private $cacheHitKey = null;
197
198    /**
199     * Set this to true to add debug info to the HTML output.
200     * Warning: this may cause RSS readers to spuriously mark articles as "new"
201     * (T22601)
202     * @var bool
203     */
204    public $enableDebugComment = false;
205
206    /** @var bool If true, line X is not displayed when X is 1, for example
207     *    to increase readability and conserve space with many small diffs.
208     */
209    protected $mReducedLineNumbers = false;
210
211    /** @var string Link to action=markpatrolled */
212    protected $mMarkPatrolledLink = null;
213
214    /** @var bool Show rev_deleted content if allowed */
215    protected $unhide = false;
216
217    /** @var bool Refresh the diff cache */
218    protected $mRefreshCache = false;
219
220    /** @var SlotDiffRenderer[]|null DifferenceEngine classes for the slots, keyed by role name. */
221    protected $slotDiffRenderers = null;
222
223    /**
224     * Temporary hack for B/C while slot diff related methods of DifferenceEngine are being
225     * deprecated. When true, we are inside a DifferenceEngineSlotDiffRenderer and
226     * $slotDiffRenderers should not be used.
227     * @var bool
228     */
229    protected $isSlotDiffRenderer = false;
230
231    /**
232     * A set of options that will be passed to the SlotDiffRenderer upon creation
233     * @var array
234     */
235    private $slotDiffOptions = [];
236
237    /**
238     * Extra query parameters to be appended to diff page links
239     * @var array
240     */
241    private $extraQueryParams = [];
242
243    /** @var ManifoldTextDiffer|null */
244    private $textDiffer;
245
246    protected LinkRenderer $linkRenderer;
247    private IContentHandlerFactory $contentHandlerFactory;
248    private RevisionStore $revisionStore;
249    private ArchivedRevisionLookup $archivedRevisionLookup;
250    private HookRunner $hookRunner;
251    private WikiPageFactory $wikiPageFactory;
252    private UserOptionsLookup $userOptionsLookup;
253    private CommentFormatter $commentFormatter;
254    private IConnectionProvider $dbProvider;
255    private UserGroupManager $userGroupManager;
256    private UserEditTracker $userEditTracker;
257    private UserIdentityUtils $userIdentityUtils;
258    private RecentChangeLookup $recentChangeLookup;
259    private ChangeTagsFormatter $changeTagsFormatter;
260
261    /** @var Message[] */
262    private $revisionLoadErrors = [];
263
264    /**
265     * @param IContextSource|null $context Context to use, anything else will be ignored
266     * @param int $old Old ID we want to show and diff with.
267     * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
268     * @param int $rcid Deprecated, no longer used!
269     * @param bool $refreshCache If set, refreshes the diff cache
270     * @param bool $unhide If set, allow viewing deleted revs
271     */
272    public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
273        $refreshCache = false, $unhide = false
274    ) {
275        if ( $context instanceof IContextSource ) {
276            $this->setContext( $context );
277        }
278
279        wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
280
281        $this->mOldid = $old;
282        $this->mNewid = $new;
283        $this->mRefreshCache = $refreshCache;
284        $this->unhide = $unhide;
285
286        $services = MediaWikiServices::getInstance();
287        $this->linkRenderer = $services->getLinkRenderer();
288        $this->contentHandlerFactory = $services->getContentHandlerFactory();
289        $this->revisionStore = $services->getRevisionStore();
290        $this->archivedRevisionLookup = $services->getArchivedRevisionLookup();
291        $this->hookRunner = new HookRunner( $services->getHookContainer() );
292        $this->wikiPageFactory = $services->getWikiPageFactory();
293        $this->userOptionsLookup = $services->getUserOptionsLookup();
294        $this->commentFormatter = $services->getCommentFormatter();
295        $this->dbProvider = $services->getConnectionProvider();
296        $this->userGroupManager = $services->getUserGroupManager();
297        $this->userEditTracker = $services->getUserEditTracker();
298        $this->userIdentityUtils = $services->getUserIdentityUtils();
299        $this->recentChangeLookup = $services->getRecentChangeLookup();
300        $this->changeTagsFormatter = $services->getChangeTagsFormatter();
301    }
302
303    /**
304     * @return SlotDiffRenderer[] Diff renderers for each slot, keyed by role name.
305     *   Includes slots only present in one of the revisions. Does not include slots
306     *   for which content is identical in the two revisions.
307     */
308    protected function getSlotDiffRenderers() {
309        if ( $this->isSlotDiffRenderer ) {
310            throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
311        }
312
313        if ( $this->slotDiffRenderers === null ) {
314            if ( !$this->loadRevisionData() ) {
315                return [];
316            }
317
318            $slotContents = $this->getSlotContents();
319            $this->slotDiffRenderers = [];
320            foreach ( $slotContents as $role => $contents ) {
321                if ( $contents['new'] && $contents['old']
322                    && $contents['new']->equals( $contents['old'] )
323                ) {
324                    // Do not produce a diff of identical content
325                    continue;
326                }
327                if ( !$contents['new'] && !$contents['old'] ) {
328                    // Nothing to diff (i.e both revisions are corrupted), just ignore
329                    continue;
330                }
331                $handler = ( $contents['new'] ?: $contents['old'] )->getContentHandler();
332                $this->slotDiffRenderers[$role] = $handler->getSlotDiffRenderer(
333                    $this->getContext(),
334                    $this->slotDiffOptions + [
335                        'contentLanguage' => $this->getDiffLang()->getCode(),
336                        'textDiffer' => $this->getTextDiffer()
337                    ]
338                );
339            }
340        }
341
342        return $this->slotDiffRenderers;
343    }
344
345    /**
346     * Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
347     * This is used in legacy mode when the DifferenceEngine is wrapped in a
348     * DifferenceEngineSlotDiffRenderer.
349     * @internal For use by DifferenceEngineSlotDiffRenderer only.
350     */
351    public function markAsSlotDiffRenderer() {
352        $this->isSlotDiffRenderer = true;
353    }
354
355    /**
356     * Get the old and new content objects for all slots.
357     * This method does not do any permission checks.
358     * @return (Content|null)[][] [ role => [ 'old' => Content|null, 'new' => Content|null ], ... ]
359     */
360    protected function getSlotContents() {
361        if ( $this->isContentOverridden ) {
362            return [
363                SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
364            ];
365        } elseif ( !$this->loadRevisionData() ) {
366            return [];
367        }
368
369        $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
370        $oldSlots = $this->mOldRevisionRecord ?
371            $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
372            [];
373        // The order here will determine the visual order of the diff. The current logic is
374        // slots of the new revision first in natural order, then deleted ones. This is ad hoc
375        // and should not be relied on - in the future we may want the ordering to depend
376        // on the page type.
377        $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
378
379        $slots = [];
380        foreach ( $roles as $role ) {
381            $slots[$role] = [
382                'old' => $this->loadSingleSlot(
383                    $oldSlots[$role] ?? null,
384                    'old'
385                ),
386                'new' => $this->loadSingleSlot(
387                    $newSlots[$role] ?? null,
388                    'new'
389                )
390            ];
391        }
392        // move main slot to front
393        if ( isset( $slots[SlotRecord::MAIN] ) ) {
394            $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
395        }
396        return $slots;
397    }
398
399    /**
400     * Load the content of a single slot record
401     *
402     * @param SlotRecord|null $slot
403     * @param string $which "new" or "old"
404     * @return Content|null
405     */
406    private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
407        if ( !$slot ) {
408            return null;
409        }
410        try {
411            return $slot->getContent();
412        } catch ( BadRevisionException ) {
413            $this->addRevisionLoadError( $which );
414            return null;
415        }
416    }
417
418    /**
419     * Set a message to show as a notice at the top of the page
420     *
421     * @param string $which "new" or "old"
422     */
423    private function addRevisionLoadError( $which ) {
424        $this->revisionLoadErrors[] = $this->msg( $which === 'new'
425            ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
426        );
427    }
428
429    /**
430     * If errors were encountered while loading the revision contents, this
431     * will return an array of Messages describing the errors.
432     *
433     * @return Message[]
434     */
435    public function getRevisionLoadErrors() {
436        return $this->revisionLoadErrors;
437    }
438
439    /**
440     * Determine whether there was an error loading the new revision
441     * @return bool
442     */
443    private function hasNewRevisionLoadError() {
444        foreach ( $this->revisionLoadErrors as $error ) {
445            if ( $error->getKey() === 'difference-bad-new-revision' ) {
446                return true;
447            }
448        }
449        return false;
450    }
451
452    /** @inheritDoc */
453    public function getTitle() {
454        // T202454 avoid errors when there is no title
455        return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
456    }
457
458    /**
459     * Set reduced line numbers mode.
460     * When set, line X is not displayed when X is 1, for example to increase readability and
461     * conserve space with many small diffs.
462     * @param bool $value
463     */
464    public function setReducedLineNumbers( $value = true ) {
465        $this->mReducedLineNumbers = $value;
466    }
467
468    /**
469     * Get the language in which the diff text is written
470     *
471     * @return Language
472     */
473    public function getDiffLang() {
474        # Default language in which the diff text is written.
475        $this->mDiffLang ??= $this->getDefaultLanguage();
476        return $this->mDiffLang;
477    }
478
479    /**
480     * Get the language to use if none has been set by setTextLanguage().
481     * Wikibase overrides this to use the user language.
482     *
483     * @return Language
484     */
485    protected function getDefaultLanguage() {
486        return $this->getTitle()->getPageLanguage();
487    }
488
489    /**
490     * @return bool
491     */
492    public function wasCacheHit() {
493        return $this->mCacheHit;
494    }
495
496    /**
497     * Get the ID of old revision (left pane) of the diff. 0 for the revision
498     * previous to getNewid(), false if the old revision does not exist, null
499     * 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 getOldid() {
504        $this->loadRevisionIds();
505
506        return $this->mOldid;
507    }
508
509    /**
510     * Get the ID of new revision (right pane) of the diff. 0 for the latest revision,
511     * false if the new revision does not exist, null if it's unsaved.
512     * To get a real revision ID instead of 0, call loadRevisionData() first.
513     * @return int|false|null
514     */
515    public function getNewid() {
516        $this->loadRevisionIds();
517
518        return $this->mNewid;
519    }
520
521    /**
522     * Get the left side of the diff.
523     * Could be null when the first revision of the page is diffed to 'prev' (or in the case of
524     * load failure).
525     * @return RevisionRecord|null
526     */
527    public function getOldRevision() {
528        return $this->mOldRevisionRecord ?: null;
529    }
530
531    /**
532     * Get the right side of the diff.
533     * Should not be null but can still happen in the case of load failure.
534     * @return RevisionRecord|null
535     */
536    public function getNewRevision() {
537        return $this->mNewRevisionRecord;
538    }
539
540    /**
541     * Look up a special:Undelete link to the given deleted revision id,
542     * as a workaround for being unable to load deleted diffs in currently.
543     *
544     * @param int $id Revision ID
545     *
546     * @return string|bool Link HTML or false
547     */
548    public function deletedLink( $id ) {
549        if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
550            $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
551            if ( $revRecord ) {
552                $title = Title::newFromPageIdentity( $revRecord->getPage() );
553
554                return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
555                    'target' => $title->getPrefixedText(),
556                    'timestamp' => $revRecord->getTimestamp()
557                ] );
558            }
559        }
560
561        return false;
562    }
563
564    /**
565     * Build a wikitext link toward a deleted revision, if viewable.
566     *
567     * @param int $id Revision ID
568     *
569     * @return string Wikitext fragment
570     */
571    public function deletedIdMarker( $id ) {
572        $link = $this->deletedLink( $id );
573        if ( $link ) {
574            return "[$link $id]";
575        } else {
576            return (string)$id;
577        }
578    }
579
580    private function showMissingRevision() {
581        $out = $this->getOutput();
582
583        $missing = [];
584        if ( $this->mOldid && !$this->mOldRevisionRecord ) {
585            $missing[] = $this->deletedIdMarker( $this->mOldid );
586        }
587        if ( $this->mNewid && !$this->mNewRevisionRecord ) {
588            $missing[] = $this->deletedIdMarker( $this->mNewid );
589        }
590
591        $out->setPageTitleMsg( $this->msg( 'errorpagetitle' ) );
592
593        // Don't display the deletion log for the main page, it's probably not useful
594        $key = $this->getTitle()->equals( Title::newMainPage() ) ?
595            'difference-missing-revision-nolog' :
596            'difference-missing-revision';
597
598        $msg = $this->msg( $key )
599            ->params( Message::listParam( $missing ) )
600            ->numParams( count( $missing ) )
601            ->parseAsBlock();
602        $out->addHTML( $msg );
603    }
604
605    /**
606     * Checks whether one of the given Revisions was deleted
607     *
608     * @return bool
609     */
610    public function hasDeletedRevision() {
611        $this->loadRevisionData();
612        return (
613                $this->mNewRevisionRecord &&
614                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
615            ) ||
616            (
617                $this->mOldRevisionRecord &&
618                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
619            );
620    }
621
622    /**
623     * Check whether the user can read both of the pages for the current diff.
624     *
625     * This does not check access to deleted revisions, use isUserAllowedToSeeRevisions() for that.
626     *
627     * @since 1.44
628     */
629    public function authorizeView( Authority $performer ): PermissionStatus {
630        $this->loadRevisionData();
631        $permStatus = PermissionStatus::newEmpty();
632        if ( $this->mNewPage ) {
633            $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
634        }
635        if ( $this->mOldPage ) {
636            $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
637        }
638        return $permStatus;
639    }
640
641    /**
642     * Checks whether one of the given Revisions was suppressed
643     *
644     * @return bool
645     */
646    public function hasSuppressedRevision() {
647        return $this->hasDeletedRevision() && (
648            ( $this->mOldRevisionRecord &&
649                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
650            ( $this->mNewRevisionRecord &&
651                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
652        );
653    }
654
655    /**
656     * Renders user associated edit count
657     *
658     * @param UserIdentity $user
659     * @return string
660     */
661    private function getUserEditCount( $user ): string {
662        $editCount = $this->userEditTracker->getUserEditCount( $user );
663        if ( $editCount === null ) {
664            return '';
665        }
666
667        return Html::rawElement( 'div', [
668            'class' => 'mw-diff-usereditcount',
669        ],
670            $this->msg(
671                'diff-user-edits',
672                $this->getLanguage()->formatNum( $editCount )
673            )->parse()
674        );
675    }
676
677    /**
678     * Renders user roles
679     *
680     * @param UserIdentity $user
681     * @return string
682     */
683    private function getUserRoles( UserIdentity $user ) {
684        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
685            return '';
686        }
687        $userGroups = $this->userGroupManager->getUserGroups( $user );
688        $userGroupLinks = [];
689        foreach ( $userGroups as $group ) {
690            $userGroupLinks[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
691        }
692        return Html::rawElement( 'div', [
693            'class' => 'mw-diff-userroles',
694        ], $this->getLanguage()->commaList( $userGroupLinks ) );
695    }
696
697    /**
698     * Renders user associated meta data
699     *
700     * @param UserIdentity|null $user
701     * @return string
702     */
703    private function getUserMetaData( ?UserIdentity $user ) {
704        if ( !$user ) {
705            return '';
706        }
707        return Html::rawElement( 'div', [
708            'class' => 'mw-diff-usermetadata',
709        ], $this->getUserRoles( $user ) . $this->getUserEditCount( $user ) );
710    }
711
712    /**
713     * Checks whether the current user has permission for accessing the revisions of the diff.
714     * Note that this does not check whether the user has permission to view the page, it only
715     * checks revdelete permissions.
716     *
717     * It is the caller's responsibility to call
718     * $this->getUserPermissionErrors or similar checks.
719     *
720     * @param Authority $performer
721     * @return bool
722     */
723    public function isUserAllowedToSeeRevisions( Authority $performer ) {
724        $this->loadRevisionData();
725
726        if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
727            RevisionRecord::DELETED_TEXT,
728            $performer
729        ) ) {
730            return false;
731        }
732
733        // $this->mNewRev will only be falsy if a loading error occurred
734        // (in which case the user is allowed to see).
735        return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
736            RevisionRecord::DELETED_TEXT,
737            $performer
738        );
739    }
740
741    /**
742     * Checks whether the diff should be hidden from the current user
743     * This is based on whether the user is allowed to see it and has specifically asked to see it.
744     *
745     * @param Authority $performer
746     * @return bool
747     */
748    public function shouldBeHiddenFromUser( Authority $performer ) {
749        return $this->hasDeletedRevision() && ( !$this->unhide ||
750            !$this->isUserAllowedToSeeRevisions( $performer ) );
751    }
752
753    /**
754     * @param bool $diffOnly
755     */
756    public function showDiffPage( $diffOnly = false ) {
757        # Allow frames except in certain special cases
758        $out = $this->getOutput();
759        $out->getMetadata()->setPreventClickjacking( false );
760        $out->setRobotPolicy( 'noindex,nofollow' );
761
762        // Allow extensions to add any extra output here
763        $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
764
765        if ( !$this->loadRevisionData() ) {
766            if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
767                $this->showMissingRevision();
768            }
769            return;
770        }
771
772        $user = $this->getUser();
773        $permStatus = $this->authorizeView( $this->getAuthority() );
774        if ( !$permStatus->isGood() ) {
775            throw new PermissionsError( 'read', $permStatus );
776        }
777
778        $rollback = '';
779
780        $query = $this->extraQueryParams;
781        # Carry over 'diffonly' param via navigation links
782        if ( $diffOnly != MediaWikiServices::getInstance()
783            ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
784        ) {
785            $query['diffonly'] = $diffOnly;
786        }
787        # Cascade unhide param in links for easy deletion browsing
788        if ( $this->unhide ) {
789            $query['unhide'] = 1;
790        }
791
792        # Check if one of the revisions is deleted/suppressed
793        $deleted = $this->hasDeletedRevision();
794        $suppressed = $this->hasSuppressedRevision();
795        $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
796
797        $revisionTools = [];
798        $breadCrumbs = '';
799
800        # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
801        # a diff between a version V and its previous version V' AND the version V
802        # is the first version of that article. In that case, V' does not exist.
803        if ( $this->mOldRevisionRecord === false ) {
804            if ( $this->mNewPage ) {
805                $out->setPageTitleMsg(
806                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
807                );
808            }
809            $samePage = true;
810            $oldHeader = '';
811            // Allow extensions to change the $oldHeader variable
812            $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
813        } else {
814            $this->hookRunner->onDifferenceEngineViewHeader( $this );
815
816            if ( !$this->mOldPage || !$this->mNewPage ) {
817                // XXX say something to the user?
818                $samePage = false;
819            } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
820                $out->setPageTitleMsg(
821                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
822                );
823                $samePage = true;
824            } else {
825                $out->setPageTitleMsg( $this->msg( 'difference-title-multipage' )->plaintextParams(
826                    $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
827                $out->addSubtitle( $this->msg( 'difference-multipage' ) );
828                $samePage = false;
829            }
830
831            if ( $samePage && $this->mNewPage &&
832                $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
833            ) {
834                if ( $this->mNewRevisionRecord->isCurrent() &&
835                    $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
836                ) {
837                    $rollbackLink = Linker::generateRollback(
838                        $this->mNewRevisionRecord,
839                        $this->getContext(),
840                        [ 'noBrackets' ]
841                    );
842                    if ( $rollbackLink ) {
843                        $out->getMetadata()->setPreventClickjacking( true );
844                        $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
845                    }
846                }
847
848                if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
849                    $this->userCanEdit( $this->mNewRevisionRecord )
850                ) {
851                    $undoLink = $this->linkRenderer->makeKnownLink(
852                        $this->mNewPage,
853                        $this->msg( 'editundo' )->text(),
854                        [ 'title' => Linker::titleAttrib( 'undo' ) ],
855                        [
856                            'action' => 'edit',
857                            'undoafter' => $this->mOldid,
858                            'undo' => $this->mNewid
859                        ]
860                    );
861                    $revisionTools['mw-diff-undo'] = $undoLink;
862                }
863            }
864            # Make "previous revision link"
865            $hasPrevious = $samePage && $this->mOldPage &&
866                $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
867            if ( $hasPrevious ) {
868                $prevlinkQuery = [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query;
869                $prevlink = $this->linkRenderer->makeKnownLink(
870                    $this->mOldPage,
871                    $this->msg( 'previousdiff' )->text(),
872                    [ 'id' => 'differences-prevlink' ],
873                    $prevlinkQuery
874                );
875                $breadCrumbs .= $this->linkRenderer->makeKnownLink(
876                    $this->mOldPage,
877                    $this->msg( 'previousdiff' )->text(),
878                    [
879                        'class' => 'mw-diff-revision-history-link-previous'
880                    ],
881                    $prevlinkQuery
882                );
883            } else {
884                $prevlink = "\u{00A0}";
885            }
886
887            if ( $this->mOldRevisionRecord->isMinor() ) {
888                $oldminor = ChangesList::flag( 'minor', $this->getContext() );
889            } else {
890                $oldminor = '';
891            }
892
893            $oldRevRecord = $this->mOldRevisionRecord;
894
895            $ldel = $this->revisionDeleteLink( $oldRevRecord );
896            $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
897            $oldChangeTags = $this->changeTagsFormatter->formatTagsAsSummaryList(
898                $this->mOldTags,
899                $this->getContext(),
900                $this->getAuthority()
901            );
902            $oldRevComment = $this->commentFormatter
903                ->formatRevision(
904                    $oldRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
905                );
906
907            if ( $oldRevComment === '' ) {
908                $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
909                $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
910            }
911
912            $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
913                '<div id="mw-diff-otitle2">' .
914                Linker::revUserTools( $oldRevRecord, !$this->unhide ) .
915                $this->getUserMetaData( $oldRevRecord->getUser() ) .
916                '</div>' .
917                '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
918                '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
919                '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
920
921            // Allow extensions to change the $oldHeader variable
922            $this->hookRunner->onDifferenceEngineOldHeader(
923                $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
924        }
925
926        $out->addJsConfigVars( [
927            'wgDiffOldId' => $this->mOldid,
928            'wgDiffNewId' => $this->mNewid,
929        ] );
930
931        # Make "next revision link"
932        # Skip next link on the top revision
933        if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
934            $nextlinkQuery = [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query;
935            $nextlink = $this->linkRenderer->makeKnownLink(
936                $this->mNewPage,
937                $this->msg( 'nextdiff' )->text(),
938                [ 'id' => 'differences-nextlink' ],
939                $nextlinkQuery
940            );
941            $breadCrumbs .= $this->linkRenderer->makeKnownLink(
942                $this->mNewPage,
943                $this->msg( 'nextdiff' )->text(),
944                [
945                    'class' => 'mw-diff-revision-history-link-next'
946                ],
947                $nextlinkQuery
948            );
949        } else {
950            $nextlink = "\u{00A0}";
951        }
952
953        if ( $this->mNewRevisionRecord->isMinor() ) {
954            $newminor = ChangesList::flag( 'minor', $this->getContext() );
955        } else {
956            $newminor = '';
957        }
958
959        # Handle RevisionDelete links...
960        $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
961
962        # Allow extensions to define their own revision tools
963        $this->hookRunner->onDiffTools(
964            $this->mNewRevisionRecord,
965            $revisionTools,
966            $this->mOldRevisionRecord ?: null,
967            $user
968        );
969
970        $formattedRevisionTools = [];
971        // Put each one in parentheses (poor man's button)
972        foreach ( $revisionTools as $key => $tool ) {
973            $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
974            $element = Html::rawElement(
975                'span',
976                [ 'class' => $toolClass ],
977                $tool
978            );
979            $formattedRevisionTools[] = $element;
980        }
981
982        $newRevRecord = $this->mNewRevisionRecord;
983
984        $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
985            ' ' . implode( ' ', $formattedRevisionTools );
986        $newChangeTags = $this->changeTagsFormatter->formatTagsAsSummaryList(
987            $this->mNewTags,
988            $this->getContext(),
989            $this->getAuthority()
990        );
991        $newRevComment = $this->commentFormatter->formatRevision(
992            $newRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
993        );
994
995        if ( $newRevComment === '' ) {
996            $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
997            $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
998        }
999
1000        $newMobileFooter = $this->getMobileFooter( $newRevRecord, $formattedRevisionTools );
1001
1002        $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
1003            '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
1004            $rollback .
1005            $this->getUserMetaData( $newRevRecord->getUser() ) .
1006            '</div>' .
1007            '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
1008            '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
1009            '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
1010
1011        // Allow extensions to change the $newHeader variable
1012        $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
1013            $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
1014            $rdel, $this->unhide );
1015
1016        $out->addHTML(
1017            Html::rawElement( 'div', [
1018                'class' => 'mw-diff-revision-history-links'
1019            ], $breadCrumbs )
1020        );
1021
1022        $out->addHTML(
1023            Html::rawElement( 'div', [
1024                'class' => 'mw-diff-mobile-footer'
1025            ], $newMobileFooter )
1026        );
1027        $addMessageBoxStyles = false;
1028        # If the diff cannot be shown due to a deleted revision, then output
1029        # the diff header and links to unhide (if available)...
1030        if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
1031            $this->showDiffStyle();
1032            $multi = $this->getMultiNotice();
1033            $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
1034            if ( !$allowed ) {
1035                # Give explanation for why revision is not visible
1036                $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
1037            } else {
1038                # Give explanation and add a link to view the diff...
1039                $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
1040                $msg = [
1041                    $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
1042                    $this->getTitle()->getFullURL( $query )
1043                ];
1044            }
1045            $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
1046            $addMessageBoxStyles = true;
1047        # Otherwise, output a regular diff...
1048        } else {
1049            # Add deletion notice if the user is viewing deleted content
1050            $notice = '';
1051            if ( $deleted ) {
1052                $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
1053                $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
1054                $addMessageBoxStyles = true;
1055            }
1056
1057            # Add an error if the content can't be loaded
1058            $this->getSlotContents();
1059            foreach ( $this->getRevisionLoadErrors() as $msg ) {
1060                $notice .= Html::warningBox( $msg->parse() );
1061                $addMessageBoxStyles = true;
1062            }
1063
1064            // Check if inline switcher will be needed
1065            if ( $this->getTextDiffer()->hasFormat( 'inline' ) ) {
1066                $out->enableOOUI();
1067            }
1068
1069            $this->showTablePrefixes();
1070            $this->showDiff( $oldHeader, $newHeader, $notice );
1071            if ( !$diffOnly ) {
1072                $this->renderNewRevision();
1073            }
1074
1075            // Allow extensions to optionally not show the final patrolled link
1076            if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1077                # Add redundant patrol link on bottom...
1078                $out->addHTML( $this->markPatrolledLink() );
1079            }
1080        }
1081        if ( $addMessageBoxStyles ) {
1082            $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1083        }
1084    }
1085
1086    /**
1087     * Add table prefixes
1088     */
1089    private function showTablePrefixes() {
1090        $parts = [];
1091        foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1092            $parts += $slotDiffRenderer->getTablePrefix( $this->getContext(), $this->mNewPage );
1093        }
1094        ksort( $parts );
1095        $nonEmptyParts = array_values( array_filter( $parts ) );
1096        if ( $nonEmptyParts ) {
1097            $language = $this->getLanguage();
1098            $attrs = [
1099                'class' => 'mw-diff-table-prefix',
1100                'dir' => $language->getDir(),
1101                'lang' => $language->getCode(),
1102            ];
1103            $this->getOutput()->addHTML(
1104                Html::rawElement( 'div', $attrs, implode( '', $nonEmptyParts ) ) );
1105        }
1106    }
1107
1108    /**
1109     * Build a link to mark a change as patrolled.
1110     *
1111     * Returns empty string if there's either no revision to patrol or the user is not allowed to.
1112     *
1113     * Side effect: When the patrol link is build, this method will call
1114     * OutputPage::getMetadata()->setPreventClickjacking(true) and load a
1115     * JS module.
1116     *
1117     * @return string HTML or empty string
1118     */
1119    public function markPatrolledLink() {
1120        if ( $this->mMarkPatrolledLink === null ) {
1121            $linkInfo = $this->getMarkPatrolledLinkInfo();
1122            // If false, there is no patrol link needed/allowed
1123            if ( !$linkInfo || !$this->mNewPage ) {
1124                $this->mMarkPatrolledLink = '';
1125            } else {
1126                $patrolLinkClass = 'patrollink';
1127                $this->mMarkPatrolledLink = ' <span class="' . $patrolLinkClass . '"' .
1128                    ' data-mw-interface>[' .
1129                    $this->linkRenderer->makeKnownLink(
1130                        $this->mNewPage,
1131                        $this->msg( 'markaspatrolleddiff' )->text(),
1132                        [],
1133                        [
1134                            'action' => 'markpatrolled',
1135                            'rcid' => $linkInfo['rcid'],
1136                        ]
1137                    ) . ']</span>';
1138                // Allow extensions to change the markpatrolled link
1139                $this->hookRunner->onDifferenceEngineMarkPatrolledLink( $this,
1140                    $this->mMarkPatrolledLink, $linkInfo['rcid'] );
1141            }
1142        }
1143        return $this->mMarkPatrolledLink;
1144    }
1145
1146    /**
1147     * Returns an array of meta data needed to build a "mark as patrolled" link and
1148     * adds a JS module to the output.
1149     *
1150     * @return array|false An array of meta data for a patrol link (rcid only)
1151     *  or false if no link is needed
1152     */
1153    protected function getMarkPatrolledLinkInfo() {
1154        $user = $this->getUser();
1155        $config = $this->getConfig();
1156
1157        // Prepare a change patrol link, if applicable
1158        if (
1159            // Is patrolling enabled and the user allowed to?
1160            $config->get( MainConfigNames::UseRCPatrol ) &&
1161            $this->mNewPage &&
1162            $this->getAuthority()->probablyCan( 'patrol', $this->mNewPage ) &&
1163            // Only do this if the revision isn't more than 6 hours older
1164            // than the Max RC age (6h because the RC might not be cleaned out regularly)
1165            RecentChange::isInRCLifespan( $this->mNewRevisionRecord->getTimestamp(), 21600 )
1166        ) {
1167            // Look for an unpatrolled change corresponding to this diff
1168            $change = $this->recentChangeLookup->getRecentChangeByConds(
1169                [
1170                    'rc_this_oldid' => $this->mNewid,
1171                    'rc_patrolled' => RecentChange::PRC_UNPATROLLED
1172                ],
1173                __METHOD__
1174            );
1175
1176            if ( $change && !$change->getPerformerIdentity()->equals( $user ) ) {
1177                $rcid = $change->getAttribute( 'rc_id' );
1178            } else {
1179                // None found or the page has been created by the current user.
1180                // If the user could patrol this it already would be patrolled
1181                $rcid = 0;
1182            }
1183
1184            // Allow extensions to possibly change the rcid here
1185            // For example the rcid might be set to zero due to the user
1186            // being the same as the performer of the change but an extension
1187            // might still want to show it under certain conditions
1188            $this->hookRunner->onDifferenceEngineMarkPatrolledRCID( $rcid, $this, $change, $user );
1189
1190            // Build the link
1191            if ( $rcid ) {
1192                $this->getOutput()->getMetadata()->setPreventClickjacking( true );
1193                $this->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1194
1195                return [ 'rcid' => $rcid ];
1196            }
1197        }
1198
1199        // No mark as patrolled link applicable
1200        return false;
1201    }
1202
1203    /**
1204     * @param RevisionRecord $revRecord
1205     *
1206     * @return string
1207     */
1208    private function revisionDeleteLink( RevisionRecord $revRecord ) {
1209        $link = Linker::getRevDeleteLink(
1210            $this->getAuthority(),
1211            $revRecord,
1212            $revRecord->getPageAsLinkTarget()
1213        );
1214        if ( $link !== '' ) {
1215            $link = "\u{00A0}\u{00A0}\u{00A0}" . $link . ' ';
1216        }
1217
1218        return $link;
1219    }
1220
1221    /**
1222     * Show the new revision of the page.
1223     *
1224     * @note Not supported after calling setContent().
1225     */
1226    public function renderNewRevision() {
1227        if ( $this->isContentOverridden ) {
1228            // The code below only works with a RevisionRecord object. We could construct a
1229            // fake RevisionRecord (here or in setContent), but since this does not seem
1230            // needed at the moment, we'll just fail for now.
1231            throw new LogicException(
1232                __METHOD__
1233                . ' is not supported after calling setContent(). Use setRevisions() instead.'
1234            );
1235        }
1236
1237        $out = $this->getOutput();
1238        $revHeader = $this->getRevisionHeader( $this->mNewRevisionRecord );
1239        # Add "current version as of X" title
1240        $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
1241        <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
1242        # Page content may be handled by a hooked call instead...
1243        if ( $this->hookRunner->onArticleContentOnDiff( $this, $out ) ) {
1244            $this->loadNewText();
1245            if ( !$this->mNewPage ) {
1246                // New revision is unsaved; bail out.
1247                // TODO in theory rendering the new revision is a meaningful thing to do
1248                // even if it's unsaved, but a lot of untangling is required to do it safely.
1249                return;
1250            }
1251            if ( $this->hasNewRevisionLoadError() ) {
1252                // There was an error loading the new revision
1253                return;
1254            }
1255
1256            $out->setRevisionId( $this->mNewid );
1257            $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() );
1258            $out->getMetadata()->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() );
1259            $out->setArticleFlag( true );
1260
1261            if ( !$this->hookRunner->onArticleRevisionViewCustom(
1262                $this->mNewRevisionRecord, $this->mNewPage, $this->mOldid, $out )
1263            ) {
1264                // Handled by extension
1265                // NOTE: sync with hooks called in Article::view()
1266            } else {
1267                // Normal page
1268                if ( $this->getTitle()->equals( $this->mNewPage ) ) {
1269                    // If the Title stored in the context is the same as the one
1270                    // of the new revision, we can use its associated WikiPage
1271                    // object.
1272                    $wikiPage = $this->getWikiPage();
1273                } else {
1274                    // Otherwise we need to create our own WikiPage object
1275                    $wikiPage = $this->wikiPageFactory->newFromTitle( $this->mNewPage );
1276                }
1277
1278                # Use appropriate parser options for the Article.
1279                $article = Article::newFromWikiPage( $wikiPage, $this->getContext() );
1280                $parserOptions = $article->getParserOptions();
1281                // Override the render reason.
1282                $parserOptions->setRenderReason( 'diff-page' );
1283
1284                $errors = [];
1285                $parserOutput = $article->getPage()->getParserOutput(
1286                    $parserOptions, $this->mNewRevisionRecord,
1287                    options: [
1288                        // we already checked
1289                        ParserOutputAccess::OPT_NO_AUDIENCE_CHECK => true,
1290                        // Update cascading protection
1291                        ParserOutputAccess::OPT_LINKS_UPDATE => true,
1292                    ],
1293                    errors: $errors
1294                );
1295
1296                if ( $parserOutput !== false ) {
1297                    // Allow extensions to change parser output here
1298                    if ( $this->hookRunner->onDifferenceEngineRenderRevisionAddParserOutput(
1299                        $this, $out, $parserOutput, $wikiPage )
1300                    ) {
1301                        $editLinks = $this->mNewRevisionRecord->isCurrent()
1302                            && $this->getAuthority()->probablyCan(
1303                                'edit',
1304                                $this->mNewRevisionRecord->getPage() );
1305                        if ( !$editLinks ) {
1306                            $parserOptions->setSuppressSectionEditLinks();
1307                        }
1308                        $out->addParserOutput( $parserOutput, $parserOptions, [
1309                            'absoluteURLs' => $this->slotDiffOptions['expand-url'] ?? false
1310                        ] );
1311                    }
1312                } else {
1313                    $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1314                    // @phan-suppress-next-line PhanEmptyForeach $errors written by-reference
1315                    foreach ( $errors as $msg ) {
1316                        $out->addHTML( Html::errorBox(
1317                            $this->msg( $msg )->parse()
1318                        ) );
1319                    }
1320                }
1321            }
1322        }
1323    }
1324
1325    /**
1326     * Get the diff text, send it to the OutputPage object
1327     * Returns false if the diff could not be generated, otherwise returns true
1328     *
1329     * @param string|false $otitle Header for old text or false
1330     * @param string|false $ntitle Header for new text or false
1331     * @param string $notice HTML between diff header and body
1332     *
1333     * @return bool
1334     */
1335    public function showDiff( $otitle, $ntitle, $notice = '' ) {
1336        // Allow extensions to affect the output here
1337        $this->hookRunner->onDifferenceEngineShowDiff( $this );
1338
1339        $diff = $this->getDiff( $otitle, $ntitle, $notice );
1340        if ( $diff === false ) {
1341            $this->showMissingRevision();
1342            return false;
1343        }
1344
1345        $this->showDiffStyle();
1346        if ( $this->slotDiffOptions['expand-url'] ?? false ) {
1347            $diff = Linker::expandLocalLinks( $diff );
1348        }
1349        $this->getOutput()->addHTML( $diff );
1350        return true;
1351    }
1352
1353    /**
1354     * Add style sheets for diff display.
1355     */
1356    public function showDiffStyle() {
1357        if ( !$this->isSlotDiffRenderer ) {
1358            $this->getOutput()->addModules( 'mediawiki.diff' );
1359            $this->getOutput()->addModuleStyles( [
1360                'mediawiki.interface.helpers.styles',
1361                'mediawiki.diff.styles'
1362            ] );
1363            foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1364                $slotDiffRenderer->addModules( $this->getOutput() );
1365            }
1366        }
1367    }
1368
1369    /**
1370     * Get complete diff table, including header
1371     *
1372     * @param string|false $otitle Header for old text or false
1373     * @param string|false $ntitle Header for new text or false
1374     * @param string $notice HTML between diff header and body
1375     *
1376     * @return string|false
1377     */
1378    public function getDiff( $otitle, $ntitle, $notice = '' ) {
1379        $body = $this->getDiffBody();
1380        if ( $body === false ) {
1381            return false;
1382        }
1383
1384        $multi = $this->getMultiNotice();
1385        // Display a message when the diff is empty
1386        if ( $body === '' ) {
1387            $notice .= '<div class="mw-diff-empty">' .
1388                $this->msg( 'diff-empty' )->parse() .
1389                "</div>\n";
1390        }
1391
1392        if ( $this->cacheHitKey !== null ) {
1393            $body .= "\n<!-- diff cache key " . htmlspecialchars( $this->cacheHitKey ) . " -->\n";
1394        }
1395
1396        return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
1397    }
1398
1399    private function incrementStats( string $cacheStatus ): void {
1400        $stats = MediaWikiServices::getInstance()->getStatsFactory();
1401        $stats->getCounter( 'diff_cache_total' )
1402            ->setLabel( 'status', $cacheStatus )
1403            ->increment();
1404    }
1405
1406    /**
1407     * Get the diff table body, without header
1408     *
1409     * @return string|false
1410     */
1411    public function getDiffBody() {
1412        $this->mCacheHit = true;
1413        // Check if the diff should be hidden from this user
1414        if ( !$this->isContentOverridden ) {
1415            if ( !$this->loadRevisionData() ) {
1416                return false;
1417            } elseif ( $this->mOldRevisionRecord &&
1418                !$this->mOldRevisionRecord->userCan(
1419                    RevisionRecord::DELETED_TEXT,
1420                    $this->getAuthority()
1421                )
1422            ) {
1423                return false;
1424            } elseif ( $this->mNewRevisionRecord &&
1425                !$this->mNewRevisionRecord->userCan(
1426                    RevisionRecord::DELETED_TEXT,
1427                    $this->getAuthority()
1428                ) ) {
1429                return false;
1430            }
1431            // Short-circuit
1432            if ( $this->mOldRevisionRecord === false || (
1433                $this->mOldRevisionRecord &&
1434                $this->mNewRevisionRecord &&
1435                $this->mOldRevisionRecord->getId() &&
1436                $this->mOldRevisionRecord->getId() == $this->mNewRevisionRecord->getId()
1437            ) ) {
1438                if ( $this->hookRunner->onDifferenceEngineShowEmptyOldContent( $this ) ) {
1439                    return '';
1440                }
1441            }
1442        }
1443
1444        // Cacheable?
1445        $key = false;
1446        $services = MediaWikiServices::getInstance();
1447        $cache = $services->getMainWANObjectCache();
1448        $stats = $services->getStatsdDataFactory();
1449        if ( $this->mOldid && $this->mNewid ) {
1450            $key = $cache->makeKey( ...$this->getDiffBodyCacheKeyParams() );
1451
1452            // Try cache
1453            if ( !$this->mRefreshCache ) {
1454                $difftext = $cache->get( $key );
1455                if ( is_string( $difftext ) ) {
1456                    $this->incrementStats( 'hit' );
1457                    $difftext = $this->localiseDiff( $difftext );
1458                    $this->cacheHitKey = $key;
1459                    return $difftext;
1460                }
1461            } // don't try to load but save the result
1462        }
1463        $this->mCacheHit = false;
1464        $this->cacheHitKey = null;
1465
1466        // Loadtext is permission safe, this just clears out the diff
1467        if ( !$this->loadText() ) {
1468            return false;
1469        }
1470
1471        $difftext = '';
1472        // We've checked for revdelete at the beginning of this method; it's OK to ignore
1473        // read permissions here.
1474        $slotContents = $this->getSlotContents();
1475        foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) {
1476            try {
1477                $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
1478                    $slotContents[$role]['new'] );
1479            } catch ( IncompatibleDiffTypesException $e ) {
1480                $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1481            }
1482            if ( $slotDiff && $role !== SlotRecord::MAIN ) {
1483                // FIXME: ask SlotRoleHandler::getSlotNameMessage
1484                $slotTitle = $role;
1485                $difftext .= $this->getSlotHeader( $slotTitle );
1486            }
1487            $difftext .= $slotDiff;
1488        }
1489
1490        // Save to cache for 7 days
1491        if ( !$this->hookRunner->onAbortDiffCache( $this ) ) {
1492            $this->incrementStats( 'uncacheable' );
1493        } elseif ( $key !== false ) {
1494            $this->incrementStats( 'miss' );
1495            $cache->set( $key, $difftext, 7 * 86400 );
1496        } else {
1497            $this->incrementStats( 'uncacheable' );
1498        }
1499        // localise line numbers and title attribute text
1500        $difftext = $this->localiseDiff( $difftext );
1501
1502        return $difftext;
1503    }
1504
1505    /**
1506     * Get the diff table body for one slot, without header
1507     *
1508     * @param string $role
1509     * @return string|false
1510     */
1511    public function getDiffBodyForRole( $role ) {
1512        $diffRenderers = $this->getSlotDiffRenderers();
1513        if ( !isset( $diffRenderers[$role] ) ) {
1514            return false;
1515        }
1516
1517        $slotContents = $this->getSlotContents();
1518        try {
1519            $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
1520                $slotContents[$role]['new'] );
1521        } catch ( IncompatibleDiffTypesException $e ) {
1522            $slotDiff = $this->getSlotError( $e->getMessageObject()->parse() );
1523        }
1524        if ( $slotDiff === '' ) {
1525            return false;
1526        }
1527
1528        if ( $role !== SlotRecord::MAIN ) {
1529            // TODO use human-readable role name at least
1530            $slotTitle = $role;
1531            $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
1532        }
1533
1534        return $this->localiseDiff( $slotDiff );
1535    }
1536
1537    /**
1538     * Get a slot header for inclusion in a diff body (as a table row).
1539     *
1540     * @param string $headerText The text of the header
1541     * @return string
1542     */
1543    protected function getSlotHeader( $headerText ) {
1544        // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1545        $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1546        $userLang = $this->getLanguage()->getHtmlCode();
1547        return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ],
1548            Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) );
1549    }
1550
1551    /**
1552     * Get an error message for inclusion in a diff body (as a table row).
1553     *
1554     * @param string $errorText The HTML of the error
1555     * @return string
1556     */
1557    protected function getSlotError( $errorText ) {
1558        // The old revision is missing on oldid=<first>&diff=prev; only 2 columns in that case.
1559        $columnCount = $this->mOldRevisionRecord ? 4 : 2;
1560        $userLang = $this->getLanguage()->getHtmlCode();
1561        return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-error', 'lang' => $userLang ],
1562            Html::rawElement( 'td', [ 'colspan' => $columnCount ], $errorText ) );
1563    }
1564
1565    /**
1566     * Get the cache key parameters
1567     *
1568     * Subclasses can replace the first element in the array to something
1569     * more specific to the type of diff (e.g. "inline-diff"), or append
1570     * if the cache should vary on more things. Overriding entirely should
1571     * be avoided.
1572     *
1573     * @since 1.31
1574     *
1575     * @return string[]
1576     * @phan-return non-empty-array<string>
1577     */
1578    protected function getDiffBodyCacheKeyParams() {
1579        if ( !$this->mOldid || !$this->mNewid ) {
1580            throw new BadMethodCallException( 'mOldid and mNewid must be set to get diff cache key.' );
1581        }
1582
1583        $params = [
1584            'diff',
1585            self::DIFF_VERSION,
1586            "old-{$this->mOldid}",
1587            "rev-{$this->mNewid}"
1588        ];
1589
1590        $extraKeys = [];
1591        if ( !$this->isSlotDiffRenderer ) {
1592            foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) {
1593                $extraKeys = array_merge( $extraKeys, $slotDiffRenderer->getExtraCacheKeys() );
1594            }
1595        }
1596        ksort( $extraKeys );
1597        return array_merge( $params, array_values( $extraKeys ) );
1598    }
1599
1600    /**
1601     * Implements DifferenceEngineSlotDiffRenderer::getExtraCacheKeys(). Only used when
1602     * DifferenceEngine is wrapped in DifferenceEngineSlotDiffRenderer.
1603     * @return string[]
1604     * @internal for use by DifferenceEngineSlotDiffRenderer only
1605     * @deprecated
1606     */
1607    public function getExtraCacheKeys() {
1608        // This method is called when the DifferenceEngine is used for a slot diff. We only care
1609        // about special things, not the revision IDs, which are added to the cache key by the
1610        // page-level DifferenceEngine, and which might not have a valid value for this object.
1611        $this->mOldid = 123456789;
1612        $this->mNewid = 987654321;
1613
1614        // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless.
1615        $params = $this->getDiffBodyCacheKeyParams();
1616
1617        // Try to get rid of the standard keys to keep the cache key human-readable:
1618        // call the getDiffBodyCacheKeyParams implementation of the base class, and if
1619        // the child class includes the same keys, drop them.
1620        // Uses an obscure PHP feature where static calls to non-static methods are allowed
1621        // as long as we are already in a non-static method of the same class, and the call context
1622        // ($this) will be inherited.
1623        // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
1624        $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams();
1625        if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) {
1626            $params = array_slice( $params, count( $standardParams ) );
1627        }
1628
1629        return $params;
1630    }
1631
1632    /**
1633     * @param array $options for the difference engine. Available options:
1634     *   - diff-type: The text diff format, e.g. "table" or "inline". If the
1635     *     specified format is not supported, the option will be ignored, so
1636     *     the site default format (table) will be used.
1637     *   - expand-url: If true, put full URLs in href attributes (for action=render)
1638     *     FIXME: expand-url is not a slot diff option, it is a DifferenceEngine option.
1639     *   - inline-toggle: If true, and the inline format is supported
1640     *     a format selector toggle switch will be shown.
1641     */
1642    public function setSlotDiffOptions( $options ) {
1643        $validatedOptions = [];
1644        if ( isset( $options['diff-type'] )
1645            && $this->getTextDiffer()->hasFormat( $options['diff-type'] )
1646        ) {
1647            $validatedOptions['diff-type'] = $options['diff-type'];
1648        }
1649        if ( !empty( $options['expand-url'] ) ) {
1650            $validatedOptions['expand-url'] = true;
1651        }
1652        if ( !empty( $options['inline-toggle'] ) ) {
1653            $validatedOptions['inline-toggle'] = true;
1654        }
1655        $this->slotDiffOptions = $validatedOptions;
1656    }
1657
1658    /**
1659     * Set query parameters to append to diff page links
1660     *
1661     * @since 1.41
1662     *
1663     * @param array $params
1664     */
1665    public function setExtraQueryParams( $params ) {
1666        $this->extraQueryParams = $params;
1667    }
1668
1669    /**
1670     * Generate a diff, no caching.
1671     *
1672     * @since 1.21
1673     *
1674     * @param Content $old Old content
1675     * @param Content $new New content
1676     *
1677     * @throws Exception If old or new content is not an instance of TextContent.
1678     * @return bool|string
1679     *
1680     * @deprecated since 1.32, use a SlotDiffRenderer instead.
1681     */
1682    public function generateContentDiffBody( Content $old, Content $new ) {
1683        $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() );
1684        if (
1685            $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer
1686            && $this->isSlotDiffRenderer
1687        ) {
1688            // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine
1689            // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class).
1690            // This will happen when a content model has no custom slot diff renderer, it does have
1691            // a custom difference engine, but that does not override this method.
1692            throw new LogicException( get_class( $this ) . ': could not maintain backwards compatibility. '
1693                . 'Please use a SlotDiffRenderer.' );
1694        }
1695        return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString();
1696    }
1697
1698    /**
1699     * Generate a diff, no caching
1700     *
1701     * @param string $otext Old text, must be already segmented
1702     * @param string $ntext New text, must be already segmented
1703     *
1704     * @throws LogicException If content handling for text content is configured in a way
1705     *   that makes maintaining B/C hard.
1706     * @return bool|string
1707     *
1708     * @deprecated since 1.32, use a TextSlotDiffRenderer instead.
1709     */
1710    public function generateTextDiffBody( $otext, $ntext ) {
1711        $slotDiffRenderer = $this->contentHandlerFactory
1712            ->getContentHandler( CONTENT_MODEL_TEXT )
1713            ->getSlotDiffRenderer( $this->getContext() );
1714        if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) {
1715            // Someone used the GetSlotDiffRenderer hook to replace the renderer.
1716            // This is too unlikely to happen to bother handling properly.
1717            throw new LogicException( 'The slot diff renderer for text content should be a '
1718                . 'TextSlotDiffRenderer subclass' );
1719        }
1720        return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString();
1721    }
1722
1723    /**
1724     * Process DiffEngine config and get a sensible, usable engine
1725     *
1726     * @return string 'wikidiff2', 'php', or path to an executable
1727     * @internal For use by this class and within Core only.
1728     */
1729    public static function getEngine() {
1730        $differenceEngine = new self;
1731        $engine = $differenceEngine->getTextDiffer()->getEngineForFormat( 'table' );
1732        if ( $engine === 'external' ) {
1733            return MediaWikiServices::getInstance()->getMainConfig()
1734                ->get( MainConfigNames::ExternalDiffEngine );
1735        } else {
1736            return $engine;
1737        }
1738    }
1739
1740    /**
1741     * Generate a debug comment indicating diff generating time,
1742     * server node, and generator backend.
1743     *
1744     * @param string $generator What diff engine was used
1745     *
1746     * @return string
1747     */
1748    protected function debug( $generator = "internal" ) {
1749        if ( !$this->enableDebugComment ) {
1750            return '';
1751        }
1752        $data = [ $generator ];
1753        if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
1754            $data[] = wfHostname();
1755        }
1756        $data[] = ConvertibleTimestamp::now( TS::DB );
1757
1758        return "<!-- diff generator: " .
1759            implode( " ", array_map( "htmlspecialchars", $data ) ) .
1760            " -->\n";
1761    }
1762
1763    /**
1764     * @return string
1765     */
1766    private function getDebugString() {
1767        $engine = self::getEngine();
1768        if ( $engine === 'wikidiff2' ) {
1769            return $this->debug( 'wikidiff2' );
1770        } elseif ( $engine === 'php' ) {
1771            return $this->debug( 'native PHP' );
1772        } else {
1773            return $this->debug( "external $engine" );
1774        }
1775    }
1776
1777    /**
1778     * Localise diff output
1779     *
1780     * @param string $text
1781     * @return string
1782     */
1783    private function localiseDiff( $text ) {
1784        return $this->getTextDiffer()->localize( $this->getTextDiffFormat(), $text );
1785    }
1786
1787    /**
1788     * Replace a common convention for language-independent line numbers with
1789     * the text in the user's language.
1790     *
1791     * @deprecated since 1.41, use BaseTextDiffer::localizeLineNumbers()
1792     * @param string $text
1793     * @return string
1794     */
1795    public function localiseLineNumbers( $text ) {
1796        return preg_replace_callback( '/<!--LINE (\d+)-->/',
1797            function ( array $matches ) {
1798                if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
1799                    return '';
1800                }
1801                return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
1802            }, $text );
1803    }
1804
1805    /**
1806     * If there are revisions between the ones being compared, return a note saying so.
1807     *
1808     * @return string
1809     */
1810    public function getMultiNotice() {
1811        // The notice only make sense if we are diffing two saved revisions of the same page.
1812        if (
1813            !$this->mOldRevisionRecord || !$this->mNewRevisionRecord
1814            || !$this->mOldPage || !$this->mNewPage
1815            || !$this->mOldPage->equals( $this->mNewPage )
1816            || $this->mOldRevisionRecord->getId() === null
1817            || $this->mNewRevisionRecord->getId() === null
1818            // (T237709) Deleted revs might have different page IDs
1819            || $this->mNewPage->getArticleID() !== $this->mOldRevisionRecord->getPageId()
1820            || $this->mNewPage->getArticleID() !== $this->mNewRevisionRecord->getPageId()
1821        ) {
1822            return '';
1823        }
1824
1825        if ( $this->mOldRevisionRecord->getTimestamp() > $this->mNewRevisionRecord->getTimestamp() ) {
1826            $oldRevRecord = $this->mNewRevisionRecord; // flip
1827            $newRevRecord = $this->mOldRevisionRecord; // flip
1828        } else { // normal case
1829            $oldRevRecord = $this->mOldRevisionRecord;
1830            $newRevRecord = $this->mNewRevisionRecord;
1831        }
1832
1833        // Don't show the notice if too many rows must be scanned
1834        // @todo show some special message for that case
1835        $nEdits = 0;
1836        $revisionIdList = $this->revisionStore->getRevisionIdsBetween(
1837            $this->mNewPage->getArticleID(),
1838            $oldRevRecord,
1839            $newRevRecord,
1840            1000
1841        );
1842        // only count revisions that are visible
1843        if ( count( $revisionIdList ) > 0 ) {
1844            foreach ( $revisionIdList as $revisionId ) {
1845                $revision = $this->revisionStore->getRevisionById( $revisionId );
1846                if ( $revision->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ) ) {
1847                    $nEdits++;
1848                }
1849            }
1850        }
1851        if ( $nEdits > 0 && $nEdits <= 1000 ) {
1852            // Use an invalid username to get the wiki's default gender (as fallback)
1853            $newRevUserForGender = '[HIDDEN]';
1854            $limit = 100; // use diff-multi-manyusers if too many users
1855            try {
1856                $users = $this->revisionStore->getAuthorsBetween(
1857                    $this->mNewPage->getArticleID(),
1858                    $oldRevRecord,
1859                    $newRevRecord,
1860                    null,
1861                    $limit
1862                );
1863                $numUsers = count( $users );
1864
1865                $newRevUser = $newRevRecord->getUser( RevisionRecord::RAW );
1866                $newRevUserText = $newRevUser ? $newRevUser->getName() : '';
1867                $newRevUserSafe = $newRevRecord->getUser(
1868                    RevisionRecord::FOR_THIS_USER,
1869                    $this->getAuthority()
1870                );
1871                $newRevUserForGender = $newRevUserSafe ? $newRevUserSafe->getName() : '[HIDDEN]';
1872                if ( $numUsers == 1 && $users[0]->getName() == $newRevUserText ) {
1873                    $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
1874                }
1875            } catch ( InvalidArgumentException ) {
1876                $numUsers = 0;
1877            }
1878
1879            return self::intermediateEditsMsg( $nEdits, $numUsers, $limit, $newRevUserForGender );
1880        }
1881
1882        return '';
1883    }
1884
1885    /**
1886     * Get a notice about how many intermediate edits and users there are
1887     *
1888     * @param int $numEdits
1889     * @param int $numUsers
1890     * @param int $limit
1891     * @param string $lastUser
1892     *
1893     * @return string
1894     */
1895    public static function intermediateEditsMsg( $numEdits, $numUsers, $limit, $lastUser = '[HIDDEN]' ) {
1896        if ( $numUsers === 0 ) {
1897            $msg = 'diff-multi-sameuser';
1898            return wfMessage( $msg )
1899                ->numParams( $numEdits, $numUsers )
1900                ->params( $lastUser )
1901                ->parse();
1902        } elseif ( $numUsers > $limit ) {
1903            $msg = 'diff-multi-manyusers';
1904            $numUsers = $limit;
1905        } else {
1906            $msg = 'diff-multi-otherusers';
1907        }
1908
1909        return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
1910    }
1911
1912    /**
1913     * @param RevisionRecord $revRecord
1914     * @return bool whether the user can see and edit the revision.
1915     */
1916    private function userCanEdit( RevisionRecord $revRecord ) {
1917        if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1918            return false;
1919        }
1920
1921        return true;
1922    }
1923
1924    /**
1925     * Get a header for a specified revision.
1926     *
1927     * @param RevisionRecord $rev
1928     * @param string $complete 'complete' to get the header wrapped depending
1929     *        the visibility of the revision and a link to edit the page.
1930     *
1931     * @return string HTML fragment
1932     */
1933    public function getRevisionHeader( RevisionRecord $rev, $complete = '' ) {
1934        $lang = $this->getLanguage();
1935        $user = $this->getUser();
1936        $revtimestamp = $rev->getTimestamp();
1937        $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
1938        $dateofrev = $lang->userDate( $revtimestamp, $user );
1939        $timeofrev = $lang->userTime( $revtimestamp, $user );
1940
1941        $header = $this->msg(
1942            $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
1943            $timestamp,
1944            $dateofrev,
1945            $timeofrev
1946        );
1947
1948        if ( $complete !== 'complete' ) {
1949            return $header->escaped();
1950        }
1951
1952        $title = $rev->getPageAsLinkTarget();
1953
1954        if ( $this->userCanEdit( $rev ) ) {
1955            $header = $this->linkRenderer->makeKnownLink(
1956                $title,
1957                $header->text(),
1958                [],
1959                [ 'oldid' => $rev->getId() ]
1960            );
1961            $editQuery = [ 'action' => 'edit' ];
1962            if ( !$rev->isCurrent() ) {
1963                $editQuery['oldid'] = $rev->getId();
1964            }
1965
1966            $key = $this->getAuthority()->probablyCan( 'edit', $rev->getPage() ) ? 'editold' : 'viewsourceold';
1967            $msg = $this->msg( $key )->text();
1968            $editLink = $this->linkRenderer->makeKnownLink( $title, $msg, [], $editQuery );
1969            $header .= ' ' . Html::rawElement(
1970                'span',
1971                [ 'class' => 'mw-diff-edit' ],
1972                $editLink
1973            );
1974        } else {
1975            $header = $header->escaped();
1976        }
1977
1978        // Machine readable information
1979        $header .= Html::element( 'span',
1980            [
1981                'class' => 'mw-diff-timestamp',
1982                'data-timestamp' => wfTimestamp( TS::ISO_8601, $revtimestamp ),
1983            ], ''
1984        );
1985
1986        if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1987            return Html::rawElement(
1988                'span',
1989                [ 'class' => Linker::getRevisionDeletedClass( $rev ) ],
1990                $header
1991            );
1992        }
1993
1994        return $header;
1995    }
1996
1997    /**
1998     * Add the header to a diff body
1999     *
2000     * @param string $diff Diff body
2001     * @param string $otitle Old revision header
2002     * @param string $ntitle New revision header
2003     * @param string $multi Notice telling user that there are intermediate
2004     *   revisions between the ones being compared
2005     * @param string $notice Other notices, e.g. that user is viewing deleted content
2006     *
2007     * @return string
2008     */
2009    public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
2010        // shared.css sets diff in interface language/dir, but the actual content
2011        // is often in a different language, mostly the page content language/dir
2012        $header = Html::openElement( 'table', [
2013            'class' => [
2014                'diff',
2015                // The following classes are used here:
2016                // * diff-type-table
2017                // * diff-type-inline
2018                'diff-type-' . $this->getTextDiffFormat(),
2019                // The following classes are used here:
2020                // * diff-contentalign-left
2021                // * diff-contentalign-right
2022                'diff-contentalign-' . $this->getDiffLang()->alignStart(),
2023                // The following classes are used here:
2024                // * diff-editfont-monospace
2025                // * diff-editfont-sans-serif
2026                // * diff-editfont-serif
2027                'diff-editfont-' . $this->userOptionsLookup->getOption(
2028                    $this->getUser(),
2029                    'editfont'
2030                )
2031            ],
2032            'data-mw-interface' => '',
2033        ] );
2034        $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
2035
2036        if ( !$diff && !$otitle ) {
2037            $header .= "
2038            <tr class=\"diff-title\" lang=\"{$userLang}\">
2039            <td class=\"diff-ntitle\">{$ntitle}</td>
2040            </tr>";
2041            $multiColspan = 1;
2042        } else {
2043            if ( $diff ) { // Safari/Chrome show broken output if cols not used
2044                $header .= "
2045                <col class=\"diff-marker\" />
2046                <col class=\"diff-content\" />
2047                <col class=\"diff-marker\" />
2048                <col class=\"diff-content\" />";
2049                $colspan = 2;
2050                $multiColspan = 4;
2051            } else {
2052                $colspan = 1;
2053                $multiColspan = 2;
2054            }
2055            if ( $otitle || $ntitle ) {
2056                // FIXME Hardcoding values from TableDiffFormatter.
2057                $deletedClass = 'diff-side-deleted';
2058                $addedClass = 'diff-side-added';
2059                $header .= "
2060                <tr class=\"diff-title\" lang=\"{$userLang}\">
2061                <td colspan=\"$colspan\" class=\"diff-otitle {$deletedClass}\">{$otitle}</td>
2062                <td colspan=\"$colspan\" class=\"diff-ntitle {$addedClass}\">{$ntitle}</td>
2063                </tr>";
2064            }
2065        }
2066
2067        if ( $multi != '' ) {
2068            $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2069                "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
2070        }
2071        if ( $notice != '' ) {
2072            $header .= "<tr><td colspan=\"{$multiColspan}\" " .
2073                "class=\"diff-notice\" lang=\"{$userLang}\">{$notice}</td></tr>";
2074        }
2075
2076        return $header . $diff . "</table>";
2077    }
2078
2079    /**
2080     * Use specified text instead of loading from the database
2081     * @param Content $oldContent
2082     * @param Content $newContent
2083     * @since 1.21
2084     * @deprecated since 1.32, use setRevisions or ContentHandler::getSlotDiffRenderer.
2085     */
2086    public function setContent( Content $oldContent, Content $newContent ) {
2087        $this->mOldContent = $oldContent;
2088        $this->mNewContent = $newContent;
2089
2090        $this->mTextLoaded = 2;
2091        $this->mRevisionsLoaded = true;
2092        $this->isContentOverridden = true;
2093        $this->slotDiffRenderers = null;
2094    }
2095
2096    /**
2097     * Use specified text instead of loading from the database.
2098     * @param RevisionRecord|null $oldRevision
2099     * @param RevisionRecord $newRevision
2100     */
2101    public function setRevisions(
2102        ?RevisionRecord $oldRevision, RevisionRecord $newRevision
2103    ) {
2104        if ( $oldRevision ) {
2105            $this->mOldRevisionRecord = $oldRevision;
2106            $this->mOldid = $oldRevision->getId();
2107            $this->mOldPage = Title::newFromPageIdentity( $oldRevision->getPage() );
2108            // This method is meant for edit diffs and such so there is no reason to provide a
2109            // revision that's not readable to the user, but check it just in case.
2110            $this->mOldContent = $oldRevision->getContent( SlotRecord::MAIN,
2111                RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2112            if ( !$this->mOldContent ) {
2113                $this->addRevisionLoadError( 'old' );
2114            }
2115        } else {
2116            $this->mOldPage = null;
2117            $this->mOldRevisionRecord = $this->mOldid = false;
2118        }
2119        $this->mNewRevisionRecord = $newRevision;
2120        $this->mNewid = $newRevision->getId();
2121        $this->mNewPage = Title::newFromPageIdentity( $newRevision->getPage() );
2122        $this->mNewContent = $newRevision->getContent( SlotRecord::MAIN,
2123            RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
2124        if ( !$this->mNewContent ) {
2125            $this->addRevisionLoadError( 'new' );
2126        }
2127
2128        $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true;
2129        $this->mTextLoaded = $oldRevision ? 2 : 1;
2130        $this->isContentOverridden = false;
2131        $this->slotDiffRenderers = null;
2132    }
2133
2134    /**
2135     * Set the language in which the diff text is written
2136     *
2137     * @param Language $lang
2138     * @since 1.19
2139     */
2140    public function setTextLanguage( Language $lang ) {
2141        $this->mDiffLang = $lang;
2142    }
2143
2144    /**
2145     * Maps a revision pair definition as accepted by DifferenceEngine constructor
2146     * to a pair of actual integers representing revision ids.
2147     *
2148     * @param int $old Revision id, e.g. from URL parameter 'oldid'
2149     * @param int|string $new Revision id or strings 'next' or 'prev', e.g. from URL parameter 'diff'
2150     *
2151     * @return array List of two revision ids, older first, later second.
2152     *     Zero signifies invalid argument passed.
2153     *     false signifies that there is no previous/next revision ($old is the oldest/newest one).
2154     * @phan-return (int|false)[]
2155     */
2156    public function mapDiffPrevNext( $old, $new ) {
2157        if ( $new === 'prev' ) {
2158            // Show diff between revision $old and the previous one. Get previous one from DB.
2159            $newid = intval( $old );
2160            $oldid = false;
2161            $newRev = $this->revisionStore->getRevisionById( $newid );
2162            if ( $newRev ) {
2163                $oldRev = $this->revisionStore->getPreviousRevision( $newRev );
2164                if ( $oldRev ) {
2165                    $oldid = $oldRev->getId();
2166                }
2167            }
2168        } elseif ( $new === 'next' ) {
2169            // Show diff between revision $old and the next one. Get next one from DB.
2170            $oldid = intval( $old );
2171            $newid = false;
2172            $oldRev = $this->revisionStore->getRevisionById( $oldid );
2173            if ( $oldRev ) {
2174                $newRev = $this->revisionStore->getNextRevision( $oldRev );
2175                if ( $newRev ) {
2176                    $newid = $newRev->getId();
2177                }
2178            }
2179        } else {
2180            $oldid = intval( $old );
2181            $newid = intval( $new );
2182        }
2183
2184        // @phan-suppress-next-line PhanTypeMismatchReturn getId does not return null here
2185        return [ $oldid, $newid ];
2186    }
2187
2188    private function loadRevisionIds() {
2189        if ( $this->mRevisionsIdsLoaded ) {
2190            return;
2191        }
2192
2193        $this->mRevisionsIdsLoaded = true;
2194
2195        $old = $this->mOldid;
2196        $new = $this->mNewid;
2197
2198        [ $this->mOldid, $this->mNewid ] = self::mapDiffPrevNext( $old, $new );
2199        if ( $new === 'next' && $this->mNewid === false ) {
2200            # if no result, NewId points to the newest old revision. The only newer
2201            # revision is cur, which is "0".
2202            $this->mNewid = 0;
2203        }
2204
2205        $this->hookRunner->onNewDifferenceEngine(
2206            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2207            $this->getTitle(), $this->mOldid, $this->mNewid, $old, $new );
2208    }
2209
2210    /**
2211     * Load revision metadata for the specified revisions. If newid is 0, then compare
2212     * the old revision in oldid to the latest revision of the current page (as defined
2213     * by the request context); if oldid is 0, then compare the revision in newid to the
2214     * immediately previous one.
2215     *
2216     * If oldid is false, leave the corresponding RevisionRecord object set to false. This can
2217     * happen with 'diff=prev' pointing to a non-existent revision, and is also used directly
2218     * by the API.
2219     *
2220     * @return bool Whether both revisions were loaded successfully. Setting mOldRevisionRecord
2221     *   to false counts as successful loading.
2222     */
2223    public function loadRevisionData() {
2224        if ( $this->mRevisionsLoaded ) {
2225            return $this->isContentOverridden ||
2226            ( $this->mOldRevisionRecord !== null && $this->mNewRevisionRecord !== null );
2227        }
2228
2229        // Whether it succeeds or fails, we don't want to try again
2230        $this->mRevisionsLoaded = true;
2231
2232        $this->loadRevisionIds();
2233
2234        // Load the new RevisionRecord object
2235        if ( $this->mNewid ) {
2236            $this->mNewRevisionRecord = $this->revisionStore->getRevisionById( $this->mNewid );
2237        } else {
2238            $this->mNewRevisionRecord = $this->revisionStore->getRevisionByTitle( $this->getTitle() );
2239        }
2240
2241        // Load the old RevisionRecord object
2242        $this->mOldRevisionRecord = false;
2243        if ( $this->mOldid ) {
2244            $this->mOldRevisionRecord = $this->revisionStore->getRevisionById( $this->mOldid );
2245        } elseif ( $this->mOldid === 0 && $this->mNewRevisionRecord instanceof RevisionRecord ) {
2246            $revRecord = $this->revisionStore->getPreviousRevision( $this->mNewRevisionRecord );
2247            // No previous revision; mark to show as first-version only.
2248            $this->mOldid = $revRecord ? $revRecord->getId() : false;
2249            $this->mOldRevisionRecord = $revRecord ?? false;
2250        } /* elseif ( $this->mOldid === false ) leave mOldRevisionRecord false; */
2251
2252        if ( $this->mOldRevisionRecord === null || $this->mNewRevisionRecord === null ) {
2253            return false;
2254        }
2255
2256        // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
2257        $this->mNewid = $this->mNewRevisionRecord->getId();
2258        $this->mNewPage = $this->mNewid ?
2259            Title::newFromPageIdentity( $this->mNewRevisionRecord->getPage() ) :
2260            null;
2261
2262        if ( $this->mOldRevisionRecord && $this->mOldRevisionRecord->getId() ) {
2263            $this->mOldPage = Title::newFromPageIdentity( $this->mOldRevisionRecord->getPage() );
2264        } else {
2265            $this->mOldPage = null;
2266        }
2267
2268        // Load tags information for both revisions
2269        $dbr = $this->dbProvider->getReplicaDatabase();
2270        $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
2271        if ( $this->mOldid !== false ) {
2272            $tagIds = $dbr->newSelectQueryBuilder()
2273                ->select( 'ct_tag_id' )
2274                ->from( 'change_tag' )
2275                ->where( [ 'ct_rev_id' => $this->mOldid ] )
2276                ->caller( __METHOD__ )->fetchFieldValues();
2277            $tags = [];
2278            foreach ( $tagIds as $tagId ) {
2279                try {
2280                    $tags[] = $changeTagDefStore->getName( (int)$tagId );
2281                } catch ( NameTableAccessException ) {
2282                    continue;
2283                }
2284            }
2285            $this->mOldTags = implode( ',', $tags );
2286        } else {
2287            $this->mOldTags = false;
2288        }
2289
2290        $tagIds = $dbr->newSelectQueryBuilder()
2291            ->select( 'ct_tag_id' )
2292            ->from( 'change_tag' )
2293            ->where( [ 'ct_rev_id' => $this->mNewid ] )
2294            ->caller( __METHOD__ )->fetchFieldValues();
2295        $tags = [];
2296        foreach ( $tagIds as $tagId ) {
2297            try {
2298                $tags[] = $changeTagDefStore->getName( (int)$tagId );
2299            } catch ( NameTableAccessException ) {
2300                continue;
2301            }
2302        }
2303        $this->mNewTags = implode( ',', $tags );
2304
2305        return true;
2306    }
2307
2308    /**
2309     * Load the text of the revisions, as well as revision data.
2310     * When the old revision is missing (mOldRev is false), loading mOldContent is not attempted.
2311     *
2312     * @return bool Whether the content of both revisions could be loaded successfully.
2313     *   (When mOldRev is false, that still counts as a success.)
2314     */
2315    public function loadText() {
2316        if ( $this->mTextLoaded == 2 ) {
2317            return $this->loadRevisionData() &&
2318                ( $this->mOldRevisionRecord === false || $this->mOldContent )
2319                && $this->mNewContent;
2320        }
2321
2322        // Whether it succeeds or fails, we don't want to try again
2323        $this->mTextLoaded = 2;
2324
2325        if ( !$this->loadRevisionData() ) {
2326            return false;
2327        }
2328
2329        if ( $this->mOldRevisionRecord ) {
2330            $this->mOldContent = $this->mOldRevisionRecord->getContent(
2331                SlotRecord::MAIN,
2332                RevisionRecord::FOR_THIS_USER,
2333                $this->getAuthority()
2334            );
2335            if ( $this->mOldContent === null ) {
2336                return false;
2337            }
2338        }
2339
2340        $this->mNewContent = $this->mNewRevisionRecord->getContent(
2341            SlotRecord::MAIN,
2342            RevisionRecord::FOR_THIS_USER,
2343            $this->getAuthority()
2344        );
2345        $this->hookRunner->onDifferenceEngineLoadTextAfterNewContentIsLoaded( $this );
2346        if ( $this->mNewContent === null ) {
2347            return false;
2348        }
2349
2350        return true;
2351    }
2352
2353    /**
2354     * Load the text of the new revision, not the old one
2355     *
2356     * @return bool Whether the content of the new revision could be loaded successfully.
2357     */
2358    public function loadNewText() {
2359        if ( $this->mTextLoaded >= 1 ) {
2360            return $this->loadRevisionData();
2361        }
2362
2363        $this->mTextLoaded = 1;
2364
2365        if ( !$this->loadRevisionData() ) {
2366            return false;
2367        }
2368
2369        $this->mNewContent = $this->mNewRevisionRecord->getContent(
2370            SlotRecord::MAIN,
2371            RevisionRecord::FOR_THIS_USER,
2372            $this->getAuthority()
2373        );
2374
2375        $this->hookRunner->onDifferenceEngineAfterLoadNewText( $this );
2376
2377        return true;
2378    }
2379
2380    /**
2381     * Get the TextDiffer which will be used for rendering text
2382     *
2383     * @return ManifoldTextDiffer
2384     */
2385    protected function getTextDiffer() {
2386        if ( $this->textDiffer === null ) {
2387            $this->textDiffer = new ManifoldTextDiffer(
2388                $this->getContext(),
2389                $this->getDiffLang(),
2390                $this->getConfig()->get( MainConfigNames::DiffEngine ),
2391                $this->getConfig()->get( MainConfigNames::ExternalDiffEngine ),
2392                $this->getConfig()->get( MainConfigNames::Wikidiff2Options )
2393            );
2394        }
2395        return $this->textDiffer;
2396    }
2397
2398    /**
2399     * Get the list of supported text diff formats
2400     *
2401     * @since 1.41
2402     * @return array|string[]
2403     */
2404    public function getSupportedFormats() {
2405        return $this->getTextDiffer()->getFormats();
2406    }
2407
2408    /**
2409     * Get the selected text diff format
2410     *
2411     * @since 1.41
2412     * @return string
2413     */
2414    public function getTextDiffFormat() {
2415        return $this->slotDiffOptions['diff-type'] ?? 'table';
2416    }
2417
2418    /**
2419     * @param RevisionRecord|null $newRevRecord
2420     * @param array $formattedRevisionTools
2421     * @return string
2422     */
2423    private function getMobileFooter( ?RevisionRecord $newRevRecord, array $formattedRevisionTools ): string {
2424        $this->getOutput()->addModuleStyles( [ 'codex-styles' ] );
2425        $summary = Html::rawElement(
2426            'summary',
2427            [ "class" => "cdx-accordion--has-icon" ],
2428            Html::rawElement(
2429                'h3',
2430                [ "class" => "cdx-accordion__header" ],
2431                Html::rawElement(
2432                    'span',
2433                    [ 'class' => 'cdx-accordion__header__title' ],
2434                    Linker::revUserTools( $newRevRecord, !$this->unhide )
2435                )
2436            )
2437        );
2438        $rollbackLink = '';
2439        if ( $this->mNewRevisionRecord->isCurrent() &&
2440            $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
2441        ) {
2442            $rollbackLink = Linker::generateRollback(
2443                $this->mNewRevisionRecord,
2444                $this->getContext(),
2445                [ 'noBrackets' ]
2446            );
2447        }
2448        $user = $newRevRecord->getUser();
2449        $userGroups = [];
2450        if ( $user !== null ) {
2451            $userGroups = $this->userGroupManager->getUserGroups( $user );
2452        }
2453        $userGroupCount = count( $userGroups );
2454        $userEditCount = $user === null ? '' : $this->getUserEditCount( $user );
2455        $userGroupList = [];
2456        foreach ( $userGroups as $userGroup ) {
2457            $userGroupList[] = $this->msg( "group-$userGroup" )->escaped();
2458        }
2459        if ( $userGroupCount == 0 ) {
2460            $userGroupsPopover = '';
2461        } else {
2462            $popover = Html::rawElement(
2463                'div',
2464                [ 'class' => 'cdx-popover mw-diff-usergroups-popover', 'role' => 'tooltip' ],
2465                Html::rawElement(
2466                    'div',
2467                    [ 'class' => 'cdx-popover__body' ],
2468                    $this->msg( 'diff-usergroups-list', $this->getLanguage()->commaList( $userGroupList ) )->escaped()
2469                ) . Html::rawElement( 'div', [ 'class' => 'cdx-popover__arrow' ] )
2470            );
2471            $popoverTrigger = Html::element(
2472                'span',
2473                [ 'class' => 'cdx-popover-trigger cdx-button__icon cdx-icon cdx-icon--info ', 'tabindex' => '0' ],
2474            );
2475            $userGroupsPopover = Html::rawElement(
2476                'div',
2477                [ 'class' => 'mw-diff-usermetadata' ],
2478
2479                Html::element( 'span',
2480                    [ 'class' => 'mw-diff-usergroups-popover-text' ],
2481                    $this->msg( 'diff-usergroups', $userGroupCount )->text()
2482                ) .
2483                Html::rawElement(
2484                    'div',
2485                    [ 'class' => 'mw-diff-usergroups-popover-wrapper' ],
2486                    $popoverTrigger . $popover
2487                )
2488            );
2489        }
2490
2491        $content = Html::rawElement(
2492            'div',
2493            [ "class" => "cdx-accordion__content" ],
2494            $userGroupsPopover
2495            . $userEditCount
2496            . $rollbackLink
2497            . implode( '', $formattedRevisionTools )
2498        );
2499        return Html::rawElement(
2500            'details',
2501            [ "class" => "mw-diff-new-mobile-footer-accordion cdx-accordion cdx-accordion--separation-minimal" ],
2502            $summary . $content
2503        );
2504    }
2505
2506}
2507
2508/** @deprecated class alias since 1.46 */
2509class_alias( DifferenceEngine::class, 'DifferenceEngine' );