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