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