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