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