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