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