Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
85.29% |
87 / 102 |
|
28.57% |
2 / 7 |
CRAP | |
0.00% |
0 / 1 |
RevisionRenderer | |
85.29% |
87 / 102 |
|
28.57% |
2 / 7 |
30.49 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRenderedRevision | |
90.00% |
36 / 40 |
|
0.00% |
0 / 1 |
11.12 | |||
getSpeculativeRevId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getSpeculativePageId | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
combineSlotOutput | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
9 | |||
splitSlotOutput | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
5.26 |
1 | <?php |
2 | /** |
3 | * This file is part of MediaWiki. |
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 | */ |
22 | |
23 | namespace MediaWiki\Revision; |
24 | |
25 | use InvalidArgumentException; |
26 | use MediaWiki\Content\Renderer\ContentRenderer; |
27 | use MediaWiki\Html\Html; |
28 | use MediaWiki\Parser\ParserOptions; |
29 | use MediaWiki\Parser\ParserOutput; |
30 | use MediaWiki\Permissions\Authority; |
31 | use Psr\Log\LoggerInterface; |
32 | use Psr\Log\NullLogger; |
33 | use Wikimedia\Rdbms\ILoadBalancer; |
34 | |
35 | /** |
36 | * The RevisionRenderer service provides access to rendered output for revisions. |
37 | * It does so by acting as a factory for RenderedRevision instances, which in turn |
38 | * provide lazy access to ParserOutput objects. |
39 | * |
40 | * One key responsibility of RevisionRenderer is implementing the layout used to combine |
41 | * the output of multiple slots. |
42 | * |
43 | * @since 1.32 |
44 | */ |
45 | class RevisionRenderer { |
46 | |
47 | /** @var LoggerInterface */ |
48 | private $saveParseLogger; |
49 | |
50 | /** @var ILoadBalancer */ |
51 | private $loadBalancer; |
52 | |
53 | /** @var SlotRoleRegistry */ |
54 | private $roleRegistry; |
55 | |
56 | /** @var ContentRenderer */ |
57 | private $contentRenderer; |
58 | |
59 | /** @var string|false */ |
60 | private $dbDomain; |
61 | |
62 | /** |
63 | * @param ILoadBalancer $loadBalancer |
64 | * @param SlotRoleRegistry $roleRegistry |
65 | * @param ContentRenderer $contentRenderer |
66 | * @param string|false $dbDomain DB domain of the relevant wiki or false for the current one |
67 | */ |
68 | public function __construct( |
69 | ILoadBalancer $loadBalancer, |
70 | SlotRoleRegistry $roleRegistry, |
71 | ContentRenderer $contentRenderer, |
72 | $dbDomain = false |
73 | ) { |
74 | $this->loadBalancer = $loadBalancer; |
75 | $this->roleRegistry = $roleRegistry; |
76 | $this->contentRenderer = $contentRenderer; |
77 | $this->dbDomain = $dbDomain; |
78 | $this->saveParseLogger = new NullLogger(); |
79 | } |
80 | |
81 | public function setLogger( LoggerInterface $saveParseLogger ) { |
82 | $this->saveParseLogger = $saveParseLogger; |
83 | } |
84 | |
85 | // phpcs:disable Generic.Files.LineLength.TooLong |
86 | /** |
87 | * @param RevisionRecord $rev |
88 | * @param ParserOptions|null $options |
89 | * @param Authority|null $forPerformer User for privileged access. Default is unprivileged |
90 | * (public) access, unless the 'audience' hint is set to something else RevisionRecord::RAW. |
91 | * @param array{use-master?:bool,audience?:int,known-revision-output?:ParserOutput,causeAction?:?string,previous-output?:?ParserOutput} $hints |
92 | * Hints given as an associative array. Known keys: |
93 | * - 'use-master' Use primary DB when rendering for the parser cache during save. |
94 | * Default is to use a replica. |
95 | * - 'audience' the audience to use for content access. Default is |
96 | * RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER |
97 | * if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks. |
98 | * - 'known-revision-output' a combined ParserOutput for the revision, perhaps from |
99 | * some cache. the caller is responsible for ensuring that the ParserOutput indeed |
100 | * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, |
101 | * for the time until caches have been changed to store RenderedRevision states instead |
102 | * of ParserOutput objects. |
103 | * - 'previous-output' A previously-rendered ParserOutput for this page. This |
104 | * can be used by Parsoid for selective updates. |
105 | * - 'causeAction' the reason for rendering. This should be informative, for used for |
106 | * logging and debugging. |
107 | * |
108 | * @return RenderedRevision|null The rendered revision, or null if the audience checks fails. |
109 | * @throws BadRevisionException |
110 | * @throws RevisionAccessException |
111 | */ |
112 | // phpcs:enable Generic.Files.LineLength.TooLong |
113 | public function getRenderedRevision( |
114 | RevisionRecord $rev, |
115 | ?ParserOptions $options = null, |
116 | ?Authority $forPerformer = null, |
117 | array $hints = [] |
118 | ) { |
119 | if ( $rev->getWikiId() !== $this->dbDomain ) { |
120 | throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() ); |
121 | } |
122 | |
123 | $audience = $hints['audience'] |
124 | ?? ( $forPerformer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC ); |
125 | |
126 | if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forPerformer ) ) { |
127 | // Returning null here is awkward, but consistent with the signature of |
128 | // RevisionRecord::getContent(). |
129 | return null; |
130 | } |
131 | |
132 | if ( !$options ) { |
133 | $options = $forPerformer ? |
134 | ParserOptions::newFromUser( $forPerformer->getUser() ) : |
135 | ParserOptions::newFromAnon(); |
136 | } |
137 | |
138 | if ( isset( $hints['causeAction'] ) ) { |
139 | $options->setRenderReason( $hints['causeAction'] ); |
140 | } |
141 | |
142 | $usePrimary = $hints['use-master'] ?? false; |
143 | |
144 | $dbIndex = $usePrimary |
145 | ? DB_PRIMARY // use latest values |
146 | : DB_REPLICA; // T154554 |
147 | |
148 | $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) { |
149 | return $this->getSpeculativeRevId( $dbIndex ); |
150 | } ); |
151 | $options->setSpeculativePageIdCallback( function () use ( $dbIndex ) { |
152 | return $this->getSpeculativePageId( $dbIndex ); |
153 | } ); |
154 | |
155 | if ( !$rev->getId() && $rev->getTimestamp() ) { |
156 | // This is an unsaved revision with an already determined timestamp. |
157 | // Make the "current" time used during parsing match that of the revision. |
158 | // Any REVISION* parser variables will match up if the revision is saved. |
159 | $options->setTimestamp( $rev->getTimestamp() ); |
160 | } |
161 | |
162 | $previousOutput = $hints['previous-output'] ?? null; |
163 | $renderedRevision = new RenderedRevision( |
164 | $rev, |
165 | $options, |
166 | $this->contentRenderer, |
167 | function ( RenderedRevision $rrev, array $hints ) use ( $options, $previousOutput ) { |
168 | $h = [ 'previous-output' => $previousOutput ] + $hints; |
169 | return $this->combineSlotOutput( $rrev, $options, $h ); |
170 | }, |
171 | $audience, |
172 | $forPerformer |
173 | ); |
174 | |
175 | $renderedRevision->setSaveParseLogger( $this->saveParseLogger ); |
176 | |
177 | if ( isset( $hints['known-revision-output'] ) ) { |
178 | $renderedRevision->setRevisionParserOutput( $hints['known-revision-output'] ); |
179 | } |
180 | |
181 | return $renderedRevision; |
182 | } |
183 | |
184 | private function getSpeculativeRevId( $dbIndex ) { |
185 | // Use a separate primary DB connection in order to see the latest data, by avoiding |
186 | // stale data from REPEATABLE-READ snapshots. |
187 | $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; |
188 | |
189 | $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags ); |
190 | |
191 | return 1 + (int)$db->newSelectQueryBuilder() |
192 | ->select( 'MAX(rev_id)' ) |
193 | ->from( 'revision' ) |
194 | ->caller( __METHOD__ )->fetchField(); |
195 | } |
196 | |
197 | private function getSpeculativePageId( $dbIndex ) { |
198 | // Use a separate primary DB connection in order to see the latest data, by avoiding |
199 | // stale data from REPEATABLE-READ snapshots. |
200 | $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; |
201 | |
202 | $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags ); |
203 | |
204 | return 1 + (int)$db->newSelectQueryBuilder() |
205 | ->select( 'MAX(page_id)' ) |
206 | ->from( 'page' ) |
207 | ->caller( __METHOD__ )->fetchField(); |
208 | } |
209 | |
210 | /** |
211 | * This implements the layout for combining the output of multiple slots. |
212 | * |
213 | * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout. |
214 | * |
215 | * @param RenderedRevision $rrev |
216 | * @param ParserOptions $options |
217 | * @param array $hints see RenderedRevision::getRevisionParserOutput() |
218 | * |
219 | * @return ParserOutput |
220 | * @throws SuppressedDataException |
221 | * @throws BadRevisionException |
222 | * @throws RevisionAccessException |
223 | */ |
224 | private function combineSlotOutput( RenderedRevision $rrev, ParserOptions $options, array $hints = [] ) { |
225 | $revision = $rrev->getRevision(); |
226 | $slots = $revision->getSlots()->getSlots(); |
227 | |
228 | $withHtml = $hints['generate-html'] ?? true; |
229 | $previousOutputs = $this->splitSlotOutput( $rrev, $options, $hints['previous-output'] ?? null ); |
230 | |
231 | // short circuit if there is only the main slot |
232 | // T351026 hack: if use-parsoid is set, only return main slot output for now |
233 | // T351113 will remove this hack. |
234 | if ( array_keys( $slots ) === [ SlotRecord::MAIN ] || $options->getUseParsoid() ) { |
235 | $h = [ 'previous-output' => $previousOutputs[SlotRecord::MAIN] ] + $hints; |
236 | return $rrev->getSlotParserOutput( SlotRecord::MAIN, $h ); |
237 | } |
238 | |
239 | // move main slot to front |
240 | if ( isset( $slots[SlotRecord::MAIN] ) ) { |
241 | $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots; |
242 | } |
243 | |
244 | $combinedOutput = new ParserOutput( null ); |
245 | $slotOutput = []; |
246 | |
247 | $options = $rrev->getOptions(); |
248 | $options->registerWatcher( [ $combinedOutput, 'recordOption' ] ); |
249 | |
250 | foreach ( $slots as $role => $slot ) { |
251 | $h = [ 'previous-output' => $previousOutputs[$role] ] + $hints; |
252 | $out = $rrev->getSlotParserOutput( $role, $h ); |
253 | $slotOutput[$role] = $out; |
254 | |
255 | // XXX: should the SlotRoleHandler be able to intervene here? |
256 | $combinedOutput->mergeInternalMetaDataFrom( $out ); |
257 | $combinedOutput->mergeTrackingMetaDataFrom( $out ); |
258 | } |
259 | |
260 | if ( $withHtml ) { |
261 | $html = ''; |
262 | $first = true; |
263 | /** @var ParserOutput $out */ |
264 | foreach ( $slotOutput as $role => $out ) { |
265 | $roleHandler = $this->roleRegistry->getRoleHandler( $role ); |
266 | |
267 | // TODO: put more fancy layout logic here, see T200915. |
268 | $layout = $roleHandler->getOutputLayoutHints(); |
269 | $display = $layout['display'] ?? 'section'; |
270 | |
271 | if ( $display === 'none' ) { |
272 | continue; |
273 | } |
274 | |
275 | if ( $first ) { |
276 | // skip header for the first slot |
277 | $first = false; |
278 | } else { |
279 | // NOTE: this placeholder is hydrated by ParserOutput::getText(). |
280 | $headText = Html::element( 'mw:slotheader', [], $role ); |
281 | $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText ); |
282 | } |
283 | |
284 | // XXX: do we want to put a wrapper div around the output? |
285 | // Do we want to let $roleHandler do that? |
286 | $html .= $out->getRawText(); |
287 | $combinedOutput->mergeHtmlMetaDataFrom( $out ); |
288 | } |
289 | |
290 | $combinedOutput->setRawText( $html ); |
291 | } |
292 | |
293 | $options->registerWatcher( null ); |
294 | return $combinedOutput; |
295 | } |
296 | |
297 | /** |
298 | * This reverses ::combineSlotOutput() in order to enable selective |
299 | * update of individual slots. |
300 | * |
301 | * @todo Currently this doesn't do much other than disable selective |
302 | * update if there is more than one slot. But in the case where |
303 | * slot combination is reversible, this should reverse it and attempt |
304 | * to reconstruct the original split ParserOutputs from the merged |
305 | * ParserOutput. |
306 | * |
307 | * @param RenderedRevision $rrev |
308 | * @param ParserOptions $options |
309 | * @param ?ParserOutput $previousOutput A combined ParserOutput for a |
310 | * previous parse, or null if none available. |
311 | * @return array<string,?ParserOutput> A mapping from role name to a |
312 | * previous ParserOutput for that slot in the previous parse |
313 | */ |
314 | private function splitSlotOutput( RenderedRevision $rrev, ParserOptions $options, ?ParserOutput $previousOutput ) { |
315 | // If there is no previous parse, then there is nothing to split. |
316 | $revision = $rrev->getRevision(); |
317 | $revslots = $revision->getSlots(); |
318 | if ( $previousOutput === null ) { |
319 | return array_fill_keys( $revslots->getSlotRoles(), null ); |
320 | } |
321 | |
322 | // short circuit if there is only the main slot |
323 | // T351026 hack: if use-parsoid is set, only return main slot output for now |
324 | // T351113 will remove this hack. |
325 | if ( $revslots->getSlotRoles() === [ SlotRecord::MAIN ] || $options->getUseParsoid() ) { |
326 | return [ SlotRecord::MAIN => $previousOutput ]; |
327 | } |
328 | |
329 | // @todo Currently slot combination is not reversible |
330 | return array_fill_keys( $revslots->getSlotRoles(), null ); |
331 | } |
332 | } |