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