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