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