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