SpecialBlock [Vue]: load extension-provided messages
[mediawiki.git] / includes / Storage / PageUpdater.php
blobcd81aba7bd29ba70ea2a0cee5ecb0ec53cbc0948
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Storage;
23 use InvalidArgumentException;
24 use LogicException;
25 use ManualLogEntry;
26 use MediaWiki\CommentStore\CommentStoreComment;
27 use MediaWiki\Config\ServiceOptions;
28 use MediaWiki\Content\Content;
29 use MediaWiki\Content\ContentHandler;
30 use MediaWiki\Content\IContentHandlerFactory;
31 use MediaWiki\Content\ValidationParams;
32 use MediaWiki\Deferred\AtomicSectionUpdate;
33 use MediaWiki\Deferred\DeferredUpdates;
34 use MediaWiki\HookContainer\HookContainer;
35 use MediaWiki\HookContainer\HookRunner;
36 use MediaWiki\MainConfigNames;
37 use MediaWiki\MediaWikiServices;
38 use MediaWiki\Page\PageIdentity;
39 use MediaWiki\Page\WikiPageFactory;
40 use MediaWiki\Revision\MutableRevisionRecord;
41 use MediaWiki\Revision\RevisionAccessException;
42 use MediaWiki\Revision\RevisionRecord;
43 use MediaWiki\Revision\RevisionStore;
44 use MediaWiki\Revision\SlotRecord;
45 use MediaWiki\Revision\SlotRoleRegistry;
46 use MediaWiki\Title\Title;
47 use MediaWiki\Title\TitleFormatter;
48 use MediaWiki\User\User;
49 use MediaWiki\User\UserEditTracker;
50 use MediaWiki\User\UserGroupManager;
51 use MediaWiki\User\UserIdentity;
52 use Psr\Log\LoggerInterface;
53 use RecentChange;
54 use RuntimeException;
55 use Wikimedia\Assert\Assert;
56 use Wikimedia\Rdbms\IConnectionProvider;
57 use Wikimedia\Rdbms\IDatabase;
58 use Wikimedia\Rdbms\IDBAccessObject;
59 use WikiPage;
61 /**
62 * Controller-like object for creating and updating pages by creating new revisions.
64 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
65 * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
66 * This allows application logic to safely perform edit conflict resolution using the parent
67 * revision's content.
69 * MCR migration note: this replaces the relevant methods in WikiPage.
71 * @see docs/pageupdater.md for more information.
73 * @since 1.32
74 * @ingroup Page
75 * @author Daniel Kinzler
77 class PageUpdater {
79 /**
80 * Options that have to be present in the ServiceOptions object passed to the constructor.
81 * @note When adding options here, also add them to PageUpdaterFactory::CONSTRUCTOR_OPTIONS.
82 * @internal
84 public const CONSTRUCTOR_OPTIONS = [
85 MainConfigNames::ManualRevertSearchRadius,
86 MainConfigNames::UseRCPatrol,
89 /**
90 * @var UserIdentity
92 private $author;
94 /**
95 * TODO Remove this eventually.
96 * @var WikiPage
98 private $wikiPage;
101 * @var PageIdentity
103 private $pageIdentity;
106 * @var DerivedPageDataUpdater
108 private $derivedDataUpdater;
111 * @var IConnectionProvider
113 private $dbProvider;
116 * @var RevisionStore
118 private $revisionStore;
121 * @var SlotRoleRegistry
123 private $slotRoleRegistry;
126 * @var IContentHandlerFactory
128 private $contentHandlerFactory;
131 * @var HookRunner
133 private $hookRunner;
136 * @var HookContainer
138 private $hookContainer;
140 /** @var UserEditTracker */
141 private $userEditTracker;
143 /** @var UserGroupManager */
144 private $userGroupManager;
146 /** @var TitleFormatter */
147 private $titleFormatter;
150 * @var bool see $wgUseAutomaticEditSummaries
151 * @see $wgUseAutomaticEditSummaries
153 private $useAutomaticEditSummaries = true;
156 * @var int the RC patrol status the new revision should be marked with.
158 private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
161 * @var bool whether to create a log entry for new page creations.
163 private $usePageCreationLog = true;
166 * @var bool Whether null-edits create a revision.
168 private $forceEmptyRevision = false;
171 * @var bool Whether to prevent new revision creation by throwing if it is
172 * attempted.
174 private $preventChange = false;
177 * @var array
179 private $tags = [];
182 * @var RevisionSlotsUpdate
184 private $slotsUpdate;
187 * @var PageUpdateStatus|null
189 private $status = null;
192 * @var EditResultBuilder
194 private $editResultBuilder;
197 * @var EditResult|null
199 private $editResult = null;
202 * @var ServiceOptions
204 private $serviceOptions;
207 * @var int
209 private $flags = 0;
211 /** @var string[] */
212 private $softwareTags = [];
214 /** @var LoggerInterface */
215 private $logger;
218 * @param UserIdentity $author
219 * @param PageIdentity $page
220 * @param DerivedPageDataUpdater $derivedDataUpdater
221 * @param IConnectionProvider $dbProvider
222 * @param RevisionStore $revisionStore
223 * @param SlotRoleRegistry $slotRoleRegistry
224 * @param IContentHandlerFactory $contentHandlerFactory
225 * @param HookContainer $hookContainer
226 * @param UserEditTracker $userEditTracker
227 * @param UserGroupManager $userGroupManager
228 * @param TitleFormatter $titleFormatter
229 * @param ServiceOptions $serviceOptions
230 * @param string[] $softwareTags Array of currently enabled software change tags. Can be
231 * obtained from ChangeTagsStore->getSoftwareTags()
232 * @param LoggerInterface $logger
233 * @param WikiPageFactory $wikiPageFactory
235 public function __construct(
236 UserIdentity $author,
237 PageIdentity $page,
238 DerivedPageDataUpdater $derivedDataUpdater,
239 IConnectionProvider $dbProvider,
240 RevisionStore $revisionStore,
241 SlotRoleRegistry $slotRoleRegistry,
242 IContentHandlerFactory $contentHandlerFactory,
243 HookContainer $hookContainer,
244 UserEditTracker $userEditTracker,
245 UserGroupManager $userGroupManager,
246 TitleFormatter $titleFormatter,
247 ServiceOptions $serviceOptions,
248 array $softwareTags,
249 LoggerInterface $logger,
250 WikiPageFactory $wikiPageFactory
252 $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
253 $this->serviceOptions = $serviceOptions;
255 $this->author = $author;
256 $this->pageIdentity = $page;
257 $this->wikiPage = $wikiPageFactory->newFromTitle( $page );
258 $this->derivedDataUpdater = $derivedDataUpdater;
260 $this->dbProvider = $dbProvider;
261 $this->revisionStore = $revisionStore;
262 $this->slotRoleRegistry = $slotRoleRegistry;
263 $this->contentHandlerFactory = $contentHandlerFactory;
264 $this->hookContainer = $hookContainer;
265 $this->hookRunner = new HookRunner( $hookContainer );
266 $this->userEditTracker = $userEditTracker;
267 $this->userGroupManager = $userGroupManager;
268 $this->titleFormatter = $titleFormatter;
270 $this->slotsUpdate = new RevisionSlotsUpdate();
271 $this->editResultBuilder = new EditResultBuilder(
272 $revisionStore,
273 $softwareTags,
274 new ServiceOptions(
275 EditResultBuilder::CONSTRUCTOR_OPTIONS,
277 MainConfigNames::ManualRevertSearchRadius =>
278 $serviceOptions->get( MainConfigNames::ManualRevertSearchRadius )
282 $this->softwareTags = $softwareTags;
283 $this->logger = $logger;
287 * Sets any flags to use when performing the update.
288 * Flags passed in subsequent calls to this method as well as calls to prepareUpdate()
289 * or saveRevision() are aggregated using bitwise OR.
291 * Known flags:
293 * EDIT_NEW
294 * Create a new page, or fail with "edit-already-exists" if the page exists.
295 * EDIT_UPDATE
296 * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
297 * EDIT_MINOR
298 * Mark this revision as minor
299 * EDIT_SUPPRESS_RC
300 * Do not log the change in recentchanges
301 * EDIT_FORCE_BOT
302 * Mark the revision as automated ("bot edit")
303 * EDIT_AUTOSUMMARY
304 * Fill in blank summaries with generated text where possible
305 * EDIT_INTERNAL
306 * Signal that the page retrieve/save cycle happened entirely in this request.
308 * @param int $flags Bitfield
309 * @return $this
311 public function setFlags( int $flags ) {
312 $this->flags |= $flags;
313 return $this;
317 * Prepare the update.
318 * This sets up the RevisionRecord to be saved.
319 * @since 1.37
321 * @param int $flags Bitfield, will be combined with flags set via setFlags().
322 * EDIT_FORCE_BOT and EDIT_INTERNAL will bypass the edit stash.
324 * @return PreparedUpdate
326 public function prepareUpdate( int $flags = 0 ): PreparedUpdate {
327 $this->setFlags( $flags );
329 // Load the data from the primary database if needed. Needed to check flags.
330 $this->derivedDataUpdater->setCause( 'edit-page', $this->author->getName() );
332 $this->grabParentRevision();
333 if ( !$this->derivedDataUpdater->isUpdatePrepared() ) {
334 // Avoid statsd noise and wasted cycles check the edit stash (T136678)
335 $useStashed = !( ( $this->flags & EDIT_INTERNAL ) || ( $this->flags & EDIT_FORCE_BOT ) );
336 // Prepare the update. This performs PST and generates the canonical ParserOutput.
337 $this->derivedDataUpdater->prepareContent(
338 $this->author,
339 $this->slotsUpdate,
340 $useStashed
344 return $this->derivedDataUpdater;
348 * @param UserIdentity $user
350 * @return User
352 private static function toLegacyUser( UserIdentity $user ) {
353 return User::newFromIdentity( $user );
357 * After creation of the user during the save process, update the stored
358 * UserIdentity.
359 * @since 1.39
361 * @param UserIdentity $author
363 public function updateAuthor( UserIdentity $author ) {
364 if ( $this->author->getName() !== $author->getName() ) {
365 throw new InvalidArgumentException( 'Cannot replace the author with an author ' .
366 'of a different name, since DerivedPageDataUpdater may have stored the ' .
367 'old name.' );
369 $this->author = $author;
373 * Can be used to enable or disable automatic summaries that are applied to certain kinds of
374 * changes, like completely blanking a page.
376 * @param bool $useAutomaticEditSummaries
377 * @return $this
378 * @see $wgUseAutomaticEditSummaries
380 public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
381 $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
382 return $this;
386 * Sets the "patrolled" status of the edit.
387 * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
389 * @see $wgUseRCPatrol
390 * @see $wgUseNPPatrol
392 * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
393 * @return $this
395 public function setRcPatrolStatus( $status ) {
396 $this->rcPatrolStatus = $status;
397 return $this;
401 * Whether to create a log entry for new page creations.
403 * @see $wgPageCreationLog
405 * @param bool $use
406 * @return $this
408 public function setUsePageCreationLog( $use ) {
409 $this->usePageCreationLog = $use;
410 return $this;
414 * Set whether null-edits should create a revision. Enabling this allows the creation of dummy
415 * revisions ("null revisions") to mark events such as renaming in the page history.
417 * Callers should typically also call setOriginalRevisionId() to indicate the ID of the revision
418 * that is being repeated. That ID can be obtained from grabParentRevision()->getId().
420 * @since 1.38
422 * @note this calls $this->setOriginalRevisionId() with the ID of the current revision,
423 * starting the CAS bracket by virtue of calling $this->grabParentRevision().
425 * @note saveRevision() will fail with a LogicException if setForceEmptyRevision( true )
426 * was called and also content was changed via setContent(), removeSlot(), or inheritSlot().
428 * @param bool $forceEmptyRevision
429 * @return $this
431 public function setForceEmptyRevision( bool $forceEmptyRevision ): self {
432 $this->forceEmptyRevision = $forceEmptyRevision;
434 if ( $forceEmptyRevision ) {
435 // XXX: throw if there is no current/parent revision?
436 $original = $this->grabParentRevision();
437 $this->setOriginalRevisionId( $original ? $original->getId() : false );
440 $this->derivedDataUpdater->setForceEmptyRevision( $forceEmptyRevision );
441 return $this;
444 private function getWikiId() {
445 return $this->revisionStore->getWikiId();
449 * Get the page we're currently updating.
450 * @return PageIdentity
452 public function getPage(): PageIdentity {
453 return $this->pageIdentity;
457 * @return Title
459 private function getTitle() {
460 // NOTE: eventually, this won't use WikiPage any more
461 return $this->wikiPage->getTitle();
465 * @return WikiPage
467 private function getWikiPage() {
468 // NOTE: eventually, this won't use WikiPage any more
469 return $this->wikiPage;
473 * Checks whether this update conflicts with another update performed between the client
474 * loading data to prepare an edit, and the client committing the edit. This is intended to
475 * detect user level "edit conflict" when the latest revision known to the client
476 * is no longer the current revision when processing the update.
478 * An update expected to create a new page can be checked by setting $expectedParentRevision = 0.
479 * Such an update is considered to have a conflict if a current revision exists (that is,
480 * the page was created since the edit was initiated on the client).
482 * This method returning true indicates to calling code that edit conflict resolution should
483 * be applied before saving any data. It does not prevent the update from being performed, and
484 * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
485 * A "late" conflict is a CAS failure caused by an update being performed concurrently between
486 * the time grabParentRevision() was called and the time saveRevision() trying to insert the
487 * new revision.
489 * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
490 * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
491 * This method calls grabParentRevision(), and thus causes the expected parent revision
492 * for the update to be fixed to the page's current revision at this point in time.
493 * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
494 * will fail with the "edit-conflict" status if the current revision of the page changes after
495 * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert
496 * a new revision.
498 * @see grabParentRevision()
500 * @param int $expectedParentRevision The ID of the revision the client expects to be the
501 * current one. Use 0 to indicate that the page is expected to not yet exist.
503 * @return bool
505 public function hasEditConflict( $expectedParentRevision ) {
506 $parent = $this->grabParentRevision();
507 $parentId = $parent ? $parent->getId() : 0;
509 return $parentId !== $expectedParentRevision;
513 * Returns the revision that was the page's current revision when grabParentRevision()
514 * was first called. This revision is the expected parent revision of the update, and will be
515 * recorded as the new revision's parent revision (unless no new revision is created because
516 * the content was not changed).
518 * This method MUST not be called after saveRevision() was called!
520 * The current revision determined by the first call to this method effectively acts a
521 * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
522 * concurrent updates created a new revision.
524 * Application code should call this method before applying transformations to the new
525 * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
526 * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
527 * updates.
529 * @see DerivedPageDataUpdater::grabCurrentRevision()
531 * @note The expected parent revision is not to be confused with the logical base revision.
532 * The base revision is specified by the client, the parent revision is determined from the
533 * database. If base revision and parent revision are not the same, the updates is considered
534 * to require edit conflict resolution.
536 * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
538 public function grabParentRevision() {
539 return $this->derivedDataUpdater->grabCurrentRevision();
543 * Set the new content for the given slot role
545 * @param string $role A slot role name (such as SlotRecord::MAIN)
546 * @param Content $content
547 * @return $this
549 public function setContent( $role, Content $content ) {
550 $this->ensureRoleAllowed( $role );
552 $this->slotsUpdate->modifyContent( $role, $content );
553 return $this;
557 * Set the new slot for the given slot role
559 * @param SlotRecord $slot
560 * @return $this
562 public function setSlot( SlotRecord $slot ) {
563 $this->ensureRoleAllowed( $slot->getRole() );
565 $this->slotsUpdate->modifySlot( $slot );
566 return $this;
570 * Explicitly inherit a slot from some earlier revision.
572 * The primary use case for this is rollbacks, when slots are to be inherited from
573 * the rollback target, overriding the content from the parent revision (which is the
574 * revision being rolled back).
576 * This should typically not be used to inherit slots from the parent revision, which
577 * happens implicitly. Using this method causes the given slot to be treated as "modified"
578 * during revision creation, even if it has the same content as in the parent revision.
580 * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
581 * by the new revision.
582 * @return $this
584 public function inheritSlot( SlotRecord $originalSlot ) {
585 // NOTE: slots can be inherited even if the role is not "allowed" on the title.
586 // NOTE: this slot is inherited from some other revision, but it's
587 // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
588 // since it's not implicitly inherited from the parent revision.
589 $inheritedSlot = SlotRecord::newInherited( $originalSlot );
590 $this->slotsUpdate->modifySlot( $inheritedSlot );
591 return $this;
595 * Removes the slot with the given role.
597 * This discontinues the "stream" of slots with this role on the page,
598 * preventing the new revision, and any subsequent revisions, from
599 * inheriting the slot with this role.
601 * @param string $role A slot role name (but not SlotRecord::MAIN)
603 public function removeSlot( $role ) {
604 $this->ensureRoleNotRequired( $role );
606 $this->slotsUpdate->removeSlot( $role );
610 * Sets the ID of an earlier revision that is being repeated or restored by this update.
611 * The new revision is expected to have the exact same content as the given original revision.
612 * This is used with rollbacks and with dummy "null" revisions which are created to record
613 * things like page moves. setForceEmptyRevision() calls this implicitly.
615 * @param int|bool $originalRevId The original revision id, or false if no earlier revision
616 * is known to be repeated or restored by this update.
617 * @return $this
619 public function setOriginalRevisionId( $originalRevId ) {
620 $this->editResultBuilder->setOriginalRevision( $originalRevId );
621 return $this;
625 * Marks this edit as a revert and applies relevant information.
626 * Will also cause the PageUpdater to add a relevant change tag when saving the edit.
628 * @param int $revertMethod The method used to make the revert:
629 * REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
630 * @param int $newestRevertedRevId the revision ID of the latest reverted revision.
631 * @param int|null $revertAfterRevId the revision ID after which revisions
632 * are being reverted. Defaults to the revision before the $newestRevertedRevId.
633 * @return $this
634 * @see EditResultBuilder::markAsRevert()
636 public function markAsRevert(
637 int $revertMethod,
638 int $newestRevertedRevId,
639 ?int $revertAfterRevId = null
641 $this->editResultBuilder->markAsRevert(
642 $revertMethod, $newestRevertedRevId, $revertAfterRevId
644 return $this;
648 * Returns the EditResult associated with this PageUpdater.
649 * Will return null if PageUpdater::saveRevision() wasn't called yet.
650 * Will also return null if the update was not successful.
652 * @return EditResult|null
654 public function getEditResult(): ?EditResult {
655 return $this->editResult;
659 * Sets a tag to apply to this update.
660 * Callers are responsible for permission checks,
661 * using ChangeTags::canAddTagsAccompanyingChange.
662 * @param string $tag
663 * @return $this
665 public function addTag( string $tag ) {
666 $this->tags[] = trim( $tag );
667 return $this;
671 * Sets tags to apply to this update.
672 * Callers are responsible for permission checks,
673 * using ChangeTags::canAddTagsAccompanyingChange.
674 * @param string[] $tags
675 * @return $this
677 public function addTags( array $tags ) {
678 Assert::parameterElementType( 'string', $tags, '$tags' );
679 foreach ( $tags as $tag ) {
680 $this->addTag( $tag );
682 return $this;
686 * Sets software tag to this update. If the tag is not defined in the
687 * current software tags, it's ignored.
689 * @since 1.38
690 * @param string $tag
691 * @return $this
693 public function addSoftwareTag( string $tag ): self {
694 if ( in_array( $tag, $this->softwareTags ) ) {
695 $this->addTag( $tag );
697 return $this;
701 * Returns the list of tags set using the addTag() method.
703 * @return string[]
705 public function getExplicitTags() {
706 return $this->tags;
710 * @return string[]
712 private function computeEffectiveTags() {
713 $tags = $this->tags;
714 $editResult = $this->getEditResult();
716 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
717 $old_content = $this->getParentContent( $role );
719 $handler = $this->getContentHandler( $role );
720 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
722 // TODO: MCR: Do this for all slots. Also add tags for removing roles!
723 $tag = $handler->getChangeTag( $old_content, $content, $this->flags );
724 // If there is no applicable tag, null is returned, so we need to check
725 if ( $tag ) {
726 $tags[] = $tag;
730 $tags = array_merge( $tags, $editResult->getRevertTags() );
732 return array_unique( $tags );
736 * Returns the content of the given slot of the parent revision, with no audience checks applied.
737 * If there is no parent revision or the slot is not defined, this returns null.
739 * @param string $role slot role name
740 * @return Content|null
742 private function getParentContent( $role ) {
743 $parent = $this->grabParentRevision();
745 if ( $parent && $parent->hasSlot( $role ) ) {
746 return $parent->getContent( $role, RevisionRecord::RAW );
749 return null;
753 * @param string $role slot role name
754 * @return ContentHandler
756 private function getContentHandler( $role ) {
757 if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
758 $slot = $this->slotsUpdate->getModifiedSlot( $role );
759 } else {
760 $parent = $this->grabParentRevision();
762 if ( $parent ) {
763 $slot = $parent->getSlot( $role, RevisionRecord::RAW );
764 } else {
765 throw new RevisionAccessException(
766 'No such slot: {role}',
767 [ 'role' => $role ]
772 return $this->contentHandlerFactory->getContentHandler( $slot->getModel() );
776 * @return CommentStoreComment
778 private function makeAutoSummary() {
779 if ( !$this->useAutomaticEditSummaries || ( $this->flags & EDIT_AUTOSUMMARY ) === 0 ) {
780 return CommentStoreComment::newUnsavedComment( '' );
783 // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
784 // TODO: combine auto-summaries for multiple slots!
785 // XXX: this logic should not be in the storage layer!
786 $roles = $this->slotsUpdate->getModifiedRoles();
787 $role = reset( $roles );
789 if ( $role === false ) {
790 return CommentStoreComment::newUnsavedComment( '' );
793 $handler = $this->getContentHandler( $role );
794 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
795 $old_content = $this->getParentContent( $role );
796 $summary = $handler->getAutosummary( $old_content, $content, $this->flags );
798 return CommentStoreComment::newUnsavedComment( $summary );
802 * Change an existing article or create a new article. Updates RC and all necessary caches,
803 * optionally via the deferred update array. This does not check user permissions.
805 * It is guaranteed that saveRevision() will fail if the current revision of the page
806 * changes after grabParentRevision() was called and before saveRevision() can insert
807 * a new revision, as per the CAS mechanism described above.
809 * The caller is however responsible for calling hasEditConflict() to detect a
810 * user-level edit conflict, and to adjust the content of the new revision accordingly,
811 * e.g. by using a 3-way-merge.
813 * MCR migration note: this replaces WikiPage::doUserEditContent. Callers that change to using
814 * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
816 * @param CommentStoreComment $summary Edit summary
817 * @param int $flags Bitfield, will be combined with the flags set via setFlags(). See
818 * there for details.
820 * @note If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
821 * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
822 * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
823 * was unexpectedly created or deleted while revision creation is in progress. This can be
824 * viewed as part of the CAS mechanism described above.
826 * @return RevisionRecord|null The new revision, or null if no new revision was created due
827 * to a failure or a null-edit. Use wasRevisionCreated(), wasSuccessful() and getStatus()
828 * to determine the outcome of the revision creation.
830 public function saveRevision( CommentStoreComment $summary, int $flags = 0 ) {
831 $this->setFlags( $flags );
833 if ( $this->wasCommitted() ) {
834 throw new RuntimeException(
835 'saveRevision() or updateRevision() has already been called on this PageUpdater!'
839 // Low-level check
840 if ( $this->getPage()->getDBkey() === '' ) {
841 throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
844 // NOTE: slots can be inherited even if the role is not "allowed" on the title.
845 $status = PageUpdateStatus::newGood();
846 $this->checkAllRolesAllowed(
847 $this->slotsUpdate->getModifiedRoles(),
848 $status
850 $this->checkNoRolesRequired(
851 $this->slotsUpdate->getRemovedRoles(),
852 $status
855 if ( !$status->isOK() ) {
856 return null;
859 // Make sure the given content is allowed in the respective slots of this page
860 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
861 $slot = $this->slotsUpdate->getModifiedSlot( $role );
862 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
864 if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getPage() ) ) {
865 $contentHandler = $this->contentHandlerFactory
866 ->getContentHandler( $slot->getModel() );
867 $this->status = PageUpdateStatus::newFatal( 'content-not-allowed-here',
868 ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
869 $this->titleFormatter->getPrefixedText( $this->getPage() ),
870 wfMessage( $roleHandler->getNameMessageKey() )
871 // TODO: defer message lookup to caller
873 return null;
877 // Load the data from the primary database if needed. Needed to check flags.
878 // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
879 // wasn't called yet. If the page is modified by another process before we are done with
880 // it, this method must fail (with status 'edit-conflict')!
881 // NOTE: The parent revision may be different from the edit's base revision.
882 $this->prepareUpdate();
884 // Detect whether update or creation should be performed.
885 if ( !( $this->flags & EDIT_NEW ) && !( $this->flags & EDIT_UPDATE ) ) {
886 $this->flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
889 // Trigger pre-save hook (using provided edit summary)
890 $renderedRevision = $this->derivedDataUpdater->getRenderedRevision();
891 $hookStatus = PageUpdateStatus::newGood( [] );
892 $allowedByHook = $this->hookRunner->onMultiContentSave(
893 $renderedRevision, $this->author, $summary, $this->flags, $hookStatus
895 if ( $allowedByHook && $this->hookContainer->isRegistered( 'PageContentSave' ) ) {
896 // Also run the legacy hook.
897 // NOTE: WikiPage should only be used for the legacy hook,
898 // and only if something uses the legacy hook.
899 $mainContent = $this->derivedDataUpdater->getSlots()->getContent( SlotRecord::MAIN );
901 $legacyUser = self::toLegacyUser( $this->author );
903 // Deprecated since 1.35.
904 $allowedByHook = $this->hookRunner->onPageContentSave(
905 $this->getWikiPage(), $legacyUser, $mainContent, $summary,
906 (bool)( $this->flags & EDIT_MINOR ), null, null, $this->flags, $hookStatus
910 if ( !$allowedByHook ) {
911 // The hook has prevented this change from being saved.
912 if ( $hookStatus->isOK() ) {
913 // Hook returned false but didn't call fatal(); use generic message
914 $hookStatus->fatal( 'edit-hook-aborted' );
917 $this->status = $hookStatus;
918 $this->logger->info( "Hook prevented page save", [ 'status' => $hookStatus ] );
919 return null;
922 // Provide autosummaries if one is not provided and autosummaries are enabled
923 // XXX: $summary == null seems logical, but the empty string may actually come from the user
924 // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
925 if ( $summary->text === '' && $summary->data === null ) {
926 $summary = $this->makeAutoSummary();
929 // Actually create the revision and create/update the page.
930 // Do NOT yet set $this->status!
931 if ( $this->flags & EDIT_UPDATE ) {
932 $status = $this->doModify( $summary );
933 } else {
934 $status = $this->doCreate( $summary );
937 // Promote user to any groups they meet the criteria for
938 DeferredUpdates::addCallableUpdate( function () {
939 $this->userGroupManager->addUserToAutopromoteOnceGroups( $this->author, 'onEdit' );
940 // Also run 'onView' for backwards compatibility
941 $this->userGroupManager->addUserToAutopromoteOnceGroups( $this->author, 'onView' );
942 } );
944 // NOTE: set $this->status only after all hooks have been called,
945 // so wasCommitted doesn't return true when called indirectly from a hook handler!
946 $this->status = $status;
948 // TODO: replace bad status with Exceptions!
949 return $this->status
950 ? $this->status->getNewRevision()
951 : null;
955 * Updates derived slots of an existing article. Does not update RC. Updates all necessary
956 * caches, optionally via the deferred update array. This does not check user permissions.
957 * Does not do a PST.
959 * Use wasRevisionCreated(), wasSuccessful() and getStatus() to determine the outcome of the
960 * revision update.
962 * @param int $revId
963 * @since 1.36
965 public function updateRevision( int $revId = 0 ) {
966 if ( $this->wasCommitted() ) {
967 throw new RuntimeException(
968 'saveRevision() or updateRevision() has already been called on this PageUpdater!'
972 // Low-level check
973 if ( $this->getPage()->getDBkey() === '' ) {
974 throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
977 $status = PageUpdateStatus::newGood();
978 $this->checkAllRolesAllowed(
979 $this->slotsUpdate->getModifiedRoles(),
980 $status
982 $this->checkAllRolesDerived(
983 $this->slotsUpdate->getModifiedRoles(),
984 $status
986 $this->checkAllRolesDerived(
987 $this->slotsUpdate->getRemovedRoles(),
988 $status
991 if ( $revId === 0 ) {
992 $revision = $this->grabParentRevision();
993 } else {
994 $revision = $this->revisionStore->getRevisionById( $revId, IDBAccessObject::READ_LATEST );
996 if ( $revision === null ) {
997 $status->fatal( 'edit-gone-missing' );
1000 if ( !$status->isOK() ) {
1001 $this->status = $status;
1002 return;
1005 // Make sure the given content is allowed in the respective slots of this page
1006 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
1007 $slot = $this->slotsUpdate->getModifiedSlot( $role );
1008 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
1010 if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getPage() ) ) {
1011 $contentHandler = $this->contentHandlerFactory
1012 ->getContentHandler( $slot->getModel() );
1013 $this->status = PageUpdateStatus::newFatal(
1014 'content-not-allowed-here',
1015 ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
1016 $this->titleFormatter->getPrefixedText( $this->getPage() ),
1017 wfMessage( $roleHandler->getNameMessageKey() )
1018 // TODO: defer message lookup to caller
1020 return;
1024 // XXX: do we need PST?
1026 $this->flags |= EDIT_INTERNAL;
1027 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable revision is checked
1028 $this->status = $this->doUpdate( $revision );
1032 * Whether saveRevision() has been called on this instance
1034 * @return bool
1036 public function wasCommitted() {
1037 return $this->status !== null;
1041 * The Status object indicating whether saveRevision() was successful.
1042 * Must not be called before saveRevision() or updateRevision() was called on this instance.
1044 * @note This is here for compatibility with WikiPage::doUserEditContent. It may be deprecated
1045 * soon.
1047 * Possible status errors:
1048 * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
1049 * set the fatal flag of $status.
1050 * edit-gone-missing: In update mode, but the article didn't exist.
1051 * edit-conflict: In update mode, the article changed unexpectedly.
1052 * edit-no-change: Warning that the text was the same as before.
1053 * edit-already-exists: In creation mode, but the article already exists.
1055 * Extensions may define additional errors.
1057 * $return->value will contain an associative array with members as follows:
1058 * new: Boolean indicating if the function attempted to create a new article.
1059 * revision-record: The RevisionRecord object for the inserted revision, or null.
1061 * @return PageUpdateStatus
1063 public function getStatus(): PageUpdateStatus {
1064 if ( !$this->status ) {
1065 throw new LogicException(
1066 'getStatus() is undefined before saveRevision() or updateRevision() have been called'
1069 return $this->status;
1073 * Whether saveRevision() completed successfully. This is not the same as wasRevisionCreated():
1074 * when the new content is exactly the same as the old one (DerivedPageDataUpdater::isChange()
1075 * returns false) and setForceEmptyRevision( true ) is not set, no new revision is created, but
1076 * the save is considered successful. This behavior constitutes a "null edit".
1078 * @return bool
1080 public function wasSuccessful() {
1081 return $this->status && $this->status->isOK();
1085 * Whether saveRevision() was called and created a new page.
1087 * @return bool
1089 public function isNew() {
1090 return $this->status && $this->status->wasPageCreated();
1094 * Whether saveRevision() did create a revision because the content didn't change: (null-edit).
1095 * Whether the content changed or not is determined by DerivedPageDataUpdater::isChange().
1097 * @deprecated since 1.38, use wasRevisionCreated() instead.
1098 * @return bool
1100 public function isUnchanged() {
1101 return !$this->wasRevisionCreated();
1105 * Whether the prepared edit is a change compared to the previous revision.
1107 * @return bool
1109 public function isChange() {
1110 return $this->derivedDataUpdater->isChange();
1114 * Disable new revision creation, throwing an exception if it is attempted.
1116 * @return $this
1118 public function preventChange() {
1119 $this->preventChange = true;
1120 return $this;
1124 * Whether saveRevision() did create a revision. This is not the same as wasSuccessful():
1125 * when the new content is exactly the same as the old one (DerivedPageDataUpdater::isChange()
1126 * returns false) and setForceEmptyRevision( true ) is not set, no new revision is created, but
1127 * the save is considered successful. This behavior constitutes a "null edit".
1129 * @since 1.38
1131 * @return bool
1133 public function wasRevisionCreated(): bool {
1134 return $this->status
1135 && $this->status->wasRevisionCreated();
1139 * The new revision created by saveRevision(), or null if saveRevision() has not yet been
1140 * called, failed, or did not create a new revision because the content did not change.
1142 * @return RevisionRecord|null
1144 public function getNewRevision() {
1145 return $this->status
1146 ? $this->status->getNewRevision()
1147 : null;
1151 * Constructs a MutableRevisionRecord based on the Content prepared by the
1152 * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
1153 * with PST applied, and removing discontinued slots.
1155 * This calls Content::prepareSave() to verify that the slot content can be saved.
1156 * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
1158 * @param CommentStoreComment $comment
1159 * @param PageUpdateStatus $status
1161 * @return MutableRevisionRecord
1163 private function makeNewRevision(
1164 CommentStoreComment $comment,
1165 PageUpdateStatus $status
1167 $title = $this->getTitle();
1168 $parent = $this->grabParentRevision();
1170 // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
1171 // TODO: introduce something like an UnsavedRevisionFactory service instead!
1172 /** @var MutableRevisionRecord $rev */
1173 $rev = $this->derivedDataUpdater->getRevision();
1174 '@phan-var MutableRevisionRecord $rev';
1176 // Avoid fatal error when the Title's ID changed, T204793
1177 if (
1178 $rev->getPageId() !== null && $title->exists()
1179 && $rev->getPageId() !== $title->getArticleID()
1181 $titlePageId = $title->getArticleID();
1182 $revPageId = $rev->getPageId();
1183 $masterPageId = $title->getArticleID( IDBAccessObject::READ_LATEST );
1185 if ( $revPageId === $masterPageId ) {
1186 wfWarn( __METHOD__ . ": Encountered stale Title object: old ID was $titlePageId, "
1187 . "continuing with new ID from primary DB, $masterPageId" );
1188 } else {
1189 throw new InvalidArgumentException(
1190 "Revision inherited page ID $revPageId from its parent, "
1191 . "but the provided Title object belongs to page ID $masterPageId"
1196 if ( $parent ) {
1197 $oldid = $parent->getId();
1198 $rev->setParentId( $oldid );
1200 if ( $title->getArticleID() !== $parent->getPageId() ) {
1201 wfWarn( __METHOD__ . ': Encountered stale Title object with no page ID! '
1202 . 'Using page ID from parent revision: ' . $parent->getPageId() );
1204 } else {
1205 $oldid = 0;
1208 $rev->setComment( $comment );
1209 $rev->setUser( $this->author );
1210 $rev->setMinorEdit( ( $this->flags & EDIT_MINOR ) > 0 );
1212 foreach ( $rev->getSlots()->getSlots() as $slot ) {
1213 $content = $slot->getContent();
1215 // XXX: We may push this up to the "edit controller" level, see T192777.
1216 $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
1217 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId is not null here
1218 $validationParams = new ValidationParams( $this->getPage(), $this->flags, $oldid );
1219 $prepStatus = $contentHandler->validateSave( $content, $validationParams );
1221 // TODO: MCR: record which problem arose in which slot.
1222 $status->merge( $prepStatus );
1225 $this->checkAllRequiredRoles(
1226 $rev->getSlotRoles(),
1227 $status
1230 return $rev;
1234 * Builds the EditResult for this update.
1235 * Should be called by either doModify or doCreate.
1237 * @param RevisionRecord $revision
1238 * @param bool $isNew
1240 private function buildEditResult( RevisionRecord $revision, bool $isNew ) {
1241 $this->editResultBuilder->setRevisionRecord( $revision );
1242 $this->editResultBuilder->setIsNew( $isNew );
1243 $this->editResult = $this->editResultBuilder->buildEditResult();
1247 * Update derived slots in an existing revision. If the revision is the current revision,
1248 * this will update page_touched and trigger secondary updates.
1250 * We do not have sufficient information to know whether to or how to update recentchanges
1251 * here, so, as opposed to doCreate(), updating recentchanges is left as the responsibility
1252 * of the caller.
1254 * @param RevisionRecord $revision
1255 * @return PageUpdateStatus
1257 private function doUpdate( RevisionRecord $revision ): PageUpdateStatus {
1258 $currentRevision = $this->grabParentRevision();
1259 if ( !$currentRevision ) {
1260 // Article gone missing
1261 return PageUpdateStatus::newFatal( 'edit-gone-missing' );
1264 $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1265 $dbw->startAtomic( __METHOD__ );
1267 $slots = $this->revisionStore->updateSlotsOn( $revision, $this->slotsUpdate, $dbw );
1269 $dbw->endAtomic( __METHOD__ );
1271 // Return the slots and revision to the caller
1272 $newRevisionRecord = MutableRevisionRecord::newUpdatedRevisionRecord( $revision, $slots );
1273 $status = PageUpdateStatus::newGood( [
1274 'revision-record' => $newRevisionRecord,
1275 'slots' => $slots,
1276 ] );
1278 $isCurrent = $revision->getId( $this->getWikiId() ) ===
1279 $currentRevision->getId( $this->getWikiId() );
1281 if ( $isCurrent ) {
1282 // Update page_touched
1283 $this->getTitle()->invalidateCache( $newRevisionRecord->getTimestamp() );
1285 $this->buildEditResult( $newRevisionRecord, false );
1287 // Do secondary updates once the main changes have been committed...
1288 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1289 DeferredUpdates::addUpdate(
1290 $this->getAtomicSectionUpdate(
1291 $dbw,
1292 $wikiPage,
1293 $newRevisionRecord,
1294 $revision->getComment(),
1295 [ 'changed' => false, ]
1297 DeferredUpdates::PRESEND
1301 return $status;
1305 * @param CommentStoreComment $summary The edit summary
1306 * @return PageUpdateStatus
1308 private function doModify( CommentStoreComment $summary ): PageUpdateStatus {
1309 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1311 // Update article, but only if changed.
1312 $status = PageUpdateStatus::newEmpty( false );
1314 $oldRev = $this->grabParentRevision();
1315 $oldid = $oldRev ? $oldRev->getId() : 0;
1317 if ( !$oldRev ) {
1318 // Article gone missing
1319 return $status->fatal( 'edit-gone-missing' );
1322 $newRevisionRecord = $this->makeNewRevision(
1323 $summary,
1324 $status
1327 if ( !$status->isOK() ) {
1328 return $status;
1331 $now = $newRevisionRecord->getTimestamp();
1333 $changed = $this->derivedDataUpdater->isChange();
1335 if ( $changed ) {
1336 if ( $this->forceEmptyRevision ) {
1337 throw new LogicException(
1338 "Content was changed even though forceEmptyRevision() was called."
1341 if ( $this->preventChange ) {
1342 throw new LogicException(
1343 "Content was changed even though preventChange() was called."
1348 // We build the EditResult before the $change if/else branch in order to pass
1349 // the correct $newRevisionRecord to EditResultBuilder. In case this is a null
1350 // edit, $newRevisionRecord will be later overridden to its parent revision, which
1351 // would confuse EditResultBuilder.
1352 if ( !$changed ) {
1353 // This is a null edit, ensure original revision ID is set properly
1354 $this->editResultBuilder->setOriginalRevision( $oldRev );
1356 $this->buildEditResult( $newRevisionRecord, false );
1358 $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1360 if ( $changed || $this->forceEmptyRevision ) {
1361 $dbw->startAtomic( __METHOD__ );
1363 // Get the latest page_latest value while locking it.
1364 // Do a CAS style check to see if it's the same as when this method
1365 // started. If it changed then bail out before touching the DB.
1366 $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
1367 if ( $latestNow != $oldid ) {
1368 // We don't need to roll back, since we did not modify the database yet.
1369 // XXX: Or do we want to rollback, any transaction started by calling
1370 // code will fail? If we want that, we should probably throw an exception.
1371 $dbw->endAtomic( __METHOD__ );
1373 // Page updated or deleted in the mean time
1374 return $status->fatal( 'edit-conflict' );
1377 // At this point we are now committed to returning an OK
1378 // status unless some DB query error or other exception comes up.
1379 // This way callers don't have to call rollback() if $status is bad
1380 // unless they actually try to catch exceptions (which is rare).
1382 // Save revision content and meta-data
1383 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1385 // Update page_latest and friends to reflect the new revision
1386 // TODO: move to storage service
1387 $wasRedirect = $this->derivedDataUpdater->wasRedirect();
1388 if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, null, $wasRedirect ) ) {
1389 throw new PageUpdateException( "Failed to update page row to use new revision." );
1392 $editResult = $this->getEditResult();
1393 $tags = $this->computeEffectiveTags();
1394 $this->hookRunner->onRevisionFromEditComplete(
1395 $wikiPage,
1396 $newRevisionRecord,
1397 $editResult->getOriginalRevisionId(),
1398 $this->author,
1399 $tags
1402 // Update recentchanges
1403 if ( !( $this->flags & EDIT_SUPPRESS_RC ) ) {
1404 // Add RC row to the DB
1405 RecentChange::notifyEdit(
1406 $now,
1407 $this->getPage(),
1408 $newRevisionRecord->isMinor(),
1409 $this->author,
1410 $summary->text, // TODO: pass object when that becomes possible
1411 $oldid,
1412 $newRevisionRecord->getTimestamp(),
1413 ( $this->flags & EDIT_FORCE_BOT ) > 0,
1415 $oldRev->getSize(),
1416 $newRevisionRecord->getSize(),
1417 $newRevisionRecord->getId(),
1418 $this->rcPatrolStatus,
1419 $tags,
1420 $editResult
1422 } else {
1423 MediaWikiServices::getInstance()->getChangeTagsStore()
1424 ->addTags( $tags, null, $newRevisionRecord->getId(), null );
1427 $this->userEditTracker->incrementUserEditCount( $this->author );
1429 $dbw->endAtomic( __METHOD__ );
1431 // Return the new revision to the caller
1432 $status->setNewRevision( $newRevisionRecord );
1433 } else {
1434 // T34948: revision ID must be set to page {{REVISIONID}} and
1435 // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1436 // Since we don't insert a new revision into the database, the least
1437 // error-prone way is to reuse given old revision.
1438 $newRevisionRecord = $oldRev;
1440 $status->warning( 'edit-no-change' );
1441 // Update page_touched as updateRevisionOn() was not called.
1442 // Other cache updates are managed in WikiPage::onArticleEdit()
1443 // via WikiPage::doEditUpdates().
1444 $this->getTitle()->invalidateCache( $now );
1447 // Do secondary updates once the main changes have been committed...
1448 // NOTE: the updates have to be processed before sending the response to the client
1449 // (DeferredUpdates::PRESEND), otherwise the client may already be following the
1450 // HTTP redirect to the standard view before derived data has been created - most
1451 // importantly, before the parser cache has been updated. This would cause the
1452 // content to be parsed a second time, or may cause stale content to be shown.
1453 DeferredUpdates::addUpdate(
1454 $this->getAtomicSectionUpdate(
1455 $dbw,
1456 $wikiPage,
1457 $newRevisionRecord,
1458 $summary,
1459 [ 'changed' => $changed, ]
1461 DeferredUpdates::PRESEND
1464 return $status;
1468 * @param CommentStoreComment $summary The edit summary
1469 * @return PageUpdateStatus
1471 private function doCreate( CommentStoreComment $summary ): PageUpdateStatus {
1472 if ( $this->preventChange ) {
1473 throw new LogicException(
1474 "Content was changed even though preventChange() was called."
1477 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1479 if ( !$this->derivedDataUpdater->getSlots()->hasSlot( SlotRecord::MAIN ) ) {
1480 throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
1483 $status = PageUpdateStatus::newEmpty( true );
1485 $newRevisionRecord = $this->makeNewRevision(
1486 $summary,
1487 $status
1490 if ( !$status->isOK() ) {
1491 return $status;
1494 $this->buildEditResult( $newRevisionRecord, true );
1495 $now = $newRevisionRecord->getTimestamp();
1497 $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1498 $dbw->startAtomic( __METHOD__ );
1500 // Add the page record unless one already exists for the title
1501 // TODO: move to storage service
1502 $newid = $wikiPage->insertOn( $dbw );
1503 if ( $newid === false ) {
1504 $dbw->endAtomic( __METHOD__ );
1505 return $status->fatal( 'edit-already-exists' );
1508 // At this point we are now committed to returning an OK
1509 // status unless some DB query error or other exception comes up.
1510 // This way callers don't have to call rollback() if $status is bad
1511 // unless they actually try to catch exceptions (which is rare).
1512 $newRevisionRecord->setPageId( $newid );
1514 // Save the revision text...
1515 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1517 // Update the page record with revision data
1518 // TODO: move to storage service
1519 if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, 0, false ) ) {
1520 throw new PageUpdateException( "Failed to update page row to use new revision." );
1523 $tags = $this->computeEffectiveTags();
1524 $this->hookRunner->onRevisionFromEditComplete(
1525 $wikiPage, $newRevisionRecord, false, $this->author, $tags
1528 // Update recentchanges
1529 if ( !( $this->flags & EDIT_SUPPRESS_RC ) ) {
1530 // Add RC row to the DB
1531 RecentChange::notifyNew(
1532 $now,
1533 $this->getPage(),
1534 $newRevisionRecord->isMinor(),
1535 $this->author,
1536 $summary->text, // TODO: pass object when that becomes possible
1537 ( $this->flags & EDIT_FORCE_BOT ) > 0,
1539 $newRevisionRecord->getSize(),
1540 $newRevisionRecord->getId(),
1541 $this->rcPatrolStatus,
1542 $tags
1544 } else {
1545 MediaWikiServices::getInstance()->getChangeTagsStore()
1546 ->addTags( $tags, null, $newRevisionRecord->getId(), null );
1549 $this->userEditTracker->incrementUserEditCount( $this->author );
1551 if ( $this->usePageCreationLog ) {
1552 // Log the page creation
1553 // @TODO: Do we want a 'recreate' action?
1554 $logEntry = new ManualLogEntry( 'create', 'create' );
1555 $logEntry->setPerformer( $this->author );
1556 $logEntry->setTarget( $this->getPage() );
1557 $logEntry->setComment( $summary->text );
1558 $logEntry->setTimestamp( $now );
1559 $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
1560 $logEntry->insert();
1561 // Note that we don't publish page creation events to recentchanges
1562 // (i.e. $logEntry->publish()) since this would create duplicate entries,
1563 // one for the edit and one for the page creation.
1566 $dbw->endAtomic( __METHOD__ );
1568 // Return the new revision to the caller
1569 $status->setNewRevision( $newRevisionRecord );
1571 // Do secondary updates once the main changes have been committed...
1572 DeferredUpdates::addUpdate(
1573 $this->getAtomicSectionUpdate(
1574 $dbw,
1575 $wikiPage,
1576 $newRevisionRecord,
1577 $summary,
1578 [ 'created' => true ]
1580 DeferredUpdates::PRESEND
1583 return $status;
1586 private function getAtomicSectionUpdate(
1587 IDatabase $dbw,
1588 WikiPage $wikiPage,
1589 RevisionRecord $newRevisionRecord,
1590 CommentStoreComment $summary,
1591 array $hints = []
1593 return new AtomicSectionUpdate(
1594 $dbw,
1595 __METHOD__,
1596 function () use (
1597 $wikiPage, $newRevisionRecord,
1598 $summary, $hints
1600 // set debug data
1601 $hints['causeAction'] = 'edit-page';
1602 $hints['causeAgent'] = $this->author->getName();
1604 $editResult = $this->getEditResult();
1605 $hints['editResult'] = $editResult;
1607 if ( $editResult->isRevert() ) {
1608 // Should the reverted tag update be scheduled right away?
1609 // The revert is approved if either patrolling is disabled or the
1610 // edit is patrolled or autopatrolled.
1611 $approved = !$this->serviceOptions->get( MainConfigNames::UseRCPatrol ) ||
1612 $this->rcPatrolStatus === RecentChange::PRC_PATROLLED ||
1613 $this->rcPatrolStatus === RecentChange::PRC_AUTOPATROLLED;
1615 // Allow extensions to override the patrolling subsystem.
1616 $this->hookRunner->onBeforeRevertedTagUpdate(
1617 $wikiPage,
1618 $this->author,
1619 $summary,
1620 $this->flags,
1621 $newRevisionRecord,
1622 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Not null already checked
1623 $editResult,
1624 $approved
1626 $hints['approved'] = $approved;
1629 // Update links tables, site stats, etc.
1630 $this->derivedDataUpdater->prepareUpdate( $newRevisionRecord, $hints );
1631 $this->derivedDataUpdater->doUpdates();
1633 $created = $hints['created'] ?? false;
1634 $this->flags |= ( $created ? EDIT_NEW : EDIT_UPDATE );
1636 // PageSaveComplete replaced old PageContentInsertComplete and
1637 // PageContentSaveComplete hooks since 1.35
1638 $this->hookRunner->onPageSaveComplete(
1639 $wikiPage,
1640 $this->author,
1641 $summary->text,
1642 $this->flags,
1643 $newRevisionRecord,
1644 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Not null already checked
1645 $editResult
1652 * @return string[] Slots required for this page update, as a list of role names.
1654 private function getRequiredSlotRoles() {
1655 return $this->slotRoleRegistry->getRequiredRoles( $this->getPage() );
1659 * @return string[] Slots allowed for this page update, as a list of role names.
1661 private function getAllowedSlotRoles() {
1662 return $this->slotRoleRegistry->getAllowedRoles( $this->getPage() );
1665 private function ensureRoleAllowed( $role ) {
1666 $allowedRoles = $this->getAllowedSlotRoles();
1667 if ( !in_array( $role, $allowedRoles ) ) {
1668 throw new PageUpdateException( "Slot role `$role` is not allowed." );
1672 private function ensureRoleNotRequired( $role ) {
1673 $requiredRoles = $this->getRequiredSlotRoles();
1674 if ( in_array( $role, $requiredRoles ) ) {
1675 throw new PageUpdateException( "Slot role `$role` is required." );
1680 * @param array $roles
1681 * @param PageUpdateStatus $status
1683 private function checkAllRolesAllowed( array $roles, PageUpdateStatus $status ) {
1684 $allowedRoles = $this->getAllowedSlotRoles();
1686 $forbidden = array_diff( $roles, $allowedRoles );
1687 if ( $forbidden ) {
1688 $status->error(
1689 'edit-slots-cannot-add',
1690 count( $forbidden ),
1691 implode( ', ', $forbidden )
1697 * @param array $roles
1698 * @param PageUpdateStatus $status
1700 private function checkAllRolesDerived( array $roles, PageUpdateStatus $status ) {
1701 $notDerived = array_filter(
1702 $roles,
1703 function ( $role ) {
1704 return !$this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
1707 if ( $notDerived ) {
1708 $status->error(
1709 'edit-slots-not-derived',
1710 count( $notDerived ),
1711 implode( ', ', $notDerived )
1717 * @param array $roles
1718 * @param PageUpdateStatus $status
1720 private function checkNoRolesRequired( array $roles, PageUpdateStatus $status ) {
1721 $requiredRoles = $this->getRequiredSlotRoles();
1723 $needed = array_diff( $roles, $requiredRoles );
1724 if ( $needed ) {
1725 $status->error(
1726 'edit-slots-cannot-remove',
1727 count( $needed ),
1728 implode( ', ', $needed )
1734 * @param array $roles
1735 * @param PageUpdateStatus $status
1737 private function checkAllRequiredRoles( array $roles, PageUpdateStatus $status ) {
1738 $requiredRoles = $this->getRequiredSlotRoles();
1740 $missing = array_diff( $requiredRoles, $roles );
1741 if ( $missing ) {
1742 $status->error(
1743 'edit-slots-missing',
1744 count( $missing ),
1745 implode( ', ', $missing )