Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.67% |
78 / 90 |
|
33.33% |
2 / 6 |
CRAP | |
0.00% |
0 / 1 |
RevisionRenderer | |
86.67% |
78 / 90 |
|
33.33% |
2 / 6 |
25.37 | |
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 | |
89.47% |
34 / 38 |
|
0.00% |
0 / 1 |
11.14 | |||
getSpeculativeRevId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getSpeculativePageId | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
combineSlotOutput | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
9 |
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\ParserOutput; |
29 | use MediaWiki\Permissions\Authority; |
30 | use ParserOptions; |
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 | /** |
89 | * @param RevisionRecord $rev |
90 | * @param ParserOptions|null $options |
91 | * @param Authority|null $forPerformer User for privileged access. Default is unprivileged |
92 | * (public) access, unless the 'audience' hint is set to something else RevisionRecord::RAW. |
93 | * @param array $hints Hints given as an associative array. Known keys: |
94 | * - 'use-master' Use primary DB when rendering for the parser cache during save. |
95 | * Default is to use a replica. |
96 | * - 'audience' the audience to use for content access. Default is |
97 | * RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER |
98 | * if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks. |
99 | * - 'known-revision-output' a combined ParserOutput for the revision, perhaps from |
100 | * some cache. the caller is responsible for ensuring that the ParserOutput indeed |
101 | * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, |
102 | * for the time until caches have been changed to store RenderedRevision states instead |
103 | * of ParserOutput objects. |
104 | * - 'causeAction' the reason for rendering. This should be informative, for used for |
105 | * logging and debugging. |
106 | * @phan-param array{use-master?:bool,audience?:int,known-revision-output?:ParserOutput} $hints |
107 | * |
108 | * @return RenderedRevision|null The rendered revision, or null if the audience checks fails. |
109 | */ |
110 | public function getRenderedRevision( |
111 | RevisionRecord $rev, |
112 | ParserOptions $options = null, |
113 | Authority $forPerformer = null, |
114 | array $hints = [] |
115 | ) { |
116 | if ( $rev->getWikiId() !== $this->dbDomain ) { |
117 | throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() ); |
118 | } |
119 | |
120 | $audience = $hints['audience'] |
121 | ?? ( $forPerformer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC ); |
122 | |
123 | if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forPerformer ) ) { |
124 | // Returning null here is awkward, but consistent with the signature of |
125 | // RevisionRecord::getContent(). |
126 | return null; |
127 | } |
128 | |
129 | if ( !$options ) { |
130 | $options = $forPerformer ? |
131 | ParserOptions::newFromUser( $forPerformer->getUser() ) : |
132 | ParserOptions::newFromAnon(); |
133 | } |
134 | |
135 | if ( isset( $hints['causeAction'] ) ) { |
136 | $options->setRenderReason( $hints['causeAction'] ); |
137 | } |
138 | |
139 | $usePrimary = $hints['use-master'] ?? false; |
140 | |
141 | $dbIndex = $usePrimary |
142 | ? DB_PRIMARY // use latest values |
143 | : DB_REPLICA; // T154554 |
144 | |
145 | $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) { |
146 | return $this->getSpeculativeRevId( $dbIndex ); |
147 | } ); |
148 | $options->setSpeculativePageIdCallback( function () use ( $dbIndex ) { |
149 | return $this->getSpeculativePageId( $dbIndex ); |
150 | } ); |
151 | |
152 | if ( !$rev->getId() && $rev->getTimestamp() ) { |
153 | // This is an unsaved revision with an already determined timestamp. |
154 | // Make the "current" time used during parsing match that of the revision. |
155 | // Any REVISION* parser variables will match up if the revision is saved. |
156 | $options->setTimestamp( $rev->getTimestamp() ); |
157 | } |
158 | |
159 | $renderedRevision = new RenderedRevision( |
160 | $rev, |
161 | $options, |
162 | $this->contentRenderer, |
163 | function ( RenderedRevision $rrev, array $hints ) use ( $options ) { |
164 | return $this->combineSlotOutput( $rrev, $options, $hints ); |
165 | }, |
166 | $audience, |
167 | $forPerformer |
168 | ); |
169 | |
170 | $renderedRevision->setSaveParseLogger( $this->saveParseLogger ); |
171 | |
172 | if ( isset( $hints['known-revision-output'] ) ) { |
173 | $renderedRevision->setRevisionParserOutput( $hints['known-revision-output'] ); |
174 | } |
175 | |
176 | return $renderedRevision; |
177 | } |
178 | |
179 | private function getSpeculativeRevId( $dbIndex ) { |
180 | // Use a separate primary DB connection in order to see the latest data, by avoiding |
181 | // stale data from REPEATABLE-READ snapshots. |
182 | $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; |
183 | |
184 | $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->dbDomain, $flags ); |
185 | |
186 | return 1 + (int)$db->newSelectQueryBuilder() |
187 | ->select( 'MAX(rev_id)' ) |
188 | ->from( 'revision' ) |
189 | ->caller( __METHOD__ )->fetchField(); |
190 | } |
191 | |
192 | private function getSpeculativePageId( $dbIndex ) { |
193 | // Use a separate primary DB connection in order to see the latest data, by avoiding |
194 | // stale data from REPEATABLE-READ snapshots. |
195 | $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; |
196 | |
197 | $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->dbDomain, $flags ); |
198 | |
199 | return 1 + (int)$db->newSelectQueryBuilder() |
200 | ->select( 'MAX(page_id)' ) |
201 | ->from( 'page' ) |
202 | ->caller( __METHOD__ )->fetchField(); |
203 | } |
204 | |
205 | /** |
206 | * This implements the layout for combining the output of multiple slots. |
207 | * |
208 | * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout. |
209 | * |
210 | * @param RenderedRevision $rrev |
211 | * @param ParserOptions $options |
212 | * @param array $hints see RenderedRevision::getRevisionParserOutput() |
213 | * |
214 | * @return ParserOutput |
215 | */ |
216 | private function combineSlotOutput( RenderedRevision $rrev, ParserOptions $options, array $hints = [] ) { |
217 | $revision = $rrev->getRevision(); |
218 | $slots = $revision->getSlots()->getSlots(); |
219 | |
220 | $withHtml = $hints['generate-html'] ?? true; |
221 | |
222 | // short circuit if there is only the main slot |
223 | // T351026 hack: if use-parsoid is set, only return main slot output for now |
224 | // T351113 will remove this hack. |
225 | if ( array_keys( $slots ) === [ SlotRecord::MAIN ] || $options->getUseParsoid() ) { |
226 | return $rrev->getSlotParserOutput( SlotRecord::MAIN, $hints ); |
227 | } |
228 | |
229 | // move main slot to front |
230 | if ( isset( $slots[SlotRecord::MAIN] ) ) { |
231 | $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots; |
232 | } |
233 | |
234 | $combinedOutput = new ParserOutput( null ); |
235 | $slotOutput = []; |
236 | |
237 | $options = $rrev->getOptions(); |
238 | $options->registerWatcher( [ $combinedOutput, 'recordOption' ] ); |
239 | |
240 | foreach ( $slots as $role => $slot ) { |
241 | $out = $rrev->getSlotParserOutput( $role, $hints ); |
242 | $slotOutput[$role] = $out; |
243 | |
244 | // XXX: should the SlotRoleHandler be able to intervene here? |
245 | $combinedOutput->mergeInternalMetaDataFrom( $out ); |
246 | $combinedOutput->mergeTrackingMetaDataFrom( $out ); |
247 | } |
248 | |
249 | if ( $withHtml ) { |
250 | $html = ''; |
251 | $first = true; |
252 | /** @var ParserOutput $out */ |
253 | foreach ( $slotOutput as $role => $out ) { |
254 | $roleHandler = $this->roleRegistry->getRoleHandler( $role ); |
255 | |
256 | // TODO: put more fancy layout logic here, see T200915. |
257 | $layout = $roleHandler->getOutputLayoutHints(); |
258 | $display = $layout['display'] ?? 'section'; |
259 | |
260 | if ( $display === 'none' ) { |
261 | continue; |
262 | } |
263 | |
264 | if ( $first ) { |
265 | // skip header for the first slot |
266 | $first = false; |
267 | } else { |
268 | // NOTE: this placeholder is hydrated by ParserOutput::getText(). |
269 | $headText = Html::element( 'mw:slotheader', [], $role ); |
270 | $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText ); |
271 | } |
272 | |
273 | // XXX: do we want to put a wrapper div around the output? |
274 | // Do we want to let $roleHandler do that? |
275 | $html .= $out->getRawText(); |
276 | $combinedOutput->mergeHtmlMetaDataFrom( $out ); |
277 | } |
278 | |
279 | $combinedOutput->setRawText( $html ); |
280 | } |
281 | |
282 | $options->registerWatcher( null ); |
283 | return $combinedOutput; |
284 | } |
285 | |
286 | } |