Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.42% covered (warning)
84.42%
455 / 539
63.64% covered (warning)
63.64%
35 / 55
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageUpdater
84.42% covered (warning)
84.42%
455 / 539
63.64% covered (warning)
63.64%
35 / 55
206.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 setFlags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareUpdate
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 toLegacyUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateAuthor
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 setUseAutomaticEditSummaries
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setRcPatrolStatus
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUsePageCreationLog
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setForceEmptyRevision
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getWikiId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikiPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasEditConflict
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 grabParentRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setSlot
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 inheritSlot
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 removeSlot
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setOriginalRevisionId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 markAsRevert
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getEditResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addTags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addSoftwareTag
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getExplicitTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 computeEffectiveTags
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getParentContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getContentHandler
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 makeAutoSummary
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 saveRevision
81.54% covered (warning)
81.54%
53 / 65
0.00% covered (danger)
0.00%
0 / 1
18.82
 updateRevision
60.47% covered (warning)
60.47%
26 / 43
0.00% covered (danger)
0.00%
0 / 1
11.95
 wasCommitted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 wasSuccessful
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isNew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isUnchanged
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isChange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preventChange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 wasRevisionCreated
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getNewRevision
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makeNewRevision
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
8.09
 buildEditResult
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 doUpdate
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
3
 doModify
97.18% covered (success)
97.18%
69 / 71
0.00% covered (danger)
0.00%
0 / 1
12
 doCreate
95.00% covered (success)
95.00%
57 / 60
0.00% covered (danger)
0.00%
0 / 1
7
 getAtomicSectionUpdate
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
5
 getRequiredSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureRoleAllowed
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 ensureRoleNotRequired
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 checkAllRolesAllowed
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 checkAllRolesDerived
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
2.29
 checkNoRolesRequired
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 checkAllRequiredRoles
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
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.
7 *
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.
12 *
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
17 *
18 * @file
19 */
20
21namespace MediaWiki\Storage;
22
23use InvalidArgumentException;
24use LogicException;
25use ManualLogEntry;
26use MediaWiki\CommentStore\CommentStoreComment;
27use MediaWiki\Config\ServiceOptions;
28use MediaWiki\Content\Content;
29use MediaWiki\Content\ContentHandler;
30use MediaWiki\Content\IContentHandlerFactory;
31use MediaWiki\Content\ValidationParams;
32use MediaWiki\Deferred\AtomicSectionUpdate;
33use MediaWiki\Deferred\DeferredUpdates;
34use MediaWiki\DomainEvent\DomainEventDispatcher;
35use MediaWiki\HookContainer\HookContainer;
36use MediaWiki\HookContainer\HookRunner;
37use MediaWiki\MainConfigNames;
38use MediaWiki\Page\PageIdentity;
39use MediaWiki\Page\WikiPageFactory;
40use MediaWiki\Revision\MutableRevisionRecord;
41use MediaWiki\Revision\RevisionAccessException;
42use MediaWiki\Revision\RevisionRecord;
43use MediaWiki\Revision\RevisionStore;
44use MediaWiki\Revision\SlotRecord;
45use MediaWiki\Revision\SlotRoleRegistry;
46use MediaWiki\Title\Title;
47use MediaWiki\Title\TitleFormatter;
48use MediaWiki\User\User;
49use MediaWiki\User\UserGroupManager;
50use MediaWiki\User\UserIdentity;
51use Psr\Log\LoggerInterface;
52use RecentChange;
53use RuntimeException;
54use Wikimedia\Assert\Assert;
55use Wikimedia\Rdbms\IConnectionProvider;
56use Wikimedia\Rdbms\IDatabase;
57use Wikimedia\Rdbms\IDBAccessObject;
58use WikiPage;
59
60/**
61 * Controller-like object for creating and updating pages by creating new revisions.
62 *
63 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
64 * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
65 * This allows application logic to safely perform edit conflict resolution using the parent
66 * revision's content.
67 *
68 * MCR migration note: this replaces the relevant methods in WikiPage.
69 *
70 * @see docs/pageupdater.md for more information.
71 *
72 * @since 1.32
73 * @ingroup Page
74 * @author Daniel Kinzler
75 */
76class PageUpdater {
77
78    /**
79     * Options that have to be present in the ServiceOptions object passed to the constructor.
80     * @note When adding options here, also add them to PageUpdaterFactory::CONSTRUCTOR_OPTIONS.
81     * @internal
82     */
83    public const CONSTRUCTOR_OPTIONS = [
84        MainConfigNames::ManualRevertSearchRadius,
85        MainConfigNames::UseRCPatrol,
86    ];
87
88    /**
89     * @var UserIdentity
90     */
91    private $author;
92
93    /**
94     * TODO Remove this eventually.
95     * @var WikiPage
96     */
97    private $wikiPage;
98
99    /**
100     * @var PageIdentity
101     */
102    private $pageIdentity;
103
104    /**
105     * @var DerivedPageDataUpdater
106     */
107    private $derivedDataUpdater;
108
109    /**
110     * @var IConnectionProvider
111     */
112    private $dbProvider;
113
114    /**
115     * @var RevisionStore
116     */
117    private $revisionStore;
118
119    /**
120     * @var SlotRoleRegistry
121     */
122    private $slotRoleRegistry;
123
124    /**
125     * @var IContentHandlerFactory
126     */
127    private $contentHandlerFactory;
128
129    /**
130     * @var DomainEventDispatcher
131     */
132    private $eventDispatcher;
133
134    /**
135     * @var HookRunner
136     */
137    private $hookRunner;
138
139    /**
140     * @var HookContainer
141     */
142    private $hookContainer;
143
144    /** @var UserGroupManager */
145    private $userGroupManager;
146
147    /** @var TitleFormatter */
148    private $titleFormatter;
149
150    /**
151     * @var bool see $wgUseAutomaticEditSummaries
152     * @see $wgUseAutomaticEditSummaries
153     */
154    private $useAutomaticEditSummaries = true;
155
156    /**
157     * @var int the RC patrol status the new revision should be marked with.
158     */
159    private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
160
161    /**
162     * @var bool whether to create a log entry for new page creations.
163     */
164    private $usePageCreationLog = true;
165
166    /**
167     * @var bool Whether null-edits create a revision.
168     */
169    private $forceEmptyRevision = false;
170
171    /**
172     * @var bool Whether to prevent new revision creation by throwing if it is
173     *   attempted.
174     */
175    private $preventChange = false;
176
177    /**
178     * @var array
179     */
180    private $tags = [];
181
182    /**
183     * @var RevisionSlotsUpdate
184     */
185    private $slotsUpdate;
186
187    /**
188     * @var PageUpdateStatus|null
189     */
190    private $status = null;
191
192    /**
193     * @var EditResultBuilder
194     */
195    private $editResultBuilder;
196
197    /**
198     * @var EditResult|null
199     */
200    private $editResult = null;
201
202    /**
203     * @var ServiceOptions
204     */
205    private $serviceOptions;
206
207    /**
208     * @var int
209     */
210    private $flags = 0;
211
212    /** @var string[] */
213    private $softwareTags = [];
214
215    /** @var LoggerInterface */
216    private $logger;
217
218    /**
219     * @param UserIdentity $author
220     * @param PageIdentity $page
221     * @param DerivedPageDataUpdater $derivedDataUpdater
222     * @param IConnectionProvider $dbProvider
223     * @param RevisionStore $revisionStore
224     * @param SlotRoleRegistry $slotRoleRegistry
225     * @param IContentHandlerFactory $contentHandlerFactory
226     * @param DomainEventDispatcher $eventDispatcher
227     * @param HookContainer $hookContainer
228     * @param UserGroupManager $userGroupManager
229     * @param TitleFormatter $titleFormatter
230     * @param ServiceOptions $serviceOptions
231     * @param string[] $softwareTags Array of currently enabled software change tags. Can be
232     *        obtained from ChangeTagsStore->getSoftwareTags()
233     * @param LoggerInterface $logger
234     * @param WikiPageFactory $wikiPageFactory
235     */
236    public function __construct(
237        UserIdentity $author,
238        PageIdentity $page,
239        DerivedPageDataUpdater $derivedDataUpdater,
240        IConnectionProvider $dbProvider,
241        RevisionStore $revisionStore,
242        SlotRoleRegistry $slotRoleRegistry,
243        IContentHandlerFactory $contentHandlerFactory,
244        DomainEventDispatcher $eventDispatcher,
245        HookContainer $hookContainer,
246        UserGroupManager $userGroupManager,
247        TitleFormatter $titleFormatter,
248        ServiceOptions $serviceOptions,
249        array $softwareTags,
250        LoggerInterface $logger,
251        WikiPageFactory $wikiPageFactory
252    ) {
253        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
254        $this->serviceOptions = $serviceOptions;
255
256        $this->author = $author;
257        $this->pageIdentity = $page;
258        $this->wikiPage = $wikiPageFactory->newFromTitle( $page );
259        $this->derivedDataUpdater = $derivedDataUpdater;
260
261        $this->dbProvider = $dbProvider;
262        $this->revisionStore = $revisionStore;
263        $this->slotRoleRegistry = $slotRoleRegistry;
264        $this->contentHandlerFactory = $contentHandlerFactory;
265        $this->eventDispatcher = $eventDispatcher;
266        $this->hookContainer = $hookContainer;
267        $this->hookRunner = new HookRunner( $hookContainer );
268        $this->userGroupManager = $userGroupManager;
269        $this->titleFormatter = $titleFormatter;
270
271        $this->slotsUpdate = new RevisionSlotsUpdate();
272        $this->editResultBuilder = new EditResultBuilder(
273            $revisionStore,
274            $softwareTags,
275            new ServiceOptions(
276                EditResultBuilder::CONSTRUCTOR_OPTIONS,
277                [
278                    MainConfigNames::ManualRevertSearchRadius =>
279                        $serviceOptions->get( MainConfigNames::ManualRevertSearchRadius )
280                ]
281            )
282        );
283        $this->softwareTags = $softwareTags;
284        $this->logger = $logger;
285    }
286
287    /**
288     * Sets any flags to use when performing the update.
289     * Flags passed in subsequent calls to this method as well as calls to prepareUpdate()
290     * or saveRevision() are aggregated using bitwise OR.
291     *
292     * Known flags:
293     *
294     *      EDIT_NEW
295     *          Create a new page, or fail with "edit-already-exists" if the page exists.
296     *      EDIT_UPDATE
297     *          Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
298     *      EDIT_MINOR
299     *          Mark this revision as minor
300     *      EDIT_SUPPRESS_RC
301     *          Do not log the change in recentchanges
302     *      EDIT_FORCE_BOT
303     *          Mark the revision as automated ("bot edit")
304     *      EDIT_AUTOSUMMARY
305     *          Fill in blank summaries with generated text where possible
306     *      EDIT_INTERNAL
307     *          Signal that the page retrieve/save cycle happened entirely in this request.
308     *
309     * @param int $flags Bitfield
310     * @return $this
311     */
312    public function setFlags( int $flags ) {
313        $this->flags |= $flags;
314        return $this;
315    }
316
317    /**
318     * Prepare the update.
319     * This sets up the RevisionRecord to be saved.
320     * @since 1.37
321     *
322     * @param int $flags Bitfield, will be combined with flags set via setFlags().
323     *        EDIT_FORCE_BOT and EDIT_INTERNAL will bypass the edit stash.
324     *
325     * @return PreparedUpdate
326     */
327    public function prepareUpdate( int $flags = 0 ): PreparedUpdate {
328        $this->setFlags( $flags );
329
330        // Load the data from the primary database if needed. Needed to check flags.
331        $this->derivedDataUpdater->setCause( 'edit-page', $this->author->getName() );
332
333        $this->grabParentRevision();
334        if ( !$this->derivedDataUpdater->isUpdatePrepared() ) {
335            // Avoid statsd noise and wasted cycles check the edit stash (T136678)
336            $useStashed = !( ( $this->flags & EDIT_INTERNAL ) || ( $this->flags & EDIT_FORCE_BOT ) );
337            // Prepare the update. This performs PST and generates the canonical ParserOutput.
338            $this->derivedDataUpdater->prepareContent(
339                $this->author,
340                $this->slotsUpdate,
341                $useStashed
342            );
343        }
344
345        return $this->derivedDataUpdater;
346    }
347
348    /**
349     * @param UserIdentity $user
350     *
351     * @return User
352     */
353    private static function toLegacyUser( UserIdentity $user ) {
354        return User::newFromIdentity( $user );
355    }
356
357    /**
358     * After creation of the user during the save process, update the stored
359     * UserIdentity.
360     * @since 1.39
361     *
362     * @param UserIdentity $author
363     */
364    public function updateAuthor( UserIdentity $author ) {
365        if ( $this->author->getName() !== $author->getName() ) {
366            throw new InvalidArgumentException( 'Cannot replace the author with an author ' .
367                'of a different name, since DerivedPageDataUpdater may have stored the ' .
368                'old name.' );
369        }
370        $this->author = $author;
371    }
372
373    /**
374     * Can be used to enable or disable automatic summaries that are applied to certain kinds of
375     * changes, like completely blanking a page.
376     *
377     * @param bool $useAutomaticEditSummaries
378     * @return $this
379     * @see $wgUseAutomaticEditSummaries
380     */
381    public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
382        $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
383        return $this;
384    }
385
386    /**
387     * Sets the "patrolled" status of the edit.
388     * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
389     *
390     * @see $wgUseRCPatrol
391     * @see $wgUseNPPatrol
392     *
393     * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
394     * @return $this
395     */
396    public function setRcPatrolStatus( $status ) {
397        $this->rcPatrolStatus = $status;
398        return $this;
399    }
400
401    /**
402     * Whether to create a log entry for new page creations.
403     *
404     * @see $wgPageCreationLog
405     *
406     * @param bool $use
407     * @return $this
408     */
409    public function setUsePageCreationLog( $use ) {
410        $this->usePageCreationLog = $use;
411        return $this;
412    }
413
414    /**
415     * Set whether null-edits should create a revision. Enabling this allows the creation of dummy
416     * revisions ("null revisions") to mark events such as renaming in the page history.
417     *
418     * Callers should typically also call setOriginalRevisionId() to indicate the ID of the revision
419     * that is being repeated. That ID can be obtained from grabParentRevision()->getId().
420     *
421     * @since 1.38
422     *
423     * @note this calls $this->setOriginalRevisionId() with the ID of the current revision,
424     * starting the CAS bracket by virtue of calling $this->grabParentRevision().
425     *
426     * @note saveRevision() will fail with a LogicException if setForceEmptyRevision( true )
427     * was called and also content was changed via setContent(), removeSlot(), or inheritSlot().
428     *
429     * @param bool $forceEmptyRevision
430     * @return $this
431     */
432    public function setForceEmptyRevision( bool $forceEmptyRevision ): self {
433        $this->forceEmptyRevision = $forceEmptyRevision;
434
435        if ( $forceEmptyRevision ) {
436            // XXX: throw if there is no current/parent revision?
437            $original = $this->grabParentRevision();
438            $this->setOriginalRevisionId( $original ? $original->getId() : false );
439        }
440
441        $this->derivedDataUpdater->setForceEmptyRevision( $forceEmptyRevision );
442        return $this;
443    }
444
445    private function getWikiId() {
446        return $this->revisionStore->getWikiId();
447    }
448
449    /**
450     * Get the page we're currently updating.
451     * @return PageIdentity
452     */
453    public function getPage(): PageIdentity {
454        return $this->pageIdentity;
455    }
456
457    /**
458     * @return Title
459     */
460    private function getTitle() {
461        // NOTE: eventually, this won't use WikiPage any more
462        return $this->wikiPage->getTitle();
463    }
464
465    /**
466     * @return WikiPage
467     */
468    private function getWikiPage() {
469        // NOTE: eventually, this won't use WikiPage any more
470        return $this->wikiPage;
471    }
472
473    /**
474     * Checks whether this update conflicts with another update performed between the client
475     * loading data to prepare an edit, and the client committing the edit. This is intended to
476     * detect user level "edit conflict" when the latest revision known to the client
477     * is no longer the current revision when processing the update.
478     *
479     * An update expected to create a new page can be checked by setting $expectedParentRevision = 0.
480     * Such an update is considered to have a conflict if a current revision exists (that is,
481     * the page was created since the edit was initiated on the client).
482     *
483     * This method returning true indicates to calling code that edit conflict resolution should
484     * be applied before saving any data. It does not prevent the update from being performed, and
485     * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
486     * A "late" conflict is a CAS failure caused by an update being performed concurrently between
487     * the time grabParentRevision() was called and the time saveRevision() trying to insert the
488     * new revision.
489     *
490     * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
491     * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
492     * This method calls grabParentRevision(), and thus causes the expected parent revision
493     * for the update to be fixed to the page's current revision at this point in time.
494     * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
495     * will fail with the "edit-conflict" status if the current revision of the page changes after
496     * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert
497     * a new revision.
498     *
499     * @see grabParentRevision()
500     *
501     * @param int $expectedParentRevision The ID of the revision the client expects to be the
502     *        current one. Use 0 to indicate that the page is expected to not yet exist.
503     *
504     * @return bool
505     */
506    public function hasEditConflict( $expectedParentRevision ) {
507        $parent = $this->grabParentRevision();
508        $parentId = $parent ? $parent->getId() : 0;
509
510        return $parentId !== $expectedParentRevision;
511    }
512
513    /**
514     * Returns the revision that was the page's current revision when grabParentRevision()
515     * was first called. This revision is the expected parent revision of the update, and will be
516     * recorded as the new revision's parent revision (unless no new revision is created because
517     * the content was not changed).
518     *
519     * This method MUST not be called after saveRevision() was called!
520     *
521     * The current revision determined by the first call to this method effectively acts a
522     * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
523     * concurrent updates created a new revision.
524     *
525     * Application code should call this method before applying transformations to the new
526     * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
527     * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
528     * updates.
529     *
530     * @see DerivedPageDataUpdater::grabCurrentRevision()
531     *
532     * @note The expected parent revision is not to be confused with the logical base revision.
533     * The base revision is specified by the client, the parent revision is determined from the
534     * database. If base revision and parent revision are not the same, the updates is considered
535     * to require edit conflict resolution.
536     *
537     * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
538     */
539    public function grabParentRevision() {
540        return $this->derivedDataUpdater->grabCurrentRevision();
541    }
542
543    /**
544     * Set the new content for the given slot role
545     *
546     * @param string $role A slot role name (such as SlotRecord::MAIN)
547     * @param Content $content
548     * @return $this
549     */
550    public function setContent( $role, Content $content ) {
551        $this->ensureRoleAllowed( $role );
552
553        $this->slotsUpdate->modifyContent( $role, $content );
554        return $this;
555    }
556
557    /**
558     * Set the new slot for the given slot role
559     *
560     * @param SlotRecord $slot
561     * @return $this
562     */
563    public function setSlot( SlotRecord $slot ) {
564        $this->ensureRoleAllowed( $slot->getRole() );
565
566        $this->slotsUpdate->modifySlot( $slot );
567        return $this;
568    }
569
570    /**
571     * Explicitly inherit a slot from some earlier revision.
572     *
573     * The primary use case for this is rollbacks, when slots are to be inherited from
574     * the rollback target, overriding the content from the parent revision (which is the
575     * revision being rolled back).
576     *
577     * This should typically not be used to inherit slots from the parent revision, which
578     * happens implicitly. Using this method causes the given slot to be treated as "modified"
579     * during revision creation, even if it has the same content as in the parent revision.
580     *
581     * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
582     *        by the new revision.
583     * @return $this
584     */
585    public function inheritSlot( SlotRecord $originalSlot ) {
586        // NOTE: slots can be inherited even if the role is not "allowed" on the title.
587        // NOTE: this slot is inherited from some other revision, but it's
588        // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
589        // since it's not implicitly inherited from the parent revision.
590        $inheritedSlot = SlotRecord::newInherited( $originalSlot );
591        $this->slotsUpdate->modifySlot( $inheritedSlot );
592        return $this;
593    }
594
595    /**
596     * Removes the slot with the given role.
597     *
598     * This discontinues the "stream" of slots with this role on the page,
599     * preventing the new revision, and any subsequent revisions, from
600     * inheriting the slot with this role.
601     *
602     * @param string $role A slot role name (but not SlotRecord::MAIN)
603     */
604    public function removeSlot( $role ) {
605        $this->ensureRoleNotRequired( $role );
606
607        $this->slotsUpdate->removeSlot( $role );
608    }
609
610    /**
611     * Sets the ID of an earlier revision that is being repeated or restored by this update.
612     * The new revision is expected to have the exact same content as the given original revision.
613     * This is used with rollbacks and with dummy "null" revisions which are created to record
614     * things like page moves. setForceEmptyRevision() calls this implicitly.
615     *
616     * @param int|bool $originalRevId The original revision id, or false if no earlier revision
617     * is known to be repeated or restored by this update.
618     * @return $this
619     */
620    public function setOriginalRevisionId( $originalRevId ) {
621        $this->editResultBuilder->setOriginalRevision( $originalRevId );
622        return $this;
623    }
624
625    /**
626     * Marks this edit as a revert and applies relevant information.
627     * Will also cause the PageUpdater to add a relevant change tag when saving the edit.
628     *
629     * @param int $revertMethod The method used to make the revert:
630     *        REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
631     * @param int $newestRevertedRevId the revision ID of the latest reverted revision.
632     * @param int|null $revertAfterRevId the revision ID after which revisions
633     *   are being reverted. Defaults to the revision before the $newestRevertedRevId.
634     * @return $this
635     * @see EditResultBuilder::markAsRevert()
636     */
637    public function markAsRevert(
638        int $revertMethod,
639        int $newestRevertedRevId,
640        ?int $revertAfterRevId = null
641    ) {
642        $this->editResultBuilder->markAsRevert(
643            $revertMethod, $newestRevertedRevId, $revertAfterRevId
644        );
645        return $this;
646    }
647
648    /**
649     * Returns the EditResult associated with this PageUpdater.
650     * Will return null if PageUpdater::saveRevision() wasn't called yet.
651     * Will also return null if the update was not successful.
652     *
653     * @return EditResult|null
654     */
655    public function getEditResult(): ?EditResult {
656        return $this->editResult;
657    }
658
659    /**
660     * Sets a tag to apply to this update.
661     * Callers are responsible for permission checks,
662     * using ChangeTags::canAddTagsAccompanyingChange.
663     * @param string $tag
664     * @return $this
665     */
666    public function addTag( string $tag ) {
667        $this->tags[] = trim( $tag );
668        return $this;
669    }
670
671    /**
672     * Sets tags to apply to this update.
673     * Callers are responsible for permission checks,
674     * using ChangeTags::canAddTagsAccompanyingChange.
675     * @param string[] $tags
676     * @return $this
677     */
678    public function addTags( array $tags ) {
679        Assert::parameterElementType( 'string', $tags, '$tags' );
680        foreach ( $tags as $tag ) {
681            $this->addTag( $tag );
682        }
683        return $this;
684    }
685
686    /**
687     * Sets software tag to this update. If the tag is not defined in the
688     * current software tags, it's ignored.
689     *
690     * @since 1.38
691     * @param string $tag
692     * @return $this
693     */
694    public function addSoftwareTag( string $tag ): self {
695        if ( in_array( $tag, $this->softwareTags ) ) {
696            $this->addTag( $tag );
697        }
698        return $this;
699    }
700
701    /**
702     * Returns the list of tags set using the addTag() method.
703     *
704     * @return string[]
705     */
706    public function getExplicitTags() {
707        return $this->tags;
708    }
709
710    /**
711     * @return string[]
712     */
713    private function computeEffectiveTags() {
714        $tags = $this->tags;
715        $editResult = $this->getEditResult();
716
717        foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
718            $old_content = $this->getParentContent( $role );
719
720            $handler = $this->getContentHandler( $role );
721            $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
722
723            // TODO: MCR: Do this for all slots. Also add tags for removing roles!
724            $tag = $handler->getChangeTag( $old_content, $content, $this->flags );
725            // If there is no applicable tag, null is returned, so we need to check
726            if ( $tag ) {
727                $tags[] = $tag;
728            }
729        }
730
731        $tags = array_merge( $tags, $editResult->getRevertTags() );
732
733        return array_unique( $tags );
734    }
735
736    /**
737     * Returns the content of the given slot of the parent revision, with no audience checks applied.
738     * If there is no parent revision or the slot is not defined, this returns null.
739     *
740     * @param string $role slot role name
741     * @return Content|null
742     */
743    private function getParentContent( $role ) {
744        $parent = $this->grabParentRevision();
745
746        if ( $parent && $parent->hasSlot( $role ) ) {
747            return $parent->getContent( $role, RevisionRecord::RAW );
748        }
749
750        return null;
751    }
752
753    /**
754     * @param string $role slot role name
755     * @return ContentHandler
756     */
757    private function getContentHandler( $role ) {
758        if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
759            $slot = $this->slotsUpdate->getModifiedSlot( $role );
760        } else {
761            $parent = $this->grabParentRevision();
762
763            if ( $parent ) {
764                $slot = $parent->getSlot( $role, RevisionRecord::RAW );
765            } else {
766                throw new RevisionAccessException(
767                    'No such slot: {role}',
768                    [ 'role' => $role ]
769                );
770            }
771        }
772
773        return $this->contentHandlerFactory->getContentHandler( $slot->getModel() );
774    }
775
776    /**
777     * @return CommentStoreComment
778     */
779    private function makeAutoSummary() {
780        if ( !$this->useAutomaticEditSummaries || ( $this->flags & EDIT_AUTOSUMMARY ) === 0 ) {
781            return CommentStoreComment::newUnsavedComment( '' );
782        }
783
784        // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
785        // TODO: combine auto-summaries for multiple slots!
786        // XXX: this logic should not be in the storage layer!
787        $roles = $this->slotsUpdate->getModifiedRoles();
788        $role = reset( $roles );
789
790        if ( $role === false ) {
791            return CommentStoreComment::newUnsavedComment( '' );
792        }
793
794        $handler = $this->getContentHandler( $role );
795        $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
796        $old_content = $this->getParentContent( $role );
797        $summary = $handler->getAutosummary( $old_content, $content, $this->flags );
798
799        return CommentStoreComment::newUnsavedComment( $summary );
800    }
801
802    /**
803     * Change an existing article or create a new article. Updates RC and all necessary caches,
804     * optionally via the deferred update array. This does not check user permissions.
805     *
806     * It is guaranteed that saveRevision() will fail if the current revision of the page
807     * changes after grabParentRevision() was called and before saveRevision() can insert
808     * a new revision, as per the CAS mechanism described above.
809     *
810     * The caller is however responsible for calling hasEditConflict() to detect a
811     * user-level edit conflict, and to adjust the content of the new revision accordingly,
812     * e.g. by using a 3-way-merge.
813     *
814     * MCR migration note: this replaces WikiPage::doUserEditContent. Callers that change to using
815     * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
816     *
817     * @param CommentStoreComment $summary Edit summary
818     * @param int $flags Bitfield, will be combined with the flags set via setFlags(). See
819     *        there for details.
820     *
821     * @note If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
822     * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
823     * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
824     * was unexpectedly created or deleted while revision creation is in progress. This can be
825     * viewed as part of the CAS mechanism described above.
826     *
827     * @return RevisionRecord|null The new revision, or null if no new revision was created due
828     *         to a failure or a null-edit. Use wasRevisionCreated(), wasSuccessful() and getStatus()
829     *         to determine the outcome of the revision creation.
830     */
831    public function saveRevision( CommentStoreComment $summary, int $flags = 0 ) {
832        $this->setFlags( $flags );
833
834        if ( $this->wasCommitted() ) {
835            throw new RuntimeException(
836                'saveRevision() or updateRevision() has already been called on this PageUpdater!'
837            );
838        }
839
840        // Low-level check
841        if ( $this->getPage()->getDBkey() === '' ) {
842            throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
843        }
844
845        // NOTE: slots can be inherited even if the role is not "allowed" on the title.
846        $status = PageUpdateStatus::newGood();
847        $this->checkAllRolesAllowed(
848            $this->slotsUpdate->getModifiedRoles(),
849            $status
850        );
851        $this->checkNoRolesRequired(
852            $this->slotsUpdate->getRemovedRoles(),
853            $status
854        );
855
856        if ( !$status->isOK() ) {
857            return null;
858        }
859
860        // Make sure the given content is allowed in the respective slots of this page
861        foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
862            $slot = $this->slotsUpdate->getModifiedSlot( $role );
863            $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
864
865            if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getPage() ) ) {
866                $contentHandler = $this->contentHandlerFactory
867                    ->getContentHandler( $slot->getModel() );
868                $this->status = PageUpdateStatus::newFatal( 'content-not-allowed-here',
869                    ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
870                    $this->titleFormatter->getPrefixedText( $this->getPage() ),
871                    wfMessage( $roleHandler->getNameMessageKey() )
872                    // TODO: defer message lookup to caller
873                );
874                return null;
875            }
876        }
877
878        // Load the data from the primary database if needed. Needed to check flags.
879        // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
880        // wasn't called yet. If the page is modified by another process before we are done with
881        // it, this method must fail (with status 'edit-conflict')!
882        // NOTE: The parent revision may be different from the edit's base revision.
883        $this->prepareUpdate();
884
885        // Detect whether update or creation should be performed.
886        if ( !( $this->flags & EDIT_NEW ) && !( $this->flags & EDIT_UPDATE ) ) {
887            $this->flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
888        }
889
890        // Trigger pre-save hook (using provided edit summary)
891        $renderedRevision = $this->derivedDataUpdater->getRenderedRevision();
892        $hookStatus = PageUpdateStatus::newGood( [] );
893        $allowedByHook = $this->hookRunner->onMultiContentSave(
894            $renderedRevision, $this->author, $summary, $this->flags, $hookStatus
895        );
896        if ( $allowedByHook && $this->hookContainer->isRegistered( 'PageContentSave' ) ) {
897            // Also run the legacy hook.
898            // NOTE: WikiPage should only be used for the legacy hook,
899            // and only if something uses the legacy hook.
900            $mainContent = $this->derivedDataUpdater->getSlots()->getContent( SlotRecord::MAIN );
901
902            $legacyUser = self::toLegacyUser( $this->author );
903
904            // Deprecated since 1.35.
905            $allowedByHook = $this->hookRunner->onPageContentSave(
906                $this->getWikiPage(), $legacyUser, $mainContent, $summary,
907                (bool)( $this->flags & EDIT_MINOR ), null, null, $this->flags, $hookStatus
908            );
909        }
910
911        if ( !$allowedByHook ) {
912            // The hook has prevented this change from being saved.
913            if ( $hookStatus->isOK() ) {
914                // Hook returned false but didn't call fatal(); use generic message
915                $hookStatus->fatal( 'edit-hook-aborted' );
916            }
917
918            $this->status = $hookStatus;
919            $this->logger->info( "Hook prevented page save", [ 'status' => $hookStatus ] );
920            return null;
921        }
922
923        // Provide autosummaries if one is not provided and autosummaries are enabled
924        // XXX: $summary == null seems logical, but the empty string may actually come from the user
925        // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
926        if ( $summary->text === '' && $summary->data === null ) {
927            $summary = $this->makeAutoSummary();
928        }
929
930        // Actually create the revision and create/update the page.
931        // Do NOT yet set $this->status!
932        if ( $this->flags & EDIT_UPDATE ) {
933            $status = $this->doModify( $summary );
934        } else {
935            $status = $this->doCreate( $summary );
936        }
937
938        // Promote user to any groups they meet the criteria for
939        DeferredUpdates::addCallableUpdate( function () {
940            $this->userGroupManager->addUserToAutopromoteOnceGroups( $this->author, 'onEdit' );
941            // Also run 'onView' for backwards compatibility
942            $this->userGroupManager->addUserToAutopromoteOnceGroups( $this->author, 'onView' );
943        } );
944
945        // NOTE: set $this->status only after all hooks have been called,
946        // so wasCommitted doesn't return true when called indirectly from a hook handler!
947        $this->status = $status;
948
949        // TODO: replace bad status with Exceptions!
950        return $this->status
951            ? $this->status->getNewRevision()
952            : null;
953    }
954
955    /**
956     * Updates derived slots of an existing article. Does not update RC. Updates all necessary
957     * caches, optionally via the deferred update array. This does not check user permissions.
958     * Does not do a PST.
959     *
960     * Use wasRevisionCreated(), wasSuccessful() and getStatus() to determine the outcome of the
961     * revision update.
962     *
963     * @param int $revId
964     * @since 1.36
965     */
966    public function updateRevision( int $revId = 0 ) {
967        if ( $this->wasCommitted() ) {
968            throw new RuntimeException(
969                'saveRevision() or updateRevision() has already been called on this PageUpdater!'
970            );
971        }
972
973        // Low-level check
974        if ( $this->getPage()->getDBkey() === '' ) {
975            throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
976        }
977
978        $status = PageUpdateStatus::newGood();
979        $this->checkAllRolesAllowed(
980            $this->slotsUpdate->getModifiedRoles(),
981            $status
982        );
983        $this->checkAllRolesDerived(
984            $this->slotsUpdate->getModifiedRoles(),
985            $status
986        );
987        $this->checkAllRolesDerived(
988            $this->slotsUpdate->getRemovedRoles(),
989            $status
990        );
991
992        if ( $revId === 0 ) {
993            $revision = $this->grabParentRevision();
994        } else {
995            $revision = $this->revisionStore->getRevisionById( $revId, IDBAccessObject::READ_LATEST );
996        }
997        if ( $revision === null ) {
998            $status->fatal( 'edit-gone-missing' );
999        }
1000
1001        if ( !$status->isOK() ) {
1002            $this->status = $status;
1003            return;
1004        }
1005
1006        // Make sure the given content is allowed in the respective slots of this page
1007        foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
1008            $slot = $this->slotsUpdate->getModifiedSlot( $role );
1009            $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
1010
1011            if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getPage() ) ) {
1012                $contentHandler = $this->contentHandlerFactory
1013                    ->getContentHandler( $slot->getModel() );
1014                $this->status = PageUpdateStatus::newFatal(
1015                    'content-not-allowed-here',
1016                    ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
1017                    $this->titleFormatter->getPrefixedText( $this->getPage() ),
1018                    wfMessage( $roleHandler->getNameMessageKey() )
1019                // TODO: defer message lookup to caller
1020                );
1021                return;
1022            }
1023        }
1024
1025        // XXX: do we need PST?
1026
1027        $this->flags |= EDIT_INTERNAL;
1028        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable revision is checked
1029        $this->status = $this->doUpdate( $revision );
1030    }
1031
1032    /**
1033     * Whether saveRevision() has been called on this instance
1034     *
1035     * @return bool
1036     */
1037    public function wasCommitted() {
1038        return $this->status !== null;
1039    }
1040
1041    /**
1042     * The Status object indicating whether saveRevision() was successful.
1043     * Must not be called before saveRevision() or updateRevision() was called on this instance.
1044     *
1045     * @note This is here for compatibility with WikiPage::doUserEditContent. It may be deprecated
1046     * soon.
1047     *
1048     * Possible status errors:
1049     *     edit-hook-aborted: The ArticleSave hook aborted the update but didn't
1050     *       set the fatal flag of $status.
1051     *     edit-gone-missing: In update mode, but the article didn't exist.
1052     *     edit-conflict: In update mode, the article changed unexpectedly.
1053     *     edit-no-change: Warning that the text was the same as before.
1054     *     edit-already-exists: In creation mode, but the article already exists.
1055     *
1056     *  Extensions may define additional errors.
1057     *
1058     *  $return->value will contain an associative array with members as follows:
1059     *     new: Boolean indicating if the function attempted to create a new article.
1060     *     revision-record: The RevisionRecord object for the inserted revision, or null.
1061     *
1062     * @return PageUpdateStatus
1063     */
1064    public function getStatus(): PageUpdateStatus {
1065        if ( !$this->status ) {
1066            throw new LogicException(
1067                'getStatus() is undefined before saveRevision() or updateRevision() have been called'
1068            );
1069        }
1070        return $this->status;
1071    }
1072
1073    /**
1074     * Whether saveRevision() completed successfully. This is not the same as wasRevisionCreated():
1075     * when the new content is exactly the same as the old one (DerivedPageDataUpdater::isChange()
1076     * returns false) and setForceEmptyRevision( true ) is not set, no new revision is created, but
1077     * the save is considered successful. This behavior constitutes a "null edit".
1078     *
1079     * @return bool
1080     */
1081    public function wasSuccessful() {
1082        return $this->status && $this->status->isOK();
1083    }
1084
1085    /**
1086     * Whether saveRevision() was called and created a new page.
1087     *
1088     * @return bool
1089     */
1090    public function isNew() {
1091        return $this->status && $this->status->wasPageCreated();
1092    }
1093
1094    /**
1095     * Whether saveRevision() did create a revision because the content didn't change: (null-edit).
1096     * Whether the content changed or not is determined by DerivedPageDataUpdater::isChange().
1097     *
1098     * @deprecated since 1.38, use wasRevisionCreated() instead.
1099     * @return bool
1100     */
1101    public function isUnchanged() {
1102        return !$this->wasRevisionCreated();
1103    }
1104
1105    /**
1106     * Whether the prepared edit is a change compared to the previous revision.
1107     *
1108     * @return bool
1109     */
1110    public function isChange() {
1111        return $this->derivedDataUpdater->isChange();
1112    }
1113
1114    /**
1115     * Disable new revision creation, throwing an exception if it is attempted.
1116     *
1117     * @return $this
1118     */
1119    public function preventChange() {
1120        $this->preventChange = true;
1121        return $this;
1122    }
1123
1124    /**
1125     * Whether saveRevision() did create a revision. This is not the same as wasSuccessful():
1126     * when the new content is exactly the same as the old one (DerivedPageDataUpdater::isChange()
1127     * returns false) and setForceEmptyRevision( true ) is not set, no new revision is created, but
1128     * the save is considered successful. This behavior constitutes a "null edit".
1129     *
1130     * @since 1.38
1131     *
1132     * @return bool
1133     */
1134    public function wasRevisionCreated(): bool {
1135        return $this->status
1136            && $this->status->wasRevisionCreated();
1137    }
1138
1139    /**
1140     * The new revision created by saveRevision(), or null if saveRevision() has not yet been
1141     * called, failed, or did not create a new revision because the content did not change.
1142     *
1143     * @return RevisionRecord|null
1144     */
1145    public function getNewRevision() {
1146        return $this->status
1147            ? $this->status->getNewRevision()
1148            : null;
1149    }
1150
1151    /**
1152     * Constructs a MutableRevisionRecord based on the Content prepared by the
1153     * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
1154     * with PST applied, and removing discontinued slots.
1155     *
1156     * This calls Content::prepareSave() to verify that the slot content can be saved.
1157     * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
1158     *
1159     * @param CommentStoreComment $comment
1160     * @param PageUpdateStatus $status
1161     *
1162     * @return MutableRevisionRecord
1163     */
1164    private function makeNewRevision(
1165        CommentStoreComment $comment,
1166        PageUpdateStatus $status
1167    ) {
1168        $title = $this->getTitle();
1169        $parent = $this->grabParentRevision();
1170
1171        // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
1172        // TODO: introduce something like an UnsavedRevisionFactory service instead!
1173        /** @var MutableRevisionRecord $rev */
1174        $rev = $this->derivedDataUpdater->getRevision();
1175        '@phan-var MutableRevisionRecord $rev';
1176
1177        // Avoid fatal error when the Title's ID changed, T204793
1178        if (
1179            $rev->getPageId() !== null && $title->exists()
1180            && $rev->getPageId() !== $title->getArticleID()
1181        ) {
1182            $titlePageId = $title->getArticleID();
1183            $revPageId = $rev->getPageId();
1184            $masterPageId = $title->getArticleID( IDBAccessObject::READ_LATEST );
1185
1186            if ( $revPageId === $masterPageId ) {
1187                wfWarn( __METHOD__ . ": Encountered stale Title object: old ID was $titlePageId"
1188                    . "continuing with new ID from primary DB, $masterPageId" );
1189            } else {
1190                throw new InvalidArgumentException(
1191                    "Revision inherited page ID $revPageId from its parent, "
1192                    . "but the provided Title object belongs to page ID $masterPageId"
1193                );
1194            }
1195        }
1196
1197        if ( $parent ) {
1198            $oldid = $parent->getId();
1199            $rev->setParentId( $oldid );
1200
1201            if ( $title->getArticleID() !== $parent->getPageId() ) {
1202                wfWarn( __METHOD__ . ': Encountered stale Title object with no page ID! '
1203                    . 'Using page ID from parent revision: ' . $parent->getPageId() );
1204            }
1205        } else {
1206            $oldid = 0;
1207        }
1208
1209        $rev->setComment( $comment );
1210        $rev->setUser( $this->author );
1211        $rev->setMinorEdit( ( $this->flags & EDIT_MINOR ) > 0 );
1212
1213        foreach ( $rev->getSlots()->getSlots() as $slot ) {
1214            $content = $slot->getContent();
1215
1216            // XXX: We may push this up to the "edit controller" level, see T192777.
1217            $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
1218            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId is not null here
1219            $validationParams = new ValidationParams( $this->getPage(), $this->flags, $oldid );
1220            $prepStatus = $contentHandler->validateSave( $content, $validationParams );
1221
1222            // TODO: MCR: record which problem arose in which slot.
1223            $status->merge( $prepStatus );
1224        }
1225
1226        $this->checkAllRequiredRoles(
1227            $rev->getSlotRoles(),
1228            $status
1229        );
1230
1231        return $rev;
1232    }
1233
1234    /**
1235     * Builds the EditResult for this update.
1236     * Should be called by either doModify or doCreate.
1237     *
1238     * @param RevisionRecord $revision
1239     * @param bool $isNew
1240     */
1241    private function buildEditResult( RevisionRecord $revision, bool $isNew ) {
1242        $this->editResultBuilder->setRevisionRecord( $revision );
1243        $this->editResultBuilder->setIsNew( $isNew );
1244        $this->editResult = $this->editResultBuilder->buildEditResult();
1245    }
1246
1247    /**
1248     * Update derived slots in an existing revision. If the revision is the current revision,
1249     * this will update page_touched and trigger secondary updates.
1250     *
1251     * We do not have sufficient information to know whether to or how to update recentchanges
1252     * here, so, as opposed to doCreate(), updating recentchanges is left as the responsibility
1253     * of the caller.
1254     *
1255     * @param RevisionRecord $revision
1256     * @return PageUpdateStatus
1257     */
1258    private function doUpdate( RevisionRecord $revision ): PageUpdateStatus {
1259        $currentRevision = $this->grabParentRevision();
1260        if ( !$currentRevision ) {
1261            // Article gone missing
1262            return PageUpdateStatus::newFatal( 'edit-gone-missing' );
1263        }
1264
1265        $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1266        $dbw->startAtomic( __METHOD__ );
1267
1268        $slots = $this->revisionStore->updateSlotsOn( $revision, $this->slotsUpdate, $dbw );
1269
1270        $dbw->endAtomic( __METHOD__ );
1271
1272        // Return the slots and revision to the caller
1273        $newRevisionRecord = MutableRevisionRecord::newUpdatedRevisionRecord( $revision, $slots );
1274        $status = PageUpdateStatus::newGood( [
1275            'revision-record' => $newRevisionRecord,
1276            'slots' => $slots,
1277        ] );
1278
1279        $isCurrent = $revision->getId( $this->getWikiId() ) ===
1280            $currentRevision->getId( $this->getWikiId() );
1281
1282        if ( $isCurrent ) {
1283            // Update page_touched
1284            $this->getTitle()->invalidateCache( $newRevisionRecord->getTimestamp() );
1285
1286            $this->buildEditResult( $newRevisionRecord, false );
1287
1288            // Do secondary updates once the main changes have been committed...
1289            $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1290            DeferredUpdates::addUpdate(
1291                $this->getAtomicSectionUpdate(
1292                    $dbw,
1293                    $wikiPage,
1294                    $newRevisionRecord,
1295                    $revision->getComment(),
1296                    [ 'changed' => false, ]
1297                ),
1298                DeferredUpdates::PRESEND
1299            );
1300        }
1301
1302        return $status;
1303    }
1304
1305    /**
1306     * @param CommentStoreComment $summary The edit summary
1307     * @return PageUpdateStatus
1308     */
1309    private function doModify( CommentStoreComment $summary ): PageUpdateStatus {
1310        $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1311
1312        // Update article, but only if changed.
1313        $status = PageUpdateStatus::newEmpty( false );
1314
1315        $oldRev = $this->grabParentRevision();
1316        $oldid = $oldRev ? $oldRev->getId() : 0;
1317
1318        if ( !$oldRev ) {
1319            // Article gone missing
1320            return $status->fatal( 'edit-gone-missing' );
1321        }
1322
1323        $newRevisionRecord = $this->makeNewRevision(
1324            $summary,
1325            $status
1326        );
1327
1328        if ( !$status->isOK() ) {
1329            return $status;
1330        }
1331
1332        $now = $newRevisionRecord->getTimestamp();
1333
1334        $changed = $this->derivedDataUpdater->isChange();
1335
1336        if ( $changed ) {
1337            if ( $this->forceEmptyRevision ) {
1338                throw new LogicException(
1339                    "Content was changed even though forceEmptyRevision() was called."
1340                );
1341            }
1342            if ( $this->preventChange ) {
1343                throw new LogicException(
1344                    "Content was changed even though preventChange() was called."
1345                );
1346            }
1347        }
1348
1349        // We build the EditResult before the $change if/else branch in order to pass
1350        // the correct $newRevisionRecord to EditResultBuilder. In case this is a null
1351        // edit, $newRevisionRecord will be later overridden to its parent revision, which
1352        // would confuse EditResultBuilder.
1353        if ( !$changed ) {
1354            // This is a null edit, ensure original revision ID is set properly
1355            $this->editResultBuilder->setOriginalRevision( $oldRev );
1356        }
1357        $this->buildEditResult( $newRevisionRecord, false );
1358
1359        $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1360
1361        if ( $changed || $this->forceEmptyRevision ) {
1362            $dbw->startAtomic( __METHOD__ );
1363
1364            // Get the latest page_latest value while locking it.
1365            // Do a CAS style check to see if it's the same as when this method
1366            // started. If it changed then bail out before touching the DB.
1367            $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
1368            if ( $latestNow != $oldid ) {
1369                // We don't need to roll back, since we did not modify the database yet.
1370                // XXX: Or do we want to rollback, any transaction started by calling
1371                // code will fail? If we want that, we should probably throw an exception.
1372                $dbw->endAtomic( __METHOD__ );
1373
1374                // Page updated or deleted in the mean time
1375                return $status->fatal( 'edit-conflict' );
1376            }
1377
1378            // At this point we are now committed to returning an OK
1379            // status unless some DB query error or other exception comes up.
1380            // This way callers don't have to call rollback() if $status is bad
1381            // unless they actually try to catch exceptions (which is rare).
1382
1383            // Save revision content and meta-data
1384            $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1385
1386            // Update page_latest and friends to reflect the new revision
1387            // TODO: move to storage service
1388            $wasRedirect = $this->derivedDataUpdater->wasRedirect();
1389            if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, null, $wasRedirect ) ) {
1390                throw new PageUpdateException( "Failed to update page row to use new revision." );
1391            }
1392
1393            $editResult = $this->getEditResult();
1394            $tags = $this->computeEffectiveTags();
1395            $this->hookRunner->onRevisionFromEditComplete(
1396                $wikiPage,
1397                $newRevisionRecord,
1398                $editResult->getOriginalRevisionId(),
1399                $this->author,
1400                $tags
1401            );
1402
1403            $this->eventDispatcher->dispatch( new PageUpdatedEvent(
1404                $this->slotsUpdate,
1405                $newRevisionRecord,
1406                $oldRev,
1407                $editResult,
1408                $tags,
1409                $this->flags,
1410                $this->rcPatrolStatus
1411            ), $this->dbProvider );
1412
1413            $dbw->endAtomic( __METHOD__ );
1414
1415            // Return the new revision to the caller
1416            $status->setNewRevision( $newRevisionRecord );
1417        } else {
1418            // T34948: revision ID must be set to page {{REVISIONID}} and
1419            // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
1420            // Since we don't insert a new revision into the database, the least
1421            // error-prone way is to reuse given old revision.
1422            $newRevisionRecord = $oldRev;
1423
1424            $status->warning( 'edit-no-change' );
1425            // Update page_touched as updateRevisionOn() was not called.
1426            // Other cache updates are managed in WikiPage::onArticleEdit()
1427            // via WikiPage::doEditUpdates().
1428            $this->getTitle()->invalidateCache( $now );
1429        }
1430
1431        // Do secondary updates once the main changes have been committed...
1432        // NOTE: the updates have to be processed before sending the response to the client
1433        // (DeferredUpdates::PRESEND), otherwise the client may already be following the
1434        // HTTP redirect to the standard view before derived data has been created - most
1435        // importantly, before the parser cache has been updated. This would cause the
1436        // content to be parsed a second time, or may cause stale content to be shown.
1437        DeferredUpdates::addUpdate(
1438            $this->getAtomicSectionUpdate(
1439                $dbw,
1440                $wikiPage,
1441                $newRevisionRecord,
1442                $summary,
1443                [ 'changed' => $changed, ]
1444            ),
1445            DeferredUpdates::PRESEND
1446        );
1447
1448        return $status;
1449    }
1450
1451    /**
1452     * @param CommentStoreComment $summary The edit summary
1453     * @return PageUpdateStatus
1454     */
1455    private function doCreate( CommentStoreComment $summary ): PageUpdateStatus {
1456        if ( $this->preventChange ) {
1457            throw new LogicException(
1458                "Content was changed even though preventChange() was called."
1459            );
1460        }
1461        $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1462
1463        if ( !$this->derivedDataUpdater->getSlots()->hasSlot( SlotRecord::MAIN ) ) {
1464            throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
1465        }
1466
1467        $status = PageUpdateStatus::newEmpty( true );
1468
1469        $newRevisionRecord = $this->makeNewRevision(
1470            $summary,
1471            $status
1472        );
1473
1474        if ( !$status->isOK() ) {
1475            return $status;
1476        }
1477
1478        $this->buildEditResult( $newRevisionRecord, true );
1479        $now = $newRevisionRecord->getTimestamp();
1480
1481        $dbw = $this->dbProvider->getPrimaryDatabase( $this->getWikiId() );
1482        $dbw->startAtomic( __METHOD__ );
1483
1484        // Add the page record unless one already exists for the title
1485        // TODO: move to storage service
1486        $newid = $wikiPage->insertOn( $dbw );
1487        if ( $newid === false ) {
1488            $dbw->endAtomic( __METHOD__ );
1489            return $status->fatal( 'edit-already-exists' );
1490        }
1491
1492        // At this point we are now committed to returning an OK
1493        // status unless some DB query error or other exception comes up.
1494        // This way callers don't have to call rollback() if $status is bad
1495        // unless they actually try to catch exceptions (which is rare).
1496        $newRevisionRecord->setPageId( $newid );
1497
1498        // Save the revision text...
1499        $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1500
1501        // Update the page record with revision data
1502        // TODO: move to storage service
1503        if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, 0, false ) ) {
1504            throw new PageUpdateException( "Failed to update page row to use new revision." );
1505        }
1506
1507        $tags = $this->computeEffectiveTags();
1508        $this->hookRunner->onRevisionFromEditComplete(
1509            $wikiPage, $newRevisionRecord, false, $this->author, $tags
1510        );
1511
1512        if ( $this->usePageCreationLog ) {
1513            // Log the page creation
1514            // @TODO: Do we want a 'recreate' action?
1515            $logEntry = new ManualLogEntry( 'create', 'create' );
1516            $logEntry->setPerformer( $this->author );
1517            $logEntry->setTarget( $this->getPage() );
1518            $logEntry->setComment( $summary->text );
1519            $logEntry->setTimestamp( $now );
1520            $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
1521            $logEntry->insert();
1522            // Note that we don't publish page creation events to recentchanges
1523            // (i.e. $logEntry->publish()) since this would create duplicate entries,
1524            // one for the edit and one for the page creation.
1525        }
1526
1527        $this->eventDispatcher->dispatch( new PageUpdatedEvent(
1528            $this->slotsUpdate,
1529            $newRevisionRecord,
1530            null,
1531            null,
1532            $tags,
1533            $this->flags,
1534            $this->rcPatrolStatus
1535        ), $this->dbProvider );
1536
1537        $dbw->endAtomic( __METHOD__ );
1538
1539        // Return the new revision to the caller
1540        $status->setNewRevision( $newRevisionRecord );
1541
1542        // Do secondary updates once the main changes have been committed...
1543        DeferredUpdates::addUpdate(
1544            $this->getAtomicSectionUpdate(
1545                $dbw,
1546                $wikiPage,
1547                $newRevisionRecord,
1548                $summary,
1549                [ 'created' => true ]
1550            ),
1551            DeferredUpdates::PRESEND
1552        );
1553
1554        return $status;
1555    }
1556
1557    private function getAtomicSectionUpdate(
1558        IDatabase $dbw,
1559        WikiPage $wikiPage,
1560        RevisionRecord $newRevisionRecord,
1561        CommentStoreComment $summary,
1562        array $hints = []
1563    ) {
1564        return new AtomicSectionUpdate(
1565            $dbw,
1566            __METHOD__,
1567            function () use (
1568                $wikiPage, $newRevisionRecord,
1569                $summary, $hints
1570            ) {
1571                // set debug data
1572                $hints['causeAction'] = 'edit-page';
1573                $hints['causeAgent'] = $this->author->getName();
1574
1575                $editResult = $this->getEditResult();
1576                $hints['editResult'] = $editResult;
1577
1578                if ( $editResult->isRevert() ) {
1579                    // Should the reverted tag update be scheduled right away?
1580                    // The revert is approved if either patrolling is disabled or the
1581                    // edit is patrolled or autopatrolled.
1582                    $approved = !$this->serviceOptions->get( MainConfigNames::UseRCPatrol ) ||
1583                        $this->rcPatrolStatus === RecentChange::PRC_PATROLLED ||
1584                        $this->rcPatrolStatus === RecentChange::PRC_AUTOPATROLLED;
1585
1586                    // Allow extensions to override the patrolling subsystem.
1587                    $this->hookRunner->onBeforeRevertedTagUpdate(
1588                        $wikiPage,
1589                        $this->author,
1590                        $summary,
1591                        $this->flags,
1592                        $newRevisionRecord,
1593                        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Not null already checked
1594                        $editResult,
1595                        $approved
1596                    );
1597                    $hints['approved'] = $approved;
1598                }
1599
1600                // Update links tables, site stats, etc.
1601                $this->derivedDataUpdater->prepareUpdate( $newRevisionRecord, $hints );
1602                $this->derivedDataUpdater->doUpdates();
1603
1604                $created = $hints['created'] ?? false;
1605                $this->flags |= ( $created ? EDIT_NEW : EDIT_UPDATE );
1606
1607                // PageSaveComplete replaced old PageContentInsertComplete and
1608                // PageContentSaveComplete hooks since 1.35
1609                $this->hookRunner->onPageSaveComplete(
1610                    $wikiPage,
1611                    $this->author,
1612                    $summary->text,
1613                    $this->flags,
1614                    $newRevisionRecord,
1615                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Not null already checked
1616                    $editResult
1617                );
1618            }
1619        );
1620    }
1621
1622    /**
1623     * @return string[] Slots required for this page update, as a list of role names.
1624     */
1625    private function getRequiredSlotRoles() {
1626        return $this->slotRoleRegistry->getRequiredRoles( $this->getPage() );
1627    }
1628
1629    /**
1630     * @return string[] Slots allowed for this page update, as a list of role names.
1631     */
1632    private function getAllowedSlotRoles() {
1633        return $this->slotRoleRegistry->getAllowedRoles( $this->getPage() );
1634    }
1635
1636    private function ensureRoleAllowed( $role ) {
1637        $allowedRoles = $this->getAllowedSlotRoles();
1638        if ( !in_array( $role, $allowedRoles ) ) {
1639            throw new PageUpdateException( "Slot role `$role` is not allowed." );
1640        }
1641    }
1642
1643    private function ensureRoleNotRequired( $role ) {
1644        $requiredRoles = $this->getRequiredSlotRoles();
1645        if ( in_array( $role, $requiredRoles ) ) {
1646            throw new PageUpdateException( "Slot role `$role` is required." );
1647        }
1648    }
1649
1650    /**
1651     * @param array $roles
1652     * @param PageUpdateStatus $status
1653     */
1654    private function checkAllRolesAllowed( array $roles, PageUpdateStatus $status ) {
1655        $allowedRoles = $this->getAllowedSlotRoles();
1656
1657        $forbidden = array_diff( $roles, $allowedRoles );
1658        if ( $forbidden ) {
1659            $status->error(
1660                'edit-slots-cannot-add',
1661                count( $forbidden ),
1662                implode( ', ', $forbidden )
1663            );
1664        }
1665    }
1666
1667    /**
1668     * @param array $roles
1669     * @param PageUpdateStatus $status
1670     */
1671    private function checkAllRolesDerived( array $roles, PageUpdateStatus $status ) {
1672        $notDerived = array_filter(
1673            $roles,
1674            function ( $role ) {
1675                return !$this->slotRoleRegistry->getRoleHandler( $role )->isDerived();
1676            }
1677        );
1678        if ( $notDerived ) {
1679            $status->error(
1680                'edit-slots-not-derived',
1681                count( $notDerived ),
1682                implode( ', ', $notDerived )
1683            );
1684        }
1685    }
1686
1687    /**
1688     * @param array $roles
1689     * @param PageUpdateStatus $status
1690     */
1691    private function checkNoRolesRequired( array $roles, PageUpdateStatus $status ) {
1692        $requiredRoles = $this->getRequiredSlotRoles();
1693
1694        $needed = array_diff( $roles, $requiredRoles );
1695        if ( $needed ) {
1696            $status->error(
1697                'edit-slots-cannot-remove',
1698                count( $needed ),
1699                implode( ', ', $needed )
1700            );
1701        }
1702    }
1703
1704    /**
1705     * @param array $roles
1706     * @param PageUpdateStatus $status
1707     */
1708    private function checkAllRequiredRoles( array $roles, PageUpdateStatus $status ) {
1709        $requiredRoles = $this->getRequiredSlotRoles();
1710
1711        $missing = array_diff( $requiredRoles, $roles );
1712        if ( $missing ) {
1713            $status->error(
1714                'edit-slots-missing',
1715                count( $missing ),
1716                implode( ', ', $missing )
1717            );
1718        }
1719    }
1720
1721}