Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
92.90% |
419 / 451 |
|
45.71% |
16 / 35 |
CRAP | |
0.00% |
0 / 1 |
ContributionsPager | |
92.90% |
419 / 451 |
|
45.71% |
16 / 35 |
134.94 | |
0.00% |
0 / 1 |
__construct | |
95.92% |
47 / 49 |
|
0.00% |
0 / 1 |
8 | |||
getDefaultQuery | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
reallyDoQuery | |
97.37% |
37 / 38 |
|
0.00% |
0 / 1 |
9 | |||
getRevisionQuery | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQueryInfo | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
12 | |||
getNamespaceCond | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
getTagFilter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTagInvert | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTarget | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isNewOnly | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespace | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doBatchLookups | |
76.92% |
30 / 39 |
|
0.00% |
0 / 1 |
11.23 | |||
getStartBody | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getEndBody | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getEmptyBody | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
tryCreatingRevisionRecord | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
8 | |||
createRevisionRecord | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
populateAttributes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
formatArticleLink | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
formatDiffHistLinks | |
98.33% |
59 / 60 |
|
0.00% |
0 / 1 |
7 | |||
formatDateLink | |
93.94% |
31 / 33 |
|
0.00% |
0 / 1 |
7.01 | |||
formatTopMarkText | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
7 | |||
formatCharDiff | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
formatComment | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
formatUserLink | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
formatFlags | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
formatVisibilityLink | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
formatTags | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
revisionUserIsDeleted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
formatRow | |
92.31% |
24 / 26 |
|
0.00% |
0 / 1 |
11.06 | |||
getTemplateParams | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
4 | |||
getProcessedTemplate | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getSqlComment | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
preventClickjacking | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setPreventClickjacking | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPreventClickjacking | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
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 | * @ingroup Pager |
20 | */ |
21 | |
22 | namespace MediaWiki\Pager; |
23 | |
24 | use ChangesList; |
25 | use ChangeTags; |
26 | use HtmlArmor; |
27 | use InvalidArgumentException; |
28 | use MapCacheLRU; |
29 | use MediaWiki\Cache\LinkBatchFactory; |
30 | use MediaWiki\CommentFormatter\CommentFormatter; |
31 | use MediaWiki\Context\IContextSource; |
32 | use MediaWiki\HookContainer\HookContainer; |
33 | use MediaWiki\HookContainer\HookRunner; |
34 | use MediaWiki\Html\Html; |
35 | use MediaWiki\Html\TemplateParser; |
36 | use MediaWiki\Linker\Linker; |
37 | use MediaWiki\Linker\LinkRenderer; |
38 | use MediaWiki\MainConfigNames; |
39 | use MediaWiki\MediaWikiServices; |
40 | use MediaWiki\Parser\Sanitizer; |
41 | use MediaWiki\Revision\RevisionRecord; |
42 | use MediaWiki\Revision\RevisionStore; |
43 | use MediaWiki\SpecialPage\SpecialPage; |
44 | use MediaWiki\Title\NamespaceInfo; |
45 | use MediaWiki\Title\Title; |
46 | use MediaWiki\User\UserFactory; |
47 | use MediaWiki\User\UserIdentity; |
48 | use MediaWiki\User\UserRigorOptions; |
49 | use stdClass; |
50 | use Wikimedia\Rdbms\FakeResultWrapper; |
51 | use Wikimedia\Rdbms\IResultWrapper; |
52 | |
53 | /** |
54 | * Pager for Special:Contributions |
55 | * @ingroup Pager |
56 | */ |
57 | abstract class ContributionsPager extends RangeChronologicalPager { |
58 | |
59 | /** @inheritDoc */ |
60 | public $mGroupByDate = true; |
61 | |
62 | /** |
63 | * @var string[] Local cache for escaped messages |
64 | */ |
65 | protected $messages; |
66 | |
67 | /** |
68 | * @var bool Get revisions from the archive table (if true) or the revision table (if false) |
69 | */ |
70 | protected $isArchive; |
71 | |
72 | /** |
73 | * @var bool Run hooks to allow extensions to modify the page |
74 | */ |
75 | protected $runHooks; |
76 | |
77 | /** |
78 | * @var string User name, or a string describing an IP address range |
79 | */ |
80 | protected $target; |
81 | |
82 | /** |
83 | * @var string|int A single namespace number, or an empty string for all namespaces |
84 | */ |
85 | private $namespace; |
86 | |
87 | /** |
88 | * @var string[]|false Name of tag to filter, or false to ignore tags |
89 | */ |
90 | private $tagFilter; |
91 | |
92 | /** |
93 | * @var bool Set to true to invert the tag selection |
94 | */ |
95 | private $tagInvert; |
96 | |
97 | /** |
98 | * @var bool Set to true to invert the namespace selection |
99 | */ |
100 | private $nsInvert; |
101 | |
102 | /** |
103 | * @var bool Set to true to show both the subject and talk namespace, no matter which got |
104 | * selected |
105 | */ |
106 | private $associated; |
107 | |
108 | /** |
109 | * @var bool Set to true to show only deleted revisions |
110 | */ |
111 | private $deletedOnly; |
112 | |
113 | /** |
114 | * @var bool Set to true to show only latest (a.k.a. current) revisions |
115 | */ |
116 | private $topOnly; |
117 | |
118 | /** |
119 | * @var bool Set to true to show only new pages |
120 | */ |
121 | private $newOnly; |
122 | |
123 | /** |
124 | * @var bool Set to true to hide edits marked as minor by the user |
125 | */ |
126 | private $hideMinor; |
127 | |
128 | /** |
129 | * @var bool Set to true to only include mediawiki revisions. |
130 | * (restricts extensions from executing additional queries to include their own contributions) |
131 | */ |
132 | private $revisionsOnly; |
133 | |
134 | /** @var bool */ |
135 | private $preventClickjacking = false; |
136 | |
137 | protected ?Title $currentPage; |
138 | protected ?RevisionRecord $currentRevRecord; |
139 | |
140 | /** |
141 | * @var array |
142 | */ |
143 | private $mParentLens; |
144 | |
145 | /** @var UserIdentity */ |
146 | protected $targetUser; |
147 | |
148 | /** |
149 | * Set to protected to allow subclasses access for overrides |
150 | */ |
151 | protected TemplateParser $templateParser; |
152 | |
153 | private CommentFormatter $commentFormatter; |
154 | private HookRunner $hookRunner; |
155 | private LinkBatchFactory $linkBatchFactory; |
156 | protected NamespaceInfo $namespaceInfo; |
157 | protected RevisionStore $revisionStore; |
158 | |
159 | /** @var string[] */ |
160 | private $formattedComments = []; |
161 | |
162 | /** @var RevisionRecord[] Cached revisions by ID */ |
163 | private $revisions = []; |
164 | |
165 | /** @var MapCacheLRU */ |
166 | private $tagsCache; |
167 | |
168 | /** |
169 | * Field names for various attributes. These may be overridden in a subclass, |
170 | * for example for getting revisions from the archive table. |
171 | */ |
172 | protected string $revisionIdField = 'rev_id'; |
173 | protected string $revisionParentIdField = 'rev_parent_id'; |
174 | protected string $revisionTimestampField = 'rev_timestamp'; |
175 | protected string $revisionLengthField = 'rev_len'; |
176 | protected string $revisionDeletedField = 'rev_deleted'; |
177 | protected string $revisionMinorField = 'rev_minor_edit'; |
178 | protected string $userNameField = 'rev_user_text'; |
179 | protected string $pageNamespaceField = 'page_namespace'; |
180 | protected string $pageTitleField = 'page_title'; |
181 | |
182 | /** |
183 | * @param LinkRenderer $linkRenderer |
184 | * @param LinkBatchFactory $linkBatchFactory |
185 | * @param HookContainer $hookContainer |
186 | * @param RevisionStore $revisionStore |
187 | * @param NamespaceInfo $namespaceInfo |
188 | * @param CommentFormatter $commentFormatter |
189 | * @param UserFactory $userFactory |
190 | * @param IContextSource $context |
191 | * @param array $options |
192 | * @param UserIdentity|null $targetUser |
193 | */ |
194 | public function __construct( |
195 | LinkRenderer $linkRenderer, |
196 | LinkBatchFactory $linkBatchFactory, |
197 | HookContainer $hookContainer, |
198 | RevisionStore $revisionStore, |
199 | NamespaceInfo $namespaceInfo, |
200 | CommentFormatter $commentFormatter, |
201 | UserFactory $userFactory, |
202 | IContextSource $context, |
203 | array $options, |
204 | ?UserIdentity $targetUser |
205 | ) { |
206 | $this->isArchive = $options['isArchive'] ?? false; |
207 | $this->runHooks = $options['runHooks'] ?? true; |
208 | |
209 | // Set ->target before calling parent::__construct() so |
210 | // parent can call $this->getIndexField() and get the right result. Set |
211 | // the rest too just to keep things simple. |
212 | if ( $targetUser ) { |
213 | $this->target = $options['target'] ?? $targetUser->getName(); |
214 | $this->targetUser = $targetUser; |
215 | } else { |
216 | // Use target option |
217 | // It's possible for the target to be empty. This is used by |
218 | // ContribsPagerTest and does not cause newFromName() to return |
219 | // false. It's probably not used by any production code. |
220 | $this->target = $options['target'] ?? ''; |
221 | // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null |
222 | $this->targetUser = $userFactory->newFromName( |
223 | $this->target, UserRigorOptions::RIGOR_NONE |
224 | ); |
225 | if ( !$this->targetUser ) { |
226 | // This can happen if the target contained "#". Callers |
227 | // typically pass user input through title normalization to |
228 | // avoid it. |
229 | throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' . |
230 | 'broken to use even with validation disabled.' ); |
231 | } |
232 | } |
233 | |
234 | $this->namespace = $options['namespace'] ?? ''; |
235 | $this->tagFilter = $options['tagfilter'] ?? false; |
236 | $this->tagInvert = $options['tagInvert'] ?? false; |
237 | $this->nsInvert = $options['nsInvert'] ?? false; |
238 | $this->associated = $options['associated'] ?? false; |
239 | |
240 | $this->deletedOnly = !empty( $options['deletedOnly'] ); |
241 | $this->topOnly = !empty( $options['topOnly'] ); |
242 | $this->newOnly = !empty( $options['newOnly'] ); |
243 | $this->hideMinor = !empty( $options['hideMinor'] ); |
244 | $this->revisionsOnly = !empty( $options['revisionsOnly'] ); |
245 | |
246 | parent::__construct( $context, $linkRenderer ); |
247 | |
248 | $msgs = [ |
249 | 'diff', |
250 | 'hist', |
251 | 'pipe-separator', |
252 | 'uctop', |
253 | 'changeslist-nocomment', |
254 | 'undeleteviewlink', |
255 | 'undeleteviewlink', |
256 | 'deletionlog', |
257 | ]; |
258 | |
259 | foreach ( $msgs as $msg ) { |
260 | $this->messages[$msg] = $this->msg( $msg )->escaped(); |
261 | } |
262 | |
263 | // Date filtering: use timestamp if available |
264 | $startTimestamp = ''; |
265 | $endTimestamp = ''; |
266 | if ( isset( $options['start'] ) && $options['start'] ) { |
267 | $startTimestamp = $options['start'] . ' 00:00:00'; |
268 | } |
269 | if ( isset( $options['end'] ) && $options['end'] ) { |
270 | $endTimestamp = $options['end'] . ' 23:59:59'; |
271 | } |
272 | $this->getDateRangeCond( $startTimestamp, $endTimestamp ); |
273 | |
274 | $this->templateParser = new TemplateParser(); |
275 | $this->linkBatchFactory = $linkBatchFactory; |
276 | $this->hookRunner = new HookRunner( $hookContainer ); |
277 | $this->revisionStore = $revisionStore; |
278 | $this->namespaceInfo = $namespaceInfo; |
279 | $this->commentFormatter = $commentFormatter; |
280 | $this->tagsCache = new MapCacheLRU( 50 ); |
281 | } |
282 | |
283 | public function getDefaultQuery() { |
284 | $query = parent::getDefaultQuery(); |
285 | $query['target'] = $this->target; |
286 | |
287 | return $query; |
288 | } |
289 | |
290 | /** |
291 | * This method basically executes the exact same code as the parent class, though with |
292 | * a hook added, to allow extensions to add additional queries. |
293 | * |
294 | * @param string $offset Index offset, inclusive |
295 | * @param int $limit Exact query limit |
296 | * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING |
297 | * @return IResultWrapper |
298 | */ |
299 | public function reallyDoQuery( $offset, $limit, $order ) { |
300 | [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo( |
301 | $offset, |
302 | $limit, |
303 | $order |
304 | ); |
305 | |
306 | $options['MAX_EXECUTION_TIME'] = |
307 | $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ); |
308 | /* |
309 | * This hook will allow extensions to add in additional queries, so they can get their data |
310 | * in My Contributions as well. Extensions should append their results to the $data array. |
311 | * |
312 | * Extension queries have to implement the navbar requirement as well. They should |
313 | * - have a column aliased as $pager->getIndexField() |
314 | * - have LIMIT set |
315 | * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset |
316 | * - have the ORDER BY specified based upon the details provided by the navbar |
317 | * |
318 | * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY |
319 | * |
320 | * &$data: an array of results of all contribs queries |
321 | * $pager: the ContribsPager object hooked into |
322 | * $offset: see phpdoc above |
323 | * $limit: see phpdoc above |
324 | * $descending: see phpdoc above |
325 | */ |
326 | $dbr = $this->getDatabase(); |
327 | $data = [ $dbr->newSelectQueryBuilder() |
328 | ->tables( is_array( $tables ) ? $tables : [ $tables ] ) |
329 | ->fields( $fields ) |
330 | ->conds( $conds ) |
331 | ->caller( $fname ) |
332 | ->options( $options ) |
333 | ->joinConds( $join_conds ) |
334 | ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) ) |
335 | ->fetchResultSet() ]; |
336 | if ( !$this->revisionsOnly && $this->runHooks ) { |
337 | // These hooks were moved from ContribsPager and DeletedContribsPager. For backwards |
338 | // compatability, they keep the same names. But they should be run for any contributions |
339 | // pager, otherwise the entries from extensions would be missing. |
340 | $reallyDoQueryHook = $this->isArchive ? |
341 | 'onDeletedContribsPager__reallyDoQuery' : |
342 | 'onContribsPager__reallyDoQuery'; |
343 | // TODO: Range offsets are fairly important and all handlers should take care of it. |
344 | // If this hook will be replaced (e.g. unified with the DeletedContribsPager one), |
345 | // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577). |
346 | $this->hookRunner->$reallyDoQueryHook( $data, $this, $offset, $limit, $order ); |
347 | } |
348 | |
349 | $result = []; |
350 | |
351 | // loop all results and collect them in an array |
352 | foreach ( $data as $query ) { |
353 | foreach ( $query as $i => $row ) { |
354 | // If the query results are in descending order, the indexes must also be in descending order |
355 | $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i; |
356 | // Left-pad with zeroes, because these values will be sorted as strings |
357 | $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT ); |
358 | // use index column as key, allowing us to easily sort in PHP |
359 | $indexFieldValues = array_map( |
360 | static fn ( $fieldName ) => $row->$fieldName, |
361 | (array)$this->mIndexField |
362 | ); |
363 | $result[implode( '-', $indexFieldValues ) . "-$index"] = $row; |
364 | } |
365 | } |
366 | |
367 | // sort results |
368 | if ( $order === self::QUERY_ASCENDING ) { |
369 | ksort( $result ); |
370 | } else { |
371 | krsort( $result ); |
372 | } |
373 | |
374 | // enforce limit |
375 | $result = array_slice( $result, 0, $limit ); |
376 | |
377 | // get rid of array keys |
378 | $result = array_values( $result ); |
379 | |
380 | return new FakeResultWrapper( $result ); |
381 | } |
382 | |
383 | /** |
384 | * Get queryInfo for the main query selecting revisions, not including |
385 | * filtering on namespace, date, etc. |
386 | * |
387 | * @return array |
388 | */ |
389 | abstract protected function getRevisionQuery(); |
390 | |
391 | public function getQueryInfo() { |
392 | $queryInfo = $this->getRevisionQuery(); |
393 | |
394 | if ( $this->deletedOnly ) { |
395 | $queryInfo['conds'][] = $this->revisionDeletedField . ' != 0'; |
396 | } |
397 | |
398 | if ( !$this->isArchive && $this->topOnly ) { |
399 | $queryInfo['conds'][] = $this->revisionIdField . ' = page_latest'; |
400 | } |
401 | |
402 | if ( $this->newOnly ) { |
403 | $queryInfo['conds'][] = $this->revisionParentIdField . ' = 0'; |
404 | } |
405 | |
406 | if ( $this->hideMinor ) { |
407 | $queryInfo['conds'][] = $this->revisionMinorField . ' = 0'; |
408 | } |
409 | |
410 | $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() ); |
411 | |
412 | // Paranoia: avoid brute force searches (T19342) |
413 | $dbr = $this->getDatabase(); |
414 | if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) { |
415 | $queryInfo['conds'][] = $dbr->bitAnd( |
416 | $this->revisionDeletedField, RevisionRecord::DELETED_USER |
417 | ) . ' = 0'; |
418 | } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { |
419 | $queryInfo['conds'][] = $dbr->bitAnd( |
420 | $this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER |
421 | ) . ' != ' . RevisionRecord::SUPPRESSED_USER; |
422 | } |
423 | |
424 | // Index fields must be present in the result rows, as reallyDoQuery() tries to access them. |
425 | $indexFields = array_diff( |
426 | (array)$this->mIndexField, |
427 | $queryInfo['fields'] |
428 | ); |
429 | |
430 | foreach ( $indexFields as $indexField ) { |
431 | // Skip if already added as an alias |
432 | if ( !array_key_exists( $indexField, $queryInfo['fields'] ) ) { |
433 | $queryInfo['fields'][] = $indexField; |
434 | } |
435 | } |
436 | |
437 | MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQuery( |
438 | $queryInfo['tables'], |
439 | $queryInfo['fields'], |
440 | $queryInfo['conds'], |
441 | $queryInfo['join_conds'], |
442 | $queryInfo['options'], |
443 | $this->tagFilter, |
444 | $this->tagInvert, |
445 | ); |
446 | |
447 | if ( !$this->isArchive && $this->runHooks ) { |
448 | $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo ); |
449 | } |
450 | |
451 | return $queryInfo; |
452 | } |
453 | |
454 | protected function getNamespaceCond() { |
455 | if ( $this->namespace !== '' ) { |
456 | $dbr = $this->getDatabase(); |
457 | $namespaces = [ $this->namespace ]; |
458 | $eq_op = $this->nsInvert ? '!=' : '='; |
459 | if ( $this->associated ) { |
460 | $namespaces[] = $this->namespaceInfo->getAssociated( $this->namespace ); |
461 | } |
462 | return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ]; |
463 | } |
464 | |
465 | return []; |
466 | } |
467 | |
468 | /** |
469 | * @return false|string[] |
470 | */ |
471 | public function getTagFilter() { |
472 | return $this->tagFilter; |
473 | } |
474 | |
475 | /** |
476 | * @return bool |
477 | */ |
478 | public function getTagInvert() { |
479 | return $this->tagInvert; |
480 | } |
481 | |
482 | /** |
483 | * @return string |
484 | */ |
485 | public function getTarget() { |
486 | return $this->target; |
487 | } |
488 | |
489 | /** |
490 | * @return bool |
491 | */ |
492 | public function isNewOnly() { |
493 | return $this->newOnly; |
494 | } |
495 | |
496 | /** |
497 | * @return int|string |
498 | */ |
499 | public function getNamespace() { |
500 | return $this->namespace; |
501 | } |
502 | |
503 | protected function doBatchLookups() { |
504 | # Do a link batch query |
505 | $this->mResult->seek( 0 ); |
506 | $parentRevIds = []; |
507 | $this->mParentLens = []; |
508 | $revisions = []; |
509 | $linkBatch = $this->linkBatchFactory->newLinkBatch(); |
510 | # Give some pointers to make (last) links |
511 | foreach ( $this->mResult as $row ) { |
512 | $revisionRecord = $this->tryCreatingRevisionRecord( $row ); |
513 | if ( !$revisionRecord ) { |
514 | continue; |
515 | } |
516 | if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) { |
517 | $parentRevIds[] = (int)$row->{$this->revisionParentIdField}; |
518 | } |
519 | $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField}; |
520 | if ( $this->target !== $row->{$this->userNameField} ) { |
521 | // If the target does not match the author, batch the author's talk page |
522 | $linkBatch->add( NS_USER_TALK, $row->{$this->userNameField} ); |
523 | } |
524 | $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} ); |
525 | $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row ); |
526 | } |
527 | // Fetch rev_len/ar_len for revisions not already scanned above |
528 | // TODO: is it possible to make this fully abstract? |
529 | if ( $this->isArchive ) { |
530 | $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) ); |
531 | if ( $parentRevIds ) { |
532 | $result = $this->revisionStore |
533 | ->newArchiveSelectQueryBuilder( $this->getDatabase() ) |
534 | ->clearFields() |
535 | ->fields( [ $this->revisionIdField, $this->revisionLengthField ] ) |
536 | ->where( [ $this->revisionIdField => $parentRevIds ] ) |
537 | ->caller( __METHOD__ ) |
538 | ->fetchResultSet(); |
539 | foreach ( $result as $row ) { |
540 | $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField}; |
541 | } |
542 | } |
543 | } |
544 | $this->mParentLens += $this->revisionStore->getRevisionSizes( |
545 | array_diff( $parentRevIds, array_keys( $this->mParentLens ) ) |
546 | ); |
547 | $linkBatch->execute(); |
548 | |
549 | $revisionBatch = $this->commentFormatter->createRevisionBatch() |
550 | ->authority( $this->getAuthority() ) |
551 | ->revisions( $revisions ); |
552 | |
553 | if ( !$this->isArchive ) { |
554 | // Only show public comments, because this page might be public |
555 | $revisionBatch = $revisionBatch->hideIfDeleted(); |
556 | } |
557 | |
558 | $this->formattedComments = $revisionBatch->execute(); |
559 | |
560 | # For performance, save the revision objects for later. |
561 | # The array is indexed by rev_id. doBatchLookups() may be called |
562 | # multiple times with different results, so merge the revisions array, |
563 | # ignoring any duplicates. |
564 | $this->revisions += $revisions; |
565 | } |
566 | |
567 | /** |
568 | * @inheritDoc |
569 | */ |
570 | protected function getStartBody() { |
571 | return "<section class='mw-pager-body'>\n"; |
572 | } |
573 | |
574 | /** |
575 | * @inheritDoc |
576 | */ |
577 | protected function getEndBody() { |
578 | return "</section>\n"; |
579 | } |
580 | |
581 | /** |
582 | * @inheritDoc |
583 | */ |
584 | protected function getEmptyBody() { |
585 | return $this->msg( 'nocontribs' )->parse(); |
586 | } |
587 | |
588 | /** |
589 | * If the object looks like a revision row, or corresponds to a previously |
590 | * cached revision, return the RevisionRecord. Otherwise, return null. |
591 | * |
592 | * @since 1.35 |
593 | * |
594 | * @param mixed $row |
595 | * @param Title|null $title |
596 | * @return RevisionRecord|null |
597 | */ |
598 | public function tryCreatingRevisionRecord( $row, $title = null ) { |
599 | if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} ) |
600 | && isset( $this->revisions[$row->{$this->revisionIdField}] ) |
601 | ) { |
602 | return $this->revisions[$row->{$this->revisionIdField}]; |
603 | } |
604 | |
605 | if ( |
606 | $this->isArchive && |
607 | $this->revisionStore->isRevisionRow( $row, 'archive' ) |
608 | ) { |
609 | return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title ); |
610 | } |
611 | |
612 | if ( |
613 | !$this->isArchive && |
614 | $this->revisionStore->isRevisionRow( $row ) |
615 | ) { |
616 | return $this->revisionStore->newRevisionFromRow( $row, 0, $title ); |
617 | } |
618 | |
619 | return null; |
620 | } |
621 | |
622 | /** |
623 | * Create a revision record from a $row that models a revision. |
624 | * |
625 | * @param mixed $row |
626 | * @param Title|null $title |
627 | * @return RevisionRecord |
628 | */ |
629 | public function createRevisionRecord( $row, $title = null ) { |
630 | if ( $this->isArchive ) { |
631 | return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title ); |
632 | } |
633 | |
634 | return $this->revisionStore->newRevisionFromRow( $row, 0, $title ); |
635 | } |
636 | |
637 | /** |
638 | * Populate the HTML attributes. |
639 | * |
640 | * @param mixed $row |
641 | * @param string[] &$attributes |
642 | */ |
643 | protected function populateAttributes( $row, &$attributes ) { |
644 | $attributes['data-mw-revid'] = $this->currentRevRecord->getId(); |
645 | } |
646 | |
647 | /** |
648 | * Format a link to an article. |
649 | * |
650 | * @param mixed $row |
651 | * @return string |
652 | */ |
653 | protected function formatArticleLink( $row ) { |
654 | if ( !$this->currentPage ) { |
655 | return ''; |
656 | } |
657 | $dir = $this->getLanguage()->getDir(); |
658 | return Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink( |
659 | $this->currentPage, |
660 | $this->currentPage->getPrefixedText(), |
661 | [ 'class' => 'mw-contributions-title' ], |
662 | $this->currentPage->isRedirect() ? [ 'redirect' => 'no' ] : [] |
663 | ) ); |
664 | } |
665 | |
666 | /** |
667 | * Format diff and history links. |
668 | * |
669 | * @param mixed $row |
670 | * @return string |
671 | */ |
672 | protected function formatDiffHistLinks( $row ) { |
673 | if ( !$this->currentPage || !$this->currentRevRecord ) { |
674 | return ''; |
675 | } |
676 | if ( $this->isArchive ) { |
677 | // Add the same links as DeletedContribsPager::formatRevisionRow |
678 | $undelete = SpecialPage::getTitleFor( 'Undelete' ); |
679 | if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) { |
680 | $last = $this->getLinkRenderer()->makeKnownLink( |
681 | $undelete, |
682 | new HtmlArmor( $this->messages['diff'] ), |
683 | [], |
684 | [ |
685 | 'target' => $this->currentPage->getPrefixedText(), |
686 | 'timestamp' => $this->currentRevRecord->getTimestamp(), |
687 | 'diff' => 'prev' |
688 | ] |
689 | ); |
690 | } else { |
691 | $last = $this->messages['diff']; |
692 | } |
693 | |
694 | $logs = SpecialPage::getTitleFor( 'Log' ); |
695 | $dellog = $this->getLinkRenderer()->makeKnownLink( |
696 | $logs, |
697 | new HtmlArmor( $this->messages['deletionlog'] ), |
698 | [], |
699 | [ |
700 | 'type' => 'delete', |
701 | 'page' => $this->currentPage->getPrefixedText() |
702 | ] |
703 | ); |
704 | |
705 | $reviewlink = $this->getLinkRenderer()->makeKnownLink( |
706 | SpecialPage::getTitleFor( 'Undelete', $this->currentPage->getPrefixedDBkey() ), |
707 | new HtmlArmor( $this->messages['undeleteviewlink'] ) |
708 | ); |
709 | |
710 | return Html::rawElement( |
711 | 'span', |
712 | [ 'class' => 'mw-deletedcontribs-tools' ], |
713 | $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList( |
714 | [ $last, $dellog, $reviewlink ] ) )->escaped() |
715 | ); |
716 | } else { |
717 | # Is there a visible previous revision? |
718 | if ( $this->currentRevRecord->getParentId() !== 0 && |
719 | $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) |
720 | ) { |
721 | $difftext = $this->getLinkRenderer()->makeKnownLink( |
722 | $this->currentPage, |
723 | new HtmlArmor( $this->messages['diff'] ), |
724 | [ 'class' => 'mw-changeslist-diff' ], |
725 | [ |
726 | 'diff' => 'prev', |
727 | 'oldid' => $row->{$this->revisionIdField}, |
728 | ] |
729 | ); |
730 | } else { |
731 | $difftext = $this->messages['diff']; |
732 | } |
733 | $histlink = $this->getLinkRenderer()->makeKnownLink( |
734 | $this->currentPage, |
735 | new HtmlArmor( $this->messages['hist'] ), |
736 | [ 'class' => 'mw-changeslist-history' ], |
737 | [ 'action' => 'history' ] |
738 | ); |
739 | |
740 | // While it might be tempting to use a list here |
741 | // this would result in clutter and slows down navigating the content |
742 | // in assistive technology. |
743 | // See https://phabricator.wikimedia.org/T205581#4734812 |
744 | return Html::rawElement( 'span', |
745 | [ 'class' => 'mw-changeslist-links' ], |
746 | // The spans are needed to ensure the dividing '|' elements are not |
747 | // themselves styled as links. |
748 | Html::rawElement( 'span', [], $difftext ) . |
749 | ' ' . // Space needed for separating two words. |
750 | Html::rawElement( 'span', [], $histlink ) |
751 | ); |
752 | } |
753 | } |
754 | |
755 | /** |
756 | * Format a date link. |
757 | * |
758 | * @param mixed $row |
759 | * @return string |
760 | */ |
761 | protected function formatDateLink( $row ) { |
762 | if ( !$this->currentPage || !$this->currentRevRecord ) { |
763 | return ''; |
764 | } |
765 | if ( $this->isArchive ) { |
766 | $date = $this->getLanguage()->userTimeAndDate( |
767 | $this->currentRevRecord->getTimestamp(), |
768 | $this->getUser() |
769 | ); |
770 | |
771 | if ( $this->getAuthority()->isAllowed( 'undelete' ) && |
772 | $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) |
773 | ) { |
774 | $dateLink = $this->getLinkRenderer()->makeKnownLink( |
775 | SpecialPage::getTitleFor( 'Undelete' ), |
776 | $date, |
777 | [ 'class' => 'mw-changeslist-date' ], |
778 | [ |
779 | 'target' => $this->currentPage->getPrefixedText(), |
780 | 'timestamp' => $this->currentRevRecord->getTimestamp() |
781 | ] |
782 | ); |
783 | } else { |
784 | $dateLink = htmlspecialchars( $date ); |
785 | } |
786 | if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { |
787 | $class = Linker::getRevisionDeletedClass( $this->currentRevRecord ); |
788 | $dateLink = Html::rawElement( |
789 | 'span', |
790 | [ 'class' => $class ], |
791 | $dateLink |
792 | ); |
793 | } |
794 | } else { |
795 | $dateLink = ChangesList::revDateLink( |
796 | $this->currentRevRecord, |
797 | $this->getAuthority(), |
798 | $this->getLanguage(), |
799 | $this->currentPage |
800 | ); |
801 | } |
802 | return $dateLink; |
803 | } |
804 | |
805 | /** |
806 | * Format annotation and add extra class if a row represents a latest revision. |
807 | * |
808 | * @param mixed $row |
809 | * @param string[] &$classes |
810 | * @return string |
811 | */ |
812 | protected function formatTopMarkText( $row, &$classes ) { |
813 | if ( !$this->currentPage || !$this->currentRevRecord ) { |
814 | return ''; |
815 | } |
816 | $topmarktext = ''; |
817 | if ( !$this->isArchive ) { |
818 | $pagerTools = new PagerTools( |
819 | $this->currentRevRecord, |
820 | null, |
821 | $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new, |
822 | $this->hookRunner, |
823 | $this->currentPage, |
824 | $this->getContext(), |
825 | $this->getLinkRenderer() |
826 | ); |
827 | if ( $row->{$this->revisionIdField} === $row->page_latest ) { |
828 | $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>'; |
829 | $classes[] = 'mw-contributions-current'; |
830 | } |
831 | if ( $pagerTools->shouldPreventClickjacking() ) { |
832 | $this->setPreventClickjacking( true ); |
833 | } |
834 | $topmarktext .= $pagerTools->toHTML(); |
835 | } |
836 | return $topmarktext; |
837 | } |
838 | |
839 | /** |
840 | * Format annotation to show the size of a diff. |
841 | * |
842 | * @param mixed $row |
843 | * @return string |
844 | */ |
845 | protected function formatCharDiff( $row ) { |
846 | if ( $row->{$this->revisionParentIdField} === null ) { |
847 | // For some reason rev_parent_id isn't populated for this row. |
848 | // Its rumoured this is true on wikipedia for some revisions (T36922). |
849 | // Next best thing is to have the total number of bytes. |
850 | $chardiff = ' <span class="mw-changeslist-separator"></span> '; |
851 | $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} ); |
852 | $chardiff .= ' <span class="mw-changeslist-separator"></span> '; |
853 | } else { |
854 | $parentLen = 0; |
855 | if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) { |
856 | $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}]; |
857 | } |
858 | |
859 | $chardiff = ' <span class="mw-changeslist-separator"></span> '; |
860 | $chardiff .= ChangesList::showCharacterDifference( |
861 | $parentLen, |
862 | $row->{$this->revisionLengthField}, |
863 | $this->getContext() |
864 | ); |
865 | $chardiff .= ' <span class="mw-changeslist-separator"></span> '; |
866 | } |
867 | return $chardiff; |
868 | } |
869 | |
870 | /** |
871 | * Format a comment for a revision. |
872 | * |
873 | * @param mixed $row |
874 | * @return string |
875 | */ |
876 | protected function formatComment( $row ) { |
877 | $comment = $this->formattedComments[$row->{$this->revisionIdField}]; |
878 | |
879 | if ( $comment === '' ) { |
880 | $defaultComment = $this->messages['changeslist-nocomment']; |
881 | $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>"; |
882 | } |
883 | |
884 | // Don't wrap result of this with <bdi> or any other element, see T377555 |
885 | return $comment; |
886 | } |
887 | |
888 | /** |
889 | * Format a user link. |
890 | * |
891 | * @param mixed $row |
892 | * @return string |
893 | */ |
894 | protected function formatUserLink( $row ) { |
895 | if ( !$this->currentRevRecord ) { |
896 | return ''; |
897 | } |
898 | $dir = $this->getLanguage()->getDir(); |
899 | |
900 | // When the author is different from the target, always show user and user talk links |
901 | $userlink = ''; |
902 | $revUser = $this->currentRevRecord->getUser(); |
903 | $revUserId = $revUser ? $revUser->getId() : 0; |
904 | $revUserText = $revUser ? $revUser->getName() : ''; |
905 | if ( $this->target !== $revUserText ) { |
906 | $userlink = ' <span class="mw-changeslist-separator"></span> ' |
907 | . Html::rawElement( 'bdi', [ 'dir' => $dir ], |
908 | Linker::userLink( $revUserId, $revUserText ) ); |
909 | $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams( |
910 | Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' '; |
911 | } |
912 | return $userlink; |
913 | } |
914 | |
915 | /** |
916 | * @param mixed $row |
917 | * @return string[] |
918 | */ |
919 | protected function formatFlags( $row ) { |
920 | if ( !$this->currentRevRecord ) { |
921 | return []; |
922 | } |
923 | $flags = []; |
924 | if ( $this->currentRevRecord->getParentId() === 0 ) { |
925 | $flags[] = ChangesList::flag( 'newpage' ); |
926 | } |
927 | |
928 | if ( $this->currentRevRecord->isMinor() ) { |
929 | $flags[] = ChangesList::flag( 'minor' ); |
930 | } |
931 | return $flags; |
932 | } |
933 | |
934 | /** |
935 | * Format link for changing visibility. |
936 | * |
937 | * @param mixed $row |
938 | * @return string |
939 | */ |
940 | protected function formatVisibilityLink( $row ) { |
941 | if ( !$this->currentPage || !$this->currentRevRecord ) { |
942 | return ''; |
943 | } |
944 | $del = Linker::getRevDeleteLink( |
945 | $this->getAuthority(), |
946 | $this->currentRevRecord, |
947 | $this->currentPage |
948 | ); |
949 | if ( $del !== '' ) { |
950 | $del .= ' '; |
951 | } |
952 | return $del; |
953 | } |
954 | |
955 | /** |
956 | * @param mixed $row |
957 | * @param string[] &$classes |
958 | * @return string |
959 | */ |
960 | protected function formatTags( $row, &$classes ) { |
961 | # Tags, if any. Save some time using a cache. |
962 | [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback( |
963 | $this->tagsCache->makeKey( |
964 | $row->ts_tags ?? '', |
965 | $this->getUser()->getName(), |
966 | $this->getLanguage()->getCode() |
967 | ), |
968 | fn () => ChangeTags::formatSummaryRow( |
969 | $row->ts_tags, |
970 | null, |
971 | $this->getContext() |
972 | ) |
973 | ); |
974 | $classes = array_merge( $classes, $newClasses ); |
975 | return $tagSummary; |
976 | } |
977 | |
978 | /** |
979 | * Check whether the revision author is deleted |
980 | * |
981 | * @param mixed $row |
982 | * @return bool |
983 | */ |
984 | public function revisionUserIsDeleted( $row ) { |
985 | return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER ); |
986 | } |
987 | |
988 | /** |
989 | * Generates each row in the contributions list. |
990 | * |
991 | * Contributions which are marked "top" are currently on top of the history. |
992 | * For these contributions, a [rollback] link is shown for users with roll- |
993 | * back privileges. The rollback link restores the most recent version that |
994 | * was not written by the target user. |
995 | * |
996 | * @todo This would probably look a lot nicer in a table. |
997 | * @param stdClass|mixed $row |
998 | * @return string |
999 | */ |
1000 | public function formatRow( $row ) { |
1001 | $ret = ''; |
1002 | $classes = []; |
1003 | $attribs = []; |
1004 | |
1005 | $this->currentPage = null; |
1006 | $this->currentRevRecord = null; |
1007 | |
1008 | // Create a title for the revision if possible |
1009 | // Rows from the hook may not include title information |
1010 | if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) { |
1011 | $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} ); |
1012 | } |
1013 | |
1014 | // Flow overrides the ContribsPager::reallyDoQuery hook, causing this |
1015 | // function to be called with a special object for $row. It expects us |
1016 | // skip formatting so that the row can be formatted by the |
1017 | // ContributionsLineEnding hook below. |
1018 | // FIXME: have some better way for extensions to provide formatted rows. |
1019 | $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage ); |
1020 | if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) { |
1021 | $this->populateAttributes( $row, $attribs ); |
1022 | |
1023 | $templateParams = $this->getTemplateParams( $row, $classes ); |
1024 | $ret = $this->getProcessedTemplate( $templateParams ); |
1025 | } |
1026 | |
1027 | if ( $this->runHooks ) { |
1028 | // Let extensions add data |
1029 | $lineEndingsHook = $this->isArchive ? |
1030 | 'onDeletedContributionsLineEnding' : |
1031 | 'onContributionsLineEnding'; |
1032 | $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs ); |
1033 | } |
1034 | |
1035 | $attribs = array_filter( $attribs, |
1036 | [ Sanitizer::class, 'isReservedDataAttribute' ], |
1037 | ARRAY_FILTER_USE_KEY |
1038 | ); |
1039 | |
1040 | // TODO: Handle exceptions in the catch block above. Do any extensions rely on |
1041 | // receiving empty rows? |
1042 | |
1043 | if ( $classes === [] && $attribs === [] && $ret === '' ) { |
1044 | wfDebug( "Dropping ContributionsSpecialPage row that could not be formatted" ); |
1045 | return "<!-- Could not format ContributionsSpecialPage row. -->\n"; |
1046 | } |
1047 | $attribs['class'] = $classes; |
1048 | |
1049 | // FIXME: The signature of the ContributionsLineEnding hook makes it |
1050 | // very awkward to move this LI wrapper into the template. |