3 * This file is part of MediaWiki.
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.
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.
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
23 namespace MediaWiki\Revision
;
25 use InvalidArgumentException
;
29 use Psr\Log\LoggerInterface
;
30 use Psr\Log\NullLogger
;
35 use Wikimedia\Assert\Assert
;
38 * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
39 * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
44 class RenderedRevision
implements SlotRenderingProvider
{
51 /** @var RevisionRecord */
60 * @var int Audience to check when accessing content.
62 private $audience = RevisionRecord
::FOR_PUBLIC
;
65 * @var User|null The user to use for audience checks during content access.
67 private $forUser = null;
70 * @var ParserOutput|null The combined ParserOutput for the revision,
71 * initialized lazily by getRevisionParserOutput().
73 private $revisionOutput = null;
76 * @var ParserOutput[] The ParserOutput for each slot,
77 * initialized lazily by getSlotParserOutput().
79 private $slotsOutput = [];
82 * @var callable Callback for combining slot output into revision output.
83 * Signature: function ( RenderedRevision $this ): ParserOutput.
85 private $combineOutput;
88 * @var LoggerInterface For profiling ParserOutput re-use.
90 private $saveParseLogger;
93 * @note Application logic should not instantiate RenderedRevision instances directly,
94 * but should use a RevisionRenderer instead.
97 * @param RevisionRecord $revision The revision to render. The content for rendering will be
98 * taken from this RevisionRecord. However, if the RevisionRecord is not complete
99 * according isReadyForInsertion(), but a revision ID is known, the parser may load
100 * the revision from the database if it needs revision meta data to handle magic
101 * words like {{REVISIONUSER}}.
102 * @param ParserOptions $options
103 * @param callable $combineOutput Callback for combining slot output into revision output.
104 * Signature: function ( RenderedRevision $this ): ParserOutput.
105 * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
106 * @param User|null $forUser Required if $audience is FOR_THIS_USER.
108 public function __construct(
110 RevisionRecord
$revision,
111 ParserOptions
$options,
112 callable
$combineOutput,
113 $audience = RevisionRecord
::FOR_PUBLIC
,
116 $this->title
= $title;
117 $this->options
= $options;
119 $this->setRevisionInternal( $revision );
121 $this->combineOutput
= $combineOutput;
122 $this->saveParseLogger
= new NullLogger();
124 if ( $audience === RevisionRecord
::FOR_THIS_USER
&& !$forUser ) {
125 throw new InvalidArgumentException(
126 'User must be specified when setting audience to FOR_THIS_USER'
130 $this->audience
= $audience;
131 $this->forUser
= $forUser;
135 * @param LoggerInterface $saveParseLogger
137 public function setSaveParseLogger( LoggerInterface
$saveParseLogger ) {
138 $this->saveParseLogger
= $saveParseLogger;
142 * @return bool Whether the revision's content has been hidden from unprivileged users.
144 public function isContentDeleted() {
145 return $this->revision
->isDeleted( RevisionRecord
::DELETED_TEXT
);
149 * @return RevisionRecord
151 public function getRevision() {
152 return $this->revision
;
156 * @return ParserOptions
158 public function getOptions() {
159 return $this->options
;
163 * Sets a ParserOutput to be returned by getRevisionParserOutput().
165 * @note For internal use by RevisionRenderer only! This method may be modified
166 * or removed without notice per the deprecation policy.
170 * @param ParserOutput $output
172 public function setRevisionParserOutput( ParserOutput
$output ) {
173 $this->revisionOutput
= $output;
175 // If there is only one slot, we assume that the combined output is identical
176 // with the main slot's output. This is intended to prevent a redundant re-parse of
177 // the content in case getSlotParserOutput( SlotRecord::MAIN ) is called, for instance
178 // from ContentHandler::getSecondaryDataUpdates.
179 if ( $this->revision
->getSlotRoles() === [ SlotRecord
::MAIN
] ) {
180 $this->slotsOutput
[ SlotRecord
::MAIN
] = $output;
185 * @param array $hints Hints given as an associative array. Known keys:
186 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
187 * to just meta-data). Default is to generate HTML.
189 * @return ParserOutput
191 public function getRevisionParserOutput( array $hints = [] ) {
192 $withHtml = $hints['generate-html'] ??
true;
194 if ( !$this->revisionOutput
195 ||
( $withHtml && !$this->revisionOutput
->hasText() )
197 $output = call_user_func( $this->combineOutput
, $this, $hints );
199 Assert
::postcondition(
200 $output instanceof ParserOutput
,
201 'Callback did not return a ParserOutput object!'
204 $this->revisionOutput
= $output;
207 return $this->revisionOutput
;
211 * @param string $role
212 * @param array $hints Hints given as an associative array. Known keys:
213 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
214 * to just meta-data). Default is to generate HTML.
216 * @throws SuppressedDataException if the content is not accessible for the audience
217 * specified in the constructor.
218 * @return ParserOutput
220 public function getSlotParserOutput( $role, array $hints = [] ) {
221 $withHtml = $hints['generate-html'] ??
true;
223 if ( !isset( $this->slotsOutput
[ $role ] )
224 ||
( $withHtml && !$this->slotsOutput
[ $role ]->hasText() )
226 $content = $this->revision
->getContent( $role, $this->audience
, $this->forUser
);
229 throw new SuppressedDataException(
230 'Access to the content has been suppressed for this audience'
233 // XXX: allow SlotRoleHandler to control the ParserOutput?
234 $output = $this->getSlotParserOutputUncached( $content, $withHtml );
236 if ( $withHtml && !$output->hasText() ) {
237 throw new LogicException(
238 'HTML generation was requested, but '
239 . get_class( $content )
240 . '::getParserOutput() returns a ParserOutput with no text set.'
244 // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
245 $this->options
->registerWatcher( null );
248 $this->slotsOutput
[ $role ] = $output;
251 return $this->slotsOutput
[$role];
255 * @note This method exist to make duplicate parses easier to see during profiling
256 * @param Content $content
257 * @param bool $withHtml
258 * @return ParserOutput
260 private function getSlotParserOutputUncached( Content
$content, $withHtml ) {
261 return $content->getParserOutput(
263 $this->revision
->getId(),
270 * Updates the RevisionRecord after the revision has been saved. This can be used to discard
271 * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
274 * @note There should be no need to call this for null-edits.
276 * @param RevisionRecord $rev
278 public function updateRevision( RevisionRecord
$rev ) {
279 if ( $rev->getId() === $this->revision
->getId() ) {
283 if ( $this->revision
->getId() ) {
284 throw new LogicException( 'RenderedRevision already has a revision with ID '
285 . $this->revision
->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
288 if ( !$this->revision
->getSlots()->hasSameContent( $rev->getSlots() ) ) {
289 throw new LogicException( 'Cannot update to a revision with different content!' );
292 $this->setRevisionInternal( $rev );
294 $this->pruneRevisionSensitiveOutput(
295 $this->revision
->getPageId(),
296 $this->revision
->getId(),
297 $this->revision
->getTimestamp()
302 * Prune any output that depends on the revision ID.
304 * @param int|bool $actualPageId The actual page id, to check the used speculative page ID
305 * against; false, to not purge on vary-page-id; true, to purge on vary-page-id
307 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
308 * against,; false, to not purge on vary-revision-id; true, to purge on
309 * vary-revision-id unconditionally.
310 * @param string|bool $actualRevTimestamp The actual rev timestamp, to check against the
311 * parser output revision timestamp; false, to not purge on vary-revision-timestamp;
312 * true, to purge on vary-revision-timestamp unconditionally.
314 private function pruneRevisionSensitiveOutput(
319 if ( $this->revisionOutput
) {
320 if ( $this->outputVariesOnRevisionMetaData(
321 $this->revisionOutput
,
326 $this->revisionOutput
= null;
329 $this->saveParseLogger
->debug( __METHOD__
. ": no prepared revision output" );
332 foreach ( $this->slotsOutput
as $role => $output ) {
333 if ( $this->outputVariesOnRevisionMetaData(
339 unset( $this->slotsOutput
[$role] );
345 * @param RevisionRecord $revision
347 private function setRevisionInternal( RevisionRecord
$revision ) {
348 $this->revision
= $revision;
350 // Force the parser to use $this->revision to resolve magic words like {{REVISIONUSER}}
351 // if the revision is either known to be complete, or it doesn't have a revision ID set.
352 // If it's incomplete and we have a revision ID, the parser can do better by loading
353 // the revision from the database if needed to handle a magic word.
355 // The following considerations inform the logic described above:
357 // 1) If we have a saved revision already loaded, we want the parser to use it, instead of
360 // 2) If the revision is a fake that wraps some kind of synthetic content, such as an
361 // error message from Article, it should be used directly and things like {{REVISIONUSER}}
362 // should not expected to work, since there may not even be an actual revision to
365 // 3) If the revision is a fake constructed around a Title, a Content object, and
366 // a revision ID, to provide backwards compatibility to code that has access to those
367 // but not to a complete RevisionRecord for rendering, then we want the Parser to
368 // load the actual revision from the database when it encounters a magic word like
369 // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case.
371 // 4) Previewing an edit to a template should use the submitted unsaved
372 // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278).
373 // That revision would be complete except for the ID field.
375 // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is
376 // incomplete due to not yet having content set. However, since it doesn't have a revision
377 // ID either, the below code would still force it to be used, allowing
378 // {{subst::REVISIONUSER}} to function as expected.
380 if ( $this->revision
->isReadyForInsertion() ||
!$this->revision
->getId() ) {
381 $title = $this->title
;
382 $oldCallback = $this->options
->getCurrentRevisionCallback();
383 $this->options
->setCurrentRevisionCallback(
384 function ( Title
$parserTitle, $parser = false ) use ( $title, $oldCallback ) {
385 if ( $title->equals( $parserTitle ) ) {
386 $legacyRevision = new Revision( $this->revision
);
387 return $legacyRevision;
389 return call_user_func( $oldCallback, $parserTitle, $parser );
397 * @param ParserOutput $out
398 * @param int|bool $actualPageId The actual page id, to check the used speculative page ID
399 * against; false, to not purge on vary-page-id; true, to purge on vary-page-id
401 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
402 * against,; false, to not purge on vary-revision-id; true, to purge on
403 * vary-revision-id unconditionally.
404 * @param string|bool $actualRevTimestamp The actual rev timestamp, to check against the
405 * parser output revision timestamp; false, to not purge on vary-revision-timestamp;
406 * true, to purge on vary-revision-timestamp unconditionally.
409 private function outputVariesOnRevisionMetaData(
415 $logger = $this->saveParseLogger
;
416 $varyMsg = __METHOD__
. ": cannot use prepared output for '{title}'";
417 $context = [ 'title' => $this->title
->getPrefixedText() ];
419 if ( $out->getFlag( 'vary-revision' ) ) {
420 // If {{PAGEID}} resolved to 0, then that word need to resolve to the actual page ID
421 $logger->info( "$varyMsg (vary-revision)", $context );
424 $out->getFlag( 'vary-revision-id' )
425 && $actualRevId !== false
426 && ( $actualRevId === true ||
$out->getSpeculativeRevIdUsed() !== $actualRevId )
428 $logger->info( "$varyMsg (vary-revision-id and wrong ID)", $context );
431 $out->getFlag( 'vary-revision-timestamp' )
432 && $actualRevTimestamp !== false
433 && ( $actualRevTimestamp === true ||
434 $out->getRevisionTimestampUsed() !== $actualRevTimestamp )
436 $logger->info( "$varyMsg (vary-revision-timestamp and wrong timestamp)", $context );
439 $out->getFlag( 'vary-page-id' )
440 && $actualPageId !== false
441 && ( $actualPageId === true ||
$out->getSpeculativePageIdUsed() !== $actualPageId )
443 $logger->info( "$varyMsg (vary-page-id and wrong ID)", $context );
445 } elseif ( $out->getFlag( 'vary-revision-exists' ) ) {
446 // If {{REVISIONID}} resolved to '', it now needs to resolve to '-'.
447 // Note that edit stashing always uses '-', which can be used for both
448 // edit filter checks and canonical parser cache.
449 $logger->info( "$varyMsg (vary-revision-exists)", $context );
452 $out->getFlag( 'vary-revision-sha1' ) &&
453 $out->getRevisionUsedSha1Base36() !== $this->revision
->getSha1()
455 // If a self-transclusion used the proposed page text, it must match the final
456 // page content after PST transformations and automatically merged edit conflicts
457 $logger->info( "$varyMsg (vary-revision-sha1 with wrong SHA-1)", $context );
461 // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
462 // set for a null-edit. The reason was that the original rendering in that case was
463 // targeting the user making the null-edit, not the user who made the original edit,
464 // causing {{REVISIONUSER}} to return the wrong name.
465 // This case is now expected to be handled by the code in RevisionRenderer that
466 // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
467 // with the old, existing revision.
468 $logger->debug( __METHOD__
. ": reusing prepared output for '{title}'", $context );