Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.82% covered (warning)
82.82%
482 / 582
66.67% covered (warning)
66.67%
36 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
DerivedPageDataUpdater
82.82% covered (warning)
82.82%
482 / 582
66.67% covered (warning)
66.67%
36 / 54
405.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 doTransition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assertTransition
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 isReusableFor
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
24.33
 setForceEmptyRevision
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setArticleCountMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRcWatchCategoryMembership
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
 getPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pageExisted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParentRevision
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 grabCurrentRevision
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 isContentPrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUpdatePrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getPageId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isContentDeleted
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getRawSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRawContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentHandler
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 usePrimary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCountable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 isRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 revisionIsRedirect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 prepareContent
82.98% covered (warning)
82.98%
78 / 94
0.00% covered (danger)
0.00%
0 / 1
20.78
 getRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRenderedRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 assertHasPageState
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
4.05
 assertPrepared
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 assertHasRevision
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 isCreation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isChange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 wasRedirect
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getSlots
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionSlotsUpdate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getTouchedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModifiedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRemovedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareUpdate
68.69% covered (warning)
68.69%
68 / 99
0.00% covered (danger)
0.00%
0 / 1
52.07
 getPreparedEdit
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getSlotParserOutput
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getParserOutputForMetaData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSecondaryDataUpdates
95.35% covered (success)
95.35%
41 / 43
0.00% covered (danger)
0.00%
0 / 1
8
 shouldGenerateHTMLOnEdit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doUpdates
69.66% covered (warning)
69.66%
62 / 89
0.00% covered (danger)
0.00%
0 / 1
66.28
 triggerParserCacheUpdate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 maybeEnqueueRevertedTagUpdateJob
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 doSecondaryDataUpdates
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 doParserCacheUpdate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
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 CategoryMembershipChangeJob;
24use Content;
25use ContentHandler;
26use IDBAccessObject;
27use InvalidArgumentException;
28use JobQueueGroup;
29use Language;
30use LogicException;
31use MediaWiki\Config\ServiceOptions;
32use MediaWiki\Content\IContentHandlerFactory;
33use MediaWiki\Content\Transform\ContentTransformer;
34use MediaWiki\Deferred\DeferrableUpdate;
35use MediaWiki\Deferred\DeferredUpdates;
36use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
37use MediaWiki\Deferred\RefreshSecondaryDataUpdate;
38use MediaWiki\Deferred\SearchUpdate;
39use MediaWiki\Deferred\SiteStatsUpdate;
40use MediaWiki\Edit\PreparedEdit;
41use MediaWiki\HookContainer\HookContainer;
42use MediaWiki\HookContainer\HookRunner;
43use MediaWiki\MainConfigNames;
44use MediaWiki\Page\PageIdentity;
45use MediaWiki\Page\ParserOutputAccess;
46use MediaWiki\Page\WikiPageFactory;
47use MediaWiki\Parser\ParserOutput;
48use MediaWiki\Permissions\PermissionManager;
49use MediaWiki\ResourceLoader as RL;
50use MediaWiki\Revision\MutableRevisionRecord;
51use MediaWiki\Revision\RenderedRevision;
52use MediaWiki\Revision\RevisionRecord;
53use MediaWiki\Revision\RevisionRenderer;
54use MediaWiki\Revision\RevisionSlots;
55use MediaWiki\Revision\RevisionStore;
56use MediaWiki\Revision\SlotRecord;
57use MediaWiki\Revision\SlotRoleRegistry;
58use MediaWiki\Title\Title;
59use MediaWiki\User\TalkPageNotificationManager;
60use MediaWiki\User\User;
61use MediaWiki\User\UserIdentity;
62use MediaWiki\User\UserNameUtils;
63use MediaWiki\Utils\MWTimestamp;
64use MessageCache;
65use MWUnknownContentModelException;
66use ParserCache;
67use ParserOptions;
68use ParsoidCachePrewarmJob;
69use Psr\Log\LoggerAwareInterface;
70use Psr\Log\LoggerInterface;
71use Psr\Log\NullLogger;
72use RevertedTagUpdateJob;
73use WANObjectCache;
74use Wikimedia\Assert\Assert;
75use Wikimedia\Rdbms\ILBFactory;
76use WikiPage;
77
78/**
79 * A handle for managing updates for derived page data on edit, import, purge, etc.
80 *
81 * @note Avoid direct usage of DerivedPageDataUpdater.
82 *
83 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
84 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
85 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
86 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
87 * Content::getSecondaryDataUpdates().
88 *
89 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
90 * and re-used by callback code over the course of an update operation. It's a stepping stone
91 * on the way to a more complete refactoring of WikiPage.
92 *
93 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
94 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
95 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
96 * require prepareContent or prepareUpdate to have been called first, to initialize the
97 * DerivedPageDataUpdater.
98 *
99 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
100 * of PreparedEdit.
101 *
102 * @see docs/pageupdater.md for more information.
103 *
104 * @internal
105 * @since 1.32
106 * @ingroup Page
107 */
108class DerivedPageDataUpdater implements LoggerAwareInterface, PreparedUpdate {
109
110    /**
111     * @var UserIdentity|null
112     */
113    private $user = null;
114
115    /**
116     * @var WikiPage
117     */
118    private $wikiPage;
119
120    /**
121     * @var ParserCache
122     */
123    private $parserCache;
124
125    /**
126     * @var RevisionStore
127     */
128    private $revisionStore;
129
130    /**
131     * @var Language
132     */
133    private $contLang;
134
135    /**
136     * @var JobQueueGroup
137     */
138    private $jobQueueGroup;
139
140    /**
141     * @var MessageCache
142     */
143    private $messageCache;
144
145    /**
146     * @var ILBFactory
147     */
148    private $loadbalancerFactory;
149
150    /**
151     * @var HookRunner
152     */
153    private $hookRunner;
154
155    /**
156     * @var LoggerInterface
157     */
158    private $logger;
159
160    /**
161     * @var string see $wgArticleCountMethod
162     */
163    private $articleCountMethod;
164
165    /**
166     * @var bool see $wgRCWatchCategoryMembership
167     */
168    private $rcWatchCategoryMembership = false;
169
170    /**
171     * Stores (most of) the $options parameter of prepareUpdate().
172     * @see prepareUpdate()
173     *
174     * @phpcs:ignore Generic.Files.LineLength
175     * @phan-var array{changed:bool,created:bool,moved:bool,restored:bool,oldrevision:null|RevisionRecord,triggeringUser:null|UserIdentity,oldredirect:bool|null|string,oldcountable:bool|null|string,causeAction:null|string,causeAgent:null|string,editResult:null|EditResult,approved:bool}
176     */
177    private $options = [
178        'changed' => true,
179        // newrev is true if prepareUpdate is handling the creation of a new revision,
180        // as opposed to a null edit or a forced update.
181        'newrev' => false,
182        'created' => false,
183        'moved' => false,
184        'oldtitle' => null,
185        'restored' => false,
186        'oldrevision' => null,
187        'oldcountable' => null,
188        'oldredirect' => null,
189        'triggeringUser' => null,
190        // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
191        // to make the life of prepareUpdate() callers easier.
192        'causeAction' => null,
193        'causeAgent' => null,
194        'editResult' => null,
195        'approved' => false
196    ];
197
198    /**
199     * The state of the relevant row in page table before the edit.
200     * This is determined by the first call to grabCurrentRevision, prepareContent,
201     * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
202     * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
203     * attempt to emulate the state of the page table before the edit.
204     *
205     * Contains the following fields:
206     * - oldRevision (RevisionRecord|null): the revision that was current before the change
207     *   associated with this update. Might not be set, use getParentRevision().
208     * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
209     *   was about creating a new page); null if not known (that should not happen).
210     * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
211     *   can be null; use wasRedirect() instead of direct access.
212     * - oldCountable (bool|null): whether the page was countable before the change (or null
213     *   if we don't have that information)
214     *
215     * @var array
216     */
217    private $pageState = null;
218
219    /**
220     * @var RevisionSlotsUpdate|null
221     */
222    private $slotsUpdate = null;
223
224    /**
225     * @var RevisionRecord|null
226     */
227    private $parentRevision = null;
228
229    /**
230     * @var RevisionRecord|null
231     */
232    private $revision = null;
233
234    /**
235     * @var RenderedRevision
236     */
237    private $renderedRevision = null;
238
239    /**
240     * @var RevisionRenderer
241     */
242    private $revisionRenderer;
243
244    /** @var SlotRoleRegistry */
245    private $slotRoleRegistry;
246
247    /**
248     * @var bool Whether null-edits create a revision.
249     */
250    private $forceEmptyRevision = false;
251
252    /**
253     * A stage identifier for managing the life cycle of this instance.
254     * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
255     *
256     * @see docs/pageupdater.md for documentation of the life cycle.
257     *
258     * @var string
259     */
260    private $stage = 'new';
261
262    /**
263     * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
264     *
265     * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
266     * and constants are also overkill...
267     *
268     * @see docs/pageupdater.md for documentation of the life cycle.
269     *
270     * @var array[]
271     */
272    private const TRANSITIONS = [
273        'new' => [
274            'new' => true,
275            'knows-current' => true,
276            'has-content' => true,
277            'has-revision' => true,
278        ],
279        'knows-current' => [
280            'knows-current' => true,
281            'has-content' => true,
282            'has-revision' => true,
283        ],
284        'has-content' => [
285            'has-content' => true,
286            'has-revision' => true,
287        ],
288        'has-revision' => [
289            'has-revision' => true,
290            'done' => true,
291        ],
292    ];
293
294    /** @var IContentHandlerFactory */
295    private $contentHandlerFactory;
296
297    /** @var EditResultCache */
298    private $editResultCache;
299
300    /** @var UserNameUtils */
301    private $userNameUtils;
302
303    /** @var ContentTransformer */
304    private $contentTransformer;
305
306    /** @var PageEditStash */
307    private $pageEditStash;
308
309    /** @var TalkPageNotificationManager */
310    private $talkPageNotificationManager;
311
312    /** @var WANObjectCache */
313    private $mainWANObjectCache;
314
315    /** @var PermissionManager */
316    private $permissionManager;
317
318    /** @var bool */
319    private $warmParsoidParserCache;
320
321    /**
322     * @param ServiceOptions $options
323     * @param PageIdentity $page
324     * @param RevisionStore $revisionStore
325     * @param RevisionRenderer $revisionRenderer
326     * @param SlotRoleRegistry $slotRoleRegistry
327     * @param ParserCache $parserCache
328     * @param JobQueueGroup $jobQueueGroup
329     * @param MessageCache $messageCache
330     * @param Language $contLang
331     * @param ILBFactory $loadbalancerFactory
332     * @param IContentHandlerFactory $contentHandlerFactory
333     * @param HookContainer $hookContainer
334     * @param EditResultCache $editResultCache
335     * @param UserNameUtils $userNameUtils
336     * @param ContentTransformer $contentTransformer
337     * @param PageEditStash $pageEditStash
338     * @param TalkPageNotificationManager $talkPageNotificationManager
339     * @param WANObjectCache $mainWANObjectCache
340     * @param PermissionManager $permissionManager
341     * @param WikiPageFactory $wikiPageFactory
342     */
343    public function __construct(
344        ServiceOptions $options,
345        PageIdentity $page,
346        RevisionStore $revisionStore,
347        RevisionRenderer $revisionRenderer,
348        SlotRoleRegistry $slotRoleRegistry,
349        ParserCache $parserCache,
350        JobQueueGroup $jobQueueGroup,
351        MessageCache $messageCache,
352        Language $contLang,
353        ILBFactory $loadbalancerFactory,
354        IContentHandlerFactory $contentHandlerFactory,
355        HookContainer $hookContainer,
356        EditResultCache $editResultCache,
357        UserNameUtils $userNameUtils,
358        ContentTransformer $contentTransformer,
359        PageEditStash $pageEditStash,
360        TalkPageNotificationManager $talkPageNotificationManager,
361        WANObjectCache $mainWANObjectCache,
362        PermissionManager $permissionManager,
363        WikiPageFactory $wikiPageFactory
364    ) {
365        // TODO: Remove this cast eventually
366        $this->wikiPage = $wikiPageFactory->newFromTitle( $page );
367
368        $this->parserCache = $parserCache;
369        $this->revisionStore = $revisionStore;
370        $this->revisionRenderer = $revisionRenderer;
371        $this->slotRoleRegistry = $slotRoleRegistry;
372        $this->jobQueueGroup = $jobQueueGroup;
373        $this->messageCache = $messageCache;
374        $this->contLang = $contLang;
375        // XXX only needed for waiting for replicas to catch up; there should be a narrower
376        // interface for that.
377        $this->loadbalancerFactory = $loadbalancerFactory;
378        $this->contentHandlerFactory = $contentHandlerFactory;
379        $this->hookRunner = new HookRunner( $hookContainer );
380        $this->editResultCache = $editResultCache;
381        $this->userNameUtils = $userNameUtils;
382        $this->contentTransformer = $contentTransformer;
383        $this->pageEditStash = $pageEditStash;
384        $this->talkPageNotificationManager = $talkPageNotificationManager;
385        $this->mainWANObjectCache = $mainWANObjectCache;
386        $this->permissionManager = $permissionManager;
387
388        $this->logger = new NullLogger();
389        $this->warmParsoidParserCache = $options
390            ->get( MainConfigNames::ParsoidCacheConfig )['WarmParsoidParserCache'];
391    }
392
393    public function setLogger( LoggerInterface $logger ) {
394        $this->logger = $logger;
395    }
396
397    /**
398     * Set the cause action and cause agent, for logging and debugging.
399     * If $causeAction or $causeAgent is null, any previously set value is preserved.
400     *
401     * @param ?string $causeAction
402     * @param ?string $causeAgent
403     *
404     * @return void
405     */
406    public function setCause( ?string $causeAction, ?string $causeAgent ) {
407        if ( $causeAction ) {
408            $this->options['causeAction'] = $causeAction;
409        }
410
411        if ( $causeAgent ) {
412            $this->options['causeAgent'] = $causeAgent;
413        }
414    }
415
416    /**
417     * @return string[] [ $causeAction, $causeAgent ]
418     */
419    private function getCause(): array {
420        return [
421            $this->options['causeAction'] ?? 'unknown',
422            $this->options['causeAgent'] ?? 'unknown',
423        ];
424    }
425
426    /**
427     * Transition function for managing the life cycle of this instances.
428     *
429     * @see docs/pageupdater.md for documentation of the life cycle.
430     *
431     * @param string $newStage
432     * @return string the previous stage
433     *
434     * @throws LogicException If a transition to the given stage is not possible in the current
435     *         stage.
436     */
437    private function doTransition( $newStage ) {
438        $this->assertTransition( $newStage );
439
440        $oldStage = $this->stage;
441        $this->stage = $newStage;
442
443        return $oldStage;
444    }
445
446    /**
447     * Asserts that a transition to the given stage is possible, without performing it.
448     *
449     * @see docs/pageupdater.md for documentation of the life cycle.
450     *
451     * @param string $newStage
452     *
453     * @throws LogicException If this instance is not in the expected stage
454     */
455    private function assertTransition( $newStage ) {
456        if ( empty( self::TRANSITIONS[$this->stage][$newStage] ) ) {
457            throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
458        }
459    }
460
461    /**
462     * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
463     * the given revision.
464     *
465     * @param UserIdentity|null $user The user creating the revision in question
466     * @param RevisionRecord|null $revision New revision (after save, if already saved)
467     * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
468     * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
469     *
470     * @return bool
471     */
472    public function isReusableFor(
473        UserIdentity $user = null,
474        RevisionRecord $revision = null,
475        RevisionSlotsUpdate $slotsUpdate = null,
476        $parentId = null
477    ) {
478        if ( $revision
479            && $parentId
480            && $revision->getParentId() !== $parentId
481        ) {
482            throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
483        }
484
485        // NOTE: For null revisions, $user may be different from $this->revision->getUser
486        // and also from $revision->getUser.
487        // But $user should always match $this->user.
488        if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
489            return false;
490        }
491
492        if ( $revision && $this->revision && $this->revision->getId()
493            && $this->revision->getId() !== $revision->getId()
494        ) {
495            return false;
496        }
497
498        if ( $this->pageState
499            && $revision
500            && $revision->getParentId() !== null
501            && $this->pageState['oldId'] !== $revision->getParentId()
502        ) {
503            return false;
504        }
505
506        if ( $this->pageState
507            && $parentId !== null
508            && $this->pageState['oldId'] !== $parentId
509        ) {
510            return false;
511        }
512
513        // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
514        if ( $this->slotsUpdate
515            && $slotsUpdate
516            && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
517        ) {
518            return false;
519        }
520
521        if ( $revision
522            && $this->revision
523            && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
524        ) {
525            return false;
526        }
527
528        return true;
529    }
530
531    /**
532     * Set whether null-edits should create a revision. Enabling this allows the creation of dummy
533     * revisions ("null revisions") to mark events such as renaming in the page history.
534     *
535     * Must not be called once prepareContent() or prepareUpdate() have been called.
536     *
537     * @since 1.38
538     * @see PageUpdater setForceEmptyRevision
539     *
540     * @param bool $forceEmptyRevision
541     */
542    public function setForceEmptyRevision( bool $forceEmptyRevision ) {
543        if ( $this->revision ) {
544            throw new LogicException( 'prepareContent() or prepareUpdate() was already called.' );
545        }
546
547        $this->forceEmptyRevision = $forceEmptyRevision;
548    }
549
550    /**
551     * @param string $articleCountMethod "any" or "link".
552     * @see $wgArticleCountMethod
553     */
554    public function setArticleCountMethod( $articleCountMethod ) {
555        $this->articleCountMethod = $articleCountMethod;
556    }
557
558    /**
559     * @param bool $rcWatchCategoryMembership
560     * @see $wgRCWatchCategoryMembership
561     */
562    public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
563        $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
564    }
565
566    /**
567     * @return Title
568     */
569    private function getTitle() {
570        // NOTE: eventually, this won't use WikiPage any more
571        return $this->wikiPage->getTitle();
572    }
573
574    /**
575     * @return WikiPage
576     */
577    private function getWikiPage() {
578        // NOTE: eventually, this won't use WikiPage any more
579        return $this->wikiPage;
580    }
581
582    /**
583     * Returns the page being updated.
584     * @since 1.37
585     * @return PageIdentity
586     */
587    public function getPage(): PageIdentity {
588        return $this->wikiPage;
589    }
590
591    /**
592     * Determines whether the page being edited already existed.
593     * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
594     *
595     * @return bool
596     * @throws LogicException if called before grabCurrentRevision
597     */
598    public function pageExisted() {
599        $this->assertHasPageState( __METHOD__ );
600
601        return $this->pageState['oldId'] > 0;
602    }
603
604    /**
605     * Returns the parent revision of the new revision wrapped by this update.
606     * If the update is a null-edit, this will return the parent of the current (and new) revision.
607     * This will return null if the revision wrapped by this update created the page.
608     * Only defined after calling prepareContent() or prepareUpdate()!
609     *
610     * @return RevisionRecord|null the parent revision of the new revision, or null if
611     *         the update created the page.
612     */
613    private function getParentRevision() {
614        $this->assertPrepared( __METHOD__ );
615
616        if ( $this->parentRevision ) {
617            return $this->parentRevision;
618        }
619
620        if ( !$this->pageState['oldId'] ) {
621            // If there was no current revision, there is no parent revision,
622            // since the page didn't exist.
623            return null;
624        }
625
626        $oldId = $this->revision->getParentId();
627        $flags = $this->usePrimary() ? IDBAccessObject::READ_LATEST : 0;
628        $this->parentRevision = $oldId
629            ? $this->revisionStore->getRevisionById( $oldId, $flags )
630            : null;
631
632        return $this->parentRevision;
633    }
634
635    /**
636     * Returns the revision that was the page's current revision when grabCurrentRevision()
637     * was first called.
638     *
639     * During an edit, that revision will act as the logical parent of the new revision.
640     *
641     * Some updates are performed based on the difference between the database state at the
642     * moment this method is first called, and the state after the edit.
643     *
644     * @see docs/pageupdater.md for more information on when thie method can and should be called.
645     *
646     * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
647     * to avoid confusion, since the page's current revision is then the new revision after
648     * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
649     * Use getParentRevision() instead to access the revision that is the parent of the
650     * new revision.
651     *
652     * @return RevisionRecord|null the page's current revision, or null if the page does not
653     * yet exist.
654     */
655    public function grabCurrentRevision() {
656        if ( $this->pageState ) {
657            return $this->pageState['oldRevision'];
658        }
659
660        $this->assertTransition( 'knows-current' );
661
662        // NOTE: eventually, this won't use WikiPage any more
663        $wikiPage = $this->getWikiPage();
664
665        // Do not call WikiPage::clear(), since the caller may already have caused page data
666        // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
667        $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
668        $current = $wikiPage->getRevisionRecord();
669
670        $this->pageState = [
671            'oldRevision' => $current,
672            'oldId' => $current ? $current->getId() : 0,
673            'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
674            'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
675        ];
676
677        $this->doTransition( 'knows-current' );
678
679        return $this->pageState['oldRevision'];
680    }
681
682    /**
683     * Whether prepareUpdate() or prepareContent() have been called on this instance.
684     *
685     * @return bool
686     */
687    public function isContentPrepared() {
688        return $this->revision !== null;
689    }
690
691    /**
692     * Whether prepareUpdate() has been called on this instance.
693     *
694     * @note will also return null in case of a null-edit!
695     *
696     * @return bool
697     */
698    public function isUpdatePrepared() {
699        return $this->revision !== null && $this->revision->getId() !== null;
700    }
701
702    /**
703     * @return int
704     */
705    private function getPageId() {
706        // NOTE: eventually, this won't use WikiPage any more
707        return $this->wikiPage->getId();
708    }
709
710    /**
711     * Whether the content is deleted and thus not visible to the public.
712     *
713     * @return bool
714     */
715    public function isContentDeleted() {
716        if ( $this->revision ) {
717            return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
718        } else {
719            // If the content has not been saved yet, it cannot have been deleted yet.
720            return false;
721        }
722    }
723
724    /**
725     * Returns the slot, modified or inherited, after PST, with no audience checks applied.
726     *
727     * @param string $role slot role name
728     *
729     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
730     *        parent revision.
731     * @return SlotRecord
732     */
733    public function getRawSlot( $role ) {
734        return $this->getSlots()->getSlot( $role );
735    }
736
737    /**
738     * Returns the content of the given slot, with no audience checks.
739     *
740     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
741     *        parent revision.
742     * @param string $role slot role name
743     * @return Content
744     */
745    public function getRawContent( string $role ): Content {
746        return $this->getRawSlot( $role )->getContent();
747    }
748
749    /**
750     * @param string $role slot role name
751     * @return ContentHandler
752     * @throws MWUnknownContentModelException
753     */
754    private function getContentHandler( $role ): ContentHandler {
755        return $this->contentHandlerFactory
756            ->getContentHandler( $this->getRawSlot( $role )->getModel() );
757    }
758
759    private function usePrimary() {
760        // TODO: can we just set a flag to true in prepareContent()?
761        return $this->wikiPage->wasLoadedFrom( IDBAccessObject::READ_LATEST );
762    }
763
764    /**
765     * @return bool
766     */
767    public function isCountable(): bool {
768        // NOTE: Keep in sync with WikiPage::isCountable.
769
770        if ( !$this->getTitle()->isContentPage() ) {
771            return false;
772        }
773
774        if ( $this->isContentDeleted() ) {
775            // This should be irrelevant: countability only applies to the current revision,
776            // and the current revision is never suppressed.
777            return false;
778        }
779
780        if ( $this->isRedirect() ) {
781            return false;
782        }
783
784        $hasLinks = null;
785
786        if ( $this->articleCountMethod === 'link' ) {
787            // NOTE: it would be more appropriate to determine for each slot separately
788            // whether it has links, and use that information with that slot's
789            // isCountable() method. However, that would break parity with
790            // WikiPage::isCountable, which uses the pagelinks table to determine
791            // whether the current revision has links.
792            $hasLinks = (bool)count( $this->getParserOutputForMetaData()->getLinks() );
793        }
794
795        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
796            $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
797            if ( $roleHandler->supportsArticleCount() ) {
798                $content = $this->getRawContent( $role );
799
800                if ( $content->isCountable( $hasLinks ) ) {
801                    return true;
802                }
803            }
804        }
805
806        return false;
807    }
808
809    /**
810     * @return bool
811     */
812    public function isRedirect(): bool {
813        // NOTE: main slot determines redirect status
814        // TODO: MCR: this should be controlled by a PageTypeHandler
815        $mainContent = $this->getRawContent( SlotRecord::MAIN );
816
817        return $mainContent->isRedirect();
818    }
819
820    /**
821     * @param RevisionRecord $rev
822     *
823     * @return bool
824     */
825    private function revisionIsRedirect( RevisionRecord $rev ) {
826        // NOTE: main slot determines redirect status
827        $mainContent = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
828
829        return $mainContent->isRedirect();
830    }
831
832    /**
833     * Prepare updates based on an update which has not yet been saved.
834     *
835     * This may be used to create derived data that is needed when creating a new revision;
836     * particularly, this makes available the slots of the new revision via the getSlots()
837     * method, after applying PST and slot inheritance.
838     *
839     * The derived data prepared for revision creation may then later be re-used by doUpdates(),
840     * without the need to re-calculate.
841     *
842     * @see docs/pageupdater.md for more information on when thie method can and should be called.
843     *
844     * @note Calling this method more than once with the same $slotsUpdate
845     * has no effect. Calling this method multiple times with different content will cause
846     * an exception.
847     *
848     * @note Calling this method after prepareUpdate() has been called will cause an exception.
849     *
850     * @param UserIdentity $user The user to act as context for pre-save transformation (PST).
851     * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
852     *        by this edit, before PST.
853     * @param bool $useStash Whether to use stashed ParserOutput
854     */
855    public function prepareContent(
856        UserIdentity $user,
857        RevisionSlotsUpdate $slotsUpdate,
858        $useStash = true
859    ) {
860        if ( $this->slotsUpdate ) {
861            if ( !$this->user ) {
862                throw new LogicException(
863                    'Unexpected state: $this->slotsUpdate was initialized, '
864                    . 'but $this->user was not.'
865                );
866            }
867
868            if ( $this->user->getName() !== $user->getName() ) {
869                throw new LogicException( 'Can\'t call prepareContent() again for different user! '
870                    . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
871                );
872            }
873
874            if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
875                throw new LogicException(
876                    'Can\'t call prepareContent() again with different slot content!'
877                );
878            }
879
880            return; // prepareContent() already done, nothing to do
881        }
882
883        $this->assertTransition( 'has-content' );
884
885        $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
886        $title = $this->getTitle();
887
888        $parentRevision = $this->grabCurrentRevision();
889
890        // The edit may have already been prepared via api.php?action=stashedit
891        $stashedEdit = false;
892
893        // TODO: MCR: allow output for all slots to be stashed.
894        if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
895            $stashedEdit = $this->pageEditStash->checkCache(
896                $title,
897                $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
898                $user
899            );
900        }
901
902        $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
903        $userPopts->setRenderReason( $this->options['causeAgent'] ?? 'unknown' );
904
905        $this->hookRunner->onArticlePrepareTextForEdit( $wikiPage, $userPopts );
906
907        $this->user = $user;
908        $this->slotsUpdate = $slotsUpdate;
909
910        if ( $parentRevision ) {
911            $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
912        } else {
913            $this->revision = new MutableRevisionRecord( $title );
914        }
915
916        // NOTE: user and timestamp must be set, so they can be used for
917        // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
918        $this->revision->setTimestamp( MWTimestamp::now( TS_MW ) );
919        $this->revision->setUser( $user );
920
921        // Set up ParserOptions to operate on the new revision
922        $oldCallback = $userPopts->getCurrentRevisionRecordCallback();
923        $userPopts->setCurrentRevisionRecordCallback(
924            function ( Title $parserTitle, $parser = null ) use ( $title, $oldCallback ) {
925                if ( $parserTitle->equals( $title ) ) {
926                    return $this->revision;
927                } else {
928                    return call_user_func( $oldCallback, $parserTitle, $parser );
929                }
930            }
931        );
932
933        $pstContentSlots = $this->revision->getSlots();
934
935        foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
936            $slot = $slotsUpdate->getModifiedSlot( $role );
937
938            if ( $slot->isInherited() ) {
939                // No PST for inherited slots! Note that "modified" slots may still be inherited
940                // from an earlier version, e.g. for rollbacks.
941                $pstSlot = $slot;
942            } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
943                // TODO: MCR: allow PST content for all slots to be stashed.
944                $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
945            } else {
946                $pstContent = $this->contentTransformer->preSaveTransform(
947                    $slot->getContent(),
948                    $title,
949                    $user,
950                    $userPopts
951                );
952
953                $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
954            }
955
956            $pstContentSlots->setSlot( $pstSlot );
957        }
958
959        foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
960            $pstContentSlots->removeSlot( $role );
961        }
962
963        $this->options['created'] = ( $parentRevision === null );
964        $this->options['changed'] = ( $parentRevision === null
965            || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
966
967        $this->doTransition( 'has-content' );
968
969        if ( !$this->options['changed'] ) {
970            if ( $this->forceEmptyRevision ) {
971                // dummy revision, inherit all slots
972                foreach ( $parentRevision->getSlotRoles() as $role ) {
973                    $this->revision->inheritSlot( $parentRevision->getSlot( $role ) );
974                }
975            } else {
976                // null-edit, the new revision *is* the old revision.
977
978                // TODO: move this into MutableRevisionRecord
979                $this->revision->setId( $parentRevision->getId() );
980                $this->revision->setTimestamp( $parentRevision->getTimestamp() );
981                $this->revision->setPageId( $parentRevision->getPageId() );
982                $this->revision->setParentId( $parentRevision->getParentId() );
983                $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
984                $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
985                $this->revision->setMinorEdit( $parentRevision->isMinor() );
986                $this->revision->setVisibility( $parentRevision->getVisibility() );
987
988                // prepareUpdate() is redundant for null-edits (but not for dummy revisions)
989                $this->doTransition( 'has-revision' );
990            }
991        } else {