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