Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.88% covered (warning)
68.88%
717 / 1041
42.27% covered (danger)
42.27%
41 / 97
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiPage
68.94% covered (warning)
68.94%
717 / 1040
42.27% covered (danger)
42.27%
41 / 97
3169.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 __clone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convertSelectType
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 getPageUpdaterFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBLoadBalancer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActionOverrides
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentHandler
100.00% covered (success)
100.00%
2 / 2
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
 clear
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 clearCacheFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 clearPreparedEdit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 pageData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 pageDataFromTitle
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 pageDataFromId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadPageData
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
10.27
 wasLoadedFrom
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 loadFromRow
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 getId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 exists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasViewableContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRedirect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isNew
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getContentModel
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
3.29
 checkTouched
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getTouched
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getLanguage
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getLinksTimestamp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getLatest
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 loadLastEdit
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 setLastEdit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionRecord
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTimestamp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getCreator
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUserText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getComment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getMinorEdit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isCountable
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
8
 getRedirectTarget
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 insertRedirectEntry
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 followRedirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectURL
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getContributors
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 shouldCheckParserCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
 getParserOutput
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 doViewUpdates
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 doPurge
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 insertOn
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 updateRevisionOn
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
8
 hasDifferencesOutsideMainSlot
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 supportsSections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceSectionContent
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
27.67
 replaceSectionAtRev
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
9.88
 checkFlags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getDerivedDataUpdater
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 doUserEditContent
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
11
 newPageUpdater
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 makeParserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 makeParserOptionsFromTitleAndModel
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 prepareContentForEdit
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
4.41
 doEditUpdates
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 updateParserCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 doSecondaryDataUpdates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 doUpdateRestrictions
91.95% covered (success)
91.95%
160 / 174
0.00% covered (danger)
0.00%
0 / 1
36.68
 getCurrentUpdate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 insertNullProtectionRevision
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
4.13
 formatExpiry
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 protectDescription
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 protectDescriptionLog
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 isBatchedDelete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doDeleteArticleReal
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 lockAndGetLatest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 onArticleCreate
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 onArticleDelete
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
3.19
 onArticleEdit
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 queueBacklinksJobs
36.00% covered (danger)
36.00%
9 / 25
0.00% covered (danger)
0.00%
0 / 1
24.78
 purgeInterwikiCheckKey
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
3.46
 getCategories
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getHiddenCategories
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 getAutoDeleteReason
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 triggerOpportunisticLinksUpdate
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
132
 isLocal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWikiDisplayName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __wakeup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBkey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikiId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canExist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSamePageAs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 toPageRecord
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 getConnectionProvider
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use BadMethodCallException;
10use InvalidArgumentException;
11use MediaWiki\Actions\InfoAction;
12use MediaWiki\Category\Category;
13use MediaWiki\CommentStore\CommentStoreComment;
14use MediaWiki\Content\Content;
15use MediaWiki\Content\ContentHandler;
16use MediaWiki\Context\IContextSource;
17use MediaWiki\DAO\WikiAwareEntityTrait;
18use MediaWiki\Deferred\DeferredUpdates;
19use MediaWiki\Deferred\LinksUpdate\CategoryLinksTable;
20use MediaWiki\Deferred\LinksUpdate\PageLinksTable;
21use MediaWiki\Edit\PreparedEdit;
22use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
23use MediaWiki\JobQueue\Jobs\HTMLCacheUpdateJob;
24use MediaWiki\JobQueue\Jobs\RefreshLinksJob;
25use MediaWiki\Linker\LinkTarget;
26use MediaWiki\Logger\LoggerFactory;
27use MediaWiki\Logging\ManualLogEntry;
28use MediaWiki\MainConfigNames;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Page\Event\PageProtectionChangedEvent;
31use MediaWiki\Parser\ParserOptions;
32use MediaWiki\Parser\ParserOutput;
33use MediaWiki\Parser\ParserOutputFlags;
34use MediaWiki\Permissions\Authority;
35use MediaWiki\RecentChanges\RecentChange;
36use MediaWiki\Revision\RevisionRecord;
37use MediaWiki\Revision\RevisionStore;
38use MediaWiki\Revision\SlotRecord;
39use MediaWiki\Status\Status;
40use MediaWiki\Storage\DerivedPageDataUpdater;
41use MediaWiki\Storage\EditResult;
42use MediaWiki\Storage\PageUpdateCauses;
43use MediaWiki\Storage\PageUpdater;
44use MediaWiki\Storage\PageUpdaterFactory;
45use MediaWiki\Storage\PageUpdateStatus;
46use MediaWiki\Storage\PreparedUpdate;
47use MediaWiki\Storage\RevisionSlotsUpdate;
48use MediaWiki\Title\Title;
49use MediaWiki\Title\TitleArrayFromResult;
50use MediaWiki\User\User;
51use MediaWiki\User\UserArray;
52use MediaWiki\User\UserArrayFromResult;
53use MediaWiki\User\UserIdentity;
54use MediaWiki\Utils\MWTimestamp;
55use MediaWiki\WikiMap\WikiMap;
56use RuntimeException;
57use stdClass;
58use Stringable;
59use Wikimedia\Assert\Assert;
60use Wikimedia\Assert\PreconditionException;
61use Wikimedia\NonSerializable\NonSerializableTrait;
62use Wikimedia\Rdbms\FakeResultWrapper;
63use Wikimedia\Rdbms\IDatabase;
64use Wikimedia\Rdbms\IDBAccessObject;
65use Wikimedia\Rdbms\ILoadBalancer;
66use Wikimedia\Rdbms\IReadableDatabase;
67use Wikimedia\Rdbms\SelectQueryBuilder;
68use Wikimedia\Timestamp\TimestampFormat as TS;
69
70/**
71 * @defgroup Page Page
72 */
73
74/**
75 * Base representation for an editable wiki page.
76 *
77 * Some fields are public only for backwards-compatibility. Use accessor methods.
78 * In the past, this class was part of Article.php and everything was public.
79 *
80 * @ingroup Page
81 */
82class WikiPage implements Stringable, Page, PageRecord {
83    use NonSerializableTrait;
84    use ProtectedHookAccessorTrait;
85    use WikiAwareEntityTrait;
86
87    // Constants for $mDataLoadedFrom and related
88
89    /**
90     * @var Title
91     * @note for access by subclasses only
92     */
93    protected $mTitle;
94
95    /**
96     * @var bool
97     * @note for access by subclasses only
98     */
99    protected $mDataLoaded = false;
100
101    /**
102     * A cache of the page_is_redirect field, loaded with page data
103     * @var bool
104     */
105    private $mPageIsRedirectField = false;
106
107    /**
108     * @var bool
109     */
110    private $mIsNew = false;
111
112    /**
113     * @var int|false False means "not loaded"
114     * @note for access by subclasses only
115     */
116    protected $mLatest = false;
117
118    /**
119     * @var PreparedEdit|false Map of cache fields (text, parser output, etc.) for a proposed/new edit
120     * @note for access by subclasses only
121     */
122    protected $mPreparedEdit = false;
123
124    /**
125     * @var int|null
126     */
127    protected $mId = null;
128
129    /**
130     * @var int One of the READ_* constants
131     */
132    protected $mDataLoadedFrom = IDBAccessObject::READ_NONE;
133
134    /**
135     * @var RevisionRecord|null
136     */
137    private $mLastRevision = null;
138
139    /**
140     * @var string Timestamp of the latest revision or empty string if not loaded
141     */
142    protected $mTimestamp = '';
143
144    /**
145     * @var string
146     */
147    protected $mTouched = '19700101000000';
148
149    /**
150     * @var string|null
151     */
152    protected $mLanguage = null;
153
154    /**
155     * @var string
156     */
157    protected $mLinksUpdated = '19700101000000';
158
159    /**
160     * @var DerivedPageDataUpdater|null
161     */
162    private $derivedDataUpdater = null;
163
164    public function __construct( PageIdentity $pageIdentity ) {
165        $pageIdentity->assertWiki( PageIdentity::LOCAL );
166
167        // TODO: remove the need for casting to Title.
168        $title = Title::newFromPageIdentity( $pageIdentity );
169        if ( !$title->canExist() ) {
170            throw new InvalidArgumentException( "WikiPage constructed on a Title that cannot exist as a page: $title" );
171        }
172
173        $this->mTitle = $title;
174    }
175
176    /**
177     * Makes sure that the mTitle object is cloned
178     * to the newly cloned WikiPage.
179     */
180    public function __clone() {
181        $this->mTitle = clone $this->mTitle;
182    }
183
184    /**
185     * Convert deprecated 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants.
186     *
187     * @param stdClass|string|int $type
188     * @return mixed
189     */
190    public static function convertSelectType( $type ) {
191        switch ( $type ) {
192            case 'fromdb':
193                return IDBAccessObject::READ_NORMAL;
194            case 'fromdbmaster':
195                return IDBAccessObject::READ_LATEST;
196            case 'forupdate':
197                return IDBAccessObject::READ_LOCKING;
198            default:
199                // It may already be an integer or whatever else
200                return $type;
201        }
202    }
203
204    private function getPageUpdaterFactory(): PageUpdaterFactory {
205        return MediaWikiServices::getInstance()->getPageUpdaterFactory();
206    }
207
208    /**
209     * @return RevisionStore
210     */
211    private function getRevisionStore() {
212        return MediaWikiServices::getInstance()->getRevisionStore();
213    }
214
215    /**
216     * @return ILoadBalancer
217     */
218    private function getDBLoadBalancer() {
219        return MediaWikiServices::getInstance()->getDBLoadBalancer();
220    }
221
222    /**
223     * @todo Move this UI stuff somewhere else
224     *
225     * @see ContentHandler::getActionOverrides
226     * @return array
227     */
228    public function getActionOverrides() {
229        return $this->getContentHandler()->getActionOverrides();
230    }
231
232    /**
233     * Returns the ContentHandler instance to be used to deal with the content of this WikiPage.
234     *
235     * Shorthand for ContentHandlerFactory::getContentHandler( $this->getContentModel() );
236     *
237     * @return ContentHandler
238     *
239     * @since 1.21
240     */
241    public function getContentHandler() {
242        $factory = MediaWikiServices::getInstance()->getContentHandlerFactory();
243        return $factory->getContentHandler( $this->getContentModel() );
244    }
245
246    /**
247     * Get the title object of the article
248     * @return Title Title object of this page
249     */
250    public function getTitle(): Title {
251        return $this->mTitle;
252    }
253
254    /**
255     * Clear the object
256     * @return void
257     */
258    public function clear() {
259        $this->mDataLoaded = false;
260        $this->mDataLoadedFrom = IDBAccessObject::READ_NONE;
261
262        $this->clearCacheFields();
263    }
264
265    /**
266     * Clear the object cache fields
267     * @return void
268     */
269    protected function clearCacheFields() {
270        $this->mId = null;
271        $this->mPageIsRedirectField = false;
272        $this->mLastRevision = null; // Latest revision
273        $this->mTouched = '19700101000000';
274        $this->mLanguage = null;
275        $this->mLinksUpdated = '19700101000000';
276        $this->mTimestamp = '';
277        $this->mIsNew = false;
278        $this->mLatest = false;
279        // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already
280        // checks the requested rev ID and content against the cached one. For most
281        // content types, the output should not change during the lifetime of this cache.
282        // Clearing it can cause extra parses on edit for no reason.
283    }
284
285    /**
286     * Clear the mPreparedEdit cache field, as may be needed by mutable content types
287     * @return void
288     * @since 1.23
289     */
290    public function clearPreparedEdit() {
291        $this->mPreparedEdit = false;
292    }
293
294    /**
295     * Return the tables, fields, and join conditions to be selected to create
296     * a new page object.
297     * @since 1.31
298     * @return array[] With three keys:
299     *   - tables: (string[]) to include in the `$table` to `IReadableDatabase->select()` or
300     *     `SelectQueryBuilder::tables`
301     *   - fields: (string[]) to include in the `$vars` to `IReadableDatabase->select()` or
302     *     `SelectQueryBuilder::fields`
303     *   - joins: (array) to include in the `$join_conds` to `IReadableDatabase->select()` or
304     *     `SelectQueryBuilder::joinConds`
305     * @phan-return array{tables:string[],fields:string[],joins:array}
306     */
307    public static function getQueryInfo() {
308        $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()->get(
309            MainConfigNames::PageLanguageUseDB );
310
311        $ret = [
312            'tables' => [ 'page' ],
313            'fields' => [
314                'page_id',
315                'page_namespace',
316                'page_title',
317                'page_is_redirect',
318                'page_is_new',
319                'page_random',
320                'page_touched',
321                'page_links_updated',
322                'page_latest',
323                'page_len',
324                'page_content_model',
325            ],
326            'joins' => [],
327        ];
328
329        if ( $pageLanguageUseDB ) {
330            $ret['fields'][] = 'page_lang';
331        }
332
333        return $ret;
334    }
335
336    /**
337     * Fetch a page record with the given conditions
338     * @param IReadableDatabase $dbr
339     * @param array $conditions
340     * @param array $options
341     * @return stdClass|false Database result resource, or false on failure
342     */
343    protected function pageData( $dbr, $conditions, $options = [] ) {
344        $pageQuery = self::getQueryInfo();
345
346        $this->getHookRunner()->onArticlePageDataBefore(
347            $this, $pageQuery['fields'], $pageQuery['tables'], $pageQuery['joins'] );
348
349        $row = $dbr->newSelectQueryBuilder()
350            ->queryInfo( $pageQuery )
351            ->where( $conditions )
352            ->caller( __METHOD__ )
353            ->options( $options )
354            ->fetchRow();
355
356        $this->getHookRunner()->onArticlePageDataAfter( $this, $row );
357
358        return $row;
359    }
360
361    /**
362     * Fetch a page record matching the Title object's namespace and title
363     * using a sanitized title string
364     *
365     * @param IReadableDatabase $dbr
366     * @param Title $title
367     * @param int $recency
368     * @return stdClass|false Database result resource, or false on failure
369     */
370    public function pageDataFromTitle( $dbr, $title, $recency = IDBAccessObject::READ_NORMAL ) {
371        if ( !$title->canExist() ) {
372            return false;
373        }
374        $options = [];
375        if ( ( $recency & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) {
376            $options[] = 'FOR UPDATE';
377        } elseif ( ( $recency & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
378            $options[] = 'LOCK IN SHARE MODE';
379        }
380
381        return $this->pageData( $dbr, [
382            'page_namespace' => $title->getNamespace(),
383            'page_title' => $title->getDBkey() ], $options );
384    }
385
386    /**
387     * Fetch a page record matching the requested ID
388     *
389     * @param IReadableDatabase $dbr
390     * @param int $id
391     * @param array $options
392     * @return stdClass|false Database result resource, or false on failure
393     */
394    public function pageDataFromId( $dbr, $id, $options = [] ) {
395        return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
396    }
397
398    /**
399     * Load the object from a given source by title
400     *
401     * @param stdClass|string|int $from One of the following:
402     *   - A DB query result object.
403     *   - IDBAccessObject::READ_NORMAL to get from a replica DB.
404     *   - IDBAccessObject::READ_LATEST to get from the primary DB.
405     *   - IDBAccessObject::READ_LOCKING to get from the primary DB using SELECT FOR UPDATE.
406     *   - "fromdb", alias for IDBAccessObject::READ_NORMAL (deprecated Since 1.46)
407     *   - "fromdbmaster", alias for IDBAccessObject::READ_LATEST (deprecated Since 1.46)
408     *   - "forupdate", alias for IDBAccessObject::READ_LOCKING (deprecated Since 1.46)
409     *
410     * @return void
411     */
412    public function loadPageData( $from = IDBAccessObject::READ_NORMAL ) {
413        $from = self::convertSelectType( $from );
414        if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
415            // We already have the data from the correct location, no need to load it twice.
416            return;
417        }
418
419        if ( is_int( $from ) ) {
420            $loadBalancer = $this->getDBLoadBalancer();
421            if ( ( $from & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
422                $index = DB_PRIMARY;
423            } else {
424                $index = DB_REPLICA;
425            }
426            $db = $loadBalancer->getConnection( $index );
427            $data = $this->pageDataFromTitle( $db, $this->mTitle, $from );
428
429            if ( !$data
430                && $index == DB_REPLICA
431                && $loadBalancer->hasReplicaServers()
432                && $loadBalancer->hasOrMadeRecentPrimaryChanges()
433            ) {
434                $from = IDBAccessObject::READ_LATEST;
435                $db = $loadBalancer->getConnection( DB_PRIMARY );
436                $data = $this->pageDataFromTitle( $db, $this->mTitle, $from );
437            }
438        } else {
439            // No idea from where the caller got this data, assume replica DB.
440            $data = $from;
441            $from = IDBAccessObject::READ_NORMAL;
442        }
443
444        $this->loadFromRow( $data, $from );
445    }
446
447    /**
448     * Checks whether the page data was loaded using the given database access mode (or better).
449     *
450     * @param string|int $from One of the following:
451     *   - IDBAccessObject::READ_NORMAL to get from a replica DB.
452     *   - IDBAccessObject::READ_LATEST to get from the primary DB.
453     *   - IDBAccessObject::READ_LOCKING to get from the primary DB using SELECT FOR UPDATE.
454     *   - "fromdb", alias for IDBAccessObject::READ_NORMAL (deprecated Since 1.46)
455     *   - "fromdbmaster", alias for IDBAccessObject::READ_LATEST (deprecated Since 1.46)
456     *   - "forupdate", alias for IDBAccessObject::READ_LOCKING (deprecated Since 1.46)
457     *
458     * @return bool
459     * @since 1.32
460     */
461    public function wasLoadedFrom( $from ) {
462        $from = self::convertSelectType( $from );
463
464        if ( !is_int( $from ) ) {
465            // No idea from where the caller got this data, assume replica DB.
466            $from = IDBAccessObject::READ_NORMAL;
467        }
468
469        if ( $from <= $this->mDataLoadedFrom ) {
470            return true;
471        }
472
473        return false;
474    }
475
476    /**
477     * Load the object from a database row
478     *
479     * @param stdClass|false $data DB row containing fields returned by getQueryInfo() or false
480     * @param string|int $from One of the following:
481     *   - IDBAccessObject::READ_NORMAL if the data was from a replica DB
482     *   - IDBAccessObject::READ_LATEST if the data was from the primary DB
483     *   - IDBAccessObject::READ_LOCKING if the data was from the primary DB using SELECT FOR UPDATE
484     *   - "fromdb", alias for IDBAccessObject::READ_NORMAL (deprecated Since 1.46)
485     *   - "fromdbmaster", alias for IDBAccessObject::READ_LATEST (deprecated Since 1.46)
486     *   - "forupdate", alias for IDBAccessObject::READ_LOCKING (deprecated Since 1.46)
487     * @since 1.20
488     */
489    public function loadFromRow( $data, $from ) {
490        $from = self::convertSelectType( $from );
491
492        $lc = MediaWikiServices::getInstance()->getLinkCache();
493        $lc->clearLink( $this->mTitle );
494
495        if ( $data ) {
496            $lc->addGoodLinkObjFromRow( $this->mTitle, $data );
497
498            $this->mTitle->loadFromRow( $data );
499            $this->mId = intval( $data->page_id );
500            $this->mTouched = MWTimestamp::convert( TS::MW, $data->page_touched );
501            $this->mLanguage = $data->page_lang ?? null;
502            $this->mLinksUpdated = $data->page_links_updated === null
503                ? null
504                : MWTimestamp::convert( TS::MW, $data->page_links_updated );
505            $this->mPageIsRedirectField = (bool)$data->page_is_redirect;
506            $this->mIsNew = (bool)( $data->page_is_new ?? 0 );
507            $this->mLatest = intval( $data->page_latest );
508            // T39225: $latest may no longer match the cached latest RevisionRecord object.
509            // Double-check the ID of any cached latest RevisionRecord object for consistency.
510            // T400380: since a DB row had to be loaded in, clear the latest RevisionRecord
511            // object if it can from object cache (e.g. it is RevisionStoreCacheRecord).
512            if (
513                $this->mLastRevision && (
514                    $from > $this->mDataLoadedFrom ||
515                    $this->mLastRevision->getId() != $this->mLatest
516                )
517            ) {
518                $this->mLastRevision = null;
519                $this->mTimestamp = '';
520            }
521        } else {
522            $lc->addBadLinkObj( $this->mTitle );
523
524            $this->mTitle->loadFromRow( false );
525
526            $this->clearCacheFields();
527
528            $this->mId = 0;
529        }
530
531        $this->mDataLoaded = true;
532        $this->mDataLoadedFrom = $from;
533    }
534
535    /**
536     * @param string|false $wikiId
537     *
538     * @return int Page ID
539     */
540    public function getId( $wikiId = self::LOCAL ): int {
541        $this->assertWiki( $wikiId );
542
543        if ( !$this->mDataLoaded ) {
544            $this->loadPageData();
545        }
546        return $this->mId;
547    }
548
549    /**
550     * @return bool Whether or not the page exists in the database
551     */
552    public function exists(): bool {
553        if ( !$this->mDataLoaded ) {
554            $this->loadPageData();
555        }
556        return $this->mId > 0;
557    }
558
559    /**
560     * Check if this page is something we're going to be showing
561     * some sort of sensible content for. If we return false, page
562     * views (plain action=view) will return an HTTP 404 response,
563     * so spiders and robots can know they're following a bad link.
564     *
565     * @return bool
566     */
567    public function hasViewableContent() {
568        return $this->mTitle->isKnown();
569    }
570
571    /**
572     * Is the page a redirect, according to secondary tracking tables?
573     * If this is true, getRedirectTarget() will return a Title.
574     *
575     * @return bool
576     */
577    public function isRedirect() {
578        $this->loadPageData();
579        if ( $this->mPageIsRedirectField ) {
580            return MediaWikiServices::getInstance()->getRedirectLookup()
581                    ->getRedirectTarget( $this->getTitle() ) !== null;
582        }
583
584        return false;
585    }
586
587    /**
588     * Tests if the page is new (only has one revision).
589     * May produce false negatives for some old pages.
590     *
591     * @since 1.36
592     *
593     * @return bool
594     */
595    public function isNew() {
596        if ( !$this->mDataLoaded ) {
597            $this->loadPageData();
598        }
599
600        return $this->mIsNew;
601    }
602
603    /**
604     * Returns the page's content model id (see the CONTENT_MODEL_XXX constants).
605     *
606     * Will use the revisions actual content model if the page exists,
607     * and the page's default if the page doesn't exist yet.
608     *
609     * @return string
610     *
611     * @since 1.21
612     */
613    public function getContentModel() {
614        if ( $this->exists() ) {
615            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
616
617            return $cache->getWithSetCallback(
618                $cache->makeKey( 'page-content-model', $this->getLatest() ),
619                $cache::TTL_MONTH,
620                function () {
621                    $rev = $this->getRevisionRecord();
622                    if ( $rev ) {
623                        // Look at the revision's actual content model
624                        $slot = $rev->getSlot(
625                            SlotRecord::MAIN,
626                            RevisionRecord::RAW
627                        );
628                        return $slot->getModel();
629                    } else {
630                        LoggerFactory::getInstance( 'wikipage' )->warning(
631                            'Page exists but has no (visible) revisions!',
632                            [
633                                'page-title' => $this->mTitle->getPrefixedDBkey(),
634                                'page-id' => $this->getId(),
635                            ]
636                        );
637                        return $this->mTitle->getContentModel();
638                    }
639                },
640                [ 'pcTTL' => $cache::TTL_PROC_LONG ]
641            );
642        }
643
644        // use the default model for this page
645        return $this->mTitle->getContentModel();
646    }
647
648    /**
649     * Loads page_touched and returns a value indicating if it should be used
650     * @return bool True if this page exists and is not a redirect
651     */
652    public function checkTouched() {
653        return ( $this->exists() && !$this->isRedirect() );
654    }
655
656    /**
657     * Get the page_touched field
658     * @return string Timestamp in TS::MW format
659     */
660    public function getTouched() {
661        if ( !$this->mDataLoaded ) {
662            $this->loadPageData();
663        }
664        return $this->mTouched;
665    }
666
667    /**
668     * @return ?string language code for the page
669     */
670    public function getLanguage() {
671        if ( !$this->mDataLoaded ) {
672            $this->loadLastEdit();
673        }
674
675        return $this->mLanguage;
676    }
677
678    /**
679     * Get the page_links_updated field
680     * @return string|null Timestamp in TS::MW format
681     */
682    public function getLinksTimestamp() {
683        if ( !$this->mDataLoaded ) {
684            $this->loadPageData();
685        }
686        return $this->mLinksUpdated;
687    }
688
689    /**
690     * Get the page_latest field
691     * @param string|false $wikiId
692     * @return int The rev_id of latest revision
693     */
694    public function getLatest( $wikiId = self::LOCAL ) {
695        $this->assertWiki( $wikiId );
696
697        if ( !$this->mDataLoaded ) {
698            $this->loadPageData();
699        }
700        return (int)$this->mLatest;
701    }
702
703    /**
704     * Loads everything except the text
705     * This isn't necessary for all uses, so it's only done if needed.
706     */
707    protected function loadLastEdit() {
708        if ( $this->mLastRevision !== null ) {
709            return; // already loaded
710        }
711
712        $latest = $this->getLatest();
713        if ( !$latest ) {
714            return; // page doesn't exist or is missing page_latest info
715        }
716
717        if ( $this->mDataLoadedFrom == IDBAccessObject::READ_LOCKING ) {
718            // T39225: if session S1 loads the page row FOR UPDATE, the result always
719            // includes the latest changes committed. This is true even within REPEATABLE-READ
720            // transactions, where S1 normally only sees changes committed before the first S1
721            // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
722            // may not find it since a page row UPDATE and revision row INSERT by S2 may have
723            // happened after the first S1 SELECT.
724            // https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html#isolevel_repeatable-read
725            $revision = $this->getRevisionStore()
726                ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LOCKING );
727        } elseif ( $this->mDataLoadedFrom == IDBAccessObject::READ_LATEST ) {
728            // Bug T93976: if page_latest was loaded from the primary DB, fetch the
729            // revision from there as well, as it may not exist yet on a replica DB.
730            // Also, this keeps the queries in the same REPEATABLE-READ snapshot.
731            $revision = $this->getRevisionStore()
732                ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LATEST );
733        } else {
734            $revision = $this->getRevisionStore()->getKnownLatestRevision( $this->getTitle(), $latest );
735        }
736
737        if ( $revision ) {
738            $this->setLastEdit( $revision );
739        }
740    }
741
742    /**
743     * Set the latest revision
744     */
745    private function setLastEdit( RevisionRecord $revRecord ) {
746        $this->mLastRevision = $revRecord;
747        $this->mLatest = $revRecord->getId();
748        $this->mTimestamp = $revRecord->getTimestamp();
749        $this->mTouched = max( $this->mTouched, $revRecord->getTimestamp() );
750    }
751
752    /**
753     * Get the latest revision
754     * @since 1.32
755     * @return RevisionRecord|null
756     */
757    public function getRevisionRecord() {
758        $this->loadLastEdit();
759        return $this->mLastRevision;
760    }
761
762    /**
763     * Get the content of the latest revision. No side-effects...
764     *
765     * @param int $audience One of:
766     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
767     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
768     *   RevisionRecord::RAW              get the text regardless of permissions
769     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
770     *   to the $audience parameter
771     * @return Content|null The content of the latest revision
772     *
773     * @since 1.21
774     */
775    public function getContent( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
776        $this->loadLastEdit();
777        if ( $this->mLastRevision ) {
778            return $this->mLastRevision->getContent( SlotRecord::MAIN, $audience, $performer );
779        }
780        return null;
781    }
782
783    /**
784     * @return string MW timestamp of last article revision
785     */
786    public function getTimestamp() {
787        // Check if the field has been filled by WikiPage::setTimestamp()
788        if ( !$this->mTimestamp ) {
789            $this->loadLastEdit();
790        }
791
792        return MWTimestamp::convert( TS::MW, $this->mTimestamp );
793    }
794
795    /**
796     * Set the page timestamp (use only to avoid DB queries)
797     * @param string $ts MW timestamp of last article revision
798     * @return void
799     */
800    public function setTimestamp( $ts ) {
801        $this->mTimestamp = MWTimestamp::convert( TS::MW, $ts );
802    }
803
804    /**
805     * @param int $audience One of:
806     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
807     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
808     *   RevisionRecord::RAW              get the text regardless of permissions
809     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
810     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
811     *   a user no fallback is provided and the RevisionRecord method will throw an error)
812     * @return int User ID for the user that made the last article revision
813     */
814    public function getUser( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
815        $this->loadLastEdit();
816        if ( $this->mLastRevision ) {
817            $revUser = $this->mLastRevision->getUser( $audience, $performer );
818            return $revUser ? $revUser->getId() : 0;
819        } else {
820            return -1;
821        }
822    }
823
824    /**
825     * Get the User object of the user who created the page
826     * @param int $audience One of:
827     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
828     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
829     *   RevisionRecord::RAW              get the text regardless of permissions
830     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
831     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
832     *   a user no fallback is provided and the RevisionRecord method will throw an error)
833     * @return UserIdentity|null
834     */
835    public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
836        $revRecord = $this->getRevisionStore()->getFirstRevision( $this->getTitle() );
837        if ( $revRecord ) {
838            return $revRecord->getUser( $audience, $performer );
839        } else {
840            return null;
841        }
842    }
843
844    /**
845     * @param int $audience One of:
846     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
847     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
848     *   RevisionRecord::RAW              get the text regardless of permissions
849     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
850     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
851     *   a user no fallback is provided and the RevisionRecord method will throw an error)
852     * @return string Username of the user that made the last article revision
853     */
854    public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
855        $this->loadLastEdit();
856        if ( $this->mLastRevision ) {
857            $revUser = $this->mLastRevision->getUser( $audience, $performer );
858            return $revUser ? $revUser->getName() : '';
859        } else {
860            return '';
861        }
862    }
863
864    /**
865     * @param int $audience One of:
866     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
867     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
868     *   RevisionRecord::RAW              get the text regardless of permissions
869     * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed
870     *   to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying
871     *   a user no fallback is provided and the RevisionRecord method will throw an error)
872     * @return string|null Comment stored for the last article revision, or null if the specified
873     *  audience does not have access to the comment.
874     */
875    public function getComment( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) {
876        $this->loadLastEdit();
877        if ( $this->mLastRevision ) {
878            $revComment = $this->mLastRevision->getComment( $audience, $performer );
879            return $revComment ? $revComment->text : '';
880        } else {
881            return '';
882        }
883    }
884
885    /**
886     * Returns true if last revision was marked as "minor edit"
887     *
888     * @return bool Minor edit indicator for the last article revision.
889     */
890    public function getMinorEdit() {
891        $this->loadLastEdit();
892        if ( $this->mLastRevision ) {
893            return $this->mLastRevision->isMinor();
894        } else {
895            return false;
896        }
897    }
898
899    /**
900     * Whether the page may count towards the the site's number of "articles".
901     *
902     * This is tracked in the `site_stats` table, and calculated based on the
903     * namespace, page metadata, and content.
904     *
905     * @see $wgArticleCountMethod
906     * @see SlotRoleHandler::supportsArticleCount
907     * @see Content::isCountable
908     * @see WikitextContent::isCountable
909     * @param PreparedEdit|PreparedUpdate|false $editInfo (false):
910     *   An object returned by prepareTextForEdit() or getCurrentUpdate() respectively;
911     *   If false is given, the current database state will be used.
912     *
913     * @return bool
914     */
915    public function isCountable( $editInfo = false ) {
916        $mwServices = MediaWikiServices::getInstance();
917        $articleCountMethod = $mwServices->getMainConfig()->get( MainConfigNames::ArticleCountMethod );
918
919        // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable.
920
921        if ( !$this->mTitle->isContentPage() ) {
922            return false;
923        }
924
925        if ( $editInfo instanceof PreparedEdit ) {
926            // NOTE: only the main slot can make a page a redirect
927            $content = $editInfo->pstContent;
928        } elseif ( $editInfo instanceof PreparedUpdate ) {
929            // NOTE: only the main slot can make a page a redirect
930            $content = $editInfo->getRawContent( SlotRecord::MAIN );
931        } else {
932            $content = $this->getContent();
933        }
934
935        if ( !$content || $content->isRedirect() ) {
936            return false;
937        }
938
939        $hasLinks = null;
940
941        if ( $articleCountMethod === 'link' ) {
942            // nasty special case to avoid re-parsing to detect links
943
944            if ( $editInfo ) {
945                $hasLinks = $editInfo->output->hasLinks();
946            } else {
947                // NOTE: keep in sync with RevisionRenderer::getLinkCount
948                // NOTE: keep in sync with DerivedPageDataUpdater::isCountable
949                $dbr = $mwServices
950                    ->getConnectionProvider()
951                    ->getReplicaDatabase( PageLinksTable::VIRTUAL_DOMAIN );
952                $hasLinks = (bool)$dbr->newSelectQueryBuilder()
953                    ->select( '1' )
954                    ->from( 'pagelinks' )
955                    ->where( [ 'pl_from' => $this->getId() ] )
956                    ->caller( __METHOD__ )->fetchField();
957            }
958        }
959
960        // TODO: MCR: determine $hasLinks for each slot, and use that info
961        // with that slot's Content's isCountable method. That requires per-
962        // slot ParserOutput in the ParserCache, or per-slot info in the
963        // pagelinks table.
964        return $content->isCountable( $hasLinks );
965    }
966
967    /**
968     * If this page is a redirect, get its target
969     *
970     * The target will be fetched from the redirect table if possible.
971     *
972     * @deprecated since 1.38 Use RedirectLookup::getRedirectTarget() instead.
973     *
974     * @return Title|null Title object, or null if this page is not a redirect
975     */
976    public function getRedirectTarget() {
977        $target = MediaWikiServices::getInstance()->getRedirectLookup()->getRedirectTarget( $this );
978        return Title::castFromLinkTarget( $target );
979    }
980
981    /**
982     * Insert or update the redirect table entry for this page to indicate it redirects to $rt
983     * @deprecated since 1.43; use {@link RedirectStore::updateRedirectTarget()} instead.
984     * @param LinkTarget $rt Redirect target
985     * @param int|null $oldLatest Prior page_latest for check and set
986     * @return bool Success
987     */
988    public function insertRedirectEntry( LinkTarget $rt, $oldLatest = null ) {
989        return MediaWikiServices::getInstance()->getRedirectStore()
990            ->updateRedirectTarget( $this, $rt );
991    }
992
993    /**
994     * Get the Title object or URL this page redirects to
995     *
996     * @return bool|Title|string False, Title of in-wiki target, or string with URL
997     */
998    public function followRedirect() {
999        return $this->getRedirectURL( $this->getRedirectTarget() );
1000    }
1001
1002    /**
1003     * Get the Title object or URL to use for a redirect. We use Title
1004     * objects for same-wiki, non-special redirects and URLs for everything
1005     * else.
1006     * @param Title $rt Redirect target
1007     * @return Title|string|false False, Title object of local target, or string with URL
1008     */
1009    public function getRedirectURL( $rt ) {
1010        if ( !$rt ) {
1011            return false;
1012        }
1013
1014        if ( $rt->isExternal() ) {
1015            if ( $rt->isLocal() ) {
1016                // Offsite wikis need an HTTP redirect.
1017                // This can be hard to reverse and may produce loops,
1018                // so they may be disabled in the site configuration.
1019                $source = $this->mTitle->getFullURL( 'redirect=no' );
1020                return $rt->getFullURL( [ 'rdfrom' => $source ] );
1021            } else {
1022                // External pages without "local" bit set are not valid
1023                // redirect targets
1024                return false;
1025            }
1026        }
1027
1028        if ( $rt->isSpecialPage() ) {
1029            // Gotta handle redirects to special pages differently:
1030            // Fill the HTTP response "Location" header and ignore the rest of the page we're on.
1031            // Some pages are not valid targets.
1032            if ( $rt->isValidRedirectTarget() ) {
1033                return $rt->getFullURL();
1034            } else {
1035                return false;
1036            }
1037        } elseif ( !$rt->isValidRedirectTarget() ) {
1038            // We somehow got a bad redirect target into the database (T278367)
1039            return false;
1040        }
1041
1042        return $rt;
1043    }
1044
1045    /**
1046     * Get a list of users who have edited this article, not including the user who made
1047     * the most recent revision, which you can get from $article->getUser() if you want it
1048     * @return UserArray
1049     */
1050    public function getContributors() {
1051        // @todo: This is expensive; cache this info somewhere.
1052
1053        $services = MediaWikiServices::getInstance();
1054        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
1055        $actorNormalization = $services->getActorNormalization();
1056        $userIdentityLookup = $services->getUserIdentityLookup();
1057
1058        $user = $this->getUser()
1059            ? User::newFromId( $this->getUser() )
1060            : User::newFromName( $this->getUserText(), false );
1061
1062        $res = $dbr->newSelectQueryBuilder()
1063            ->select( [
1064                'user_id' => 'actor_user',
1065                'user_name' => 'actor_name',
1066                'actor_id' => 'MIN(rev_actor)',
1067                'user_real_name' => 'MIN(user_real_name)',
1068                'timestamp' => 'MAX(rev_timestamp)',
1069            ] )
1070            ->from( 'revision' )
1071            ->join( 'actor', null, 'rev_actor = actor_id' )
1072            ->leftJoin( 'user', null, 'actor_user = user_id' )
1073            ->where( [
1074                'rev_page' => $this->getId(),
1075                // The user who made the top revision gets credited as "this page was last edited by
1076                // John, based on contributions by Tom, Dick and Harry", so don't include them twice.
1077                $dbr->expr( 'rev_actor', '!=', $actorNormalization->findActorId( $user, $dbr ) ),
1078                // Username hidden?
1079                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . ' = 0',
1080            ] )
1081            ->groupBy( [ 'actor_user', 'actor_name' ] )
1082            ->orderBy( 'timestamp', SelectQueryBuilder::SORT_DESC )
1083            ->caller( __METHOD__ )
1084            ->fetchResultSet();
1085        return new UserArrayFromResult( $res );
1086    }
1087
1088    /**
1089     * Should the parser cache be used?
1090     *
1091     * @param ParserOptions $parserOptions ParserOptions to check
1092     * @param int $oldId
1093     * @return bool
1094     */
1095    public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
1096        // NOTE: Keep in sync with ParserOutputAccess::shouldUseCache().
1097        // TODO: Once ParserOutputAccess is stable, deprecated this method.
1098        return $this->exists()
1099            && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
1100            && $this->getContentHandler()->isParserCacheSupported();
1101    }
1102
1103    /**
1104     * Get a ParserOutput for the given ParserOptions and revision ID.
1105     *
1106     * The parser cache will be used if possible. Cache misses that result
1107     * in parser runs are debounced with PoolCounter.
1108     *
1109     * XXX merge this with updateParserCache()?
1110     *
1111     * @since 1.19
1112     * @param ParserOptions|null $parserOptions ParserOptions to use for the parse operation
1113     * @param null|int $oldid Revision ID to get the text from, passing null or 0 will
1114     *   get the latest revision (default value)
1115     * @param bool $noCache Do not read from or write to caches.
1116     * @return ParserOutput|false ParserOutput or false if the revision was not found or is not public
1117     */
1118    public function getParserOutput(
1119        ?ParserOptions $parserOptions = null, $oldid = null, $noCache = false
1120    ) {
1121        if ( $oldid ) {
1122            $revision = $this->getRevisionStore()->getRevisionByTitle( $this->getTitle(), $oldid );
1123
1124            if ( !$revision ) {
1125                return false;
1126            }
1127        } else {
1128            $revision = $this->getRevisionRecord();
1129        }
1130
1131        if ( !$parserOptions ) {
1132            $parserOptions = ParserOptions::newFromAnon();
1133        }
1134
1135        $options = $noCache ? ParserOutputAccess::OPT_NO_CACHE : 0;
1136
1137        $status = MediaWikiServices::getInstance()->getParserOutputAccess()->getParserOutput(
1138            $this, $parserOptions, $revision, $options
1139        );
1140        return $status->isOK() ? $status->getValue() : false; // convert null to false
1141    }
1142
1143    /**
1144     * Do standard deferred updates after page view (existing or missing page)
1145     * @param Authority $performer The viewing user
1146     * @param RevisionRecord|null|int $oldRev The revision being viewed, or null if
1147     *   the latest revision is used. Passing integer for $oldid is deprecated since 1.46
1148     * @param RevisionRecord|null $oldRevDeprecated Deprecated since 1.46
1149     */
1150    public function doViewUpdates(
1151        Authority $performer,
1152        $oldRev = null,
1153        $oldRevDeprecated = null
1154    ) {
1155        if ( func_num_args() > 2 ) {
1156            wfDeprecatedMsg( 'Passing $oldid to ' . __METHOD__ . ' is deprecated since 1.46.' );
1157            $oldRev = $oldRevDeprecated;
1158        }
1159
1160        if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
1161            return;
1162        }
1163
1164        DeferredUpdates::addCallableUpdate(
1165            function () use ( $performer ) {
1166                // In practice, these hook handlers simply debounce into a post-send
1167                // to do their work since none of the use cases for this hook require
1168                // a blocking pre-send callback.
1169                //
1170                // TODO: Move this hook to post-send.
1171                //
1172                // For now, it is unofficially possible for an extension to use
1173                // onPageViewUpdates to try to insert JavaScript via global $wgOut.
1174                // This isn't supported (the hook doesn't pass OutputPage), and
1175                // can't be since OutputPage may be disabled or replaced on some
1176                // pages that we do support page view updates for. We also run
1177                // this hook after HTMLFileCache, which also naturally can't
1178                // support modifying OutputPage. Handlers that modify the page
1179                // may use onBeforePageDisplay instead, which runs behind
1180                // HTMLFileCache and won't run on non-OutputPage responses.
1181                $legacyUser = MediaWikiServices::getInstance()
1182                    ->getUserFactory()
1183                    ->newFromAuthority( $performer );
1184                $this->getHookRunner()->onPageViewUpdates( $this, $legacyUser );
1185            },
1186            DeferredUpdates::PRESEND
1187        );
1188
1189        // Update newtalk and watchlist notification status
1190        MediaWikiServices::getInstance()
1191            ->getWatchlistManager()
1192            ->clearTitleUserNotifications( $performer, $this, $oldRev );
1193    }
1194
1195    /**
1196     * Perform the actions of a page purging
1197     * @return bool
1198     * @note In 1.28 (and only 1.28), this took a $flags parameter that
1199     *  controlled how much purging was done.
1200     */
1201    public function doPurge() {
1202        if ( !$this->getHookRunner()->onArticlePurge( $this ) ) {
1203            return false;
1204        }
1205
1206        $this->mTitle->invalidateCache();
1207
1208        // Clear file cache and send purge after above page_touched update was committed
1209        $hcu = MediaWikiServices::getInstance()->getHTMLCacheUpdater();
1210        $hcu->purgeTitleUrls( $this->mTitle, $hcu::PURGE_PRESEND );
1211
1212        if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI ) {
1213            MediaWikiServices::getInstance()->getMessageCache()
1214                ->updateMessageOverride( $this->mTitle, $this->getContent() );
1215        }
1216        InfoAction::invalidateCache( $this->mTitle, $this->getLatest() );
1217
1218        return true;
1219    }
1220
1221    /**
1222     * Insert a new empty page record for this article.
1223     * This *must* be followed up by creating a revision
1224     * and running $this->updateRevisionOn( ... );
1225     * or else the record will be left in a funky state.
1226     * Best if all done inside a transaction.
1227     *
1228     * @internal Low level interface, not safe for use in extensions!
1229     *
1230     * @todo Factor out into a PageStore service, to be used by PageUpdater.
1231     *
1232     * @param IDatabase $dbw
1233     * @param int|null $pageId Custom page ID that will be used for the insert statement
1234     *
1235     * @return int|false The newly created page_id key; false if the row was not
1236     *   inserted, e.g. because the title already existed or because the specified
1237     *   page ID is already in use.
1238     */
1239    public function insertOn( $dbw, $pageId = null ) {
1240        $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : [];
1241        $row = [
1242            'page_namespace'    => $this->mTitle->getNamespace(),
1243            'page_title'        => $this->mTitle->getDBkey(),
1244            'page_is_redirect'  => 0, // Will set this shortly...
1245            'page_is_new'       => 1,
1246            'page_random'       => wfRandom(),
1247            'page_touched'      => $dbw->timestamp(),
1248            'page_latest'       => 0, // Fill this in shortly...
1249            'page_len'          => 0, // Fill this in shortly...
1250        ] + $pageIdForInsert;
1251        $dbw->newInsertQueryBuilder()
1252            ->insertInto( 'page' )
1253            ->ignore()
1254            ->row( $row )
1255            ->caller( __METHOD__ )->execute();
1256
1257        if ( $dbw->affectedRows() > 0 ) {
1258            $newid = $pageId ? (int)$pageId : $dbw->insertId();
1259            $this->mId = $newid;
1260            $this->mTitle->resetArticleID( $newid );
1261
1262            // Duplicate the row on secondary links storage if needed but set the page_id
1263            $row['page_id'] = $newid;
1264            $insert = $dbw->newInsertQueryBuilder()
1265                ->insertInto( 'page' )
1266                ->ignore()
1267                ->row( $row )
1268                ->caller( __METHOD__ );
1269            MediaWikiServices::getInstance()->getLinkWriteDuplicator()->duplicate( $insert );
1270
1271            return $newid;
1272        } else {
1273            return false; // nothing changed
1274        }
1275    }
1276
1277    /**
1278     * Update the page record to point to a newly saved revision.
1279     *
1280     * @internal Low level interface, not safe for use in extensions!
1281     *
1282     * @todo Factor out into a PageStore service, or move into PageUpdater.
1283     *
1284     * @param IDatabase $dbw
1285     * @param RevisionRecord $revision For ID number, and text used to set
1286     *   length and redirect status fields.
1287     * @param int|null $lastRevision If given, will not overwrite the page field
1288     *   when different from the currently set value.
1289     *   Giving 0 indicates the new page flag should be set on.
1290     * @param bool|null $lastRevIsRedirect If given, will optimize adding and
1291     *   removing rows in redirect table.
1292     * @return bool Success; false if the page row was missing or page_latest changed
1293     */
1294    public function updateRevisionOn(
1295        $dbw,
1296        RevisionRecord $revision,
1297        $lastRevision = null,
1298        $lastRevIsRedirect = null
1299    ) {
1300        // TODO: move into PageUpdater or PageStore
1301        // NOTE: when doing that, make sure cached fields get reset in doUserEditContent,
1302        // and in the compat stub!
1303
1304        $revId = $revision->getId();
1305        Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' );
1306
1307        $content = $revision->getContent( SlotRecord::MAIN );
1308        $len = $content ? $content->getSize() : 0;
1309        $rt = $content ? $content->getRedirectTarget() : null;
1310        $isNew = $lastRevision === 0;
1311        $isRedirect = $rt !== null;
1312
1313        $conditions = [ 'page_id' => $this->getId() ];
1314
1315        if ( $lastRevision !== null ) {
1316            // An extra check against threads stepping on each other
1317            $conditions['page_latest'] = $lastRevision;
1318        }
1319
1320        $model = $revision->getMainContentModel();
1321
1322        $row = [ /* SET */
1323            'page_latest'        => $revId,
1324            'page_touched'       => $dbw->timestamp( $revision->getTimestamp() ),
1325            'page_is_new'        => $isNew ? 1 : 0,
1326            'page_is_redirect'   => $isRedirect ? 1 : 0,
1327            'page_len'           => $len,
1328            'page_content_model' => $model,
1329        ];
1330
1331        $update = $dbw->newUpdateQueryBuilder()
1332            ->update( 'page' )
1333            ->set( $row )
1334            ->where( $conditions )
1335            ->caller( __METHOD__ );
1336        $update->execute();
1337        MediaWikiServices::getInstance()->getLinkWriteDuplicator()->duplicate( $update );
1338
1339        $result = $dbw->affectedRows() > 0;
1340        if ( $result ) {
1341            $insertedRow = $this->pageData( $dbw, [ 'page_id' => $this->getId() ] );
1342
1343            if ( !$insertedRow ) {
1344                throw new RuntimeException( 'Failed to load freshly inserted row' );
1345            }
1346
1347            $this->mTitle->loadFromRow( $insertedRow );
1348            MediaWikiServices::getInstance()->getRedirectStore()
1349                ->updateRedirectTarget( $this, $rt, $lastRevIsRedirect );
1350            $this->setLastEdit( $revision );
1351            $this->mPageIsRedirectField = (bool)$rt;
1352            $this->mIsNew = $isNew;
1353
1354            // Update the LinkCache.
1355            $linkCache = MediaWikiServices::getInstance()->getLinkCache();
1356            $linkCache->addGoodLinkObjFromRow(
1357                $this->mTitle,
1358                $insertedRow
1359            );
1360        }
1361
1362        return $result;
1363    }
1364
1365    /**
1366     * Helper method for checking whether two revisions have differences that go
1367     * beyond the main slot.
1368     *
1369     * MCR migration note: this method should go away!
1370     *
1371     * @deprecated since 1.43; Use only as a stop-gap before refactoring to support MCR.
1372     *
1373     * @param RevisionRecord $a
1374     * @param RevisionRecord $b
1375     * @return bool
1376     */
1377    public static function hasDifferencesOutsideMainSlot( RevisionRecord $a, RevisionRecord $b ) {
1378        $aSlots = $a->getSlots();
1379        $bSlots = $b->getSlots();
1380        $changedRoles = $aSlots->getRolesWithDifferentContent( $bSlots );
1381
1382        return ( $changedRoles !== [ SlotRecord::MAIN ] && $changedRoles !== [] );
1383    }
1384
1385    /**
1386     * Returns true if this page's content model supports sections.
1387     *
1388     * @return bool
1389     *
1390     * @todo The skin should check this and not offer section functionality if
1391     *   sections are not supported.
1392     * @todo The EditPage should check this and not offer section functionality
1393     *   if sections are not supported.
1394     */
1395    public function supportsSections() {
1396        return $this->getContentHandler()->supportsSections();
1397    }
1398
1399    /**
1400     * @param string|int|null|false $sectionId Section identifier as a number or string
1401     * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1402     * or 'new' for a new section.
1403     * @param Content $sectionContent New content of the section.
1404     * @param string $sectionTitle New section's subject, only if $section is "new".
1405     * @param string|null $edittime Revision timestamp or null to use the latest revision.
1406     *
1407     * @return Content|null New complete article content, or null if error.
1408     *
1409     * @since 1.21
1410     * @deprecated since 1.24, use replaceSectionAtRev instead
1411     */
1412    public function replaceSectionContent(
1413        $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
1414    ) {
1415        $baseRevId = null;
1416        if ( $edittime && $sectionId !== 'new' ) {
1417            $lb = $this->getDBLoadBalancer();
1418            $rev = $this->getRevisionStore()->getRevisionByTimestamp( $this->mTitle, $edittime );
1419            // Try the primary database if this thread may have just added it.
1420            // The logic to fallback to the primary database if the replica is missing
1421            // the revision could be generalized into RevisionStore, but we don't want
1422            // to encourage loading of revisions by timestamp.
1423            if ( !$rev
1424                && $lb->hasReplicaServers()
1425                && $lb->hasOrMadeRecentPrimaryChanges()
1426            ) {
1427                $rev = $this->getRevisionStore()->getRevisionByTimestamp(
1428                    $this->mTitle, $edittime, IDBAccessObject::READ_LATEST );
1429            }
1430            if ( $rev ) {
1431                $baseRevId = $rev->getId();
1432            }
1433        }
1434
1435        return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
1436    }
1437
1438    /**
1439     * @param string|int|null|false $sectionId Section identifier as a number or string
1440     * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
1441     * or 'new' for a new section.
1442     * @param Content $sectionContent New content of the section.
1443     * @param string $sectionTitle New section's subject, only if $section is "new".
1444     * @param int|null $baseRevId
1445     *
1446     * @return Content|null New complete article content, or null if error.
1447     *
1448     * @since 1.24
1449     */
1450    public function replaceSectionAtRev( $sectionId, Content $sectionContent,
1451        $sectionTitle = '', $baseRevId = null
1452    ) {
1453        if ( strval( $sectionId ) === '' ) {
1454            // Whole-page edit; let the whole text through
1455            $newContent = $sectionContent;
1456        } else {
1457            if ( !$this->supportsSections() ) {
1458                throw new BadMethodCallException( "sections not supported for content model " .
1459                    $this->getContentHandler()->getModelID() );
1460            }
1461
1462            // T32711: always use current version when adding a new section
1463            if ( $baseRevId === null || $sectionId === 'new' ) {
1464                $oldContent = $this->getContent();
1465            } else {
1466                $revRecord = $this->getRevisionStore()->getRevisionById( $baseRevId );
1467                if ( !$revRecord ) {
1468                    wfDebug( __METHOD__ . " asked for bogus section (page: " .
1469                        $this->getId() . "; section: $sectionId)" );
1470                    return null;
1471                }
1472
1473                $oldContent = $revRecord->getContent( SlotRecord::MAIN );
1474            }
1475
1476            if ( !$oldContent ) {
1477                wfDebug( __METHOD__ . ": no page text" );
1478                return null;
1479            }
1480
1481            $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
1482        }
1483
1484        return $newContent;
1485    }
1486
1487    /**
1488     * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
1489     *
1490     * @deprecated since 1.32, use exists() instead, or simply omit the EDIT_UPDATE
1491     * and EDIT_NEW flags. To protect against race conditions, use PageUpdater::grabParentRevision.
1492     *
1493     * @param int $flags
1494     * @return int Updated $flags
1495     */
1496    public function checkFlags( $flags ) {
1497        if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
1498            if ( $this->exists() ) {
1499                $flags |= EDIT_UPDATE;
1500            } else {
1501                $flags |= EDIT_NEW;
1502            }
1503        }
1504
1505        return $flags;
1506    }
1507
1508    /**
1509     * Returns a DerivedPageDataUpdater for use with the given target revision or new content.
1510     * This method attempts to re-use the same DerivedPageDataUpdater instance for subsequent calls.
1511     * The parameters passed to this method are used to ensure that the DerivedPageDataUpdater
1512     * returned matches that caller's expectations, allowing an existing instance to be re-used
1513     * if the given parameters match that instance's internal state according to
1514     * DerivedPageDataUpdater::isReusableFor(), and creating a new instance of the parameters do not
1515     * match the existing one.
1516     *
1517     * If neither $forRevision nor $forUpdate is given, a new DerivedPageDataUpdater is always
1518     * created, replacing any DerivedPageDataUpdater currently cached.
1519     *
1520     * MCR migration note: this replaces WikiPage::prepareContentForEdit.
1521     *
1522     * @since 1.32
1523     *
1524     * @param UserIdentity|null $forUser The user that will be used for, or was used for, PST.
1525     * @param RevisionRecord|null $forRevision The revision created by the edit for which
1526     *        to perform updates, if the edit was already saved.
1527     * @param RevisionSlotsUpdate|null $forUpdate The new content to be saved by the edit (pre PST),
1528     *        if the edit was not yet saved.
1529     * @param bool $forEdit Only re-use if the cached DerivedPageDataUpdater has the current
1530     *       revision as the edit's parent revision. This ensures that the same
1531     *       DerivedPageDataUpdater cannot be re-used for two consecutive edits.
1532     *
1533     * @return DerivedPageDataUpdater
1534     */
1535    private function getDerivedDataUpdater(
1536        ?UserIdentity $forUser = null,
1537        ?RevisionRecord $forRevision = null,
1538        ?RevisionSlotsUpdate $forUpdate = null,
1539        $forEdit = false
1540    ) {
1541        if ( !$forRevision && !$forUpdate ) {
1542            // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is
1543            // going to use it with.
1544            $this->derivedDataUpdater = null;
1545        }
1546
1547        if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) {
1548            // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference
1549            // to it did not yet initialize it, because we don't know what data it will be
1550            // initialized with.
1551            $this->derivedDataUpdater = null;
1552        }
1553
1554        // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance.
1555        // However, there is no good way to construct a cache key. We'd need to check against all
1556        // cached instances.
1557
1558        if ( $this->derivedDataUpdater
1559            && !$this->derivedDataUpdater->isReusableFor(
1560                $forUser,
1561                $forRevision,
1562                $forUpdate,
1563                $forEdit ? $this->getLatest() : null
1564            )
1565        ) {
1566            $this->derivedDataUpdater = null;
1567        }
1568
1569        if ( !$this->derivedDataUpdater ) {
1570            $this->derivedDataUpdater =
1571                $this->getPageUpdaterFactory()->newDerivedPageDataUpdater( $this );
1572        }
1573
1574        return $this->derivedDataUpdater;
1575    }
1576
1577    /**
1578     * Change an existing article or create a new article. Updates RC and all necessary caches,
1579     * optionally via the deferred update array.
1580     *
1581     * @param Content $content New content
1582     * @param Authority $performer doing the edit
1583     * @param string|CommentStoreComment $summary Edit summary
1584     * @param int $flags Bitfield, see the EDIT_XXX constants such as EDIT_NEW
1585     *        or EDIT_FORCE_BOT.
1586     *
1587     * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
1588     * article will be detected. If EDIT_UPDATE is specified and the article
1589     * doesn't exist, the function will return an edit-gone-missing error. If
1590     * EDIT_NEW is specified and the article does exist, an edit-already-exists
1591     * error will be returned. These two conditions are also possible with
1592     * auto-detection due to MediaWiki's performance-optimised locking strategy.
1593     *
1594     * @param int|false $originalRevId: The ID of an original revision that the edit
1595     * restores or repeats. The new revision is expected to have the exact same content as
1596     * the given original revision. This is used with rollbacks and with dummy "null" revisions
1597     * which are created to record things like page moves. Default is false, meaning we are not
1598     * making a rollback edit.
1599     * @param array|null $tags Change tags to apply to this edit
1600     * Callers are responsible for permission checks
1601     * (with ChangeTags::canAddTagsAccompanyingChange)
1602     * @param int $undidRevId Id of revision that was undone or 0
1603     *
1604     * @return PageUpdateStatus<array> Possible errors:
1605     *     edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
1606     *       set the fatal flag of $status.
1607     *     edit-gone-missing: In update mode, but the article didn't exist.
1608     *     edit-conflict: In update mode, the article changed unexpectedly.
1609     *     edit-no-change: Warning that the text was the same as before.
1610     *     edit-already-exists: In creation mode, but the article already exists.
1611     *
1612     *  Extensions may define additional errors.
1613     *
1614     *  $return->value will contain an associative array with members as follows:
1615     *     new: Boolean indicating if the function attempted to create a new article.
1616     *     revision-record: The revision record object for the inserted revision, or null.
1617     *
1618     * @deprecated since 1.36, use PageUpdater::saveRevision instead. Note that the new method
1619     * expects callers to take care of checking EDIT_MINOR against the minoredit right, and to
1620     * apply the autopatrol right as appropriate.
1621     *
1622     * @since 1.36
1623     */
1624    public function doUserEditContent(
1625        Content $content,
1626        Authority $performer,
1627        $summary,
1628        $flags = 0,
1629        $originalRevId = false,
1630        $tags = [],
1631        $undidRevId = 0
1632    ): PageUpdateStatus {
1633        $useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get(
1634            MainConfigNames::UseNPPatrol );
1635        $useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get(
1636            MainConfigNames::UseRCPatrol );
1637        if ( !( $summary instanceof CommentStoreComment ) ) {
1638            $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
1639        }
1640
1641        // TODO: this check is here for backwards-compatibility with 1.31 behavior.
1642        // Checking the minoredit right should be done in the same place the 'bot' right is
1643        // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave.
1644        if ( ( $flags & EDIT_MINOR ) && !$performer->isAllowed( 'minoredit' ) ) {
1645            $flags &= ~EDIT_MINOR;
1646        }
1647
1648        $slotsUpdate = new RevisionSlotsUpdate();
1649        $slotsUpdate->modifyContent( SlotRecord::MAIN, $content );
1650
1651        // NOTE: while doUserEditContent() executes, callbacks to getDerivedDataUpdater and
1652        // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also
1653        // used by this PageUpdater. However, there is no guarantee for this.
1654        $updater = $this->newPageUpdater( $performer, $slotsUpdate )
1655            ->setContent( SlotRecord::MAIN, $content )
1656            ->setOriginalRevisionId( $originalRevId );
1657        if ( $undidRevId ) {
1658            $updater->setCause( PageUpdateCauses::CAUSE_UNDO );
1659            $updater->markAsRevert(
1660                EditResult::REVERT_UNDO,
1661                $undidRevId,
1662                $originalRevId ?: null
1663            );
1664        }
1665
1666        $needsPatrol = $useRCPatrol || ( $useNPPatrol && !$this->exists() );
1667
1668        // TODO: this logic should not be in the storage layer, it's here for compatibility
1669        // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
1670        // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
1671
1672        if ( $needsPatrol && $performer->authorizeWrite( 'autopatrol', $this->getTitle() ) ) {
1673            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
1674        }
1675
1676        $updater->addTags( $tags );
1677
1678        $revRec = $updater->saveRevision(
1679            $summary,
1680            $flags
1681        );
1682
1683        // $revRec will be null if the edit failed, or if no new revision was created because
1684        // the content did not change.
1685        if ( $revRec ) {
1686            // update cached fields
1687            // TODO: this is currently redundant to what is done in updateRevisionOn.
1688            // But updateRevisionOn() should move into PageStore, and then this will be needed.
1689            $this->setLastEdit( $revRec );
1690        }
1691
1692        return $updater->getStatus();
1693    }
1694
1695    /**
1696     * Returns a PageUpdater for creating new revisions on this page (or creating the page).
1697     *
1698     * The PageUpdater can also be used to detect the need for edit conflict resolution,
1699     * and to protected such conflict resolution from concurrent edits using a check-and-set
1700     * mechanism.
1701     *
1702     * @since 1.32
1703     *
1704     * @note Once extensions no longer rely on WikiPage to get access to the state of an ongoing
1705     * edit via prepareContentForEdit() and WikiPage::getCurrentUpdate(),
1706     * this method should be deprecated and callers should be migrated to using
1707     * PageUpdaterFactory::newPageUpdater() instead.
1708     *
1709     * @param Authority|UserIdentity $performer
1710     * @param RevisionSlotsUpdate|null $forUpdate If given, allows any cached ParserOutput
1711     *        that may already have been returned via getDerivedDataUpdater to be re-used.
1712     *
1713     * @return PageUpdater
1714     */
1715    public function newPageUpdater( $performer, ?RevisionSlotsUpdate $forUpdate = null ) {
1716        if ( $performer instanceof Authority ) {
1717            // TODO: Deprecate this. But better get rid of this method entirely.
1718            $performer = $performer->getUser();
1719        }
1720
1721        $pageUpdater = $this->getPageUpdaterFactory()->newPageUpdaterForDerivedPageDataUpdater(
1722            $this,
1723            $performer,
1724            $this->getDerivedDataUpdater( $performer, null, $forUpdate, true )
1725        );
1726
1727        return $pageUpdater;
1728    }
1729
1730    /**
1731     * Get parser options suitable for rendering the primary article wikitext
1732     *
1733     * @see ParserOptions::newCanonical
1734     *
1735     * @param IContextSource|UserIdentity|string $context One of the following:
1736     *   - IContextSource: Use the User and the Language of the provided
1737     *     context
1738     *   - UserIdentity: Use the provided UserIdentity object and $wgLang
1739     *     for the language, so use an IContextSource object if possible.
1740     *   - 'canonical': Canonical options (anonymous user with default
1741     *     preferences and content language).
1742     * @return ParserOptions
1743     */
1744    public function makeParserOptions( $context ) {
1745        return self::makeParserOptionsFromTitleAndModel(
1746            $this->getTitle(), $this->getContentModel(), $context
1747        );
1748    }
1749
1750    /**
1751     * Create canonical parser options for a given title and content model.
1752     * @internal
1753     * @param PageReference $pageRef
1754     * @param string $contentModel
1755     * @param IContextSource|UserIdentity|string $context See ::makeParserOptions
1756     * @return ParserOptions
1757     */
1758    public static function makeParserOptionsFromTitleAndModel(
1759        PageReference $pageRef, string $contentModel, $context
1760    ) {
1761        $options = ParserOptions::newCanonical( $context );
1762
1763        $title = Title::newFromPageReference( $pageRef );
1764        if ( $title->isConversionTable() ) {
1765            // @todo ConversionTable should become a separate content model, so
1766            // we don't need special cases like this one, but see T313455.
1767            $options->disableContentConversion();
1768        }
1769        # Add in the preferred variant from the URL or user preferences
1770        $services = MediaWikiServices::getInstance();
1771        $languageConverterFactory = $services->getLanguageConverterFactory();
1772        if ( !$languageConverterFactory->isConversionDisabled() ) {
1773            $converter = $languageConverterFactory->getLanguageConverter(
1774                $title->getPageLanguage()
1775            );
1776            if ( $converter->hasVariants() ) {
1777                $variant = $services->getLanguageFactory()->getLanguage(
1778                    $converter->getPreferredVariant()
1779                );
1780                $options->setVariant( $variant );
1781            }
1782        }
1783
1784        return $options;
1785    }
1786
1787    /**
1788     * Prepare content which is about to be saved.
1789     *
1790     * Prior to 1.30, this returned a stdClass.
1791     *
1792     * @deprecated since 1.32, use newPageUpdater() or getCurrentUpdate() instead.
1793     * @note Calling without a UserIdentity was separately deprecated from 1.37 to 1.39, since
1794     * 1.39 the UserIdentity has been required.
1795     *
1796     * @param Content $content
1797     * @param RevisionRecord|null $revision
1798     *        Used with vary-revision or vary-revision-id.
1799     * @param UserIdentity $user
1800     * @param string|null $serialFormat IGNORED
1801     * @param bool $useStash Use prepared edit stash
1802     *
1803     * @return PreparedEdit
1804     *
1805     * @since 1.21
1806     */
1807    public function prepareContentForEdit(
1808        Content $content,
1809        ?RevisionRecord $revision,
1810        UserIdentity $user,
1811        $serialFormat = null,
1812        $useStash = true
1813    ) {
1814        $slots = RevisionSlotsUpdate::newFromContent( [ SlotRecord::MAIN => $content ] );
1815        $updater = $this->getDerivedDataUpdater( $user, $revision, $slots );
1816
1817        if ( !$updater->isUpdatePrepared() ) {
1818            $updater->prepareContent( $user, $slots, $useStash );
1819
1820            if ( $revision ) {
1821                $updater->prepareUpdate(
1822                    $revision,
1823                    [
1824                        'causeAction' => 'prepare-edit',
1825                        'causeAgent' => $user->getName(),
1826                    ]
1827                );
1828            }
1829        }
1830
1831        return $updater->getPreparedEdit();
1832    }
1833
1834    /**
1835     * Do standard deferred updates after page edit.
1836     * Update links tables, site stats, search index and message cache.
1837     * Purges pages that include this page if the text was changed here.
1838     * Every 100th edit, prune the recent changes table.
1839     * Does not emit domain events.
1840     *
1841     * @deprecated since 1.32, use DerivedPageDataUpdater::doUpdates instead.
1842     *             Emitting warnings since 1.44
1843     *
1844     * @param RevisionRecord $revisionRecord (Switched from the old Revision class to
1845     *    RevisionRecord since 1.35)
1846     * @param UserIdentity $user User object that did the revision
1847     * @param array $options Array of options, see DerivedPageDataUpdater::prepareUpdate.
1848     */
1849    public function doEditUpdates(
1850        RevisionRecord $revisionRecord,
1851        UserIdentity $user,
1852        array $options = []
1853    ) {
1854        wfDeprecated( __METHOD__, '1.32' ); // emitting warnings since 1.44
1855
1856        $options += [
1857            'causeAction' => 'edit-page',
1858            'causeAgent' => $user->getName(),
1859            'emitEvents' => false // prior page state is unknown, can't emit events
1860        ];
1861
1862        $updater = $this->getDerivedDataUpdater( $user, $revisionRecord );
1863
1864        $updater->prepareUpdate( $revisionRecord, $options );
1865
1866        $updater->doUpdates();
1867    }
1868
1869    /**
1870     * Update the parser cache.
1871     *
1872     * @note This does not update links tables. Use doSecondaryDataUpdates() for that.
1873     *
1874     * @param array $options
1875     *   - causeAction: an arbitrary string identifying the reason for the update.
1876     *     See DataUpdate::getCauseAction(). (default 'edit-page')
1877     *   - causeAgent: name of the user who caused the update (string, defaults to the
1878     *     user who created the revision)
1879     * @since 1.32
1880     */
1881    public function updateParserCache( array $options = [] ) {
1882        $revision = $this->getRevisionRecord();
1883        if ( !$revision || !$revision->getId() ) {
1884            LoggerFactory::getInstance( 'wikipage' )->info(
1885                __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
1886            );
1887            return;
1888        }
1889        $userIdentity = $revision->getUser( RevisionRecord::RAW );
1890
1891        $updater = $this->getDerivedDataUpdater( $userIdentity, $revision );
1892        $updater->prepareUpdate( $revision, $options );
1893        $updater->doParserCacheUpdate();
1894    }
1895
1896    /**
1897     * Do secondary data updates (such as updating link tables).
1898     * Secondary data updates are only a small part of the updates needed after saving
1899     * a new revision; normally PageUpdater::doUpdates should be used instead (which includes
1900     * secondary data updates). This method is provided for partial purges.
1901     *
1902     * @note This does not update the parser cache. Use updateParserCache() for that.
1903     *
1904     * @param array $options
1905     *   - recursive (bool, default true): whether to do a recursive update (update pages that
1906     *     depend on this page, e.g. transclude it). This will set the $recursive parameter of
1907     *     Content::getSecondaryDataUpdates. Typically this should be true unless the update
1908     *     was something that did not really change the page, such as a null edit.
1909     *   - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1910     *     user who created the revision)
1911     *   - causeAction: an arbitrary string identifying the reason for the update.
1912     *     See DataUpdate::getCauseAction(). (default 'unknown')
1913     *   - causeAgent: name of the user who caused the update (string, default 'unknown')
1914     *   - defer: one of the DeferredUpdates constants, or false to run immediately (default: false).
1915     *     Note that even when this is set to false, some updates might still get deferred (as
1916     *     some update might directly add child updates to DeferredUpdates).
1917     *   - known-revision-output: a combined canonical ParserOutput for the revision, perhaps
1918     *     from some cache. The caller is responsible for ensuring that the ParserOutput indeed
1919     *     matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
1920     *     for the time until caches have been changed to store RenderedRevision states instead
1921     *     of ParserOutput objects. (default: null) (since 1.33)
1922     * @since 1.32
1923     */
1924    public function doSecondaryDataUpdates( array $options = [] ) {
1925        $options['recursive'] ??= true;
1926        $revision = $this->getRevisionRecord();
1927        if ( !$revision || !$revision->getId() ) {
1928            LoggerFactory::getInstance( 'wikipage' )->info(
1929                __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
1930            );
1931            return;
1932        }
1933        $userIdentity = $revision->getUser( RevisionRecord::RAW );
1934
1935        $updater = $this->getDerivedDataUpdater( $userIdentity, $revision );
1936        $updater->prepareUpdate( $revision, $options );
1937        $updater->doSecondaryDataUpdates( $options );
1938    }
1939
1940    /**
1941     * Update the article's restriction field, and leave a log entry.
1942     * This works for protection both existing and non-existing pages.
1943     *
1944     * @param array $limit Set of restriction keys
1945     * @param array $expiry Per restriction type expiration
1946     * @param bool &$cascade Set to false if cascading protection isn't allowed.
1947     * @param string $reason
1948     * @param UserIdentity $user The user updating the restrictions
1949     * @param string[] $tags Change tags to add to the pages and protection log entries
1950     *   ($user should be able to add the specified tags before this is called)
1951     * @return Status<?int> Status object; if action is taken, $status->value is the log_id of the
1952     *   protection log entry.
1953     */
1954    public function doUpdateRestrictions( array $limit, array $expiry,
1955        &$cascade, $reason, UserIdentity $user, $tags = []
1956    ) {
1957        $services = MediaWikiServices::getInstance();
1958        $readOnlyMode = $services->getReadOnlyMode();
1959        if ( $readOnlyMode->isReadOnly() ) {
1960            return Status::newFatal( 'readonlytext', $readOnlyMode->getReason() );
1961        }
1962
1963        $this->loadPageData( IDBAccessObject::READ_LATEST );
1964        $restrictionStore = $services->getRestrictionStore();
1965        $restrictionStore->loadRestrictions( $this->mTitle, IDBAccessObject::READ_LATEST );
1966        $restrictionTypes = $restrictionStore->listApplicableRestrictionTypes( $this->mTitle );
1967        $id = $this->getId();
1968
1969        if ( !$cascade ) {
1970            $cascade = false;
1971        }
1972
1973        // Take this opportunity to purge out expired restrictions
1974        Title::purgeExpiredRestrictions();
1975
1976        // @todo: Same limitations as described in ProtectionForm.php (line 37);
1977        // we expect a single selection, but the schema allows otherwise.
1978        $isProtected = false;
1979        $protect = false;
1980        $changed = false;
1981
1982        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
1983        $restrictionMapBefore = [];
1984        $restrictionMapAfter = [];
1985
1986        foreach ( $restrictionTypes as $action ) {
1987            if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
1988                $expiry[$action] = 'infinity';
1989            }
1990
1991            // Get current restrictions on $action
1992            $restrictionMapBefore[$action] = $restrictionStore->getRestrictions( $this->mTitle, $action );
1993            $limit[$action] ??= '';
1994
1995            if ( $limit[$action] === '' ) {
1996                $restrictionMapAfter[$action] = [];
1997            } else {
1998                $protect = true;
1999                $restrictionMapAfter[$action] = explode( ',', $limit[$action] );
2000            }
2001
2002            $current = implode( ',', $restrictionMapBefore[$action] );
2003            if ( $current != '' ) {
2004                $isProtected = true;
2005            }
2006
2007            if ( $limit[$action] != $current ) {
2008                $changed = true;
2009            } elseif ( $limit[$action] != '' ) {
2010                // Only check expiry change if the action is actually being
2011                // protected, since expiry does nothing on an not-protected
2012                // action.
2013                if ( $restrictionStore->getRestrictionExpiry( $this->mTitle, $action ) != $expiry[$action] ) {
2014                    $changed = true;
2015                }
2016            }
2017        }
2018
2019        if ( !$changed && $protect && $restrictionStore->areRestrictionsCascading( $this->mTitle ) != $cascade ) {
2020            $changed = true;
2021        }
2022
2023        // If nothing has changed, do nothing
2024        if ( !$changed ) {
2025            return Status::newGood();
2026        }
2027
2028        if ( !$protect ) { // No protection at all means unprotection
2029            $revCommentMsg = 'unprotectedarticle-comment';
2030            $logAction = 'unprotect';
2031        } elseif ( $isProtected ) {
2032            $revCommentMsg = 'modifiedarticleprotection-comment';
2033            $logAction = 'modify';
2034        } else {
2035            $revCommentMsg = 'protectedarticle-comment';
2036            $logAction = 'protect';
2037        }
2038
2039        $logRelationsValues = [];
2040        $logRelationsField = null;
2041        $logParamsDetails = [];
2042
2043        // Null revision (used for change tag insertion)
2044        $dummyRevisionRecord = null;
2045
2046        $legacyUser = $services->getUserFactory()->newFromUserIdentity( $user );
2047        if ( !$this->getHookRunner()->onArticleProtect( $this, $legacyUser, $limit, $reason ) ) {
2048            return Status::newGood();
2049        }
2050
2051        if ( $id ) { // Protection of existing page
2052            // Only certain restrictions can cascade...
2053            $editrestriction = isset( $limit['edit'] )
2054                ? [ $limit['edit'] ]
2055                : $restrictionStore->getRestrictions( $this->mTitle, 'edit' );
2056            foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2057                $editrestriction[$key] = 'editprotected'; // backwards compatibility
2058            }
2059            foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2060                $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2061            }
2062
2063            $cascadingRestrictionLevels = $services->getMainConfig()
2064                ->get( MainConfigNames::CascadingRestrictionLevels );
2065
2066            foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2067                $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2068            }
2069            foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2070                $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2071            }
2072
2073            // The schema allows multiple restrictions
2074            if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2075                $cascade = false;
2076            }
2077
2078            // insert dummy revision to identify the page protection change as edit summary
2079            $dummyRevisionRecord = $this->insertNullProtectionRevision(
2080                $revCommentMsg,
2081                $limit,
2082                $expiry,
2083                $cascade,
2084                $reason,
2085                $user
2086            );
2087
2088            if ( $dummyRevisionRecord === null ) {
2089                return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2090            }
2091
2092            $logRelationsField = 'pr_id';
2093
2094            // T214035: Avoid deadlock on MySQL.
2095            // Do a DELETE by primary key (pr_id) for any existing protection rows.
2096            // On MySQL and derivatives, unconditionally deleting by page ID (pr_page) would.
2097            // place a gap lock if there are no matching rows. This can deadlock when another
2098            // thread modifies protection settings for page IDs in the same gap.
2099            $existingProtectionIds = $dbw->newSelectQueryBuilder()
2100                ->select( 'pr_id' )
2101                ->from( 'page_restrictions' )
2102                ->where( [ 'pr_page' => $id, 'pr_type' => array_map( 'strval', array_keys( $limit ) ) ] )
2103                ->caller( __METHOD__ )->fetchFieldValues();
2104
2105            if ( $existingProtectionIds ) {
2106                $dbw->newDeleteQueryBuilder()
2107                    ->deleteFrom( 'page_restrictions' )
2108                    ->where( [ 'pr_id' => $existingProtectionIds ] )
2109                    ->caller( __METHOD__ )->execute();
2110            }
2111
2112            // Update restrictions table
2113            foreach ( $limit as $action => $restrictions ) {
2114                if ( $restrictions != '' ) {
2115                    $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2116                    $dbw->newInsertQueryBuilder()
2117                        ->insertInto( 'page_restrictions' )
2118                        ->row( [
2119                            'pr_page' => $id,
2120                            'pr_type' => $action,
2121                            'pr_level' => $restrictions,
2122                            'pr_cascade' => $cascadeValue,
2123                            'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2124                        ] )
2125                        ->caller( __METHOD__ )->execute();
2126                    $logRelationsValues[] = $dbw->insertId();
2127                    $logParamsDetails[] = [
2128                        'type' => $action,
2129                        'level' => $restrictions,
2130                        'expiry' => $expiry[$action],
2131                        'cascade' => (bool)$cascadeValue,
2132                    ];
2133                }
2134            }
2135        } else { // Protection of non-existing page (also known as "title protection")
2136            // Cascade protection is meaningless in this case
2137            $cascade = false;
2138
2139            if ( $limit['create'] != '' ) {
2140                $commentFields = $services->getCommentStore()->insert( $dbw, 'pt_reason', $reason );
2141                $dbw->newReplaceQueryBuilder()
2142                    ->table( 'protected_titles' )
2143                    ->uniqueIndexFields( [ 'pt_namespace', 'pt_title' ] )
2144                    ->rows( [
2145                        'pt_namespace' => $this->mTitle->getNamespace(),
2146                        'pt_title' => $this->mTitle->getDBkey(),
2147                        'pt_create_perm' => $limit['create'],
2148                        'pt_timestamp' => $dbw->timestamp(),
2149                        'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2150                        'pt_user' => $user->getId(),
2151                    ] + $commentFields )
2152                    ->caller( __METHOD__ )->execute();
2153                $logParamsDetails[] = [
2154                    'type' => 'create',
2155                    'level' => $limit['create'],
2156                    'expiry' => $expiry['create'],
2157                ];
2158            } else {
2159                $dbw->newDeleteQueryBuilder()
2160                    ->deleteFrom( 'protected_titles' )
2161                    ->where( [
2162                        'pt_namespace' => $this->mTitle->getNamespace(),
2163                        'pt_title' => $this->mTitle->getDBkey()
2164                    ] )
2165                    ->caller( __METHOD__ )->execute();
2166            }
2167        }
2168
2169        $this->getHookRunner()->onArticleProtectComplete( $this, $legacyUser, $limit, $reason );
2170
2171        $restrictionStore->flushRestrictions( $this->mTitle );
2172
2173        InfoAction::invalidateCache( $this->mTitle );
2174
2175        if ( $logAction == 'unprotect' ) {
2176            $params = [];
2177        } else {
2178            $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2179            $params = [
2180                '4::description' => $protectDescriptionLog, // parameter for IRC
2181                '5:bool:cascade' => $cascade,
2182                'details' => $logParamsDetails, // parameter for localize and api
2183            ];
2184        }
2185
2186        // Update the protection log
2187        $logEntry = new ManualLogEntry( 'protect', $logAction );
2188        $logEntry->setTarget( $this->mTitle );
2189        $logEntry->setComment( $reason );
2190        $logEntry->setPerformer( $user );
2191        $logEntry->setParameters( $params );
2192        if ( $dummyRevisionRecord !== null ) {
2193            $logEntry->setAssociatedRevId( $dummyRevisionRecord->getId() );
2194        }
2195        $logEntry->addTags( $tags );
2196        if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2197            $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2198        }
2199        $logId = $logEntry->insert();
2200        $logEntry->publish( $logId );
2201
2202        $event = new PageProtectionChangedEvent(
2203            $this,
2204            $restrictionMapBefore,
2205            $restrictionMapAfter,
2206            $expiry,
2207            $cascade,
2208            $user,
2209            $reason,
2210            $tags
2211        );
2212
2213        $dispatcher = MediaWikiServices::getInstance()->getDomainEventDispatcher();
2214        $dispatcher->dispatch( $event, $services->getConnectionProvider() );
2215
2216        return Status::newGood( $logId );
2217    }
2218
2219    /**
2220     * Get the state of an ongoing update, shortly before or just after it is saved to the database.
2221     * If there is no ongoing edit tracked by this WikiPage instance, this methods throws a
2222     * PreconditionException.
2223     *
2224     * If possible, state is shared with subsequent calls of getPreparedUpdate(),
2225     * prepareContentForEdit(), and newPageUpdater().
2226     *
2227     * @note This method should generally be avoided, since it forces WikiPage to maintain state
2228     *       representing ongoing edits. Code that initiates an edit should use newPageUpdater()
2229     *       instead. Hooks that interact with the edit should have a the relevant
2230     *       information provided as a PageUpdater, PreparedUpdate, or RenderedRevision.
2231     *
2232     * @throws PreconditionException if there is no ongoing update. This method must only be
2233     *         called after newPageUpdater() had already been called, typically while executing
2234     *         a handler for a hook that is triggered during a page edit.
2235     * @return PreparedUpdate
2236     *
2237     * @since 1.38
2238     */
2239    public function getCurrentUpdate(): PreparedUpdate {
2240        Assert::precondition(
2241            $this->derivedDataUpdater !== null,
2242            'There is no ongoing update tracked by this instance of WikiPage!'
2243        );
2244
2245        return $this->derivedDataUpdater;
2246    }
2247
2248    /**
2249     * Insert a new dummy revision (aka null revision) for this page,
2250     * to mark a change in page protection.
2251     *
2252     * @since 1.35
2253     *
2254     * @param string $revCommentMsg Comment message key for the revision
2255     * @param array $limit Set of restriction keys
2256     * @param array $expiry Per restriction type expiration
2257     * @param bool $cascade Set to false if cascading protection isn't allowed.
2258     * @param string $reason
2259     * @param UserIdentity $user User to attribute to
2260     * @return RevisionRecord|null Null on error
2261     */
2262    public function insertNullProtectionRevision(
2263        string $revCommentMsg,
2264        array $limit,
2265        array $expiry,
2266        bool $cascade,
2267        string $reason,
2268        UserIdentity $user
2269    ): ?RevisionRecord {
2270        // Prepare a dummy revision to be added to the history
2271        $editComment = wfMessage(
2272            $revCommentMsg,
2273            $this->mTitle->getPrefixedText(),
2274            $user->getName()
2275        )->inContentLanguage()->text();
2276        if ( $reason ) {
2277            $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2278        }
2279        $protectDescription = $this->protectDescription( $limit, $expiry );
2280        if ( $protectDescription ) {
2281            $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2282            $editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2283                ->inContentLanguage()->text();
2284        }
2285        if ( $cascade ) {
2286            $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2287            $editComment .= wfMessage( 'brackets' )->params(
2288                wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2289            )->inContentLanguage()->text();
2290        }
2291
2292        return $this->newPageUpdater( $user )
2293            ->setCause( PageUpdater::CAUSE_PROTECTION_CHANGE )
2294            ->saveDummyRevision( $editComment, EDIT_SILENT | EDIT_MINOR );
2295    }
2296
2297    /**
2298     * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
2299     * @return string
2300     */
2301    protected function formatExpiry( $expiry ) {
2302        if ( $expiry != 'infinity' ) {
2303            $contLang = MediaWikiServices::getInstance()->getContentLanguage();
2304            return wfMessage(
2305                'protect-expiring',
2306                $contLang->timeanddate( $expiry, false, false ),
2307                $contLang->date( $expiry, false, false ),
2308                $contLang->time( $expiry, false, false )
2309            )->inContentLanguage()->text();
2310        } else {
2311            return wfMessage( 'protect-expiry-indefinite' )
2312                ->inContentLanguage()->text();
2313        }
2314    }
2315
2316    /**
2317     * Builds the description to serve as comment for the edit.
2318     *
2319     * @param array $limit Set of restriction keys
2320     * @param array $expiry Per restriction type expiration
2321     * @return string
2322     */
2323    public function protectDescription( array $limit, array $expiry ) {
2324        $protectDescription = '';
2325
2326        foreach ( array_filter( $limit ) as $action => $restrictions ) {
2327            # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2328            # All possible message keys are listed here for easier grepping:
2329            # * restriction-create
2330            # * restriction-edit
2331            # * restriction-move
2332            # * restriction-upload
2333            $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2334            # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2335            # with '' filtered out. All possible message keys are listed below:
2336            # * protect-level-autoconfirmed
2337            # * protect-level-sysop
2338            $restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2339                ->inContentLanguage()->text();
2340
2341            $expiryText = $this->formatExpiry( $expiry[$action] );
2342
2343            if ( $protectDescription !== '' ) {
2344                $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2345            }
2346            $protectDescription .= wfMessage( 'protect-summary-desc' )
2347                ->params( $actionText, $restrictionsText, $expiryText )
2348                ->inContentLanguage()->text();
2349        }
2350
2351        return $protectDescription;
2352    }
2353
2354    /**
2355     * Builds the description to serve as comment for the log entry.
2356     *
2357     * Some bots may parse IRC lines, which are generated from log entries which contain plain
2358     * protect description text. Keep them in old format to avoid breaking compatibility.
2359     * TODO: Fix protection log to store structured description and format it on-the-fly.
2360     *
2361     * @param array $limit Set of restriction keys
2362     * @param array $expiry Per restriction type expiration
2363     * @return string
2364     */
2365    public function protectDescriptionLog( array $limit, array $expiry ) {
2366        $protectDescriptionLog = '';
2367
2368        $dirMark = MediaWikiServices::getInstance()->getContentLanguage()->getDirMark();
2369        foreach ( array_filter( $limit ) as $action => $restrictions ) {
2370            $expiryText = $this->formatExpiry( $expiry[$action] );
2371            $protectDescriptionLog .=
2372                $dirMark .
2373                "[$action=$restrictions] ($expiryText)";
2374        }
2375
2376        return trim( $protectDescriptionLog );
2377    }
2378
2379    /**
2380     * Determines if deletion of this page would be batched (executed over time by the job queue)
2381     * or not (completed in the same request as the delete call).
2382     *
2383     * It is unlikely but possible that an edit from another request could push the page over the
2384     * batching threshold after this function is called, but before the caller acts upon the
2385     * return value.  Callers must decide for themselves how to deal with this.  $safetyMargin
2386     * is provided as an unreliable but situationally useful help for some common cases.
2387     *
2388     * @deprecated since 1.37 Use DeletePage::isBatchedDelete instead.
2389     *
2390     * @param int $safetyMargin Added to the revision count when checking for batching
2391     * @return bool True if deletion would be batched, false otherwise
2392     */
2393    public function isBatchedDelete( $safetyMargin = 0 ) {
2394        $deleteRevisionsBatchSize = MediaWikiServices::getInstance()
2395            ->getMainConfig()->get( MainConfigNames::DeleteRevisionsBatchSize );
2396
2397        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
2398        $revCount = $this->getRevisionStore()->countRevisionsByPageId( $dbr, $this->getId() );
2399        $revCount += $safetyMargin;
2400
2401        return $revCount >= $deleteRevisionsBatchSize;
2402    }
2403
2404    /**
2405     * Back-end article deletion
2406     * Deletes the article with database consistency, writes logs, purges caches
2407     *
2408     * @since 1.19
2409     * @since 1.35 Signature changed, user moved to second parameter to prepare for requiring
2410     *             a user to be passed
2411     * @since 1.36 User second parameter is required
2412     * @deprecated since 1.37 Use DeletePage instead. Calling ::deleteIfAllowed and letting DeletePage handle
2413     * permission checks is preferred over doing permission checks yourself and then calling ::deleteUnsafe.
2414     * Note that DeletePage returns a good status with false value in case of scheduled deletion, instead of
2415     * a status with a warning. Also, the new method doesn't have an $error parameter, since any error is
2416     * added to the returned Status.
2417     *
2418     * @param string $reason Delete reason for deletion log
2419     * @param UserIdentity $deleter The deleting user
2420     * @param bool $suppress Suppress all revisions and log the deletion in
2421     *   the suppression log instead of the deletion log
2422     * @param bool|null $u1 Unused
2423     * @param array|string &$error Array of errors to append to
2424     * @param mixed $u2 Unused
2425     * @param string[]|null $tags Tags to apply to the deletion action
2426     * @param string $logsubtype
2427     * @param bool $immediate false allows deleting over time via the job queue
2428     * @return Status<int> Status object; if successful, $status->value is the log_id of the
2429     *   deletion log entry. If the page couldn't be deleted because it wasn't
2430     *   found, $status is a non-fatal 'cannotdelete' error
2431     */
2432    public function doDeleteArticleReal(
2433        $reason, UserIdentity $deleter, $suppress = false, $u1 = null, &$error = '', $u2 = null,
2434        $tags = [], $logsubtype = 'delete', $immediate = false
2435    ) {
2436        $services = MediaWikiServices::getInstance();
2437        $deletePage = $services->getDeletePageFactory()->newDeletePage(
2438            $this,
2439            $services->getUserFactory()->newFromUserIdentity( $deleter )
2440        );
2441
2442        $status = $deletePage
2443            ->setSuppress( $suppress )
2444            ->setTags( $tags ?: [] )
2445            ->setLogSubtype( $logsubtype )
2446            ->forceImmediate( $immediate )
2447            ->keepLegacyHookErrorsSeparate()
2448            ->deleteUnsafe( $reason );
2449        $error = $deletePage->getLegacyHookErrors();
2450        if ( $status->isGood() ) {
2451            // BC with old return format
2452            if ( $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) {
2453                $status->warning( 'delete-scheduled', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2454            } else {
2455                // @phan-suppress-next-line PhanTypeMismatchProperty Changing the type of the status parameter
2456                $status->value = $deletePage->getSuccessfulDeletionsIDs()[DeletePage::PAGE_BASE];
2457            }
2458        }
2459        return $status;
2460    }
2461
2462    /**
2463     * Lock the page row for this title+id and return page_latest (or 0)
2464     *
2465     * @return int Returns 0 if no row was found with this title+id
2466     * @since 1.27
2467     */
2468    public function lockAndGetLatest() {
2469        $dbw = $this->getConnectionProvider()->getPrimaryDatabase();
2470        return (int)$dbw->newSelectQueryBuilder()
2471            ->select( 'page_latest' )
2472            ->forUpdate()
2473            ->from( 'page' )
2474            ->where( [
2475                'page_id' => $this->getId(),
2476                // Typically page_id is enough, but some code might try to do
2477                // updates assuming the title is the same, so verify that
2478                'page_namespace' => $this->getTitle()->getNamespace(),
2479                'page_title' => $this->getTitle()->getDBkey()
2480            ] )
2481            ->caller( __METHOD__ )->fetchField();
2482    }
2483
2484    /**
2485     * The onArticle*() functions are supposed to be a kind of hooks
2486     * which should be called whenever any of the specified actions
2487     * are done.
2488     *
2489     * This is a good place to put code to clear caches, for instance.
2490     *
2491     * This is called on page move and undelete, as well as edit
2492     *
2493     * @param Title $title
2494     * @param bool $maybeIsRedirect True if the page may have been created as a redirect.
2495     *   If false, this is used as a hint to skip some unnecessary updates.
2496     */
2497    public static function onArticleCreate( Title $title, $maybeIsRedirect = true ) {
2498        // TODO: move this into a PageEventEmitter service
2499
2500        // Update existence markers on article/talk tabs...
2501        $other = $title->getOtherPage();
2502
2503        $services = MediaWikiServices::getInstance();
2504        $hcu = $services->getHTMLCacheUpdater();
2505        $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2506
2507        $title->touchLinks();
2508        $services->getRestrictionStore()->deleteCreateProtection( $title );
2509
2510        $services->getLinkCache()->invalidateTitle( $title );
2511
2512        DeferredUpdates::addCallableUpdate(
2513            static function () use ( $title, $maybeIsRedirect ) {
2514                self::queueBacklinksJobs( $title, true, $maybeIsRedirect, 'create-page' );
2515            }
2516        );
2517
2518        if ( $title->getNamespace() === NS_CATEGORY ) {
2519            // Load the Category object, which will schedule a job to create
2520            // the category table row if necessary. Checking a replica DB is ok
2521            // here, in the worst case it'll run an unnecessary recount job on
2522            // a category that probably doesn't have many members.
2523            Category::newFromTitle( $title )->getID();
2524        }
2525    }
2526
2527    /**
2528     * Clears caches when article is deleted
2529     *
2530     * @internal for use by DeletePage and MovePage.
2531     * @todo pull this into DeletePage
2532     *
2533     * @param Title $title
2534     */
2535    public static function onArticleDelete( Title $title ) {
2536        // TODO: move this into a PageEventEmitter service
2537
2538        // Update existence markers on article/talk tabs...
2539        $other = $title->getOtherPage();
2540
2541        $hcu = MediaWikiServices::getInstance()->getHTMLCacheUpdater();
2542        $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2543
2544        $title->touchLinks();
2545
2546        $services = MediaWikiServices::getInstance();
2547        $services->getLinkCache()->invalidateTitle( $title );
2548
2549        InfoAction::invalidateCache( $title );
2550
2551        // Invalidate caches of articles which include this page
2552        DeferredUpdates::addCallableUpdate( static function () use ( $title ) {
2553            self::queueBacklinksJobs( $title, true, true, 'delete-page' );
2554        } );
2555
2556        // TODO: Move to ChangeTrackingEventIngress when ready,
2557        // but make sure it happens on deletions and page moves by adding
2558        // the appropriate assertions to ChangeTrackingEventIngressSpyTrait.
2559        // Messages
2560        // User talk pages
2561        if ( $title->getNamespace() === NS_USER_TALK ) {
2562            $user = User::newFromName( $title->getText(), false );
2563            if ( $user ) {
2564                MediaWikiServices::getInstance()
2565                    ->getTalkPageNotificationManager()
2566                    ->removeUserHasNewMessages( $user );
2567            }
2568        }
2569
2570        // TODO: Create MediaEventIngress and move this there.
2571        // Image redirects
2572        $services->getRepoGroup()->getLocalRepo()->invalidateImageRedirect( $title );
2573
2574        // Purge cross-wiki cache entities referencing this page
2575        self::purgeInterwikiCheckKey( $title );
2576    }
2577
2578    /**
2579     * Purge caches on page update etc
2580     *
2581     * @param Title $title
2582     * @param RevisionRecord|null $revRecord revision that was just saved, may be null
2583     * @param string[]|null $slotsChanged The role names of the slots that were changed.
2584     *        If not given, all slots are assumed to have changed.
2585     * @param bool $maybeRedirectChanged True if the page's redirect target may have changed in the
2586     *   latest revision. If false, this is used as a hint to skip some unnecessary updates.
2587     */
2588    public static function onArticleEdit(
2589        Title $title,
2590        ?RevisionRecord $revRecord = null,
2591        $slotsChanged = null,
2592        $maybeRedirectChanged = true
2593    ) {
2594        // TODO: move this into a PageEventEmitter service
2595
2596        DeferredUpdates::addCallableUpdate(
2597            static function () use ( $title, $slotsChanged, $maybeRedirectChanged ) {
2598                self::queueBacklinksJobs(
2599                    $title,
2600                    $slotsChanged === null || in_array( SlotRecord::MAIN, $slotsChanged ),
2601                    $maybeRedirectChanged,
2602                    'edit-page'
2603                );
2604            }
2605        );
2606
2607        $services = MediaWikiServices::getInstance();
2608        $services->getLinkCache()->invalidateTitle( $title );
2609
2610        $hcu = MediaWikiServices::getInstance()->getHTMLCacheUpdater();
2611        $hcu->purgeTitleUrls( $title, $hcu::PURGE_INTENT_TXROUND_REFLECTED );
2612
2613        // Purge ?action=info cache
2614        $revid = $revRecord ? $revRecord->getId() : null;
2615        DeferredUpdates::addCallableUpdate( static function () use ( $title, $revid ) {
2616            InfoAction::invalidateCache( $title, $revid );
2617        } );
2618
2619        // Purge cross-wiki cache entities referencing this page
2620        self::purgeInterwikiCheckKey( $title