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