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