title = $title; $this->options = $options; $this->setRevisionInternal( $revision ); $this->combineOutput = $combineOutput; $this->saveParseLogger = new NullLogger(); if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) { throw new InvalidArgumentException( 'User must be specified when setting audience to FOR_THIS_USER' ); } $this->audience = $audience; $this->forUser = $forUser; } /** * @param LoggerInterface $saveParseLogger */ public function setSaveParseLogger( LoggerInterface $saveParseLogger ) { $this->saveParseLogger = $saveParseLogger; } /** * @return bool Whether the revision's content has been hidden from unprivileged users. */ public function isContentDeleted() { return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); } /** * @return RevisionRecord */ public function getRevision() { return $this->revision; } /** * @return ParserOptions */ public function getOptions() { return $this->options; } /** * Sets a ParserOutput to be returned by getRevisionParserOutput(). * * @note For internal use by RevisionRenderer only! This method may be modified * or removed without notice per the deprecation policy. * * @internal * * @param ParserOutput $output */ public function setRevisionParserOutput( ParserOutput $output ) { $this->revisionOutput = $output; // If there is only one slot, we assume that the combined output is identical // with the main slot's output. This is intended to prevent a redundant re-parse of // the content in case getSlotParserOutput( SlotRecord::MAIN ) is called, for instance // from ContentHandler::getSecondaryDataUpdates. if ( $this->revision->getSlotRoles() === [ SlotRecord::MAIN ] ) { $this->slotsOutput[ SlotRecord::MAIN ] = $output; } } /** * @param array $hints Hints given as an associative array. Known keys: * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed * to just meta-data). Default is to generate HTML. * * @return ParserOutput */ public function getRevisionParserOutput( array $hints = [] ) { $withHtml = $hints['generate-html'] ?? true; if ( !$this->revisionOutput || ( $withHtml && !$this->revisionOutput->hasText() ) ) { $output = call_user_func( $this->combineOutput, $this, $hints ); Assert::postcondition( $output instanceof ParserOutput, 'Callback did not return a ParserOutput object!' ); $this->revisionOutput = $output; } return $this->revisionOutput; } /** * @param string $role * @param array $hints Hints given as an associative array. Known keys: * - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed * to just meta-data). Default is to generate HTML. * * @throws SuppressedDataException if the content is not accessible for the audience * specified in the constructor. * @return ParserOutput */ public function getSlotParserOutput( $role, array $hints = [] ) { $withHtml = $hints['generate-html'] ?? true; if ( !isset( $this->slotsOutput[ $role ] ) || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() ) ) { $content = $this->revision->getContent( $role, $this->audience, $this->forUser ); if ( !$content ) { throw new SuppressedDataException( 'Access to the content has been suppressed for this audience' ); } else { $output = $this->getSlotParserOutputUncached( $content, $withHtml ); if ( $withHtml && !$output->hasText() ) { throw new LogicException( 'HTML generation was requested, but ' . get_class( $content ) . '::getParserOutput() returns a ParserOutput with no text set.' ); } // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput. $this->options->registerWatcher( null ); } $this->slotsOutput[ $role ] = $output; } return $this->slotsOutput[$role]; } /** * @note This method exist to make duplicate parses easier to see during profiling * @param Content $content * @param bool $withHtml * @return ParserOutput */ private function getSlotParserOutputUncached( Content $content, $withHtml ) { return $content->getParserOutput( $this->title, $this->revision->getId(), $this->options, $withHtml ); } /** * Updates the RevisionRecord after the revision has been saved. This can be used to discard * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}} * are re-evaluated. * * @note There should be no need to call this for null-edits. * * @param RevisionRecord $rev */ public function updateRevision( RevisionRecord $rev ) { if ( $rev->getId() === $this->revision->getId() ) { return; } if ( $this->revision->getId() ) { throw new LogicException( 'RenderedRevision already has a revision with ID ' . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() ); } if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) { throw new LogicException( 'Cannot update to a revision with different content!' ); } $this->setRevisionInternal( $rev ); $this->pruneRevisionSensitiveOutput( $this->revision->getId() ); } /** * Prune any output that depends on the revision ID. * * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID * against, or false to not purge on vary-revision-id, or true to purge on * vary-revision-id unconditionally. */ private function pruneRevisionSensitiveOutput( $actualRevId ) { if ( $this->revisionOutput ) { if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) { $this->revisionOutput = null; } } else { $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" ); } foreach ( $this->slotsOutput as $role => $output ) { if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) { unset( $this->slotsOutput[$role] ); } } } /** * @param RevisionRecord $revision */ private function setRevisionInternal( RevisionRecord $revision ) { $this->revision = $revision; // Force the parser to use $this->revision to resolve magic words like {{REVISIONUSER}} // if the revision is either known to be complete, or it doesn't have a revision ID set. // If it's incomplete and we have a revision ID, the parser can do better by loading // the revision from the database if needed to handle a magic word. // // The following considerations inform the logic described above: // // 1) If we have a saved revision already loaded, we want the parser to use it, instead of // loading it again. // // 2) If the revision is a fake that wraps some kind of synthetic content, such as an // error message from Article, it should be used directly and things like {{REVISIONUSER}} // should not expected to work, since there may not even be an actual revision to // refer to. // // 3) If the revision is a fake constructed around a Title, a Content object, and // a revision ID, to provide backwards compatibility to code that has access to those // but not to a complete RevisionRecord for rendering, then we want the Parser to // load the actual revision from the database when it encounters a magic word like // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case. // // 4) Previewing an edit to a template should use the submitted unsaved // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278). // That revision would be complete except for the ID field. // // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is // incomplete due to not yet having content set. However, since it doesn't have a revision // ID either, the below code would still force it to be used, allowing // {{subst::REVISIONUSER}} to function as expected. if ( $this->revision->isReadyForInsertion() || !$this->revision->getId() ) { $title = $this->title; $oldCallback = $this->options->getCurrentRevisionCallback(); $this->options->setCurrentRevisionCallback( function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) { if ( $title->equals( $parserTitle ) ) { $legacyRevision = new Revision( $this->revision ); return $legacyRevision; } else { return call_user_func( $oldCallback, $parserTitle, $parser ); } } ); } } /** * @param ParserOutput $out * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID * against, or false to not purge on vary-revision-id, or true to purge on * vary-revision-id unconditionally. * @return bool */ private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) { $method = __METHOD__; if ( $out->getFlag( 'vary-revision' ) ) { // XXX: Would be just keep the output if the speculative revision ID was correct, // but that can go wrong for some edge cases, like {{PAGEID}} during page creation. // For that specific case, it would perhaps nice to have a vary-page flag. $this->saveParseLogger->info( "$method: Prepared output has vary-revision...\n" ); return true; } elseif ( $out->getFlag( 'vary-revision-id' ) && $actualRevId !== false && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId ) ) { $this->saveParseLogger->info( "$method: Prepared output has vary-revision-id with wrong ID...\n" ); return true; } else { // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was // set for a null-edit. The reason was that the original rendering in that case was // targeting the user making the null-edit, not the user who made the original edit, // causing {{REVISIONUSER}} to return the wrong name. // This case is now expected to be handled by the code in RevisionRenderer that // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called // with the old, existing revision. wfDebug( "$method: Keeping prepared output...\n" ); return false; } } }