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