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