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
;
27 use MediaWiki\Storage\RevisionRecord
;
28 use MediaWiki\Storage\SuppressedDataException
;
31 use Psr\Log\LoggerInterface
;
32 use Psr\Log\NullLogger
;
36 use Wikimedia\Assert\Assert
;
39 * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
40 * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
45 class RenderedRevision
{
52 /** @var RevisionRecord */
61 * @var int Audience to check when accessing content.
63 private $audience = RevisionRecord
::FOR_PUBLIC
;
66 * @var User|null The user to use for audience checks during content access.
68 private $forUser = null;
71 * @var ParserOutput|null The combined ParserOutput for the revision,
72 * initialized lazily by getRevisionParserOutput().
74 private $revisionOutput = null;
77 * @var ParserOutput[] The ParserOutput for each slot,
78 * initialized lazily by getSlotParserOutput().
80 private $slotsOutput = [];
83 * @var callable Callback for combining slot output into revision output.
84 * Signature: function ( RenderedRevision $this ): ParserOutput.
86 private $combineOutput;
89 * @var LoggerInterface For profiling ParserOutput re-use.
91 private $saveParseLogger;
94 * @note Application logic should not instantiate RenderedRevision instances directly,
95 * but should use a RevisionRenderer instead.
98 * @param RevisionRecord $revision
99 * @param ParserOptions $options
100 * @param callable $combineOutput Callback for combining slot output into revision output.
101 * Signature: function ( RenderedRevision $this ): ParserOutput.
102 * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
103 * @param User|null $forUser Required if $audience is FOR_THIS_USER.
105 public function __construct(
107 RevisionRecord
$revision,
108 ParserOptions
$options,
109 callable
$combineOutput,
110 $audience = RevisionRecord
::FOR_PUBLIC
,
113 $this->title
= $title;
114 $this->options
= $options;
116 $this->setRevisionInternal( $revision );
118 $this->combineOutput
= $combineOutput;
119 $this->saveParseLogger
= new NullLogger();
121 if ( $audience === RevisionRecord
::FOR_THIS_USER
&& !$forUser ) {
122 throw new InvalidArgumentException(
123 'User must be specified when setting audience to FOR_THIS_USER'
127 $this->audience
= $audience;
128 $this->forUser
= $forUser;
132 * @param LoggerInterface $saveParseLogger
134 public function setSaveParseLogger( LoggerInterface
$saveParseLogger ) {
135 $this->saveParseLogger
= $saveParseLogger;
139 * @return bool Whether the revision's content has been hidden from unprivileged users.
141 public function isContentDeleted() {
142 return $this->revision
->isDeleted( RevisionRecord
::DELETED_TEXT
);
146 * @return RevisionRecord
148 public function getRevision() {
149 return $this->revision
;
153 * @return ParserOptions
155 public function getOptions() {
156 return $this->options
;
160 * @param array $hints Hints given as an associative array. Known keys:
161 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
162 * to just meta-data). Default is to generate HTML.
164 * @return ParserOutput
166 public function getRevisionParserOutput( array $hints = [] ) {
167 $withHtml = $hints['generate-html'] ??
true;
169 if ( !$this->revisionOutput
170 ||
( $withHtml && !$this->revisionOutput
->hasText() )
172 $output = call_user_func( $this->combineOutput
, $this, $hints );
174 Assert
::postcondition(
175 $output instanceof ParserOutput
,
176 'Callback did not return a ParserOutput object!'
179 $this->revisionOutput
= $output;
182 return $this->revisionOutput
;
186 * @param string $role
187 * @param array $hints Hints given as an associative array. Known keys:
188 * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
189 * to just meta-data). Default is to generate HTML.
191 * @throws SuppressedDataException if the content is not accessible for the audience
192 * specified in the constructor.
193 * @return ParserOutput
195 public function getSlotParserOutput( $role, array $hints = [] ) {
196 $withHtml = $hints['generate-html'] ??
true;
198 if ( !isset( $this->slotsOutput
[ $role ] )
199 ||
( $withHtml && !$this->slotsOutput
[ $role ]->hasText() )
201 $content = $this->revision
->getContent( $role, $this->audience
, $this->forUser
);
204 throw new SuppressedDataException(
205 'Access to the content has been suppressed for this audience'
208 $output = $content->getParserOutput(
210 $this->revision
->getId(),
215 if ( $withHtml && !$output->hasText() ) {
216 throw new LogicException(
217 'HTML generation was requested, but '
218 . get_class( $content )
219 . '::getParserOutput() returns a ParserOutput with no text set.'
223 // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
224 $this->options
->registerWatcher( null );
227 $this->slotsOutput
[ $role ] = $output;
230 return $this->slotsOutput
[$role];
234 * Updates the RevisionRecord after the revision has been saved. This can be used to discard
235 * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
238 * @note There should be no need to call this for null-edits.
240 * @param RevisionRecord $rev
242 public function updateRevision( RevisionRecord
$rev ) {
243 if ( $rev->getId() === $this->revision
->getId() ) {
247 if ( $this->revision
->getId() ) {
248 throw new LogicException( 'RenderedRevision already has a revision with ID '
249 . $this->revision
->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
252 if ( !$this->revision
->getSlots()->hasSameContent( $rev->getSlots() ) ) {
253 throw new LogicException( 'Cannot update to a revision with different content!' );
256 $this->setRevisionInternal( $rev );
258 $this->pruneRevisionSensitiveOutput( $this->revision
->getId() );
262 * Prune any output that depends on the revision ID.
264 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
265 * against, or false to not purge on vary-revision-id, or true to purge on
266 * vary-revision-id unconditionally.
268 private function pruneRevisionSensitiveOutput( $actualRevId ) {
269 if ( $this->revisionOutput
) {
270 if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput
, $actualRevId ) ) {
271 $this->revisionOutput
= null;
274 $this->saveParseLogger
->debug( __METHOD__
. ": no prepared revision output...\n" );
277 foreach ( $this->slotsOutput
as $role => $output ) {
278 if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
279 unset( $this->slotsOutput
[$role] );
285 * @param RevisionRecord $revision
287 private function setRevisionInternal( RevisionRecord
$revision ) {
288 $this->revision
= $revision;
290 // Make sure the parser uses the correct Revision object
291 $title = $this->title
;
292 $oldCallback = $this->options
->getCurrentRevisionCallback();
293 $this->options
->setCurrentRevisionCallback(
294 function ( Title
$parserTitle, $parser = false ) use ( $title, $oldCallback ) {
295 if ( $parserTitle->equals( $title ) ) {
296 $legacyRevision = new Revision( $this->revision
);
297 return $legacyRevision;
299 return call_user_func( $oldCallback, $parserTitle, $parser );
306 * @param ParserOutput $out
307 * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
308 * against, or false to not purge on vary-revision-id, or true to purge on
309 * vary-revision-id unconditionally.
312 private function outputVariesOnRevisionMetaData( ParserOutput
$out, $actualRevId ) {
313 $method = __METHOD__
;
315 if ( $out->getFlag( 'vary-revision' ) ) {
316 // XXX: Would be just keep the output if the speculative revision ID was correct,
317 // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
318 // For that specific case, it would perhaps nice to have a vary-page flag.
319 $this->saveParseLogger
->info(
320 "$method: Prepared output has vary-revision...\n"
323 } elseif ( $out->getFlag( 'vary-revision-id' )
324 && $actualRevId !== false
325 && ( $actualRevId === true ||
$out->getSpeculativeRevIdUsed() !== $actualRevId )
327 $this->saveParseLogger
->info(
328 "$method: Prepared output has vary-revision-id with wrong ID...\n"
332 // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
333 // set for a null-edit. The reason was that the original rendering in that case was
334 // targeting the user making the null-edit, not the user who made the original edit,
335 // causing {{REVISIONUSER}} to return the wrong name.
336 // This case is now expected to be handled by the code in RevisionRenderer that
337 // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
338 // with the old, existing revision.
340 wfDebug( "$method: Keeping prepared output...\n" );