ContribsPager.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  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. * Pager for Special:Contributions
  23. * @ingroup Pager
  24. */
  25. use MediaWiki\MediaWikiServices;
  26. use MediaWiki\Linker\LinkRenderer;
  27. use MediaWiki\Revision\RevisionRecord;
  28. use Wikimedia\Rdbms\IResultWrapper;
  29. use Wikimedia\Rdbms\FakeResultWrapper;
  30. use Wikimedia\Rdbms\IDatabase;
  31. class ContribsPager extends RangeChronologicalPager {
  32. /**
  33. * @var string[] Local cache for escaped messages
  34. */
  35. private $messages;
  36. /**
  37. * @var string User name, or a string describing an IP address range
  38. */
  39. private $target;
  40. /**
  41. * @var string|int A single namespace number, or an empty string for all namespaces
  42. */
  43. private $namespace = '';
  44. /**
  45. * @var string|false Name of tag to filter, or false to ignore tags
  46. */
  47. private $tagFilter;
  48. /**
  49. * @var bool Set to true to invert the namespace selection
  50. */
  51. private $nsInvert;
  52. /**
  53. * @var bool Set to true to show both the subject and talk namespace, no matter which got
  54. * selected
  55. */
  56. private $associated;
  57. /**
  58. * @var bool Set to true to show only deleted revisions
  59. */
  60. private $deletedOnly;
  61. /**
  62. * @var bool Set to true to show only latest (a.k.a. current) revisions
  63. */
  64. private $topOnly;
  65. /**
  66. * @var bool Set to true to show only new pages
  67. */
  68. private $newOnly;
  69. /**
  70. * @var bool Set to true to hide edits marked as minor by the user
  71. */
  72. private $hideMinor;
  73. private $preventClickjacking = false;
  74. /** @var IDatabase */
  75. private $mDbSecondary;
  76. /**
  77. * @var array
  78. */
  79. private $mParentLens;
  80. /**
  81. * @var TemplateParser
  82. */
  83. private $templateParser;
  84. public function __construct( IContextSource $context, array $options,
  85. LinkRenderer $linkRenderer = null
  86. ) {
  87. // Set ->target before calling parent::__construct() so
  88. // parent can call $this->getIndexField() and get the right result. Set
  89. // the rest too just to keep things simple.
  90. $this->target = $options['target'] ?? '';
  91. $this->namespace = $options['namespace'] ?? '';
  92. $this->tagFilter = $options['tagfilter'] ?? false;
  93. $this->nsInvert = $options['nsInvert'] ?? false;
  94. $this->associated = $options['associated'] ?? false;
  95. $this->deletedOnly = !empty( $options['deletedOnly'] );
  96. $this->topOnly = !empty( $options['topOnly'] );
  97. $this->newOnly = !empty( $options['newOnly'] );
  98. $this->hideMinor = !empty( $options['hideMinor'] );
  99. parent::__construct( $context, $linkRenderer );
  100. $msgs = [
  101. 'diff',
  102. 'hist',
  103. 'pipe-separator',
  104. 'uctop'
  105. ];
  106. foreach ( $msgs as $msg ) {
  107. $this->messages[$msg] = $this->msg( $msg )->escaped();
  108. }
  109. // Date filtering: use timestamp if available
  110. $startTimestamp = '';
  111. $endTimestamp = '';
  112. if ( $options['start'] ) {
  113. $startTimestamp = $options['start'] . ' 00:00:00';
  114. }
  115. if ( $options['end'] ) {
  116. $endTimestamp = $options['end'] . ' 23:59:59';
  117. }
  118. $this->getDateRangeCond( $startTimestamp, $endTimestamp );
  119. // Most of this code will use the 'contributions' group DB, which can map to replica DBs
  120. // with extra user based indexes or partioning by user. The additional metadata
  121. // queries should use a regular replica DB since the lookup pattern is not all by user.
  122. $this->mDbSecondary = wfGetDB( DB_REPLICA ); // any random replica DB
  123. $this->mDb = wfGetDB( DB_REPLICA, 'contributions' );
  124. $this->templateParser = new TemplateParser();
  125. }
  126. function getDefaultQuery() {
  127. $query = parent::getDefaultQuery();
  128. $query['target'] = $this->target;
  129. return $query;
  130. }
  131. /**
  132. * Wrap the navigation bar in a p element with identifying class.
  133. * In future we may want to change the `p` tag to a `div` and upstream
  134. * this to the parent class.
  135. *
  136. * @return string HTML
  137. */
  138. function getNavigationBar() {
  139. return Html::rawElement( 'p', [ 'class' => 'mw-pager-navigation-bar' ],
  140. parent::getNavigationBar()
  141. );
  142. }
  143. /**
  144. * This method basically executes the exact same code as the parent class, though with
  145. * a hook added, to allow extensions to add additional queries.
  146. *
  147. * @param string $offset Index offset, inclusive
  148. * @param int $limit Exact query limit
  149. * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
  150. * @return IResultWrapper
  151. */
  152. function reallyDoQuery( $offset, $limit, $order ) {
  153. list( $tables, $fields, $conds, $fname, $options, $join_conds ) = $this->buildQueryInfo(
  154. $offset,
  155. $limit,
  156. $order
  157. );
  158. /*
  159. * This hook will allow extensions to add in additional queries, so they can get their data
  160. * in My Contributions as well. Extensions should append their results to the $data array.
  161. *
  162. * Extension queries have to implement the navbar requirement as well. They should
  163. * - have a column aliased as $pager->getIndexField()
  164. * - have LIMIT set
  165. * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
  166. * - have the ORDER BY specified based upon the details provided by the navbar
  167. *
  168. * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
  169. *
  170. * &$data: an array of results of all contribs queries
  171. * $pager: the ContribsPager object hooked into
  172. * $offset: see phpdoc above
  173. * $limit: see phpdoc above
  174. * $descending: see phpdoc above
  175. */
  176. $data = [ $this->mDb->select(
  177. $tables, $fields, $conds, $fname, $options, $join_conds
  178. ) ];
  179. Hooks::run(
  180. 'ContribsPager::reallyDoQuery',
  181. [ &$data, $this, $offset, $limit, $order ]
  182. );
  183. $result = [];
  184. // loop all results and collect them in an array
  185. foreach ( $data as $query ) {
  186. foreach ( $query as $i => $row ) {
  187. // use index column as key, allowing us to easily sort in PHP
  188. $result[$row->{$this->getIndexField()} . "-$i"] = $row;
  189. }
  190. }
  191. // sort results
  192. if ( $order === self::QUERY_ASCENDING ) {
  193. ksort( $result );
  194. } else {
  195. krsort( $result );
  196. }
  197. // enforce limit
  198. $result = array_slice( $result, 0, $limit );
  199. // get rid of array keys
  200. $result = array_values( $result );
  201. return new FakeResultWrapper( $result );
  202. }
  203. /**
  204. * Return the table targeted for ordering and continuation
  205. *
  206. * See T200259 and T221380.
  207. *
  208. * @warning Keep this in sync with self::getQueryInfo()!
  209. *
  210. * @return string
  211. */
  212. private function getTargetTable() {
  213. $user = User::newFromName( $this->target, false );
  214. $ipRangeConds = $user->isAnon() ? $this->getIpRangeConds( $this->mDb, $this->target ) : null;
  215. if ( $ipRangeConds ) {
  216. return 'ip_changes';
  217. } else {
  218. $conds = ActorMigration::newMigration()->getWhere( $this->mDb, 'rev_user', $user );
  219. if ( isset( $conds['orconds']['actor'] ) ) {
  220. // @todo: This will need changing when revision_actor_temp goes away
  221. return 'revision_actor_temp';
  222. }
  223. }
  224. return 'revision';
  225. }
  226. function getQueryInfo() {
  227. $revQuery = Revision::getQueryInfo( [ 'page', 'user' ] );
  228. $queryInfo = [
  229. 'tables' => $revQuery['tables'],
  230. 'fields' => array_merge( $revQuery['fields'], [ 'page_is_new' ] ),
  231. 'conds' => [],
  232. 'options' => [],
  233. 'join_conds' => $revQuery['joins'],
  234. ];
  235. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  236. // WARNING: Keep this in sync with getTargetTable()!
  237. $user = User::newFromName( $this->target, false );
  238. $ipRangeConds = $user->isAnon() ? $this->getIpRangeConds( $this->mDb, $this->target ) : null;
  239. if ( $ipRangeConds ) {
  240. $queryInfo['tables'][] = 'ip_changes';
  241. $queryInfo['join_conds']['ip_changes'] = [
  242. 'LEFT JOIN', [ 'ipc_rev_id = rev_id' ]
  243. ];
  244. $queryInfo['conds'][] = $ipRangeConds;
  245. } else {
  246. // tables and joins are already handled by Revision::getQueryInfo()
  247. $conds = ActorMigration::newMigration()->getWhere( $this->mDb, 'rev_user', $user );
  248. $queryInfo['conds'][] = $conds['conds'];
  249. // Force the appropriate index to avoid bad query plans (T189026)
  250. if ( isset( $conds['orconds']['actor'] ) ) {
  251. // @todo: This will need changing when revision_actor_temp goes away
  252. $queryInfo['options']['USE INDEX']['temp_rev_user'] = 'actor_timestamp';
  253. } else {
  254. $queryInfo['options']['USE INDEX']['revision'] =
  255. isset( $conds['orconds']['userid'] ) ? 'user_timestamp' : 'usertext_timestamp';
  256. }
  257. }
  258. if ( $this->deletedOnly ) {
  259. $queryInfo['conds'][] = 'rev_deleted != 0';
  260. }
  261. if ( $this->topOnly ) {
  262. $queryInfo['conds'][] = 'rev_id = page_latest';
  263. }
  264. if ( $this->newOnly ) {
  265. $queryInfo['conds'][] = 'rev_parent_id = 0';
  266. }
  267. if ( $this->hideMinor ) {
  268. $queryInfo['conds'][] = 'rev_minor_edit = 0';
  269. }
  270. $user = $this->getUser();
  271. $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
  272. // Paranoia: avoid brute force searches (T19342)
  273. if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) {
  274. $queryInfo['conds'][] = $this->mDb->bitAnd(
  275. 'rev_deleted', RevisionRecord::DELETED_USER
  276. ) . ' = 0';
  277. } elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) {
  278. $queryInfo['conds'][] = $this->mDb->bitAnd(
  279. 'rev_deleted', RevisionRecord::SUPPRESSED_USER
  280. ) . ' != ' . RevisionRecord::SUPPRESSED_USER;
  281. }
  282. // $this->getIndexField() must be in the result rows, as reallyDoQuery() tries to access it.
  283. $indexField = $this->getIndexField();
  284. if ( $indexField !== 'rev_timestamp' ) {
  285. $queryInfo['fields'][] = $indexField;
  286. }
  287. ChangeTags::modifyDisplayQuery(
  288. $queryInfo['tables'],
  289. $queryInfo['fields'],
  290. $queryInfo['conds'],
  291. $queryInfo['join_conds'],
  292. $queryInfo['options'],
  293. $this->tagFilter
  294. );
  295. // Avoid PHP 7.1 warning from passing $this by reference
  296. $pager = $this;
  297. Hooks::run( 'ContribsPager::getQueryInfo', [ &$pager, &$queryInfo ] );
  298. return $queryInfo;
  299. }
  300. function getNamespaceCond() {
  301. if ( $this->namespace !== '' ) {
  302. $selectedNS = $this->mDb->addQuotes( $this->namespace );
  303. $eq_op = $this->nsInvert ? '!=' : '=';
  304. $bool_op = $this->nsInvert ? 'AND' : 'OR';
  305. if ( !$this->associated ) {
  306. return [ "page_namespace $eq_op $selectedNS" ];
  307. }
  308. $associatedNS = $this->mDb->addQuotes(
  309. MediaWikiServices::getInstance()->getNamespaceInfo()->getAssociated( $this->namespace )
  310. );
  311. return [
  312. "page_namespace $eq_op $selectedNS " .
  313. $bool_op .
  314. " page_namespace $eq_op $associatedNS"
  315. ];
  316. }
  317. return [];
  318. }
  319. /**
  320. * Get SQL conditions for an IP range, if applicable
  321. * @param IDatabase $db
  322. * @param string $ip The IP address or CIDR
  323. * @return string|false SQL for valid IP ranges, false if invalid
  324. */
  325. private function getIpRangeConds( $db, $ip ) {
  326. // First make sure it is a valid range and they are not outside the CIDR limit
  327. if ( !$this->isQueryableRange( $ip ) ) {
  328. return false;
  329. }
  330. list( $start, $end ) = IP::parseRange( $ip );
  331. return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end );
  332. }
  333. /**
  334. * Is the given IP a range and within the CIDR limit?
  335. *
  336. * @param string $ipRange
  337. * @return bool True if it is valid
  338. * @since 1.30
  339. */
  340. public function isQueryableRange( $ipRange ) {
  341. $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' );
  342. $bits = IP::parseCIDR( $ipRange )[1];
  343. if (
  344. ( $bits === false ) ||
  345. ( IP::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) ||
  346. ( IP::isIPv6( $ipRange ) && $bits < $limits['IPv6'] )
  347. ) {
  348. return false;
  349. }
  350. return true;
  351. }
  352. /**
  353. * @return string
  354. */
  355. public function getIndexField() {
  356. // The returned column is used for sorting and continuation, so we need to
  357. // make sure to use the right denormalized column depending on which table is
  358. // being targeted by the query to avoid bad query plans.
  359. // See T200259, T204669, T220991, and T221380.
  360. $target = $this->getTargetTable();
  361. switch ( $target ) {
  362. case 'revision':
  363. return 'rev_timestamp';
  364. case 'ip_changes':
  365. return 'ipc_rev_timestamp';
  366. case 'revision_actor_temp':
  367. return 'revactor_timestamp';
  368. default:
  369. wfWarn(
  370. __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
  371. );
  372. return 'rev_timestamp';
  373. }
  374. }
  375. /**
  376. * @return false|string
  377. */
  378. public function getTagFilter() {
  379. return $this->tagFilter;
  380. }
  381. /**
  382. * @return string
  383. */
  384. public function getTarget() {
  385. return $this->target;
  386. }
  387. /**
  388. * @return bool
  389. */
  390. public function isNewOnly() {
  391. return $this->newOnly;
  392. }
  393. /**
  394. * @return int|string
  395. */
  396. public function getNamespace() {
  397. return $this->namespace;
  398. }
  399. /**
  400. * @return string[]
  401. */
  402. protected function getExtraSortFields() {
  403. // The returned columns are used for sorting, so we need to make sure
  404. // to use the right denormalized column depending on which table is
  405. // being targeted by the query to avoid bad query plans.
  406. // See T200259, T204669, T220991, and T221380.
  407. $target = $this->getTargetTable();
  408. switch ( $target ) {
  409. case 'revision':
  410. return [ 'rev_id' ];
  411. case 'ip_changes':
  412. return [ 'ipc_rev_id' ];
  413. case 'revision_actor_temp':
  414. return [ 'revactor_rev' ];
  415. default:
  416. wfWarn(
  417. __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
  418. );
  419. return [ 'rev_id' ];
  420. }
  421. }
  422. protected function doBatchLookups() {
  423. # Do a link batch query
  424. $this->mResult->seek( 0 );
  425. $parentRevIds = [];
  426. $this->mParentLens = [];
  427. $batch = new LinkBatch();
  428. $isIpRange = $this->isQueryableRange( $this->target );
  429. # Give some pointers to make (last) links
  430. foreach ( $this->mResult as $row ) {
  431. if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
  432. $parentRevIds[] = $row->rev_parent_id;
  433. }
  434. if ( isset( $row->rev_id ) ) {
  435. $this->mParentLens[$row->rev_id] = $row->rev_len;
  436. if ( $isIpRange ) {
  437. // If this is an IP range, batch the IP's talk page
  438. $batch->add( NS_USER_TALK, $row->rev_user_text );
  439. }
  440. $batch->add( $row->page_namespace, $row->page_title );
  441. }
  442. }
  443. # Fetch rev_len for revisions not already scanned above
  444. $this->mParentLens += Revision::getParentLengths(
  445. $this->mDbSecondary,
  446. array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
  447. );
  448. $batch->execute();
  449. $this->mResult->seek( 0 );
  450. }
  451. /**
  452. * @return string
  453. */
  454. protected function getStartBody() {
  455. return "<ul class=\"mw-contributions-list\">\n";
  456. }
  457. /**
  458. * @return string
  459. */
  460. protected function getEndBody() {
  461. return "</ul>\n";
  462. }
  463. /**
  464. * Check whether the revision associated is valid for formatting. If has no associated revision
  465. * id then null is returned.
  466. *
  467. * @param object $row
  468. * @param Title|null $title
  469. * @return Revision|null
  470. */
  471. public function tryToCreateValidRevision( $row, $title = null ) {
  472. /*
  473. * There may be more than just revision rows. To make sure that we'll only be processing
  474. * revisions here, let's _try_ to build a revision out of our row (without displaying
  475. * notices though) and then trying to grab data from the built object. If we succeed,
  476. * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
  477. * to extensions to subscribe to the hook to parse the row.
  478. */
  479. Wikimedia\suppressWarnings();
  480. try {
  481. $rev = new Revision( $row, 0, $title );
  482. $validRevision = (bool)$rev->getId();
  483. } catch ( Exception $e ) {
  484. $validRevision = false;
  485. }
  486. Wikimedia\restoreWarnings();
  487. return $validRevision ? $rev : null;
  488. }
  489. /**
  490. * Generates each row in the contributions list.
  491. *
  492. * Contributions which are marked "top" are currently on top of the history.
  493. * For these contributions, a [rollback] link is shown for users with roll-
  494. * back privileges. The rollback link restores the most recent version that
  495. * was not written by the target user.
  496. *
  497. * @todo This would probably look a lot nicer in a table.
  498. * @param object $row
  499. * @return string
  500. */
  501. function formatRow( $row ) {
  502. $ret = '';
  503. $classes = [];
  504. $attribs = [];
  505. $linkRenderer = $this->getLinkRenderer();
  506. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  507. $page = null;
  508. // Create a title for the revision if possible
  509. // Rows from the hook may not include title information
  510. if ( isset( $row->page_namespace ) && isset( $row->page_title ) ) {
  511. $page = Title::newFromRow( $row );
  512. }
  513. $rev = $this->tryToCreateValidRevision( $row, $page );
  514. if ( $rev ) {
  515. $attribs['data-mw-revid'] = $rev->getId();
  516. $link = $linkRenderer->makeLink(
  517. $page,
  518. $page->getPrefixedText(),
  519. [ 'class' => 'mw-contributions-title' ],
  520. $page->isRedirect() ? [ 'redirect' => 'no' ] : []
  521. );
  522. # Mark current revisions
  523. $topmarktext = '';
  524. $user = $this->getUser();
  525. if ( $row->rev_id === $row->page_latest ) {
  526. $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
  527. $classes[] = 'mw-contributions-current';
  528. # Add rollback link
  529. if ( !$row->page_is_new &&
  530. $permissionManager->quickUserCan( 'rollback', $user, $page ) &&
  531. $permissionManager->quickUserCan( 'edit', $user, $page )
  532. ) {
  533. $this->preventClickjacking();
  534. $topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext(),
  535. [ 'noBrackets' ] );
  536. }
  537. }
  538. # Is there a visible previous revision?
  539. if ( $rev->userCan( RevisionRecord::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) {
  540. $difftext = $linkRenderer->makeKnownLink(
  541. $page,
  542. new HtmlArmor( $this->messages['diff'] ),
  543. [ 'class' => 'mw-changeslist-diff' ],
  544. [
  545. 'diff' => 'prev',
  546. 'oldid' => $row->rev_id
  547. ]
  548. );
  549. } else {
  550. $difftext = $this->messages['diff'];
  551. }
  552. $histlink = $linkRenderer->makeKnownLink(
  553. $page,
  554. new HtmlArmor( $this->messages['hist'] ),
  555. [ 'class' => 'mw-changeslist-history' ],
  556. [ 'action' => 'history' ]
  557. );
  558. if ( $row->rev_parent_id === null ) {
  559. // For some reason rev_parent_id isn't populated for this row.
  560. // Its rumoured this is true on wikipedia for some revisions (T36922).
  561. // Next best thing is to have the total number of bytes.
  562. $chardiff = ' <span class="mw-changeslist-separator"></span> ';
  563. $chardiff .= Linker::formatRevisionSize( $row->rev_len );
  564. $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
  565. } else {
  566. $parentLen = 0;
  567. if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) {
  568. $parentLen = $this->mParentLens[$row->rev_parent_id];
  569. }
  570. $chardiff = ' <span class="mw-changeslist-separator"></span> ';
  571. $chardiff .= ChangesList::showCharacterDifference(
  572. $parentLen,
  573. $row->rev_len,
  574. $this->getContext()
  575. );
  576. $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
  577. }
  578. $lang = $this->getLanguage();
  579. $comment = $lang->getDirMark() . Linker::revComment( $rev, false, true, false );
  580. $d = ChangesList::revDateLink( $rev, $user, $lang, $page );
  581. # When querying for an IP range, we want to always show user and user talk links.
  582. $userlink = '';
  583. if ( $this->isQueryableRange( $this->target ) ) {
  584. $userlink = ' <span class="mw-changeslist-separator"></span> '
  585. . $lang->getDirMark()
  586. . Linker::userLink( $rev->getUser(), $rev->getUserText() );
  587. $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
  588. Linker::userTalkLink( $rev->getUser(), $rev->getUserText() ) )->escaped() . ' ';
  589. }
  590. $flags = [];
  591. if ( $rev->getParentId() === 0 ) {
  592. $flags[] = ChangesList::flag( 'newpage' );
  593. }
  594. if ( $rev->isMinor() ) {
  595. $flags[] = ChangesList::flag( 'minor' );
  596. }
  597. $del = Linker::getRevDeleteLink( $user, $rev, $page );
  598. if ( $del !== '' ) {
  599. $del .= ' ';
  600. }
  601. // While it might be tempting to use a list here
  602. // this would result in clutter and slows down navigating the content
  603. // in assistive technology.
  604. // See https://phabricator.wikimedia.org/T205581#4734812
  605. $diffHistLinks = Html::rawElement( 'span',
  606. [ 'class' => 'mw-changeslist-links' ],
  607. // The spans are needed to ensure the dividing '|' elements are not
  608. // themselves styled as links.
  609. Html::rawElement( 'span', [], $difftext ) .
  610. ' ' . // Space needed for separating two words.
  611. Html::rawElement( 'span', [], $histlink )
  612. );
  613. # Tags, if any.
  614. list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
  615. $row->ts_tags,
  616. 'contributions',
  617. $this->getContext()
  618. );
  619. $classes = array_merge( $classes, $newClasses );
  620. Hooks::run( 'SpecialContributions::formatRow::flags', [ $this->getContext(), $row, &$flags ] );
  621. $templateParams = [
  622. 'del' => $del,
  623. 'timestamp' => $d,
  624. 'diffHistLinks' => $diffHistLinks,
  625. 'charDifference' => $chardiff,
  626. 'flags' => $flags,
  627. 'articleLink' => $link,
  628. 'userlink' => $userlink,
  629. 'logText' => $comment,
  630. 'topmarktext' => $topmarktext,
  631. 'tagSummary' => $tagSummary,
  632. ];
  633. # Denote if username is redacted for this edit
  634. if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) {
  635. $templateParams['rev-deleted-user-contribs'] =
  636. $this->msg( 'rev-deleted-user-contribs' )->escaped();
  637. }
  638. $ret = $this->templateParser->processTemplate(
  639. 'SpecialContributionsLine',
  640. $templateParams
  641. );
  642. }
  643. // Let extensions add data
  644. Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
  645. $attribs = array_filter( $attribs,
  646. [ Sanitizer::class, 'isReservedDataAttribute' ],
  647. ARRAY_FILTER_USE_KEY
  648. );
  649. // TODO: Handle exceptions in the catch block above. Do any extensions rely on
  650. // receiving empty rows?
  651. if ( $classes === [] && $attribs === [] && $ret === '' ) {
  652. wfDebug( "Dropping Special:Contribution row that could not be formatted\n" );
  653. return "<!-- Could not format Special:Contribution row. -->\n";
  654. }
  655. $attribs['class'] = $classes;
  656. // FIXME: The signature of the ContributionsLineEnding hook makes it
  657. // very awkward to move this LI wrapper into the template.
  658. return Html::rawElement( 'li', $attribs, $ret ) . "\n";
  659. }
  660. /**
  661. * Overwrite Pager function and return a helpful comment
  662. * @return string
  663. */
  664. function getSqlComment() {
  665. if ( $this->namespace || $this->deletedOnly ) {
  666. // potentially slow, see CR r58153
  667. return 'contributions page filtered for namespace or RevisionDeleted edits';
  668. } else {
  669. return 'contributions page unfiltered';
  670. }
  671. }
  672. protected function preventClickjacking() {
  673. $this->preventClickjacking = true;
  674. }
  675. /**
  676. * @return bool
  677. */
  678. public function getPreventClickjacking() {
  679. return $this->preventClickjacking;
  680. }
  681. /**
  682. * Set up date filter options, given request data.
  683. *
  684. * @param array $opts Options array
  685. * @return array Options array with processed start and end date filter options
  686. */
  687. public static function processDateFilter( array $opts ) {
  688. $start = $opts['start'] ?? '';
  689. $end = $opts['end'] ?? '';
  690. $year = $opts['year'] ?? '';
  691. $month = $opts['month'] ?? '';
  692. if ( $start !== '' && $end !== '' && $start > $end ) {
  693. $temp = $start;
  694. $start = $end;
  695. $end = $temp;
  696. }
  697. // If year/month legacy filtering options are set, convert them to display the new stamp
  698. if ( $year !== '' || $month !== '' ) {
  699. // Reuse getDateCond logic, but subtract a day because
  700. // the endpoints of our date range appear inclusive
  701. // but the internal end offsets are always exclusive
  702. $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month );
  703. $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) );
  704. $legacyDateTime = $legacyDateTime->modify( '-1 day' );
  705. // Clear the new timestamp range options if used and
  706. // replace with the converted legacy timestamp
  707. $start = '';
  708. $end = $legacyDateTime->format( 'Y-m-d' );
  709. }
  710. $opts['start'] = $start;
  711. $opts['end'] = $end;
  712. return $opts;
  713. }
  714. }