Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.40% |
521 / 603 |
|
67.86% |
38 / 56 |
CRAP | |
0.00% |
0 / 1 |
DerivedPageDataUpdater | |
86.40% |
521 / 603 |
|
67.86% |
38 / 56 |
296.59 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setCause | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setPerformer | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getCauseForTracing | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
doTransition | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
assertTransition | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
isReusableFor | |
91.67% |
22 / 24 |
|
0.00% |
0 / 1 |
24.33 | |||
setForceEmptyRevision | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
setArticleCountMethod | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getWikiPage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
pageExisted | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getParentRevision | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
getOldRevision | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
grabCurrentRevision | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
isContentPrepared | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isUpdatePrepared | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getPageId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isContentDeleted | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getRawSlot | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRawContent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
usePrimary | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isCountable | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
isRedirect | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
revisionIsRedirect | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
prepareContent | |
82.98% |
78 / 94 |
|
0.00% |
0 / 1 |
20.78 | |||
getRevision | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getRenderedRevision | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
assertHasPageState | |
20.00% |
1 / 5 |
|
0.00% |
0 / 1 |
4.05 | |||
assertPrepared | |
25.00% |
1 / 4 |
|
0.00% |
0 / 1 |
3.69 | |||
assertHasRevision | |
25.00% |
1 / 4 |
|
0.00% |
0 / 1 |
3.69 | |||
isCreation | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isChange | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
wasRedirect | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
getSlots | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionSlotsUpdate | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
getTouchedSlotRoles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getModifiedSlotRoles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRemovedSlotRoles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
prepareUpdate | |
71.56% |
78 / 109 |
|
0.00% |
0 / 1 |
48.35 | |||
getPreparedEdit | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
3.00 | |||
getSlotParserOutput | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getParserOutputForMetaData | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCanonicalParserOutput | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCanonicalParserOptions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSecondaryDataUpdates | |
95.35% |
41 / 43 |
|
0.00% |
0 / 1 |
8 | |||
shouldGenerateHTMLOnEdit | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
doUpdates | |
85.48% |
53 / 62 |
|
0.00% |
0 / 1 |
22.35 | |||
emitEvents | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
getPageRevisionUpdatedEvent | |
94.44% |
34 / 36 |
|
0.00% |
0 / 1 |
9.01 | |||
triggerParserCacheUpdate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
maybeAddRecreateChangeTag | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
doSecondaryDataUpdates | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
4 | |||
doParserCacheUpdate | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Storage; |
22 | |
23 | use InvalidArgumentException; |
24 | use LogicException; |
25 | use MediaWiki\ChangeTags\ChangeTags; |
26 | use MediaWiki\ChangeTags\ChangeTagsStore; |
27 | use MediaWiki\Config\ServiceOptions; |
28 | use MediaWiki\Content\Content; |
29 | use MediaWiki\Content\IContentHandlerFactory; |
30 | use MediaWiki\Content\Transform\ContentTransformer; |
31 | use MediaWiki\Deferred\DeferrableUpdate; |
32 | use MediaWiki\Deferred\DeferredUpdates; |
33 | use MediaWiki\Deferred\LinksUpdate\LinksUpdate; |
34 | use MediaWiki\Deferred\RefreshSecondaryDataUpdate; |
35 | use MediaWiki\Deferred\SiteStatsUpdate; |
36 | use MediaWiki\DomainEvent\DomainEventDispatcher; |
37 | use MediaWiki\Edit\PreparedEdit; |
38 | use MediaWiki\HookContainer\HookContainer; |
39 | use MediaWiki\HookContainer\HookRunner; |
40 | use MediaWiki\JobQueue\JobQueueGroup; |
41 | use MediaWiki\JobQueue\Jobs\ParsoidCachePrewarmJob; |
42 | use MediaWiki\Language\Language; |
43 | use MediaWiki\MainConfigNames; |
44 | use MediaWiki\Page\Event\PageRevisionUpdatedEvent; |
45 | use MediaWiki\Page\PageIdentity; |
46 | use MediaWiki\Page\ParserOutputAccess; |
47 | use MediaWiki\Page\ProperPageIdentity; |
48 | use MediaWiki\Page\WikiPage; |
49 | use MediaWiki\Page\WikiPageFactory; |
50 | use MediaWiki\Parser\ParserCache; |
51 | use MediaWiki\Parser\ParserOptions; |
52 | use MediaWiki\Parser\ParserOutput; |
53 | use MediaWiki\Revision\MutableRevisionRecord; |
54 | use MediaWiki\Revision\RenderedRevision; |
55 | use MediaWiki\Revision\RevisionRecord; |
56 | use MediaWiki\Revision\RevisionRenderer; |
57 | use MediaWiki\Revision\RevisionSlots; |
58 | use MediaWiki\Revision\RevisionStore; |
59 | use MediaWiki\Revision\SlotRecord; |
60 | use MediaWiki\Revision\SlotRoleRegistry; |
61 | use MediaWiki\Title\Title; |
62 | use MediaWiki\User\UserIdentity; |
63 | use MediaWiki\Utils\MWTimestamp; |
64 | use Psr\Log\LoggerAwareInterface; |
65 | use Psr\Log\LoggerInterface; |
66 | use Psr\Log\NullLogger; |
67 | use Wikimedia\Assert\Assert; |
68 | use Wikimedia\ObjectCache\WANObjectCache; |
69 | use Wikimedia\Rdbms\IDBAccessObject; |
70 | use Wikimedia\Rdbms\ILBFactory; |
71 | |
72 | /** |
73 | * A handle for managing updates for derived page data on edit, import, purge, etc. |
74 | * |
75 | * @note Avoid direct usage of DerivedPageDataUpdater. |
76 | * |
77 | * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly |
78 | * providing access to post-PST content and ParserOutput to callbacks during revision creation, |
79 | * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on |
80 | * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and |
81 | * Content::getSecondaryDataUpdates(). |
82 | * |
83 | * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance, |
84 | * and re-used by callback code over the course of an update operation. It's a stepping stone |
85 | * on the way to a more complete refactoring of WikiPage. |
86 | * |
87 | * When using a DerivedPageDataUpdater, the following life cycle must be observed: |
88 | * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required |
89 | * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates |
90 | * require prepareContent or prepareUpdate to have been called first, to initialize the |
91 | * DerivedPageDataUpdater. |
92 | * |
93 | * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases |
94 | * of PreparedEdit. |
95 | * |
96 | * @see docs/pageupdater.md for more information. |
97 | * |
98 | * @internal |
99 | * @since 1.32 |
100 | * @ingroup Page |
101 | */ |
102 | class DerivedPageDataUpdater implements LoggerAwareInterface, PreparedUpdate { |
103 | |
104 | /** |
105 | * @var UserIdentity|null |
106 | */ |
107 | private $user = null; |
108 | |
109 | /** |
110 | * @var WikiPage |
111 | */ |
112 | private $wikiPage; |
113 | |
114 | /** |
115 | * @var ParserCache |
116 | */ |
117 | private $parserCache; |
118 | |
119 | /** |
120 | * @var RevisionStore |
121 | */ |
122 | private $revisionStore; |
123 | |
124 | /** |
125 | * @var Language |
126 | */ |
127 | private $contLang; |
128 | |
129 | /** |
130 | * @var JobQueueGroup |
131 | */ |
132 | private $jobQueueGroup; |
133 | |
134 | /** |
135 | * @var ILBFactory |
136 | */ |
137 | private $loadbalancerFactory; |
138 | |
139 | /** |
140 | * @var HookRunner |
141 | */ |
142 | private $hookRunner; |
143 | |
144 | /** |
145 | * @var DomainEventDispatcher |
146 | */ |
147 | private $eventDispatcher; |
148 | |
149 | /** |
150 | * @var LoggerInterface |
151 | */ |
152 | private $logger; |
153 | |
154 | /** |
155 | * @var string see $wgArticleCountMethod |
156 | */ |
157 | private $articleCountMethod; |
158 | |
159 | /** |
160 | * Stores (most of) the $options parameter of prepareUpdate(). |
161 | * @see prepareUpdate() |
162 | * |
163 | * @var array |
164 | * @phpcs:ignore Generic.Files.LineLength |
165 | * @phan-var array{changed:bool,created:bool,moved:bool,cause:string,oldrevision:null|RevisionRecord,triggeringUser:null|UserIdentity,oldredirect:bool|null|string,oldcountable:bool|null|string,causeAction:null|string,causeAgent:null|string,editResult:null|EditResult} |
166 | */ |
167 | private $options = [ |
168 | 'changed' => true, |
169 | // newrev is true if prepareUpdate is handling the creation of a new revision, |
170 | // as opposed to a null edit or a forced update. |
171 | 'newrev' => false, |
172 | 'created' => false, |
173 | 'oldtitle' => null, |
174 | 'oldrevision' => null, |
175 | 'oldcountable' => null, |
176 | 'oldredirect' => null, |
177 | 'triggeringUser' => null, |
178 | // causeAction/causeAgent default to 'unknown' but that's handled where it's read, |
179 | // to make the life of prepareUpdate() callers easier. |
180 | 'causeAction' => null, |
181 | 'causeAgent' => null, |
182 | 'editResult' => null, |
183 | 'rcPatrolStatus' => 0, |
184 | 'tags' => [], |
185 | 'cause' => 'edit', |
186 | 'emitEvents' => true, |
187 | ] + PageRevisionUpdatedEvent::DEFAULT_FLAGS; |
188 | |
189 | /** |
190 | * The state of the relevant row in page table before the edit. |
191 | * This is determined by the first call to grabCurrentRevision, prepareContent, |
192 | * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage). |
193 | * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will |
194 | * attempt to emulate the state of the page table before the edit. |
195 | * |
196 | * Contains the following fields: |
197 | * - oldRevision (RevisionRecord|null): the revision that was current before the change |
198 | * associated with this update. Might not be set, use getParentRevision(). |
199 | * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change |
200 | * was about creating a new page); null if not known (that should not happen). |
201 | * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded, |
202 | * can be null; use wasRedirect() instead of direct access. |
203 | * - oldCountable (bool|null): whether the page was countable before the change (or null |
204 | * if we don't have that information) |
205 | * - oldRecord (ExistingPageRecord|null): the page record before the update (or null |
206 | * if the page didn't exist) |
207 | * |
208 | * @var array |
209 | */ |
210 | private $pageState = null; |
211 | |
212 | /** |
213 | * @var RevisionSlotsUpdate|null |
214 | */ |
215 | private $slotsUpdate = null; |
216 | |
217 | /** |
218 | * @var RevisionRecord|null |
219 | */ |
220 | private $parentRevision = null; |
221 | |
222 | /** |
223 | * @var RevisionRecord|null |
224 | */ |
225 | private $revision = null; |
226 | |
227 | /** |
228 | * @var RenderedRevision |
229 | */ |
230 | private $renderedRevision = null; |
231 | |
232 | /** @var ?PageRevisionUpdatedEvent */ |
233 | private $pageUpdatedEvent = null; |
234 | |
235 | /** |
236 | * @var RevisionRenderer |
237 | */ |
238 | private $revisionRenderer; |
239 | |
240 | /** @var SlotRoleRegistry */ |
241 | private $slotRoleRegistry; |
242 | |
243 | /** |
244 | * @var bool Whether null-edits create a revision. |
245 | */ |
246 | private $forceEmptyRevision = false; |
247 | |
248 | /** |
249 | * A stage identifier for managing the life cycle of this instance. |
250 | * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'. |
251 | * |
252 | * @see docs/pageupdater.md for documentation of the life cycle. |
253 | * |
254 | * @var string |
255 | */ |
256 | private $stage = 'new'; |
257 | |
258 | /** |
259 | * Transition table for managing the life cycle of DerivedPageDateUpdater instances. |
260 | * |
261 | * XXX: Overkill. This is a linear order, we could just count. Names are nice though, |
262 | * and constants are also overkill... |
263 | * |
264 | * @see docs/pageupdater.md for documentation of the life cycle. |
265 | */ |
266 | private const TRANSITIONS = [ |
267 | 'new' => [ |
268 | 'new' => true, |
269 | 'knows-current' => true, |
270 | 'has-content' => true, |
271 | 'has-revision' => true, |
272 | ], |
273 | 'knows-current' => [ |
274 | 'knows-current' => true, |
275 | 'has-content' => true, |
276 | 'has-revision' => true, |
277 | ], |
278 | 'has-content' => [ |
279 | 'has-content' => true, |
280 | 'has-revision' => true, |
281 | ], |
282 | 'has-revision' => [ |
283 | 'has-revision' => true, |
284 | 'done' => true, |
285 | ], |
286 | ]; |
287 | |
288 | /** @var IContentHandlerFactory */ |
289 | private $contentHandlerFactory; |
290 | |
291 | /** @var EditResultCache */ |
292 | private $editResultCache; |
293 | |
294 | /** @var ContentTransformer */ |
295 | private $contentTransformer; |
296 | |
297 | /** @var PageEditStash */ |
298 | private $pageEditStash; |
299 | |
300 | /** @var WANObjectCache */ |
301 | private $mainWANObjectCache; |
302 | |
303 | /** @var bool */ |
304 | private $warmParsoidParserCache; |
305 | |
306 | /** @var bool */ |
307 | private $useRcPatrol; |
308 | |
309 | private ChangeTagsStore $changeTagsStore; |
310 | |
311 | public function __construct( |
312 | ServiceOptions $options, |
313 | PageIdentity $page, |
314 | RevisionStore $revisionStore, |
315 | RevisionRenderer $revisionRenderer, |
316 | SlotRoleRegistry $slotRoleRegistry, |
317 | ParserCache $parserCache, |
318 | JobQueueGroup $jobQueueGroup, |
319 | Language $contLang, |
320 | ILBFactory $loadbalancerFactory, |
321 | IContentHandlerFactory $contentHandlerFactory, |
322 | HookContainer $hookContainer, |
323 | DomainEventDispatcher $eventDispatcher, |
324 | EditResultCache $editResultCache, |
325 | ContentTransformer $contentTransformer, |
326 | PageEditStash $pageEditStash, |
327 | WANObjectCache $mainWANObjectCache, |
328 | WikiPageFactory $wikiPageFactory, |
329 | ChangeTagsStore $changeTagsStore |
330 | ) { |
331 | // TODO: Remove this cast eventually |
332 | $this->wikiPage = $wikiPageFactory->newFromTitle( $page ); |
333 | |
334 | $this->parserCache = $parserCache; |
335 | $this->revisionStore = $revisionStore; |
336 | $this->revisionRenderer = $revisionRenderer; |
337 | $this->slotRoleRegistry = $slotRoleRegistry; |
338 | $this->jobQueueGroup = $jobQueueGroup; |
339 | $this->contLang = $contLang; |
340 | // XXX only needed for waiting for replicas to catch up; there should be a narrower |
341 | // interface for that. |
342 | $this->loadbalancerFactory = $loadbalancerFactory; |
343 | $this->contentHandlerFactory = $contentHandlerFactory; |
344 | $this->hookRunner = new HookRunner( $hookContainer ); |
345 | $this->eventDispatcher = $eventDispatcher; |
346 | $this->editResultCache = $editResultCache; |
347 | $this->contentTransformer = $contentTransformer; |
348 | $this->pageEditStash = $pageEditStash; |
349 | $this->mainWANObjectCache = $mainWANObjectCache; |
350 | $this->changeTagsStore = $changeTagsStore; |
351 | |
352 | $this->logger = new NullLogger(); |
353 | |
354 | $this->warmParsoidParserCache = $options |
355 | ->get( MainConfigNames::ParsoidCacheConfig )['WarmParsoidParserCache']; |
356 | $this->useRcPatrol = $options |
357 | ->get( MainConfigNames::UseRCPatrol ); |
358 | } |
359 | |
360 | public function setLogger( LoggerInterface $logger ) { |
361 | $this->logger = $logger; |
362 | } |
363 | |
364 | /** |
365 | * Set the cause of the update. Will be used for the PageRevisionUpdatedEvent |
366 | * and for tracing/logging in jobs, etc. |
367 | * |
368 | * @param string $cause See PageRevisionUpdatedEvent::CAUSE_XXX |
369 | * |
370 | * @return void |
371 | */ |
372 | public function setCause( string $cause ) { |
373 | // 'cause' is for use in PageRevisionUpdatedEvent, 'causeAction' is for |
374 | // use in tracing in updates, jobs, and RevisionRenderer. |
375 | // Note that PageRevisionUpdatedEvent uses causes like "edit" and "move", but |
376 | // the convention for causeAction is to use "page-edit", etc. |
377 | $this->options['cause'] = $cause; |
378 | $this->options['causeAction'] = 'page-' . $cause; |
379 | } |
380 | |
381 | /** |
382 | * Set the performer of the action. |
383 | * |
384 | * @return void |
385 | */ |
386 | public function setPerformer( UserIdentity $performer ) { |
387 | $this->options['triggeringUser'] = $performer; |
388 | $this->options['causeAgent'] = $performer->getName(); |
389 | } |
390 | |
391 | /** |
392 | * @return string[] [ $causeAction, $causeAgent ] |
393 | */ |
394 | private function getCauseForTracing(): array { |
395 | return [ |
396 | $this->options['causeAction'] ?? 'unknown', |
397 | $this->options['causeAgent'] |
398 | ?? ( $this->user ? $this->user->getName() : 'unknown' ), |
399 | ]; |
400 | } |
401 | |
402 | /** |
403 | * Transition function for managing the life cycle of this instances. |
404 | * |
405 | * @see docs/pageupdater.md for documentation of the life cycle. |
406 | * |
407 | * @param string $newStage |
408 | * @return string the previous stage |
409 | */ |
410 | private function doTransition( $newStage ) { |
411 | $this->assertTransition( $newStage ); |
412 | |
413 | $oldStage = $this->stage; |
414 | $this->stage = $newStage; |
415 | |
416 | return $oldStage; |
417 | } |
418 | |
419 | /** |
420 | * Asserts that a transition to the given stage is possible, without performing it. |
421 | * |
422 | * @see docs/pageupdater.md for documentation of the life cycle. |
423 | * |
424 | * @param string $newStage |
425 | */ |
426 | private function assertTransition( $newStage ) { |
427 | if ( empty( self::TRANSITIONS[$this->stage][$newStage] ) ) { |
428 | throw new LogicException( "Cannot transition from {$this->stage} to $newStage" ); |
429 | } |
430 | } |
431 | |
432 | /** |
433 | * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting |
434 | * the given revision. |
435 | * |
436 | * @param UserIdentity|null $user The user creating the revision in question |
437 | * @param RevisionRecord|null $revision New revision (after save, if already saved) |
438 | * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST) |
439 | * @param null|int $parentId Parent revision of the edit (use 0 for page creation) |
440 | * |
441 | * @return bool |
442 | */ |
443 | public function isReusableFor( |
444 | ?UserIdentity $user = null, |
445 | ?RevisionRecord $revision = null, |
446 | ?RevisionSlotsUpdate $slotsUpdate = null, |
447 | $parentId = null |
448 | ) { |
449 | if ( $revision |
450 | && $parentId |
451 | && $revision->getParentId() !== $parentId |
452 | ) { |
453 | throw new InvalidArgumentException( '$parentId should match the parent of $revision' ); |
454 | } |
455 | |
456 | // NOTE: For null revisions, $user may be different from $this->revision->getUser |
457 | // and also from $revision->getUser. |
458 | // But $user should always match $this->user. |
459 | if ( $user && $this->user && $user->getName() !== $this->user->getName() ) { |
460 | return false; |
461 | } |
462 | |
463 | if ( $revision && $this->revision && $this->revision->getId() |
464 | && $this->revision->getId() !== $revision->getId() |
465 | ) { |
466 | return false; |
467 | } |
468 | |
469 | if ( $this->pageState |
470 | && $revision |
471 | && $revision->getParentId() !== null |
472 | && $this->pageState['oldId'] !== $revision->getParentId() |
473 | ) { |
474 | return false; |
475 | } |
476 | |
477 | if ( $this->pageState |
478 | && $parentId !== null |
479 | && $this->pageState['oldId'] !== $parentId |
480 | ) { |
481 | return false; |
482 | } |
483 | |
484 | // NOTE: this check is the primary reason for having the $this->slotsUpdate field! |
485 | if ( $this->slotsUpdate |
486 | && $slotsUpdate |
487 | && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) |
488 | ) { |
489 | return false; |
490 | } |
491 | |
492 | if ( $revision |
493 | && $this->revision |
494 | && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() ) |
495 | ) { |
496 | return false; |
497 | } |
498 | |
499 | return true; |
500 | } |
501 | |
502 | /** |
503 | * Set whether null-edits should create a revision. Enabling this allows the creation of dummy |
504 | * revisions ("null revisions") to mark events such as renaming in the page history. |
505 | * |
506 | * Must not be called once prepareContent() or prepareUpdate() have been called. |
507 | * |
508 | * @since 1.38 |
509 | * @see PageUpdater setForceEmptyRevision |
510 | * |
511 | * @param bool $forceEmptyRevision |
512 | */ |
513 | public function setForceEmptyRevision( bool $forceEmptyRevision ) { |
514 | if ( $this->revision ) { |
515 | throw new LogicException( 'prepareContent() or prepareUpdate() was already called.' ); |
516 | } |
517 | |
518 | $this->forceEmptyRevision = $forceEmptyRevision; |
519 | } |
520 | |
521 | /** |
522 | * @param string $articleCountMethod "any" or "link". |
523 | * @see $wgArticleCountMethod |
524 | */ |
525 | public function setArticleCountMethod( $articleCountMethod ) { |
526 | $this->articleCountMethod = $articleCountMethod; |
527 | } |
528 | |
529 | /** |
530 | * @return Title |
531 | */ |
532 | private function getTitle() { |
533 | // NOTE: eventually, this won't use WikiPage any more |
534 | return $this->wikiPage->getTitle(); |
535 | } |
536 | |
537 | /** |
538 | * @return WikiPage |
539 | */ |
540 | private function getWikiPage() { |
541 | // NOTE: eventually, this won't use WikiPage any more |
542 | return $this->wikiPage; |
543 | } |
544 | |
545 | /** |
546 | * Returns the page being updated. |
547 | * @since 1.37 |
548 | * @return ProperPageIdentity (narrowed to ProperPageIdentity in 1.44) |
549 | */ |
550 | public function getPage(): ProperPageIdentity { |
551 | return $this->wikiPage; |
552 | } |
553 | |
554 | /** |
555 | * Determines whether the page being edited already existed. |
556 | * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()! |
557 | * |
558 | * @return bool |
559 | * @throws LogicException if called before grabCurrentRevision |
560 | */ |
561 | public function pageExisted() { |
562 | $this->assertHasPageState( __METHOD__ ); |
563 | |
564 | return $this->pageState['oldId'] > 0; |
565 | } |
566 | |
567 | /** |
568 | * Returns the parent revision of the new revision wrapped by this update. |
569 | * If the update is a null-edit, this will return the parent of the current (and new) revision. |
570 | * This will return null if the revision wrapped by this update created the page. |
571 | * Only defined after calling prepareContent() or prepareUpdate()! |
572 | * |
573 | * @return RevisionRecord|null the parent revision of the new revision, or null if |
574 | * the update created the page. |
575 | */ |
576 | private function getParentRevision() { |
577 | $this->assertPrepared( __METHOD__ ); |
578 | |
579 | if ( $this->parentRevision ) { |
580 | return $this->parentRevision; |
581 | } |
582 | |
583 | if ( !$this->pageState['oldId'] ) { |
584 | // If there was no current revision, there is no parent revision, |
585 | // since the page didn't exist. |
586 | return null; |
587 | } |
588 | |
589 | $oldId = $this->revision->getParentId(); |
590 | $flags = $this->usePrimary() ? IDBAccessObject::READ_LATEST : 0; |
591 | $this->parentRevision = $oldId |
592 | ? $this->revisionStore->getRevisionById( $oldId, $flags ) |
593 | : null; |
594 | |
595 | return $this->parentRevision; |
596 | } |
597 | |
598 | /** |
599 | * Returns the revision that was the page's current revision when grabCurrentRevision() |
600 | * was first called. |
601 | * |
602 | * @return RevisionRecord|null the original revision before the update, or null |
603 | * if the page did not yet exist. |
604 | */ |
605 | private function getOldRevision() { |
606 | $this->assertPrepared( __METHOD__ ); |
607 | return $this->pageState['oldRevision']; |
608 | } |
609 | |
610 | /** |
611 | * Returns the revision that was the page's current revision when grabCurrentRevision() |
612 | * was first called. |
613 | * |
614 | * During an edit, that revision will act as the logical parent of the new revision. |
615 | * |
616 | * Some updates are performed based on the difference between the database state at the |
617 | * moment this method is first called, and the state after the edit. |
618 | * |
619 | * @see docs/pageupdater.md for more information on when thie method can and should be called. |
620 | * |
621 | * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception |
622 | * to avoid confusion, since the page's current revision is then the new revision after |
623 | * the edit, which was presumably passed to prepareUpdate() as the $revision parameter. |
624 | * Use getParentRevision() instead to access the revision that is the parent of the |
625 | * new revision. |
626 | * |
627 | * @return RevisionRecord|null the page's current revision, or null if the page does not |
628 | * yet exist. |
629 | */ |
630 | public function grabCurrentRevision() { |
631 | if ( $this->pageState ) { |
632 | return $this->pageState['oldRevision']; |
633 | } |
634 | |
635 | $this->assertTransition( 'knows-current' ); |
636 | |
637 | // NOTE: eventually, this won't use WikiPage any more |
638 | $wikiPage = $this->getWikiPage(); |
639 | |
640 | // Do not call WikiPage::clear(), since the caller may already have caused page data |
641 | // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now. |
642 | $wikiPage->loadPageData( IDBAccessObject::READ_LATEST ); |
643 | $current = $wikiPage->getRevisionRecord(); |
644 | |
645 | $this->pageState = [ |
646 | 'oldRevision' => $current, |
647 | 'oldId' => $current ? $current->getId() : 0, |
648 | 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table |
649 | 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table |
650 | 'oldRecord' => $wikiPage->exists() ? $wikiPage->toPageRecord() : null, |
651 | ]; |
652 | |
653 | $this->doTransition( 'knows-current' ); |
654 | |
655 | return $this->pageState['oldRevision']; |
656 | } |
657 | |
658 | /** |
659 | * Whether prepareUpdate() or prepareContent() have been called on this instance. |
660 | * |
661 | * @return bool |
662 | */ |
663 | public function isContentPrepared() { |
664 | return $this->revision !== null; |
665 | } |
666 | |
667 | /** |
668 | * Whether prepareUpdate() has been called on this instance. |
669 | * |
670 | * @note will also return null in case of a null-edit! |
671 | * |
672 | * @return bool |
673 | */ |
674 | public function isUpdatePrepared() { |
675 | return $this->revision !== null && $this->revision->getId() !== null; |
676 | } |
677 | |
678 | /** |
679 | * @return int |
680 | */ |
681 | private function getPageId() { |
682 | // NOTE: eventually, this won't use WikiPage any more |
683 | return $this->wikiPage->getId(); |
684 | } |
685 | |
686 | /** |
687 | * Whether the content is deleted and thus not visible to the public. |
688 | * |
689 | * @return bool |
690 | */ |
691 | public function isContentDeleted() { |
692 | if ( $this->revision ) { |
693 | return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); |
694 | } else { |
695 | // If the content has not been saved yet, it cannot have been deleted yet. |
696 | return false; |
697 | } |
698 | } |
699 | |
700 | /** |
701 | * Returns the slot, modified or inherited, after PST, with no audience checks applied. |
702 | * |
703 | * @param string $role slot role name |
704 | * |
705 | * @throws PageUpdateException If the slot is neither set for update nor inherited from the |
706 | * parent revision. |
707 | * @return SlotRecord |
708 | */ |
709 | public function getRawSlot( $role ) { |
710 | return $this->getSlots()->getSlot( $role ); |
711 | } |
712 | |
713 | /** |
714 | * Returns the content of the given slot, with no audience checks. |
715 | * |
716 | * @throws PageUpdateException If the slot is neither set for update nor inherited from the |
717 | * parent revision. |
718 | * @param string $role slot role name |
719 | * @return Content |
720 | */ |
721 | public function getRawContent( string $role ): Content { |
722 | return $this->getRawSlot( $role )->getContent(); |
723 | } |
724 | |
725 | private function usePrimary(): bool { |
726 | // TODO: can we just set a flag to true in prepareContent()? |
727 | return $this->wikiPage->wasLoadedFrom( IDBAccessObject::READ_LATEST ); |
728 | } |
729 | |
730 | public function isCountable(): bool { |
731 | // NOTE: Keep in sync with WikiPage::isCountable. |
732 | |
733 | if ( !$this->getTitle()->isContentPage() ) { |
734 | return false; |
735 | } |
736 | |
737 | if ( $this->isContentDeleted() ) { |
738 | // This should be irrelevant: countability only applies to the current revision, |
739 | // and the current revision is never suppressed. |
740 | return false; |
741 | } |
742 | |
743 | if ( $this->isRedirect() ) { |
744 | return false; |
745 | } |
746 | |
747 | $hasLinks = null; |
748 | |
749 | if ( $this->articleCountMethod === 'link' ) { |
750 | // NOTE: it would be more appropriate to determine for each slot separately |
751 | // whether it has links, and use that information with that slot's |
752 | // isCountable() method. However, that would break parity with |
753 | // WikiPage::isCountable, which uses the pagelinks table to determine |
754 | // whether the current revision has links. |
755 | $hasLinks = $this->getParserOutputForMetaData()->hasLinks(); |
756 | } |
757 | |
758 | foreach ( $this->getSlots()->getSlotRoles() as $role ) { |
759 | $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role ); |
760 | if ( $roleHandler->supportsArticleCount() ) { |
761 | $content = $this->getRawContent( $role ); |
762 | |
763 | if ( $content->isCountable( $hasLinks ) ) { |
764 | return true; |
765 | } |
766 | } |
767 | } |
768 | |
769 | return false; |
770 | } |
771 | |
772 | public function isRedirect(): bool { |
773 | // NOTE: main slot determines redirect status |
774 | // TODO: MCR: this should be controlled by a PageTypeHandler |
775 | $mainContent = $this->getRawContent( SlotRecord::MAIN ); |
776 | |
777 | return $mainContent->isRedirect(); |
778 | } |
779 | |
780 | /** |
781 | * @param RevisionRecord $rev |
782 | * |
783 | * @return bool |
784 | */ |
785 | private function revisionIsRedirect( RevisionRecord $rev ) { |
786 | // NOTE: main slot determines redirect status |
787 | $mainContent = $rev->getMainContentRaw(); |
788 | |
789 | return $mainContent->isRedirect(); |
790 | } |
791 | |
792 | /** |
793 | * Prepare updates based on an update which has not yet been saved. |
794 | * |
795 | * This may be used to create derived data that is needed when creating a new revision; |
796 | * particularly, this makes available the slots of the new revision via the getSlots() |
797 | * method, after applying PST and slot inheritance. |
798 | * |
799 | * The derived data prepared for revision creation may then later be re-used by doUpdates(), |
800 | * without the need to re-calculate. |
801 | * |
802 | * @see docs/pageupdater.md for more information on when thie method can and should be called. |
803 | * |
804 | * @note Calling this method more than once with the same $slotsUpdate |
805 | * has no effect. Calling this method multiple times with different content will cause |
806 | * an exception. |
807 | * |
808 | * @note Calling this method after prepareUpdate() has been called will cause an exception. |
809 | * |
810 | * @param UserIdentity $user The user to act as context for pre-save transformation (PST). |
811 | * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated |
812 | * by this edit, before PST. |
813 | * @param bool $useStash Whether to use stashed ParserOutput |
814 | */ |
815 | public function prepareContent( |
816 | UserIdentity $user, |
817 | RevisionSlotsUpdate $slotsUpdate, |
818 | $useStash = true |
819 | ) { |
820 | if ( $this->slotsUpdate ) { |
821 | if ( !$this->user ) { |
822 | throw new LogicException( |
823 | 'Unexpected state: $this->slotsUpdate was initialized, ' |
824 | . 'but $this->user was not.' |
825 | ); |
826 | } |
827 | |
828 | if ( $this->user->getName() !== $user->getName() ) { |
829 | throw new LogicException( 'Can\'t call prepareContent() again for different user! ' |
830 | . 'Expected ' . $this->user->getName() . ', got ' . $user->getName() |
831 | ); |
832 | } |
833 | |
834 | if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) { |
835 | throw new LogicException( |
836 | 'Can\'t call prepareContent() again with different slot content!' |
837 | ); |
838 | } |
839 | |
840 | return; // prepareContent() already done, nothing to do |
841 | } |
842 | |
843 | $this->assertTransition( 'has-content' ); |
844 | |
845 | $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks! |
846 | $title = $this->getTitle(); |
847 | |
848 | $parentRevision = $this->grabCurrentRevision(); |
849 | |
850 | // The edit may have already been prepared via api.php?action=stashedit |
851 | $stashedEdit = false; |
852 | |
853 | // TODO: MCR: allow output for all slots to be stashed. |
854 | if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) { |
855 | $stashedEdit = $this->pageEditStash->checkCache( |
856 | $title, |
857 | $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(), |
858 | $user |
859 | ); |
860 | } |
861 | |
862 | $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang ); |
863 | $userPopts->setRenderReason( $this->options['causeAgent'] ?? 'unknown' ); |
864 | |
865 | $this->hookRunner->onArticlePrepareTextForEdit( $wikiPage, $userPopts ); |
866 | |
867 | $this->user = $user; |
868 | $this->slotsUpdate = $slotsUpdate; |
869 | |
870 | if ( $parentRevision ) { |
871 | $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision ); |
872 | } else { |
873 | $this->revision = new MutableRevisionRecord( $title ); |
874 | } |
875 | |
876 | // NOTE: user and timestamp must be set, so they can be used for |
877 | // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST! |
878 | $this->revision->setTimestamp( MWTimestamp::now( TS_MW ) ); |
879 | $this->revision->setUser( $user ); |
880 | |
881 | // Set up ParserOptions to operate on the new revision |
882 | $oldCallback = $userPopts->getCurrentRevisionRecordCallback(); |
883 | $userPopts->setCurrentRevisionRecordCallback( |
884 | function ( Title $parserTitle, $parser = null ) use ( $title, $oldCallback ) { |
885 | if ( $parserTitle->equals( $title ) ) { |
886 | return $this->revision; |
887 | } else { |
888 | return $oldCallback( $parserTitle, $parser ); |
889 | } |
890 | } |
891 | ); |
892 | |
893 | $pstContentSlots = $this->revision->getSlots(); |
894 | |
895 | foreach ( $slotsUpdate->getModifiedRoles() as $role ) { |
896 | $slot = $slotsUpdate->getModifiedSlot( $role ); |
897 | |
898 | if ( $slot->isInherited() ) { |
899 | // No PST for inherited slots! Note that "modified" slots may still be inherited |
900 | // from an earlier version, e.g. for rollbacks. |
901 | $pstSlot = $slot; |
902 | } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) { |
903 | // TODO: MCR: allow PST content for all slots to be stashed. |
904 | $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent ); |
905 | } else { |
906 | $pstContent = $this->contentTransformer->preSaveTransform( |
907 | $slot->getContent(), |
908 | $title, |
909 | $user, |
910 | $userPopts |
911 | ); |
912 | |
913 | $pstSlot = SlotRecord::newUnsaved( $role, $pstContent ); |
914 | } |
915 | |
916 | $pstContentSlots->setSlot( $pstSlot ); |
917 | } |
918 | |
919 | foreach ( $slotsUpdate->getRemovedRoles() as $role ) { |
920 | $pstContentSlots->removeSlot( $role ); |
921 | } |
922 | |
923 | $this->options['created'] = ( $parentRevision === null ); |
924 | $this->options['changed'] = ( $parentRevision === null |
925 | || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) ); |
926 | |
927 | $this->doTransition( 'has-content' ); |
928 | |
929 | if ( !$this->options['changed'] ) { |
930 | if ( $this->forceEmptyRevision ) { |
931 | // dummy revision, inherit all slots |
932 | foreach ( $parentRevision->getSlotRoles() as $role ) { |
933 | $this->revision->inheritSlot( $parentRevision->getSlot( $role ) ); |
934 | } |
935 | } else { |
936 | // null-edit, the new revision *is* the old revision. |
937 | |
938 | // TODO: move this into MutableRevisionRecord |
939 | $this->revision->setId( $parentRevision->getId() ); |
940 | $this->revision->setTimestamp( $parentRevision->getTimestamp() ); |
941 | $this->revision->setPageId( $parentRevision->getPageId() ); |
942 | $this->revision->setParentId( $parentRevision->getParentId() ); |
943 | $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) ); |
944 | $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) ); |
945 | $this->revision->setMinorEdit( $parentRevision->isMinor() ); |
946 | $this->revision->setVisibility( $parentRevision->getVisibility() ); |
947 | |
948 | // prepareUpdate() is redundant for null-edits (but not for dummy revisions) |
949 | $this->doTransition( 'has-revision' ); |
950 | } |
951 | } else { |
952 | $this->parentRevision = $parentRevision; |
953 | } |
954 | |
955 | $renderHints = [ 'use-master' => $this->usePrimary(), 'audience' => RevisionRecord::RAW ]; |
956 | |
957 | if ( $stashedEdit ) { |
958 | /** @var ParserOutput $output */ |
959 | $output = $stashedEdit->output; |
960 | // TODO: this should happen when stashing the ParserOutput, not now! |
961 | $output->setCacheTime( $stashedEdit->timestamp ); |
962 | |
963 | $renderHints['known-revision-output'] = $output; |
964 | |
965 | $this->logger->debug( __METHOD__ . ': using stashed edit output...' ); |
966 | } |
967 | |
968 | $renderHints['generate-html'] = $this->shouldGenerateHTMLOnEdit(); |
969 | |
970 | [ $causeAction, ] = $this->getCauseForTracing(); |
971 | $renderHints['causeAction'] = $causeAction; |
972 | |
973 | // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions |
974 | // NOTE: the revision is either new or current, so we can bypass audience checks. |
975 | $this->renderedRevision = $this->revisionRenderer->getRenderedRevision( |
976 | $this->revision, |
977 | null, |
978 | null, |
979 | $renderHints |
980 | ); |
981 | } |
982 | |
983 | /** |
984 | * Returns the update's target revision - that is, the revision that will be the current |
985 | * revision after the update. |
986 | * |
987 | * @note Callers must treat the returned RevisionRecord's content as immutable, even |
988 | * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord |
989 | * returned from here, such as the user or the comment, may be changed, but may not |
990 | * be reflected in ParserOutput until after prepareUpdate() has been called. |
991 | * |
992 | * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved |
993 | * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service |
994 | * for that purpose instead! |
995 | * |
996 | * @return RevisionRecord |
997 | */ |
998 | public function getRevision(): RevisionRecord { |
999 | $this->assertPrepared( __METHOD__ ); |
1000 | return $this->revision; |
1001 | } |
1002 | |
1003 | public function getRenderedRevision(): RenderedRevision { |
1004 | $this->assertPrepared( __METHOD__ ); |
1005 | |
1006 | return $this->renderedRevision; |
1007 | } |
1008 | |
1009 | private function assertHasPageState( string $method ) { |
1010 | if ( !$this->pageState ) { |
1011 | throw new LogicException( |
1012 | 'Must call grabCurrentRevision() or prepareContent() ' |
1013 | . 'or prepareUpdate() before calling ' . $method |
1014 | ); |
1015 | } |
1016 | } |
1017 | |
1018 | private function assertPrepared( string $method ) { |
1019 | if ( !$this->revision ) { |
1020 | throw new LogicException( |
1021 | 'Must call prepareContent() or prepareUpdate() before calling ' . $method |
1022 | ); |
1023 | } |
1024 | } |
1025 | |
1026 | private function assertHasRevision( string $method ) { |
1027 | if ( !$this->revision->getId() ) { |
1028 | throw new LogicException( |
1029 | 'Must call prepareUpdate() before calling ' . $method |
1030 | ); |
1031 | } |
1032 | } |
1033 | |
1034 | /** |
1035 | * Whether the edit creates the page. |
1036 | * |
1037 | * @return bool |
1038 | */ |
1039 | public function isCreation() { |
1040 | $this->assertPrepared( __METHOD__ ); |
1041 | return $this->options['created']; |
1042 | } |
1043 | |
1044 | /** |
1045 | * Whether the content of the current revision after the edit is different from the content of the |
1046 | * current revision before the edit. This will return false for a null-edit (no revision created), |
1047 | * as well as for a dummy revision (a "null-revision" that has the same content as its parent). |
1048 | * |
1049 | * @warning at present, dummy revision would return false after prepareContent(), |
1050 | * but true after prepareUpdate()! |
1051 | * |
1052 | * @todo This should probably be fixed. |
1053 | * |
1054 | * @return bool |
1055 | */ |
1056 | public function isChange() { |
1057 | $this->assertPrepared( __METHOD__ ); |
1058 | return $this->options['changed']; |
1059 | } |
1060 | |
1061 | /** |
1062 | * Whether the page was a redirect before the edit. |
1063 | * |
1064 | * @return bool |
1065 | */ |
1066 | public function wasRedirect() { |
1067 | $this->assertHasPageState( __METHOD__ ); |
1068 | |
1069 | if ( $this->pageState['oldIsRedirect'] === null ) { |
1070 | /** @var RevisionRecord $rev */ |
1071 | $rev = $this->pageState['oldRevision']; |
1072 | if ( $rev ) { |
1073 | $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev ); |
1074 | } else { |
1075 | $this->pageState['oldIsRedirect'] = false; |
1076 | } |
1077 | } |
1078 | |
1079 | return $this->pageState['oldIsRedirect']; |
1080 | } |
1081 | |
1082 | /** |
1083 | * Returns the slots of the target revision, after PST. |
1084 | * |
1085 | * @note Callers must treat the returned RevisionSlots instance as immutable, even |
1086 | * if it is a MutableRevisionSlots instance. |
1087 | * |
1088 | * @return RevisionSlots |
1089 | */ |
1090 | public function getSlots() { |
1091 | $this->assertPrepared( __METHOD__ ); |
1092 | return $this->revision->getSlots(); |
1093 | } |
1094 | |
1095 | /** |
1096 | * Returns the RevisionSlotsUpdate for this updater. |
1097 | * |
1098 | * @return RevisionSlotsUpdate |
1099 | */ |
1100 | private function getRevisionSlotsUpdate() { |
1101 | $this->assertPrepared( __METHOD__ ); |
1102 | |
1103 | if ( !$this->slotsUpdate ) { |
1104 | $old = $this->getParentRevision(); |
1105 | $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots( |
1106 | $this->revision->getSlots(), |
1107 | $old ? $old->getSlots() : null |
1108 | ); |
1109 | } |
1110 | return $this->slotsUpdate; |
1111 | } |
1112 | |
1113 | /** |
1114 | * Returns the role names of the slots touched by the new revision, |
1115 | * including removed roles. |
1116 | * |
1117 | * @return string[] |
1118 | */ |
1119 | public function getTouchedSlotRoles() { |
1120 | return $this->getRevisionSlotsUpdate()->getTouchedRoles(); |
1121 | } |
1122 | |
1123 | /** |
1124 | * Returns the role names of the slots modified by the new revision, |
1125 | * not including removed roles. |
1126 | * |
1127 | * @return string[] |
1128 | */ |
1129 | public function getModifiedSlotRoles(): array { |
1130 | return $this->getRevisionSlotsUpdate()->getModifiedRoles(); |
1131 | } |
1132 | |
1133 | /** |
1134 | * Returns the role names of the slots removed by the new revision. |
1135 | * |
1136 | * @return string[] |
1137 | */ |
1138 | public function getRemovedSlotRoles(): array { |
1139 | return $this->getRevisionSlotsUpdate()->getRemovedRoles(); |
1140 | } |
1141 | |
1142 | /** |
1143 | * Prepare derived data updates targeting the given RevisionRecord. |
1144 | * |
1145 | * Calling this method requires the given revision to be present in the database. |
1146 | * This may be right after a new revision has been created, or when re-generating |
1147 | * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks |
1148 | * script. |
1149 | * |
1150 | * @see docs/pageupdater.md for more information on when thie method can and should be called. |
1151 | * |
1152 | * @note Calling this method more than once with the same revision has no effect. |
1153 | * $options are only used for the first call. Calling this method multiple times with |
1154 | * different revisions will cause an exception. |
1155 | * |
1156 | * @note If grabCurrentRevision() (or prepareContent()) has been called before |
1157 | * calling this method, $revision->getParentRevision() has to refer to the revision that |
1158 | * was the current revision at the time grabCurrentRevision() was called. |
1159 | * |
1160 | * @param RevisionRecord $revision |
1161 | * @param array $options Array of options. Supports the flags defined by |
1162 | * PageRevisionUpdatedEvent. In addition, the following keys are supported used: |
1163 | * - oldtitle: PageIdentity, if the page was moved this is the source title (default null) |
1164 | * - oldrevision: RevisionRecord object for the pre-update revision (default null) |
1165 | * - triggeringUser: The user triggering the update (UserIdentity, defaults to the |
1166 | * user who created the revision) |
1167 | * - oldredirect: bool, null, or string 'no-change' (default null): |
1168 | * - bool: whether the page was counted as a redirect before that |
1169 | * revision, only used in changed is true and created is false |
1170 | * - null or 'no-change': don't update the redirect status. |
1171 | * - oldcountable: bool, null, or string 'no-change' (default null): |
1172 | * - bool: whether the page was counted as an article before that |
1173 | * revision, only used in changed is true and created is false |
1174 | * - null: if created is false, don't update the article count; if created |
1175 | * is true, do update the article count |
1176 | * - 'no-change': don't update the article count, ever |
1177 | * When set to null, pageState['oldCountable'] will be used instead if available. |
1178 | * - cause: the reason for the update, see PageRevisionUpdatedEvent::CAUSE_XXX. |
1179 | * - known-revision-output: a combined canonical ParserOutput for the revision, perhaps |
1180 | * from some cache. The caller is responsible for ensuring that the ParserOutput indeed |
1181 | * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, |
1182 | * for the time until caches have been changed to store RenderedRevision states instead |
1183 | * of ParserOutput objects. (default: null) (since 1.33) |
1184 | * - editResult: EditResult object created during the update. Required to perform reverted |
1185 | * tag update using RevertedTagUpdateJob. (default: null) (since 1.36) |
1186 | */ |
1187 | public function prepareUpdate( RevisionRecord $revision, array $options = [] ) { |
1188 | Assert::parameter( |
1189 | !isset( $options['oldrevision'] ) |
1190 | || $options['oldrevision'] instanceof RevisionRecord, |
1191 | '$options["oldrevision"]', |
1192 | 'must be a RevisionRecord' |
1193 | ); |
1194 | Assert::parameter( |
1195 | !isset( $options['triggeringUser'] ) |
1196 | || $options['triggeringUser'] instanceof UserIdentity, |
1197 | '$options["triggeringUser"]', |
1198 | 'must be a UserIdentity' |
1199 | ); |
1200 | Assert::parameter( |
1201 | !isset( $options['editResult'] ) |
1202 | || $options['editResult'] instanceof EditResult, |
1203 | '$options["editResult"]', |
1204 | 'must be an EditResult' |
1205 | ); |
1206 | |
1207 | if ( !$revision->getId() ) { |
1208 | throw new InvalidArgumentException( |
1209 | 'Revision must have an ID set for it to be used with prepareUpdate()!' |
1210 | ); |
1211 | } |
1212 | |
1213 | if ( !$this->wikiPage->exists() ) { |
1214 | // If the ongoing edit is creating the page, the state of $this->wikiPage |
1215 | // may be out of whack. This would only happen if the page creation was |
1216 | // done using a different WikiPage instance, which shouldn't be the case. |
1217 | $this->logger->warning( |
1218 | __METHOD__ . ': Reloading page meta-data after page creation', |
1219 | [ |
1220 | 'page' => (string)$this->wikiPage, |
1221 | 'rev_id' => $revision->getId(), |
1222 | ] |
1223 | ); |
1224 | |
1225 | $this->wikiPage->clear(); |
1226 | $this->wikiPage->loadPageData( IDBAccessObject::READ_LATEST ); |
1227 | } |
1228 | |
1229 | if ( $this->revision && $this->revision->getId() ) { |
1230 | if ( $this->revision->getId() === $revision->getId() ) { |
1231 | $this->options['changed'] = false; // null-edit |
1232 | } else { |
1233 | throw new LogicException( |
1234 | 'Trying to re-use DerivedPageDataUpdater with revision ' |
1235 | . $revision->getId() |
1236 | . ', but it\'s already bound to revision ' |
1237 | . $this->revision->getId() |
1238 | ); |
1239 | } |
1240 | } |
1241 | |
1242 | if ( $this->revision |
1243 | && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() ) |
1244 | ) { |
1245 | throw new LogicException( |
1246 | 'The revision provided has mismatching content!' |
1247 | ); |
1248 | } |
1249 | |
1250 | // Override fields defined in $this->options with values from $options. |
1251 | $this->options = array_intersect_key( $options, $this->options ) + $this->options; |
1252 | |
1253 | if ( $this->revision ) { |
1254 | $oldId = $this->pageState['oldId'] ?? 0; |
1255 | $this->options['newrev'] = ( $revision->getId() !== $oldId ); |
1256 | } elseif ( isset( $this->options['oldrevision'] ) ) { |
1257 | /** @var RevisionRecord $oldRev */ |
1258 | $oldRev = $this->options['oldrevision']; |
1259 | $oldId = $oldRev->getId(); |
1260 | $this->options['newrev'] = ( $revision->getId() !== $oldId ); |
1261 | } else { |
1262 | $oldId = $revision->getParentId(); |
1263 | } |
1264 | |
1265 | if ( $oldId !== null ) { |
1266 | // XXX: what if $options['changed'] disagrees? |
1267 | // MovePage creates a dummy revision with changed = false! |
1268 | // We may want to explicitly distinguish between "no new revision" (null-edit) |
1269 | // and "new revision without new content" (dummy revision). |
1270 | |
1271 | if ( $oldId === $revision->getParentId() ) { |
1272 | // NOTE: this may still be a NullRevision! |
1273 | // New revision! |
1274 | $this->options['changed'] = true; |
1275 | } elseif ( $oldId === $revision->getId() ) { |
1276 | // Null-edit! |
1277 | $this->options['changed'] = false; |
1278 | } else { |
1279 | // This indicates that calling code has given us the wrong RevisionRecord object |
1280 | throw new LogicException( |
1281 | 'The RevisionRecord mismatches old revision ID: ' |
1282 | . 'Old ID is ' . $oldId |
1283 | . ', parent ID is ' . $revision->getParentId() |
1284 | . ', revision ID is ' . $revision->getId() |
1285 | ); |
1286 | } |
1287 | } |
1288 | |
1289 | // If prepareContent() was used to generate the PST content (which is indicated by |
1290 | // $this->slotsUpdate being set), and this is not a null-edit, then the given |
1291 | // revision must have the acting user as the revision author. Otherwise, user |
1292 | // signatures generated by PST would mismatch the user in the revision record. |
1293 | if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) { |
1294 | $user = $revision->getUser(); |
1295 | if ( !$this->user->equals( $user ) ) { |
1296 | throw new LogicException( |
1297 | 'The RevisionRecord provided has a mismatching actor: expected ' |
1298 | . $this->user->getName() |
1299 | . ', got ' |
1300 | . $user->getName() |
1301 | ); |
1302 | } |
1303 | } |
1304 | |
1305 | // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent, |
1306 | // emulate the state of the page table before the edit, as good as we can. |
1307 | if ( !$this->pageState ) { |
1308 | $this->pageState = [ |
1309 | 'oldIsRedirect' => isset( $this->options['oldredirect'] ) |
1310 | && is_bool( $this->options['oldredirect'] ) |
1311 | ? $this->options['oldredirect'] |
1312 | : null, |
1313 | 'oldCountable' => isset( $this->options['oldcountable'] ) |
1314 | && is_bool( $this->options['oldcountable'] ) |
1315 | ? $this->options['oldcountable'] |
1316 | : null, |
1317 | ]; |
1318 | |
1319 | if ( $this->options['changed'] ) { |
1320 | // The edit created a new revision |
1321 | $this->pageState['oldId'] = $revision->getParentId(); |
1322 | // Old revision is null if this is a page creation |
1323 | $this->pageState['oldRevision'] = $this->options['oldrevision'] ?? null; |
1324 | } else { |
1325 | // This is a null-edit, so the old revision IS the new revision! |
1326 | $this->pageState['oldId'] = $revision->getId(); |
1327 | $this->pageState['oldRevision'] = $revision; |
1328 | } |
1329 | } |
1330 | |
1331 | // "created" is forced here |
1332 | $this->options['created'] = ( $this->options['created'] || |
1333 | ( $this->pageState['oldId'] === 0 ) ); |
1334 | |
1335 | $this->revision = $revision; |
1336 | |
1337 | $this->doTransition( 'has-revision' ); |
1338 | |
1339 | // NOTE: in case we have a User object, don't override with a UserIdentity. |
1340 | // We already checked that $revision->getUser() matches $this->user; |
1341 | if ( !$this->user ) { |
1342 | $this->user = $revision->getUser( RevisionRecord::RAW ); |
1343 | } |
1344 | |
1345 | // Prune any output that depends on the revision ID. |
1346 | if ( $this->renderedRevision ) { |
1347 | $this->renderedRevision->updateRevision( $revision ); |
1348 | } else { |
1349 | [ $causeAction, ] = $this->getCauseForTracing(); |
1350 | // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions |
1351 | // NOTE: the revision is either new or current, so we can bypass audience checks. |
1352 | $this->renderedRevision = $this->revisionRenderer->getRenderedRevision( |
1353 | $this->revision, |
1354 | null, |
1355 | null, |
1356 | [ |
1357 | 'use-master' => $this->usePrimary(), |
1358 | 'audience' => RevisionRecord::RAW, |
1359 | 'known-revision-output' => $options['known-revision-output'] ?? null, |
1360 | 'causeAction' => $causeAction |
1361 | ] |
1362 | ); |
1363 | |
1364 | // XXX: Since we presumably are dealing with the current revision, |
1365 | // we could try to get the ParserOutput from the parser cache. |
1366 | } |
1367 | |
1368 | // TODO: optionally get ParserOutput from the ParserCache here. |
1369 | // Move the logic used by RefreshLinksJob here! |
1370 | } |
1371 | |
1372 | /** |
1373 | * @deprecated since 1.43; This only exists for B/C, use the getters on DerivedPageDataUpdater directly! |
1374 | * @return PreparedEdit |
1375 | */ |
1376 | public function getPreparedEdit() { |
1377 | $this->assertPrepared( __METHOD__ ); |
1378 | |
1379 | $slotsUpdate = $this->getRevisionSlotsUpdate(); |
1380 | $preparedEdit = new PreparedEdit(); |
1381 | |
1382 | $preparedEdit->popts = $this->getCanonicalParserOptions(); |
1383 | $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ]; |
1384 | $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN ); |
1385 | $preparedEdit->newContent = |
1386 | $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) |
1387 | ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent() |
1388 | : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this? |
1389 | $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision |
1390 | $preparedEdit->revid = $this->revision ? $this->revision->getId() : null; |
1391 | $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat(); |
1392 | |
1393 | return $preparedEdit; |
1394 | } |
1395 | |
1396 | /** |
1397 | * @param string $role |
1398 | * @param bool $generateHtml |
1399 | * @return ParserOutput |
1400 | */ |
1401 | public function getSlotParserOutput( $role, $generateHtml = true ) { |
1402 | return $this->getRenderedRevision()->getSlotParserOutput( |
1403 | $role, |
1404 | [ 'generate-html' => $generateHtml ] |
1405 | ); |
1406 | } |
1407 | |
1408 | /** |
1409 | * @since 1.37 |
1410 | * @return ParserOutput |
1411 | */ |
1412 | public function getParserOutputForMetaData(): ParserOutput { |
1413 | return $this->getRenderedRevision()->getRevisionParserOutput( [ 'generate-html' => false ] ); |
1414 | } |
1415 | |
1416 | /** |
1417 | * @inheritDoc |
1418 | * @return ParserOutput |
1419 | */ |
1420 | public function getCanonicalParserOutput(): ParserOutput { |
1421 | return $this->getRenderedRevision()->getRevisionParserOutput(); |
1422 | } |
1423 | |
1424 | public function getCanonicalParserOptions(): ParserOptions { |
1425 | return $this->getRenderedRevision()->getOptions(); |
1426 | } |
1427 | |
1428 | /** |
1429 | * @param bool $recursive |
1430 | * |
1431 | * @return DeferrableUpdate[] |
1432 | */ |
1433 | public function getSecondaryDataUpdates( $recursive = false ) { |
1434 | if ( $this->isContentDeleted() ) { |
1435 | // This shouldn't happen, since the current content is always public, |
1436 | // and DataUpdates are only needed for current content. |
1437 | return []; |
1438 | } |
1439 | |
1440 | $wikiPage = $this->getWikiPage(); |
1441 | $wikiPage->loadPageData( IDBAccessObject::READ_LATEST ); |
1442 | if ( !$wikiPage->exists() ) { |
1443 | // page deleted while deferring the update |
1444 | return []; |
1445 | } |
1446 | |
1447 | $title = $wikiPage->getTitle(); |
1448 | $allUpdates = []; |
1449 | $parserOutput = $this->shouldGenerateHTMLOnEdit() ? |
1450 | $this->getCanonicalParserOutput() : $this->getParserOutputForMetaData(); |
1451 | |
1452 | // Construct a LinksUpdate for the combined canonical output. |
1453 | $linksUpdate = new LinksUpdate( |
1454 | $title, |
1455 | $parserOutput, |
1456 | $recursive, |
1457 | // Redirect target may have changed if the page is or was a redirect. |
1458 | // (We can't check if it was definitely changed without additional queries.) |
1459 | $this->isRedirect() || $this->wasRedirect() |
1460 | ); |
1461 | if ( $this->options['cause'] === PageRevisionUpdatedEvent::CAUSE_MOVE ) { |
1462 | // @phan-suppress-next-line PhanTypeMismatchArgument Oldtitle is set along with moved |
1463 | $linksUpdate->setMoveDetails( $this->options['oldtitle'] ); |
1464 | } |
1465 | |
1466 | $allUpdates[] = $linksUpdate; |
1467 | // NOTE: Run updates for all slots, not just the modified slots! Otherwise, |
1468 | // info for an inherited slot may end up being removed. This is also needed |
1469 | // to ensure that purges are effective. |
1470 | $renderedRevision = $this->getRenderedRevision(); |
1471 | |
1472 | foreach ( $this->getSlots()->getSlotRoles() as $role ) { |
1473 | $slot = $this->getRawSlot( $role ); |
1474 | $content = $slot->getContent(); |
1475 | $handler = $content->getContentHandler(); |
1476 | |
1477 | $updates = $handler->getSecondaryDataUpdates( |
1478 | $title, |
1479 | $content, |
1480 | $role, |
1481 | $renderedRevision |
1482 | ); |
1483 | |
1484 | $allUpdates = array_merge( $allUpdates, $updates ); |
1485 | } |
1486 | |
1487 | // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at |
1488 | // that time, we don't know for which slots to run deletion updates when purging a page. |
1489 | // We'd have to examine the entire history of the page to determine that. Perhaps there |
1490 | // could be a "try extra hard" mode for that case that would run a DB query to find all |
1491 | // roles/models ever used on the page. On the other hand, removing slots should be quite |
1492 | // rare, so perhaps this isn't worth the trouble. |
1493 | |
1494 | // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates() |
1495 | $parentRevision = $this->getParentRevision(); |
1496 | foreach ( $this->getRemovedSlotRoles() as $role ) { |
1497 | // HACK: we should get the content model of the removed slot from a SlotRoleHandler! |
1498 | // For now, find the slot in the parent revision - if the slot was removed, it should |
1499 | // always exist in the parent revision. |
1500 | $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW ); |
1501 | $content = $parentSlot->getContent(); |
1502 | $handler = $content->getContentHandler(); |
1503 | |
1504 | $updates = $handler->getDeletionUpdates( |
1505 | $title, |
1506 | $role |
1507 | ); |
1508 | |
1509 | $allUpdates = array_merge( $allUpdates, $updates ); |
1510 | } |
1511 | |
1512 | // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33! |
1513 | $this->hookRunner->onRevisionDataUpdates( $title, $renderedRevision, $allUpdates ); |
1514 | |
1515 | return $allUpdates; |
1516 | } |
1517 | |
1518 | /** |
1519 | * @return bool true if at least one of slots require rendering HTML on edit, false otherwise. |
1520 | * This is needed for example in populating ParserCache. |
1521 | */ |
1522 | private function shouldGenerateHTMLOnEdit(): bool { |
1523 | foreach ( $this->getSlots()->getSlotRoles() as $role ) { |
1524 | $slot = $this->getRawSlot( $role ); |
1525 | $contentHandler = $this->contentHandlerFactory->getContentHandler( $slot->getModel() ); |
1526 | if ( $contentHandler->generateHTMLOnEdit() ) { |
1527 | return true; |
1528 | } |
1529 | } |
1530 | return false; |
1531 | } |
1532 | |
1533 | /** |
1534 | * Do standard updates after page edit, purge, or import. |
1535 | * Update links tables and other derived data. |
1536 | * Purges pages that depend on this page when appropriate. |
1537 | * With a 10% chance, triggers pruning the recent changes table. |
1538 | * |
1539 | * Further updates may be triggered by core components and extensions |
1540 | * that listen to the PageRevisionUpdated event. Search for method names starting |
1541 | * with "handlePageUpdatedEvent" to find listeners. |
1542 | * |
1543 | * @note prepareUpdate() must be called before calling this method! |
1544 | * |
1545 | * MCR migration note: this replaces WikiPage::doEditUpdates. |
1546 | */ |
1547 | public function doUpdates() { |
1548 | $this->assertTransition( 'done' ); |
1549 | |
1550 | if ( $this->options['emitEvents'] ) { |
1551 | $this->emitEvents(); |
1552 | } |
1553 | |
1554 | // TODO: move more logic into ingress objects subscribed to PageRevisionUpdatedEvent! |
1555 | $event = $this->getPageRevisionUpdatedEvent(); |
1556 | |
1557 | if ( $this->shouldGenerateHTMLOnEdit() ) { |
1558 | $this->triggerParserCacheUpdate(); |
1559 | } |
1560 | |
1561 | $this->doSecondaryDataUpdates( [ |
1562 | // T52785 do not update any other pages on dummy revisions and null edits |
1563 | 'recursive' => $event->isEffectiveContentChange(), |
1564 | // Defer the getCanonicalParserOutput() call made by getSecondaryDataUpdates() |
1565 | 'defer' => DeferredUpdates::POSTSEND |
1566 | ] ); |
1567 | |
1568 | $id = $this->getPageId(); |
1569 | $title = $this->getTitle(); |
1570 | $wikiPage = $this->getWikiPage(); |
1571 | |
1572 | if ( !$title->exists() ) { |
1573 | wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out" ); |
1574 | |
1575 | $this->doTransition( 'done' ); |
1576 | return; |
1577 | } |
1578 | |
1579 | DeferredUpdates::addCallableUpdate( function () use ( $event ) { |
1580 | if ( |
1581 | $this->options['oldcountable'] === 'no-change' || |
1582 | ( !$event->isEffectiveContentChange() && !$event->hasCause( PageRevisionUpdatedEvent::CAUSE_MOVE ) ) |
1583 | ) { |
1584 | $good = 0; |
1585 | } elseif ( $event->isCreation() ) { |
1586 | $good = (int)$this->isCountable(); |
1587 | } elseif ( $this->options['oldcountable'] !== null ) { |
1588 | $good = (int)$this->isCountable() |
1589 | - (int)$this->options['oldcountable']; |
1590 | } elseif ( isset( $this->pageState['oldCountable'] ) ) { |
1591 | $good = (int)$this->isCountable() |
1592 | - (int)$this->pageState['oldCountable']; |
1593 | } else { |
1594 | $good = 0; |
1595 | } |
1596 | $edits = $event->isEffectiveContentChange() ? 1 : 0; |
1597 | $pages = $event->isCreation() ? 1 : 0; |
1598 | |
1599 | DeferredUpdates::addUpdate( SiteStatsUpdate::factory( |
1600 | [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ] |
1601 | ) ); |
1602 | } ); |
1603 | |
1604 | // TODO: move onArticleCreate and onArticleEdit into a PageEventEmitter service |
1605 | if ( $event->isCreation() ) { |
1606 | // Deferred update that adds a mw-recreated tag to edits that create new pages |
1607 | // which have an associated deletion log entry for the specific namespace/title combination |
1608 | // and which are not undeletes |
1609 | if ( !( $event->hasCause( PageRevisionUpdatedEvent::CAUSE_UNDELETE ) ) ) { |
1610 | $revision = $this->revision; |
1611 | DeferredUpdates::addCallableUpdate( function () use ( $revision, $wikiPage ) { |
1612 | $this->maybeAddRecreateChangeTag( $wikiPage, $revision->getId() ); |
1613 | } ); |
1614 | } |
1615 | WikiPage::onArticleCreate( $title, $this->isRedirect() ); |
1616 | } elseif ( $event->isEffectiveContentChange() ) { // T52785 |
1617 | // TODO: Check $event->isNominalContentChange() instead so we still |
1618 | // trigger updates on null edits, but pass a flag to suppress |
1619 | // backlink purges through queueBacklinksJobs() id |
1620 | // $event->changedLatestRevisionId() returns false. |
1621 | WikiPage::onArticleEdit( |
1622 | $title, |
1623 | $this->revision, |
1624 | $this->getTouchedSlotRoles(), |
1625 | // Redirect target may have changed if the page is or was a redirect. |
1626 | // (We can't check if it was definitely changed without additional queries.) |
1627 | $this->isRedirect() || $this->wasRedirect() |
1628 | ); |
1629 | } |
1630 | |
1631 | if ( $event->hasCause( PageRevisionUpdatedEvent::CAUSE_UNDELETE ) ) { |
1632 | $this->mainWANObjectCache->touchCheckKey( |
1633 | "DerivedPageDataUpdater:restore:page:$id" |
1634 | ); |
1635 | } |
1636 | |
1637 | $editResult = $event->getEditResult(); |
1638 | |
1639 | if ( $editResult && !$editResult->isNullEdit() ) { |
1640 | // Cache EditResult for future use, via |
1641 | // RevertTagUpdateManager::approveRevertedTagForRevision(). |
1642 | // This drives RevertedTagUpdateManager::approveRevertedTagForRevision. |
1643 | // It is only needed if RCPatrolling is enabled and the edit is a revert. |
1644 | // Skip in other cases to avoid flooding the cache, see T386217 and T388573. |
1645 | if ( $editResult->isRevert() && $this->useRcPatrol ) { |
1646 | $this->editResultCache->set( |
1647 | $this->revision->getId(), |
1648 | $editResult |
1649 | ); |
1650 | } |
1651 | } |
1652 | |
1653 | $this->doTransition( 'done' ); |
1654 | } |
1655 | |
1656 | /** |
1657 | * @internal |
1658 | */ |
1659 | public function emitEvents(): void { |
1660 | if ( !$this->options['emitEvents'] ) { |
1661 | throw new LogicException( 'emitEvents was disabled on this updater' ); |
1662 | } |
1663 | |
1664 | $event = $this->getPageRevisionUpdatedEvent(); |
1665 | |
1666 | // don't dispatch again! |
1667 | $this->options['emitEvents'] = false; |
1668 | |
1669 | $this->eventDispatcher->dispatch( $event, $this->loadbalancerFactory ); |
1670 | } |
1671 | |
1672 | private function getPageRevisionUpdatedEvent(): PageRevisionUpdatedEvent { |
1673 | if ( $this->pageUpdatedEvent ) { |
1674 | return $this->pageUpdatedEvent; |
1675 | } |
1676 | |
1677 | $this->assertHasRevision( __METHOD__ ); |
1678 | |
1679 | $flags = array_intersect_key( |
1680 | $this->options, |
1681 | PageRevisionUpdatedEvent::DEFAULT_FLAGS |
1682 | ); |
1683 | |
1684 | $pageRecordBefore = $this->pageState['oldRecord'] ?? null; |
1685 | $pageRecordAfter = $this->getWikiPage()->toPageRecord(); |
1686 | |
1687 | $revisionBefore = $this->getOldRevision(); |
1688 | $revisionAfter = $this->getRevision(); |
1689 | |
1690 | if ( $this->options['created'] ) { |
1691 | // Page creation. No prior state. |
1692 | // Force null to make sure we don't get confused during imports when |
1693 | // updates are triggered after importing the last revision of several. |
1694 | // In that case, the page and older revisions do already exist when |
1695 | // the DerivedPageDataUpdater is initialized, because they were |
1696 | // created during the import. But they didn't exist prior to the |
1697 | // import (based on the fact that the 'created' flag is set). |
1698 | $pageRecordBefore = null; |
1699 | $revisionBefore = null; |
1700 | } elseif ( !$this->options['changed'] ) { |
1701 | // Null edit. Should already be the same, just make sure. |
1702 | $pageRecordBefore = $pageRecordAfter; |
1703 | } |
1704 | |
1705 | if ( $revisionBefore && $revisionAfter->getId() === $revisionBefore->getId() ) { |
1706 | // This is a null edit, flag it as a reconciliation request. |
1707 | $flags[ PageRevisionUpdatedEvent::FLAG_RECONCILIATION_REQUEST ] = true; |
1708 | } |
1709 | |
1710 | if ( $pageRecordBefore === null && !$this->options['created'] ) { |
1711 | // If the page wasn't just created, we need the state before. |
1712 | // If we are not actually emitting the event, we can ignore the issue. |
1713 | // This is needed to support the deprecated WikiPage::doEditUpdates() |
1714 | // method. Once that is gone, we can remove this conditional. |
1715 | if ( $this->options['emitEvents'] ) { |
1716 | throw new LogicException( 'Missing page state before update' ); |
1717 | } |
1718 | } |
1719 | |
1720 | /** @var UserIdentity $performer */ |
1721 | $performer = $this->options['triggeringUser'] ?? $this->user; |
1722 | '@phan-var UserIdentity $performer'; |
1723 | |
1724 | $this->pageUpdatedEvent = new PageRevisionUpdatedEvent( |
1725 | $this->options['cause'] ?? PageUpdateCauses::CAUSE_EDIT, |
1726 | $pageRecordBefore, |
1727 | $pageRecordAfter, |
1728 | $revisionBefore, |
1729 | $revisionAfter, |
1730 | $this->getRevisionSlotsUpdate(), |
1731 | $this->options['editResult'] ?? null, |
1732 | $performer, |
1733 | $this->options['tags'] ?? [], |
1734 | $flags, |
1735 | $this->options['rcPatrolStatus'] ?? 0, |
1736 | ); |
1737 | |
1738 | return $this->pageUpdatedEvent; |
1739 | } |
1740 | |
1741 | private function triggerParserCacheUpdate() { |
1742 | $this->assertHasRevision( __METHOD__ ); |
1743 | |
1744 | $userParserOptions = ParserOptions::newFromUser( $this->user ); |
1745 | |
1746 | // Decide whether to save the final canonical parser output based on the fact that |
1747 | // users are typically redirected to viewing pages right after they edit those pages. |
1748 | // Due to vary-revision-id, getting/saving that output here might require a reparse. |
1749 | if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) { |
1750 | // Whether getting the final output requires a reparse or not, the user will |
1751 | // need canonical output anyway, since that is what their parser options use. |
1752 | // A reparse now at least has the benefit of various warm process caches. |
1753 | $this->doParserCacheUpdate(); |
1754 | } else { |
1755 | // If the user does not have canonical parse options, then don't risk another parse |
1756 | // to make output they cannot use on the page refresh that typically occurs after |
1757 | // editing. Doing the parser output save post-send will still benefit *other* users. |
1758 | DeferredUpdates::addCallableUpdate( function () { |
1759 | $this->doParserCacheUpdate(); |
1760 | } ); |
1761 | } |
1762 | } |
1763 | |
1764 | /** |
1765 | * Checks deletion logs for the specific article title and namespace combination |
1766 | * if a deletion log exists, we can assume this is a new page recreation and are tagging it with `mw-recreated`. |
1767 | * This does not consider deletions that were suppressed and therefore will not tag those. |
1768 | * |
1769 | * @param WikiPage $wikiPage |
1770 | * @param int $revisionId |
1771 | */ |
1772 | private function maybeAddRecreateChangeTag( WikiPage $wikiPage, int $revisionId ) { |
1773 | if ( $this->loadbalancerFactory->getReplicaDatabase()->newSelectQueryBuilder() |
1774 | ->select( [ '1' ] ) |
1775 | ->from( 'logging' ) |
1776 | ->where( [ |
1777 | 'log_type' => 'delete', |
1778 | 'log_title' => $wikiPage->getTitle()->getDBkey(), |
1779 | 'log_namespace' => $wikiPage->getNamespace(), |
1780 | ] )->caller( __METHOD__ )->limit( 1 )->fetchField() ) { |
1781 | $this->changeTagsStore->addTags( |
1782 | [ ChangeTags::TAG_RECREATE ], |
1783 | null, |
1784 | $revisionId ); |
1785 | } |
1786 | } |
1787 | |
1788 | /** |
1789 | * Do secondary data updates (e.g. updating link tables) or schedule them as deferred updates |
1790 | * |
1791 | * @note This does not update the parser cache. Use doParserCacheUpdate() for that. |
1792 | * @note Application logic should use Wikipage::doSecondaryDataUpdates instead. |
1793 | * |
1794 | * @param array $options |
1795 | * - recursive: make the update recursive, i.e. also update pages which transclude the |
1796 | * current page or otherwise depend on it (default: false) |
1797 | * - defer: one of the DeferredUpdates constants, or false to run immediately after waiting |
1798 | * for replication of the changes from the SecondaryDataUpdates hooks (default: false) |
1799 | * - freshness: used with 'defer'; forces an update if the last update was before the given timestamp, |
1800 | * even if the page and its dependencies didn't change since then (TS_MW; default: false) |
1801 | * @since 1.32 |
1802 | */ |
1803 | public function doSecondaryDataUpdates( array $options = [] ) { |
1804 | $this->assertHasRevision( __METHOD__ ); |
1805 | $options += [ 'recursive' => false, 'defer' => false, 'freshness' => false ]; |
1806 | $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ]; |
1807 | if ( !in_array( $options['defer'], $deferValues, true ) ) { |
1808 | throw new InvalidArgumentException( 'Invalid value for defer: ' . $options['defer'] ); |
1809 | } |
1810 | |
1811 | $triggeringUser = $this->options['triggeringUser'] ?? $this->user; |
1812 | [ $causeAction, $causeAgent ] = $this->getCauseForTracing(); |
1813 | if ( isset( $options['known-revision-output'] ) ) { |
1814 | $this->getRenderedRevision()->setRevisionParserOutput( $options['known-revision-output'] ); |
1815 | } |
1816 | |
1817 | // Bundle all of the data updates into a single deferred update wrapper so that |
1818 | // any failure will cause at most one refreshLinks job to be enqueued by |
1819 | // DeferredUpdates::doUpdates(). This is hard to do when there are many separate |
1820 | // updates that are not defined as being related. |
1821 | $update = new RefreshSecondaryDataUpdate( |
1822 | $this->loadbalancerFactory, |
1823 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Already checked |
1824 | $triggeringUser, |
1825 | $this->wikiPage, |
1826 | $this->revision, |
1827 | $this, |
1828 | [ 'recursive' => $options['recursive'], 'freshness' => $options['freshness'] ] |
1829 | ); |
1830 | $update->setCause( $causeAction, $causeAgent ); |
1831 | |
1832 | if ( $options['defer'] === false ) { |
1833 | DeferredUpdates::attemptUpdate( $update ); |
1834 | } else { |
1835 | DeferredUpdates::addUpdate( $update, $options['defer'] ); |
1836 | } |
1837 | } |
1838 | |
1839 | /** |
1840 | * Causes parser cache entries to be updated. |
1841 | * |
1842 | * @note This does not update links tables. Use doSecondaryDataUpdates() for that. |
1843 | * @note Application logic should use Wikipage::updateParserCache instead. |
1844 | */ |
1845 | public function doParserCacheUpdate() { |
1846 | $this->assertHasRevision( __METHOD__ ); |
1847 | |
1848 | $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead |
1849 | |
1850 | // NOTE: this may trigger the first parsing of the new content after an edit (when not |
1851 | // using pre-generated stashed output). |
1852 | // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse |
1853 | // to be performed post-send. The client could already follow a HTTP redirect to the |
1854 | // page view, but would then have to wait for a response until rendering is complete. |
1855 | $output = $this->getCanonicalParserOutput(); |
1856 | |
1857 | // Save it to the parser cache. Use the revision timestamp in the case of a |
1858 | // freshly saved edit, as that matches page_touched and a mismatch would trigger an |
1859 | // unnecessary reparse. |
1860 | $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp() |
1861 | : $output->getCacheTime(); |
1862 | $this->parserCache->save( |
1863 | $output, $wikiPage, $this->getCanonicalParserOptions(), |
1864 | $timestamp, $this->revision->getId() |
1865 | ); |
1866 | |
1867 | // If we enable cache warming with parsoid outputs, let's do it at the same |
1868 | // time we're populating the parser cache with pre-generated HTML. |
1869 | // Use OPT_FORCE_PARSE to avoid a useless cache lookup. |
1870 | if ( $this->warmParsoidParserCache ) { |
1871 | $cacheWarmingParams = $this->getCauseForTracing(); |
1872 | $cacheWarmingParams['options'] = ParserOutputAccess::OPT_FORCE_PARSE; |
1873 | |
1874 | $this->jobQueueGroup->lazyPush( |
1875 | ParsoidCachePrewarmJob::newSpec( |
1876 | $this->revision->getId(), |
1877 | $wikiPage->toPageRecord(), |
1878 | $cacheWarmingParams |
1879 | ) |
1880 | ); |
1881 | } |
1882 | } |
1883 | |
1884 | } |