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