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