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