Merge "Don't wait for thumbnails that take long to render"
[lhc/web/wiklou.git] / includes / Storage / DerivedPageDataUpdater.php
1 <?php
2 /**
3 * A handle for managing updates for derived page data on edit, import, purge, etc.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 namespace MediaWiki\Storage;
24
25 use ApiStashEdit;
26 use CategoryMembershipChangeJob;
27 use Content;
28 use ContentHandler;
29 use DataUpdate;
30 use DeferredUpdates;
31 use Hooks;
32 use IDBAccessObject;
33 use InvalidArgumentException;
34 use JobQueueGroup;
35 use Language;
36 use LinksUpdate;
37 use LogicException;
38 use MediaWiki\Edit\PreparedEdit;
39 use MediaWiki\Revision\RenderedRevision;
40 use MediaWiki\Revision\RevisionRenderer;
41 use MediaWiki\User\UserIdentity;
42 use MessageCache;
43 use ParserCache;
44 use ParserOptions;
45 use ParserOutput;
46 use RecentChangesUpdateJob;
47 use ResourceLoaderWikiModule;
48 use Revision;
49 use SearchUpdate;
50 use SiteStatsUpdate;
51 use Title;
52 use User;
53 use Wikimedia\Assert\Assert;
54 use Wikimedia\Rdbms\LBFactory;
55 use WikiPage;
56
57 /**
58 * A handle for managing updates for derived page data on edit, import, purge, etc.
59 *
60 * @note Avoid direct usage of DerivedPageDataUpdater.
61 *
62 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
63 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
64 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
65 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
66 * Content::getSecondaryDataUpdates().
67 *
68 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
69 * and re-used by callback code over the course of an update operation. It's a stepping stone
70 * one the way to a more complete refactoring of WikiPage.
71 *
72 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
73 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
74 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
75 * require prepareContent or prepareUpdate to have been called first, to initialize the
76 * DerivedPageDataUpdater.
77 *
78 * @see docs/pageupdater.txt for more information.
79 *
80 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
81 * of PreparedEdit.
82 *
83 * @internal
84 *
85 * @since 1.32
86 * @ingroup Page
87 */
88 class DerivedPageDataUpdater implements IDBAccessObject {
89
90 /**
91 * @var UserIdentity|null
92 */
93 private $user = null;
94
95 /**
96 * @var WikiPage
97 */
98 private $wikiPage;
99
100 /**
101 * @var ParserCache
102 */
103 private $parserCache;
104
105 /**
106 * @var RevisionStore
107 */
108 private $revisionStore;
109
110 /**
111 * @var Language
112 */
113 private $contLang;
114
115 /**
116 * @var JobQueueGroup
117 */
118 private $jobQueueGroup;
119
120 /**
121 * @var MessageCache
122 */
123 private $messageCache;
124
125 /**
126 * @var LBFactory
127 */
128 private $loadbalancerFactory;
129
130 /**
131 * @var string see $wgArticleCountMethod
132 */
133 private $articleCountMethod;
134
135 /**
136 * @var boolean see $wgRCWatchCategoryMembership
137 */
138 private $rcWatchCategoryMembership = false;
139
140 /**
141 * Stores (most of) the $options parameter of prepareUpdate().
142 * @see prepareUpdate()
143 */
144 private $options = [
145 'changed' => true,
146 'created' => false,
147 'moved' => false,
148 'restored' => false,
149 'oldrevision' => null,
150 'oldcountable' => null,
151 'oldredirect' => null,
152 'triggeringUser' => null,
153 // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
154 // to make the life of prepareUpdate() callers easier.
155 'causeAction' => null,
156 'causeAgent' => null,
157 ];
158
159 /**
160 * The state of the relevant row in page table before the edit.
161 * This is determined by the first call to grabCurrentRevision, prepareContent,
162 * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
163 * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
164 * attempt to emulate the state of the page table before the edit.
165 *
166 * Contains the following fields:
167 * - oldRevision (RevisionRecord|null): the revision that was current before the change
168 * associated with this update. Might not be set, use getOldRevision() instead of direct
169 * access.
170 * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
171 * was about creating a new page); null if not known (that should not happen).
172 * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
173 * can be null; use wasRedirect() instead of direct access.
174 * - oldCountable (bool|null): whether the page was countable before the change (or null
175 * if we don't have that information)
176 *
177 * @var array
178 */
179 private $pageState = null;
180
181 /**
182 * @var RevisionSlotsUpdate|null
183 */
184 private $slotsUpdate = null;
185
186 /**
187 * @var RevisionRecord|null
188 */
189 private $revision = null;
190
191 /**
192 * @var RenderedRevision
193 */
194 private $renderedRevision = null;
195
196 /**
197 * @var RevisionRenderer
198 */
199 private $revisionRenderer;
200
201 /**
202 * A stage identifier for managing the life cycle of this instance.
203 * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
204 *
205 * @see docs/pageupdater.txt for documentation of the life cycle.
206 *
207 * @var string
208 */
209 private $stage = 'new';
210
211 /**
212 * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
213 *
214 * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
215 * and constants are also overkill...
216 *
217 * @see docs/pageupdater.txt for documentation of the life cycle.
218 *
219 * @var array[]
220 */
221 private static $transitions = [
222 'new' => [
223 'new' => true,
224 'knows-current' => true,
225 'has-content' => true,
226 'has-revision' => true,
227 ],
228 'knows-current' => [
229 'knows-current' => true,
230 'has-content' => true,
231 'has-revision' => true,
232 ],
233 'has-content' => [
234 'has-content' => true,
235 'has-revision' => true,
236 ],
237 'has-revision' => [
238 'has-revision' => true,
239 'done' => true,
240 ],
241 ];
242
243 /**
244 * @param WikiPage $wikiPage ,
245 * @param RevisionStore $revisionStore
246 * @param RevisionRenderer $revisionRenderer
247 * @param ParserCache $parserCache
248 * @param JobQueueGroup $jobQueueGroup
249 * @param MessageCache $messageCache
250 * @param Language $contLang
251 * @param LBFactory $loadbalancerFactory
252 */
253 public function __construct(
254 WikiPage $wikiPage,
255 RevisionStore $revisionStore,
256 RevisionRenderer $revisionRenderer,
257 ParserCache $parserCache,
258 JobQueueGroup $jobQueueGroup,
259 MessageCache $messageCache,
260 Language $contLang,
261 LBFactory $loadbalancerFactory
262 ) {
263 $this->wikiPage = $wikiPage;
264
265 $this->parserCache = $parserCache;
266 $this->revisionStore = $revisionStore;
267 $this->revisionRenderer = $revisionRenderer;
268 $this->jobQueueGroup = $jobQueueGroup;
269 $this->messageCache = $messageCache;
270 $this->contLang = $contLang;
271 // XXX only needed for waiting for slaves to catch up; there should be a narrower
272 // interface for that.
273 $this->loadbalancerFactory = $loadbalancerFactory;
274 }
275
276 /**
277 * Transition function for managing the life cycle of this instances.
278 *
279 * @see docs/pageupdater.txt for documentation of the life cycle.
280 *
281 * @param string $newStage the new stage
282 * @return string the previous stage
283 *
284 * @throws LogicException If a transition to the given stage is not possible in the current
285 * stage.
286 */
287 private function doTransition( $newStage ) {
288 $this->assertTransition( $newStage );
289
290 $oldStage = $this->stage;
291 $this->stage = $newStage;
292
293 return $oldStage;
294 }
295
296 /**
297 * Asserts that a transition to the given stage is possible, without performing it.
298 *
299 * @see docs/pageupdater.txt for documentation of the life cycle.
300 *
301 * @param string $newStage the new stage
302 *
303 * @throws LogicException If this instance is not in the expected stage
304 */
305 private function assertTransition( $newStage ) {
306 if ( empty( self::$transitions[$this->stage][$newStage] ) ) {
307 throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
308 }
309 }
310
311 /**
312 * @return bool|string
313 */
314 private function getWikiId() {
315 // TODO: get from RevisionStore
316 return false;
317 }
318
319 /**
320 * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
321 * the given revision.
322 *
323 * @param UserIdentity|null $user The user creating the revision in question
324 * @param RevisionRecord|null $revision New revision (after save, if already saved)
325 * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
326 * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
327 *
328 * @return bool
329 */
330 public function isReusableFor(
331 UserIdentity $user = null,
332 RevisionRecord $revision = null,
333 RevisionSlotsUpdate $slotsUpdate = null,
334 $parentId = null
335 ) {
336 if ( $revision
337 && $parentId
338 && $revision->getParentId() !== $parentId
339 ) {
340 throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
341 }
342
343 if ( $revision
344 && $user
345 && $revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
346 ) {
347 throw new InvalidArgumentException( '$user should match the author of $revision' );
348 }
349
350 if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
351 return false;
352 }
353
354 if ( $revision && $this->revision && $this->revision->getId()
355 && $this->revision->getId() !== $revision->getId()
356 ) {
357 return false;
358 }
359
360 if ( $revision && !$user ) {
361 $user = $revision->getUser( RevisionRecord::RAW );
362 }
363
364 if ( $this->pageState
365 && $revision
366 && $revision->getParentId() !== null
367 && $this->pageState['oldId'] !== $revision->getParentId()
368 ) {
369 return false;
370 }
371
372 if ( $this->pageState
373 && $parentId !== null
374 && $this->pageState['oldId'] !== $parentId
375 ) {
376 return false;
377 }
378
379 if ( $this->revision
380 && $user
381 && $this->revision->getUser( RevisionRecord::RAW )
382 && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
383 ) {
384 return false;
385 }
386
387 if ( $revision
388 && $this->user
389 && $this->revision->getUser( RevisionRecord::RAW )
390 && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
391 ) {
392 return false;
393 }
394
395 // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
396 if ( $this->slotsUpdate
397 && $slotsUpdate
398 && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
399 ) {
400 return false;
401 }
402
403 if ( $revision
404 && $this->revision
405 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
406 ) {
407 return false;
408 }
409
410 return true;
411 }
412
413 /**
414 * @param string $articleCountMethod "any" or "link".
415 * @see $wgArticleCountMethod
416 */
417 public function setArticleCountMethod( $articleCountMethod ) {
418 $this->articleCountMethod = $articleCountMethod;
419 }
420
421 /**
422 * @param bool $rcWatchCategoryMembership
423 * @see $wgRCWatchCategoryMembership
424 */
425 public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
426 $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
427 }
428
429 /**
430 * @return Title
431 */
432 private function getTitle() {
433 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
434 return $this->wikiPage->getTitle();
435 }
436
437 /**
438 * @return WikiPage
439 */
440 private function getWikiPage() {
441 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
442 return $this->wikiPage;
443 }
444
445 /**
446 * Determines whether the page being edited already existed.
447 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
448 *
449 * @return bool
450 * @throws LogicException if called before grabCurrentRevision
451 */
452 public function pageExisted() {
453 $this->assertHasPageState( __METHOD__ );
454
455 return $this->pageState['oldId'] > 0;
456 }
457
458 /**
459 * Returns the revision that was current before the edit. This would be null if the edit
460 * created the page, or the revision's parent for a regular edit, or the revision itself
461 * for a null-edit.
462 * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
463 *
464 * @return RevisionRecord|null the revision that was current before the edit, or null if
465 * the edit created the page.
466 */
467 private function getOldRevision() {
468 $this->assertHasPageState( __METHOD__ );
469
470 // If 'oldRevision' is not set, load it!
471 // Useful if $this->oldPageState is initialized by prepareUpdate.
472 if ( !array_key_exists( 'oldRevision', $this->pageState ) ) {
473 /** @var int $oldId */
474 $oldId = $this->pageState['oldId'];
475 $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
476 $this->pageState['oldRevision'] = $oldId
477 ? $this->revisionStore->getRevisionById( $oldId, $flags )
478 : null;
479 }
480
481 return $this->pageState['oldRevision'];
482 }
483
484 /**
485 * Returns the revision that was the page's current revision when grabCurrentRevision()
486 * was first called.
487 *
488 * During an edit, that revision will act as the logical parent of the new revision.
489 *
490 * Some updates are performed based on the difference between the database state at the
491 * moment this method is first called, and the state after the edit.
492 *
493 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
494 *
495 * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
496 * to avoid confusion, since the page's current revision is then the new revision after
497 * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
498 * Use getOldRevision() instead to access the revision that used to be current before the
499 * edit.
500 *
501 * @return RevisionRecord|null the page's current revision, or null if the page does not
502 * yet exist.
503 */
504 public function grabCurrentRevision() {
505 if ( $this->pageState ) {
506 return $this->pageState['oldRevision'];
507 }
508
509 $this->assertTransition( 'knows-current' );
510
511 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
512 $wikiPage = $this->getWikiPage();
513
514 // Do not call WikiPage::clear(), since the caller may already have caused page data
515 // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
516 $wikiPage->loadPageData( self::READ_LATEST );
517 $rev = $wikiPage->getRevision();
518 $current = $rev ? $rev->getRevisionRecord() : null;
519
520 $this->pageState = [
521 'oldRevision' => $current,
522 'oldId' => $rev ? $rev->getId() : 0,
523 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
524 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
525 ];
526
527 $this->doTransition( 'knows-current' );
528
529 return $this->pageState['oldRevision'];
530 }
531
532 /**
533 * Whether prepareUpdate() or prepareContent() have been called on this instance.
534 *
535 * @return bool
536 */
537 public function isContentPrepared() {
538 return $this->revision !== null;
539 }
540
541 /**
542 * Whether prepareUpdate() has been called on this instance.
543 *
544 * @note will also return null in case of a null-edit!
545 *
546 * @return bool
547 */
548 public function isUpdatePrepared() {
549 return $this->revision !== null && $this->revision->getId() !== null;
550 }
551
552 /**
553 * @return int
554 */
555 private function getPageId() {
556 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
557 return $this->wikiPage->getId();
558 }
559
560 /**
561 * @return string
562 */
563 private function getTimestampNow() {
564 // TODO: allow an override to be injected for testing
565 return wfTimestampNow();
566 }
567
568 /**
569 * Whether the content is deleted and thus not visible to the public.
570 *
571 * @return bool
572 */
573 public function isContentDeleted() {
574 if ( $this->revision ) {
575 // XXX: if that revision is the current revision, this should be skipped
576 return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
577 } else {
578 // If the content has not been saved yet, it cannot have been deleted yet.
579 return false;
580 }
581 }
582
583 /**
584 * Returns the slot, modified or inherited, after PST, with no audience checks applied.
585 *
586 * @param string $role slot role name
587 *
588 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
589 * parent revision.
590 * @return SlotRecord
591 */
592 public function getRawSlot( $role ) {
593 return $this->getSlots()->getSlot( $role );
594 }
595
596 /**
597 * Returns the content of the given slot, with no audience checks.
598 *
599 * @throws PageUpdateException If the slot is neither set for update nor inherited from the
600 * parent revision.
601 * @param string $role slot role name
602 * @return Content
603 */
604 public function getRawContent( $role ) {
605 return $this->getRawSlot( $role )->getContent();
606 }
607
608 /**
609 * Returns the content model of the given slot
610 *
611 * @param string $role slot role name
612 * @return string
613 */
614 private function getContentModel( $role ) {
615 return $this->getRawSlot( $role )->getModel();
616 }
617
618 /**
619 * @param string $role slot role name
620 * @return ContentHandler
621 */
622 private function getContentHandler( $role ) {
623 // TODO: inject something like a ContentHandlerRegistry
624 return ContentHandler::getForModelID( $this->getContentModel( $role ) );
625 }
626
627 private function useMaster() {
628 // TODO: can we just set a flag to true in prepareContent()?
629 return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
630 }
631
632 /**
633 * @return bool
634 */
635 public function isCountable() {
636 // NOTE: Keep in sync with WikiPage::isCountable.
637
638 if ( !$this->getTitle()->isContentPage() ) {
639 return false;
640 }
641
642 if ( $this->isContentDeleted() ) {
643 // This should be irrelevant: countability only applies to the current revision,
644 // and the current revision is never suppressed.
645 return false;
646 }
647
648 if ( $this->isRedirect() ) {
649 return false;
650 }
651
652 $hasLinks = null;
653
654 if ( $this->articleCountMethod === 'link' ) {
655 $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
656 }
657
658 // TODO: MCR: ask all slots if they have links [SlotHandler/PageTypeHandler]
659 $mainContent = $this->getRawContent( 'main' );
660 return $mainContent->isCountable( $hasLinks );
661 }
662
663 /**
664 * @return bool
665 */
666 public function isRedirect() {
667 // NOTE: main slot determines redirect status
668 $mainContent = $this->getRawContent( 'main' );
669
670 return $mainContent->isRedirect();
671 }
672
673 /**
674 * @param RevisionRecord $rev
675 *
676 * @return bool
677 */
678 private function revisionIsRedirect( RevisionRecord $rev ) {
679 // NOTE: main slot determines redirect status
680 $mainContent = $rev->getContent( 'main', RevisionRecord::RAW );
681
682 return $mainContent->isRedirect();
683 }
684
685 /**
686 * Prepare updates based on an update which has not yet been saved.
687 *
688 * This may be used to create derived data that is needed when creating a new revision;
689 * particularly, this makes available the slots of the new revision via the getSlots()
690 * method, after applying PST and slot inheritance.
691 *
692 * The derived data prepared for revision creation may then later be re-used by doUpdates(),
693 * without the need to re-calculate.
694 *
695 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
696 *
697 * @note Calling this method more than once with the same $slotsUpdate
698 * has no effect. Calling this method multiple times with different content will cause
699 * an exception.
700 *
701 * @note Calling this method after prepareUpdate() has been called will cause an exception.
702 *
703 * @param User $user The user to act as context for pre-save transformation (PST).
704 * Type hint should be reduced to UserIdentity at some point.
705 * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
706 * by this edit, before PST.
707 * @param bool $useStash Whether to use stashed ParserOutput
708 */
709 public function prepareContent(
710 User $user,
711 RevisionSlotsUpdate $slotsUpdate,
712 $useStash = true
713 ) {
714 if ( $this->slotsUpdate ) {
715 if ( !$this->user ) {
716 throw new LogicException(
717 'Unexpected state: $this->slotsUpdate was initialized, '
718 . 'but $this->user was not.'
719 );
720 }
721
722 if ( $this->user->getName() !== $user->getName() ) {
723 throw new LogicException( 'Can\'t call prepareContent() again for different user! '
724 . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
725 );
726 }
727
728 if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
729 throw new LogicException(
730 'Can\'t call prepareContent() again with different slot content!'
731 );
732 }
733
734 return; // prepareContent() already done, nothing to do
735 }
736
737 $this->assertTransition( 'has-content' );
738
739 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
740 $title = $this->getTitle();
741
742 $parentRevision = $this->grabCurrentRevision();
743
744 $this->slotsOutput = [];
745 $this->canonicalParserOutput = null;
746
747 // The edit may have already been prepared via api.php?action=stashedit
748 $stashedEdit = false;
749
750 // TODO: MCR: allow output for all slots to be stashed.
751 if ( $useStash && $slotsUpdate->isModifiedSlot( 'main' ) ) {
752 $mainContent = $slotsUpdate->getModifiedSlot( 'main' )->getContent();
753 $legacyUser = User::newFromIdentity( $user );
754 $stashedEdit = ApiStashEdit::checkCache( $title, $mainContent, $legacyUser );
755 }
756
757 if ( $stashedEdit ) {
758 /** @var ParserOutput $output */
759 $output = $stashedEdit->output;
760
761 // TODO: this should happen when stashing the ParserOutput, not now!
762 $output->setCacheTime( $stashedEdit->timestamp );
763
764 // TODO: MCR: allow output for all slots to be stashed.
765 $this->canonicalParserOutput = $output;
766 }
767
768 $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
769 Hooks::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
770
771 $this->user = $user;
772 $this->slotsUpdate = $slotsUpdate;
773
774 if ( $parentRevision ) {
775 $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
776 } else {
777 $this->revision = new MutableRevisionRecord( $title );
778 }
779
780 $pstContentSlots = $this->revision->getSlots();
781
782 foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
783 $slot = $slotsUpdate->getModifiedSlot( $role );
784
785 if ( $slot->isInherited() ) {
786 // No PST for inherited slots! Note that "modified" slots may still be inherited
787 // from an earlier version, e.g. for rollbacks.
788 $pstSlot = $slot;
789 } elseif ( $role === 'main' && $stashedEdit ) {
790 // TODO: MCR: allow PST content for all slots to be stashed.
791 $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
792 } else {
793 $content = $slot->getContent();
794 $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
795 $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
796 }
797
798 $pstContentSlots->setSlot( $pstSlot );
799 }
800
801 foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
802 $pstContentSlots->removeSlot( $role );
803 }
804
805 $this->options['created'] = ( $parentRevision === null );
806 $this->options['changed'] = ( $parentRevision === null
807 || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
808
809 $this->doTransition( 'has-content' );
810
811 if ( !$this->options['changed'] ) {
812 // null-edit!
813
814 // TODO: move this into MutableRevisionRecord
815 // TODO: This needs to behave differently for a forced dummy edit!
816 $this->revision->setId( $parentRevision->getId() );
817 $this->revision->setTimestamp( $parentRevision->getTimestamp() );
818 $this->revision->setPageId( $parentRevision->getPageId() );
819 $this->revision->setParentId( $parentRevision->getParentId() );
820 $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
821 $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
822 $this->revision->setMinorEdit( $parentRevision->isMinor() );
823 $this->revision->setVisibility( $parentRevision->getVisibility() );
824
825 // prepareUpdate() is redundant for null-edits
826 $this->doTransition( 'has-revision' );
827 } else {
828 $this->revision->setUser( $user );
829 }
830 }
831
832 /**
833 * Returns the update's target revision - that is, the revision that will be the current
834 * revision after the update.
835 *
836 * @note Callers must treat the returned RevisionRecord's content as immutable, even
837 * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
838 * returned from here, such as the user or the comment, may be changed, but may not
839 * be reflected in ParserOutput until after prepareUpdate() has been called.
840 *
841 * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
842 * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
843 * for that purpose instead!
844 *
845 * @return RevisionRecord
846 */
847 public function getRevision() {
848 $this->assertPrepared( __METHOD__ );
849 return $this->revision;
850 }
851
852 /**
853 * @return RenderedRevision
854 */
855 public function getRenderedRevision() {
856 if ( !$this->renderedRevision ) {
857 $this->assertPrepared( __METHOD__ );
858
859 // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
860 // NOTE: the revision is either new or current, so we can bypass audience checks.
861 $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
862 $this->revision,
863 null,
864 null,
865 [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ]
866 );
867 }
868
869 return $this->renderedRevision;
870 }
871
872 private function assertHasPageState( $method ) {
873 if ( !$this->pageState ) {
874 throw new LogicException(
875 'Must call grabCurrentRevision() or prepareContent() '
876 . 'or prepareUpdate() before calling ' . $method
877 );
878 }
879 }
880
881 private function assertPrepared( $method ) {
882 if ( !$this->revision ) {
883 throw new LogicException(
884 'Must call prepareContent() or prepareUpdate() before calling ' . $method
885 );
886 }
887 }
888
889 private function assertHasRevision( $method ) {
890 if ( !$this->revision->getId() ) {
891 throw new LogicException(
892 'Must call prepareUpdate() before calling ' . $method
893 );
894 }
895 }
896
897 /**
898 * Whether the edit creates the page.
899 *
900 * @return bool
901 */
902 public function isCreation() {
903 $this->assertPrepared( __METHOD__ );
904 return $this->options['created'];
905 }
906
907 /**
908 * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
909 *
910 * @warning at present, "null-revisions" that do not change content but do have a revision
911 * record would return false after prepareContent(), but true after prepareUpdate()!
912 * This should probably be fixed.
913 *
914 * @return bool
915 */
916 public function isChange() {
917 $this->assertPrepared( __METHOD__ );
918 return $this->options['changed'];
919 }
920
921 /**
922 * Whether the page was a redirect before the edit.
923 *
924 * @return bool
925 */
926 public function wasRedirect() {
927 $this->assertHasPageState( __METHOD__ );
928
929 if ( $this->pageState['oldIsRedirect'] === null ) {
930 /** @var RevisionRecord $rev */
931 $rev = $this->pageState['oldRevision'];
932 if ( $rev ) {
933 $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
934 } else {
935 $this->pageState['oldIsRedirect'] = false;
936 }
937 }
938
939 return $this->pageState['oldIsRedirect'];
940 }
941
942 /**
943 * Returns the slots of the target revision, after PST.
944 *
945 * @note Callers must treat the returned RevisionSlots instance as immutable, even
946 * if it is a MutableRevisionSlots instance.
947 *
948 * @return RevisionSlots
949 */
950 public function getSlots() {
951 $this->assertPrepared( __METHOD__ );
952 return $this->revision->getSlots();
953 }
954
955 /**
956 * Returns the RevisionSlotsUpdate for this updater.
957 *
958 * @return RevisionSlotsUpdate
959 */
960 private function getRevisionSlotsUpdate() {
961 $this->assertPrepared( __METHOD__ );
962
963 if ( !$this->slotsUpdate ) {
964 $old = $this->getOldRevision();
965 $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
966 $this->revision->getSlots(),
967 $old ? $old->getSlots() : null
968 );
969 }
970 return $this->slotsUpdate;
971 }
972
973 /**
974 * Returns the role names of the slots touched by the new revision,
975 * including removed roles.
976 *
977 * @return string[]
978 */
979 public function getTouchedSlotRoles() {
980 return $this->getRevisionSlotsUpdate()->getTouchedRoles();
981 }
982
983 /**
984 * Returns the role names of the slots modified by the new revision,
985 * not including removed roles.
986 *
987 * @return string[]
988 */
989 public function getModifiedSlotRoles() {
990 return $this->getRevisionSlotsUpdate()->getModifiedRoles();
991 }
992
993 /**
994 * Returns the role names of the slots removed by the new revision.
995 *
996 * @return string[]
997 */
998 public function getRemovedSlotRoles() {
999 return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1000 }
1001
1002 /**
1003 * Prepare derived data updates targeting the given Revision.
1004 *
1005 * Calling this method requires the given revision to be present in the database.
1006 * This may be right after a new revision has been created, or when re-generating
1007 * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1008 * script.
1009 *
1010 * @see docs/pageupdater.txt for more information on when thie method can and should be called.
1011 *
1012 * @note Calling this method more than once with the same revision has no effect.
1013 * $options are only used for the first call. Calling this method multiple times with
1014 * different revisions will cause an exception.
1015 *
1016 * @note If grabCurrentRevision() (or prepareContent()) has been called before
1017 * calling this method, $revision->getParentRevision() has to refer to the revision that
1018 * was the current revision at the time grabCurrentRevision() was called.
1019 *
1020 * @param RevisionRecord $revision
1021 * @param array $options Array of options, following indexes are used:
1022 * - changed: bool, whether the revision changed the content (default true)
1023 * - created: bool, whether the revision created the page (default false)
1024 * - moved: bool, whether the page was moved (default false)
1025 * - restored: bool, whether the page was undeleted (default false)
1026 * - oldrevision: Revision object for the pre-update revision (default null)
1027 * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1028 * user who created the revision)
1029 * - oldredirect: bool, null, or string 'no-change' (default null):
1030 * - bool: whether the page was counted as a redirect before that
1031 * revision, only used in changed is true and created is false
1032 * - null or 'no-change': don't update the redirect status.
1033 * - oldcountable: bool, null, or string 'no-change' (default null):
1034 * - bool: whether the page was counted as an article before that
1035 * revision, only used in changed is true and created is false
1036 * - null: if created is false, don't update the article count; if created
1037 * is true, do update the article count
1038 * - 'no-change': don't update the article count, ever
1039 * When set to null, pageState['oldCountable'] will be used instead if available.
1040 * - causeAction: an arbitrary string identifying the reason for the update.
1041 * See DataUpdate::getCauseAction(). (default 'unknown')
1042 * - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1043 * (string, default 'unknown')
1044 */
1045 public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1046 Assert::parameter(
1047 !isset( $options['oldrevision'] )
1048 || $options['oldrevision'] instanceof Revision
1049 || $options['oldrevision'] instanceof RevisionRecord,
1050 '$options["oldrevision"]',
1051 'must be a RevisionRecord (or Revision)'
1052 );
1053 Assert::parameter(
1054 !isset( $options['triggeringUser'] )
1055 || $options['triggeringUser'] instanceof UserIdentity,
1056 '$options["triggeringUser"]',
1057 'must be a UserIdentity'
1058 );
1059
1060 if ( !$revision->getId() ) {
1061 throw new InvalidArgumentException(
1062 'Revision must have an ID set for it to be used with prepareUpdate()!'
1063 );
1064 }
1065
1066 if ( $this->revision && $this->revision->getId() ) {
1067 if ( $this->revision->getId() === $revision->getId() ) {
1068 return; // nothing to do!
1069 } else {
1070 throw new LogicException(
1071 'Trying to re-use DerivedPageDataUpdater with revision '
1072 .$revision->getId()
1073 . ', but it\'s already bound to revision '
1074 . $this->revision->getId()
1075 );
1076 }
1077 }
1078
1079 if ( $this->revision
1080 && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1081 ) {
1082 throw new LogicException(
1083 'The Revision provided has mismatching content!'
1084 );
1085 }
1086
1087 // Override fields defined in $this->options with values from $options.
1088 $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1089
1090 if ( isset( $this->pageState['oldId'] ) ) {
1091 $oldId = $this->pageState['oldId'];
1092 } elseif ( isset( $this->options['oldrevision'] ) ) {
1093 /** @var Revision|RevisionRecord $oldRev */
1094 $oldRev = $this->options['oldrevision'];
1095 $oldId = $oldRev->getId();
1096 } else {
1097 $oldId = $revision->getParentId();
1098 }
1099
1100 if ( $oldId !== null ) {
1101 // XXX: what if $options['changed'] disagrees?
1102 // MovePage creates a dummy revision with changed = false!
1103 // We may want to explicitly distinguish between "no new revision" (null-edit)
1104 // and "new revision without new content" (dummy revision).
1105
1106 if ( $oldId === $revision->getParentId() ) {
1107 // NOTE: this may still be a NullRevision!
1108 // New revision!
1109 $this->options['changed'] = true;
1110 } elseif ( $oldId === $revision->getId() ) {
1111 // Null-edit!
1112 $this->options['changed'] = false;
1113 } else {
1114 // This indicates that calling code has given us the wrong Revision object
1115 throw new LogicException(
1116 'The Revision mismatches old revision ID: '
1117 . 'Old ID is ' . $oldId
1118 . ', parent ID is ' . $revision->getParentId()
1119 . ', revision ID is ' . $revision->getId()
1120 );
1121 }
1122 }
1123
1124 // If prepareContent() was used to generate the PST content (which is indicated by
1125 // $this->slotsUpdate being set), and this is not a null-edit, then the given
1126 // revision must have the acting user as the revision author. Otherwise, user
1127 // signatures generated by PST would mismatch the user in the revision record.
1128 if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1129 $user = $revision->getUser();
1130 if ( !$this->user->equals( $user ) ) {
1131 throw new LogicException(
1132 'The Revision provided has a mismatching actor: expected '
1133 .$this->user->getName()
1134 . ', got '
1135 . $user->getName()
1136 );
1137 }
1138 }
1139
1140 // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1141 // emulate the state of the page table before the edit, as good as we can.
1142 if ( !$this->pageState ) {
1143 $this->pageState = [
1144 'oldIsRedirect' => isset( $this->options['oldredirect'] )
1145 && is_bool( $this->options['oldredirect'] )
1146 ? $this->options['oldredirect']
1147 : null,
1148 'oldCountable' => isset( $this->options['oldcountable'] )
1149 && is_bool( $this->options['oldcountable'] )
1150 ? $this->options['oldcountable']
1151 : null,
1152 ];
1153
1154 if ( $this->options['changed'] ) {
1155 // The edit created a new revision
1156 $this->pageState['oldId'] = $revision->getParentId();
1157
1158 if ( isset( $this->options['oldrevision'] ) ) {
1159 $rev = $this->options['oldrevision'];
1160 $this->pageState['oldRevision'] = $rev instanceof Revision
1161 ? $rev->getRevisionRecord()
1162 : $rev;
1163 }
1164 } else {
1165 // This is a null-edit, so the old revision IS the new revision!
1166 $this->pageState['oldId'] = $revision->getId();
1167 $this->pageState['oldRevision'] = $revision;
1168 }
1169 }
1170
1171 // "created" is forced here
1172 $this->options['created'] = ( $this->pageState['oldId'] === 0 );
1173
1174 $this->revision = $revision;
1175
1176 $this->doTransition( 'has-revision' );
1177
1178 // NOTE: in case we have a User object, don't override with a UserIdentity.
1179 // We already checked that $revision->getUser() mathces $this->user;
1180 if ( !$this->user ) {
1181 $this->user = $revision->getUser( RevisionRecord::RAW );
1182 }
1183
1184 // Prune any output that depends on the revision ID.
1185 if ( $this->renderedRevision ) {
1186 $this->renderedRevision->updateRevision( $revision );
1187 }
1188
1189 // TODO: optionally get ParserOutput from the ParserCache here.
1190 // Move the logic used by RefreshLinksJob here!
1191 }
1192
1193 /**
1194 * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1195 * @return PreparedEdit
1196 */
1197 public function getPreparedEdit() {
1198 $this->assertPrepared( __METHOD__ );
1199
1200 $slotsUpdate = $this->getRevisionSlotsUpdate();
1201 $preparedEdit = new PreparedEdit();
1202
1203 $preparedEdit->popts = $this->getCanonicalParserOptions();
1204 $preparedEdit->output = $this->getCanonicalParserOutput();
1205 $preparedEdit->pstContent = $this->revision->getContent( 'main' );
1206 $preparedEdit->newContent =
1207 $slotsUpdate->isModifiedSlot( 'main' )
1208 ? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
1209 : $this->revision->getContent( 'main' ); // XXX: can we just remove this?
1210 $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1211 $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1212 $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
1213 $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1214
1215 return $preparedEdit;
1216 }
1217
1218 /**
1219 * @param string $role
1220 * @param bool $generateHtml
1221 * @return ParserOutput
1222 */
1223 public function getSlotParserOutput( $role, $generateHtml = true ) {
1224 return $this->getRenderedRevision()->getSlotParserOutput(
1225 $role,
1226 [ 'generate-html' => $generateHtml ]
1227 );
1228 }
1229
1230 /**
1231 * @return ParserOutput
1232 */
1233 public function getCanonicalParserOutput() {
1234 return $this->getRenderedRevision()->getRevisionParserOutput();
1235 }
1236
1237 /**
1238 * @return ParserOptions
1239 */
1240 public function getCanonicalParserOptions() {
1241 return $this->getRenderedRevision()->getOptions();
1242 }
1243
1244 /**
1245 * @param bool $recursive
1246 *
1247 * @return DataUpdate[]
1248 */
1249 public function getSecondaryDataUpdates( $recursive = false ) {
1250 // TODO: MCR: getSecondaryDataUpdates() needs a complete overhaul to avoid DataUpdates
1251 // from different slots overwriting each other in the database. Plan:
1252 // * replace direct calls to Content::getSecondaryDataUpdates() with calls to this method
1253 // * Construct LinksUpdate here, on the combined ParserOutput, instead of in AbstractContent
1254 // for each slot.
1255 // * Pass $slot into getSecondaryDataUpdates() - probably be introducing a new duplicate
1256 // version of this function in ContentHandler.
1257 // * The new method gets the PreparedEdit, but no $recursive flag (that's for LinksUpdate)
1258 // * Hack: call both the old and the new getSecondaryDataUpdates method here; Pass
1259 // the per-slot ParserOutput to the old method, for B/C.
1260 // * Hack: If there is more than one slot, filter LinksUpdate from the DataUpdates
1261 // returned by getSecondaryDataUpdates, and use a LinksUpdated for the combined output
1262 // instead.
1263 // * Call the SecondaryDataUpdates hook here (or kill it - its signature doesn't make sense)
1264
1265 $content = $this->getSlots()->getContent( 'main' );
1266
1267 // NOTE: $output is the combined output, to be shown in the default view.
1268 $output = $this->getCanonicalParserOutput();
1269
1270 $updates = $content->getSecondaryDataUpdates(
1271 $this->getTitle(), null, $recursive, $output
1272 );
1273
1274 return $updates;
1275 }
1276
1277 /**
1278 * Do standard updates after page edit, purge, or import.
1279 * Update links tables, site stats, search index, title cache, message cache, etc.
1280 * Purges pages that depend on this page when appropriate.
1281 * With a 10% chance, triggers pruning the recent changes table.
1282 *
1283 * @note prepareUpdate() must be called before calling this method!
1284 *
1285 * MCR migration note: this replaces WikiPage::doEditUpdates.
1286 */
1287 public function doUpdates() {
1288 $this->assertTransition( 'done' );
1289
1290 // TODO: move logic into a PageEventEmitter service
1291
1292 $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1293
1294 $legacyUser = User::newFromIdentity( $this->user );
1295 $legacyRevision = new Revision( $this->revision );
1296
1297 $this->doParserCacheUpdate();
1298
1299 $this->doSecondaryDataUpdates( [
1300 // T52785 do not update any other pages on a null edit
1301 'recursive' => $this->options['changed'],
1302 'defer' => DeferredUpdates::POSTSEND,
1303 ] );
1304
1305 // TODO: MCR: check if *any* changed slot supports categories!
1306 if ( $this->rcWatchCategoryMembership
1307 && $this->getContentHandler( 'main' )->supportsCategories() === true
1308 && ( $this->options['changed'] || $this->options['created'] )
1309 && !$this->options['restored']
1310 ) {
1311 // Note: jobs are pushed after deferred updates, so the job should be able to see
1312 // the recent change entry (also done via deferred updates) and carry over any
1313 // bot/deletion/IP flags, ect.
1314 $this->jobQueueGroup->lazyPush(
1315 new CategoryMembershipChangeJob(
1316 $this->getTitle(),
1317 [
1318 'pageId' => $this->getPageId(),
1319 'revTimestamp' => $this->revision->getTimestamp(),
1320 ]
1321 )
1322 );
1323 }
1324
1325 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1326 $editInfo = $this->getPreparedEdit();
1327 Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
1328
1329 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1330 if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
1331 // Flush old entries from the `recentchanges` table
1332 if ( mt_rand( 0, 9 ) == 0 ) {
1333 $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
1334 }
1335 }
1336
1337 $id = $this->getPageId();
1338 $title = $this->getTitle();
1339 $dbKey = $title->getPrefixedDBkey();
1340 $shortTitle = $title->getDBkey();
1341
1342 if ( !$title->exists() ) {
1343 wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out\n" );
1344
1345 $this->doTransition( 'done' );
1346 return;
1347 }
1348
1349 if ( $this->options['oldcountable'] === 'no-change' ||
1350 ( !$this->options['changed'] && !$this->options['moved'] )
1351 ) {
1352 $good = 0;
1353 } elseif ( $this->options['created'] ) {
1354 $good = (int)$this->isCountable();
1355 } elseif ( $this->options['oldcountable'] !== null ) {
1356 $good = (int)$this->isCountable()
1357 - (int)$this->options['oldcountable'];
1358 } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1359 $good = (int)$this->isCountable()
1360 - (int)$this->pageState['oldCountable'];
1361 } else {
1362 $good = 0;
1363 }
1364 $edits = $this->options['changed'] ? 1 : 0;
1365 $pages = $this->options['created'] ? 1 : 0;
1366
1367 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1368 [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1369 ) );
1370
1371 // TODO: make search infrastructure aware of slots!
1372 $mainSlot = $this->revision->getSlot( 'main' );
1373 if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1374 DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
1375 }
1376
1377 // If this is another user's talk page, update newtalk.
1378 // Don't do this if $options['changed'] = false (null-edits) nor if
1379 // it's a minor edit and the user making the edit doesn't generate notifications for those.
1380 if ( $this->options['changed']
1381 && $title->getNamespace() == NS_USER_TALK
1382 && $shortTitle != $legacyUser->getTitleKey()
1383 && !( $this->revision->isMinor() && $legacyUser->isAllowed( 'nominornewtalk' ) )
1384 ) {
1385 $recipient = User::newFromName( $shortTitle, false );
1386 if ( !$recipient ) {
1387 wfDebug( __METHOD__ . ": invalid username\n" );
1388 } else {
1389 // Allow extensions to prevent user notification
1390 // when a new message is added to their talk page
1391 // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
1392 if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
1393 if ( User::isIP( $shortTitle ) ) {
1394 // An anonymous user
1395 $recipient->setNewtalk( true, $legacyRevision );
1396 } elseif ( $recipient->isLoggedIn() ) {
1397 $recipient->setNewtalk( true, $legacyRevision );
1398 } else {
1399 wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
1400 }
1401 }
1402 }
1403 }
1404
1405 if ( $title->getNamespace() == NS_MEDIAWIKI
1406 && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
1407 ) {
1408 $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( 'main' );
1409
1410 $this->messageCache->updateMessageOverride( $title, $mainContent );
1411 }
1412
1413 // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
1414 if ( $this->options['created'] ) {
1415 WikiPage::onArticleCreate( $title );
1416 } elseif ( $this->options['changed'] ) { // T52785
1417 WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
1418 }
1419
1420 $oldRevision = $this->getOldRevision();
1421 $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
1422
1423 // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1424 ResourceLoaderWikiModule::invalidateModuleCache(
1425 $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId() ?: wfWikiID()
1426 );
1427
1428 $this->doTransition( 'done' );
1429 }
1430
1431 /**
1432 * Do secondary data updates (such as updating link tables).
1433 *
1434 * MCR note: this method is temporarily exposed via WikiPage::doSecondaryDataUpdates.
1435 *
1436 * @param array $options
1437 * - recursive: make the update recursive, i.e. also update pages which transclude the
1438 * current page or otherwise depend on it (default: false)
1439 * - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1440 * for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1441 * - transactionTicket: a transaction ticket from LBFactory::getEmptyTransactionTicket(),
1442 * only when defer is false (default: null)
1443 * @since 1.32
1444 */
1445 public function doSecondaryDataUpdates( array $options = [] ) {
1446 $this->assertHasRevision( __METHOD__ );
1447 $options += [
1448 'recursive' => false,
1449 'defer' => false,
1450 'transactionTicket' => null,
1451 ];
1452 $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1453 if ( !in_array( $options['defer'], $deferValues, true ) ) {
1454 throw new InvalidArgumentException( 'invalid value for defer: ' . $options['defer'] );
1455 }
1456 Assert::parameterType( 'integer|null', $options['transactionTicket'],
1457 '$options[\'transactionTicket\']' );
1458
1459 $updates = $this->getSecondaryDataUpdates( $options['recursive'] );
1460
1461 $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1462 if ( !$triggeringUser instanceof User ) {
1463 $triggeringUser = User::newFromIdentity( $triggeringUser );
1464 }
1465 $causeAction = $this->options['causeAction'] ?? 'unknown';
1466 $causeAgent = $this->options['causeAgent'] ?? 'unknown';
1467 $legacyRevision = new Revision( $this->revision );
1468
1469 if ( $options['defer'] === false && $options['transactionTicket'] !== null ) {
1470 // For legacy hook handlers doing updates via LinksUpdateConstructed, make sure
1471 // any pending writes they made get flushed before the doUpdate() calls below.
1472 // This avoids snapshot-clearing errors in LinksUpdate::acquirePageLock().
1473 $this->loadbalancerFactory->commitAndWaitForReplication(
1474 __METHOD__, $options['transactionTicket']
1475 );
1476 }
1477
1478 foreach ( $updates as $update ) {
1479 $update->setCause( $causeAction, $causeAgent );
1480 if ( $update instanceof LinksUpdate ) {
1481 $update->setRevision( $legacyRevision );
1482 $update->setTriggeringUser( $triggeringUser );
1483 }
1484 if ( $options['defer'] === false ) {
1485 if ( $options['transactionTicket'] !== null ) {
1486 $update->setTransactionTicket( $options['transactionTicket'] );
1487 }
1488 $update->doUpdate();
1489 } else {
1490 DeferredUpdates::addUpdate( $update, $options['defer'] );
1491 }
1492 }
1493 }
1494
1495 public function doParserCacheUpdate() {
1496 $this->assertHasRevision( __METHOD__ );
1497
1498 $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1499
1500 // NOTE: this may trigger the first parsing of the new content after an edit (when not
1501 // using pre-generated stashed output).
1502 // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1503 // to be performed post-send. The client could already follow a HTTP redirect to the
1504 // page view, but would then have to wait for a response until rendering is complete.
1505 $output = $this->getCanonicalParserOutput();
1506
1507 // Save it to the parser cache. Use the revision timestamp in the case of a
1508 // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1509 // unnecessary reparse.
1510 $timestamp = $this->options['changed'] ? $this->revision->getTimestamp()
1511 : $output->getTimestamp();
1512 $this->parserCache->save(
1513 $output, $wikiPage, $this->getCanonicalParserOptions(),
1514 $timestamp, $this->revision->getId()
1515 );
1516 }
1517
1518 }