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