SpecialRecentChanges.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963
  1. <?php
  2. /**
  3. * Implements Special:Recentchanges
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @ingroup SpecialPage
  22. */
  23. use MediaWiki\MediaWikiServices;
  24. use Wikimedia\Rdbms\IResultWrapper;
  25. use Wikimedia\Rdbms\FakeResultWrapper;
  26. /**
  27. * A special page that lists last changes made to the wiki
  28. *
  29. * @ingroup SpecialPage
  30. */
  31. class SpecialRecentChanges extends ChangesListSpecialPage {
  32. protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
  33. protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference
  34. protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference
  35. protected static $collapsedPreferenceName = 'rcfilters-rc-collapsed';
  36. private $watchlistFilterGroupDefinition;
  37. public function __construct( $name = 'Recentchanges', $restriction = '' ) {
  38. parent::__construct( $name, $restriction );
  39. $this->watchlistFilterGroupDefinition = [
  40. 'name' => 'watchlist',
  41. 'title' => 'rcfilters-filtergroup-watchlist',
  42. 'class' => ChangesListStringOptionsFilterGroup::class,
  43. 'priority' => -9,
  44. 'isFullCoverage' => true,
  45. 'filters' => [
  46. [
  47. 'name' => 'watched',
  48. 'label' => 'rcfilters-filter-watchlist-watched-label',
  49. 'description' => 'rcfilters-filter-watchlist-watched-description',
  50. 'cssClassSuffix' => 'watched',
  51. 'isRowApplicableCallable' => function ( $ctx, $rc ) {
  52. return $rc->getAttribute( 'wl_user' );
  53. }
  54. ],
  55. [
  56. 'name' => 'watchednew',
  57. 'label' => 'rcfilters-filter-watchlist-watchednew-label',
  58. 'description' => 'rcfilters-filter-watchlist-watchednew-description',
  59. 'cssClassSuffix' => 'watchednew',
  60. 'isRowApplicableCallable' => function ( $ctx, $rc ) {
  61. return $rc->getAttribute( 'wl_user' ) &&
  62. $rc->getAttribute( 'rc_timestamp' ) &&
  63. $rc->getAttribute( 'wl_notificationtimestamp' ) &&
  64. $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
  65. },
  66. ],
  67. [
  68. 'name' => 'notwatched',
  69. 'label' => 'rcfilters-filter-watchlist-notwatched-label',
  70. 'description' => 'rcfilters-filter-watchlist-notwatched-description',
  71. 'cssClassSuffix' => 'notwatched',
  72. 'isRowApplicableCallable' => function ( $ctx, $rc ) {
  73. return $rc->getAttribute( 'wl_user' ) === null;
  74. },
  75. ]
  76. ],
  77. 'default' => ChangesListStringOptionsFilterGroup::NONE,
  78. 'queryCallable' => function ( $specialPageClassName, $context, $dbr,
  79. &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
  80. sort( $selectedValues );
  81. $notwatchedCond = 'wl_user IS NULL';
  82. $watchedCond = 'wl_user IS NOT NULL';
  83. $newCond = 'rc_timestamp >= wl_notificationtimestamp';
  84. if ( $selectedValues === [ 'notwatched' ] ) {
  85. $conds[] = $notwatchedCond;
  86. return;
  87. }
  88. if ( $selectedValues === [ 'watched' ] ) {
  89. $conds[] = $watchedCond;
  90. return;
  91. }
  92. if ( $selectedValues === [ 'watchednew' ] ) {
  93. $conds[] = $dbr->makeList( [
  94. $watchedCond,
  95. $newCond
  96. ], LIST_AND );
  97. return;
  98. }
  99. if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
  100. // no filters
  101. return;
  102. }
  103. if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
  104. $conds[] = $dbr->makeList( [
  105. $notwatchedCond,
  106. $dbr->makeList( [
  107. $watchedCond,
  108. $newCond
  109. ], LIST_AND )
  110. ], LIST_OR );
  111. return;
  112. }
  113. if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
  114. $conds[] = $watchedCond;
  115. return;
  116. }
  117. if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
  118. // no filters
  119. return;
  120. }
  121. }
  122. ];
  123. }
  124. /**
  125. * @param string|null $subpage
  126. */
  127. public function execute( $subpage ) {
  128. // Backwards-compatibility: redirect to new feed URLs
  129. $feedFormat = $this->getRequest()->getVal( 'feed' );
  130. if ( !$this->including() && $feedFormat ) {
  131. $query = $this->getFeedQuery();
  132. $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
  133. $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
  134. return;
  135. }
  136. // 10 seconds server-side caching max
  137. $out = $this->getOutput();
  138. $out->setCdnMaxage( 10 );
  139. // Check if the client has a cached version
  140. $lastmod = $this->checkLastModified();
  141. if ( $lastmod === false ) {
  142. return;
  143. }
  144. $this->addHelpLink(
  145. 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
  146. true
  147. );
  148. parent::execute( $subpage );
  149. }
  150. /**
  151. * @inheritDoc
  152. */
  153. protected function transformFilterDefinition( array $filterDefinition ) {
  154. if ( isset( $filterDefinition['showHideSuffix'] ) ) {
  155. $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
  156. }
  157. return $filterDefinition;
  158. }
  159. /**
  160. * @inheritDoc
  161. */
  162. protected function registerFilters() {
  163. parent::registerFilters();
  164. if (
  165. !$this->including() &&
  166. $this->getUser()->isLoggedIn() &&
  167. MediaWikiServices::getInstance()
  168. ->getPermissionManager()
  169. ->userHasRight( $this->getUser(), 'viewmywatchlist' )
  170. ) {
  171. $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
  172. $watchlistGroup = $this->getFilterGroup( 'watchlist' );
  173. $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
  174. $watchlistGroup->getFilter( 'watchednew' )
  175. );
  176. }
  177. $user = $this->getUser();
  178. $significance = $this->getFilterGroup( 'significance' );
  179. /** @var ChangesListBooleanFilter $hideMinor */
  180. $hideMinor = $significance->getFilter( 'hideminor' );
  181. '@phan-var ChangesListBooleanFilter $hideMinor';
  182. $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
  183. $automated = $this->getFilterGroup( 'automated' );
  184. /** @var ChangesListBooleanFilter $hideBots */
  185. $hideBots = $automated->getFilter( 'hidebots' );
  186. '@phan-var ChangesListBooleanFilter $hideBots';
  187. $hideBots->setDefault( true );
  188. /** @var ChangesListStringOptionsFilterGroup|null $reviewStatus */
  189. $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
  190. '@phan-var ChangesListStringOptionsFilterGroup|null $reviewStatus';
  191. if ( $reviewStatus !== null ) {
  192. // Conditional on feature being available and rights
  193. if ( $user->getBoolOption( 'hidepatrolled' ) ) {
  194. $reviewStatus->setDefault( 'unpatrolled' );
  195. $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
  196. /** @var ChangesListBooleanFilter $legacyHidePatrolled */
  197. $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
  198. '@phan-var ChangesListBooleanFilter $legacyHidePatrolled';
  199. $legacyHidePatrolled->setDefault( true );
  200. }
  201. }
  202. $changeType = $this->getFilterGroup( 'changeType' );
  203. /** @var ChangesListBooleanFilter $hideCategorization */
  204. $hideCategorization = $changeType->getFilter( 'hidecategorization' );
  205. '@phan-var ChangesListBooleanFilter $hideCategorization';
  206. if ( $hideCategorization !== null ) {
  207. // Conditional on feature being available
  208. $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
  209. }
  210. }
  211. /**
  212. * Process $par and put options found in $opts. Used when including the page.
  213. *
  214. * @param string $par
  215. * @param FormOptions $opts
  216. */
  217. public function parseParameters( $par, FormOptions $opts ) {
  218. parent::parseParameters( $par, $opts );
  219. $bits = preg_split( '/\s*,\s*/', trim( $par ) );
  220. foreach ( $bits as $bit ) {
  221. if ( is_numeric( $bit ) ) {
  222. $opts['limit'] = $bit;
  223. }
  224. $m = [];
  225. if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
  226. $opts['limit'] = $m[1];
  227. }
  228. if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
  229. $opts['days'] = $m[1];
  230. }
  231. if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
  232. $opts['namespace'] = $m[1];
  233. }
  234. if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
  235. $opts['tagfilter'] = $m[1];
  236. }
  237. }
  238. }
  239. /**
  240. * @inheritDoc
  241. */
  242. protected function doMainQuery( $tables, $fields, $conds, $query_options,
  243. $join_conds, FormOptions $opts
  244. ) {
  245. $dbr = $this->getDB();
  246. $user = $this->getUser();
  247. $rcQuery = RecentChange::getQueryInfo();
  248. $tables = array_merge( $tables, $rcQuery['tables'] );
  249. $fields = array_merge( $rcQuery['fields'], $fields );
  250. $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
  251. // JOIN on watchlist for users
  252. if ( $user->isLoggedIn() && MediaWikiServices::getInstance()
  253. ->getPermissionManager()
  254. ->userHasRight( $user, 'viewmywatchlist' )
  255. ) {
  256. $tables[] = 'watchlist';
  257. $fields[] = 'wl_user';
  258. $fields[] = 'wl_notificationtimestamp';
  259. $join_conds['watchlist'] = [ 'LEFT JOIN', [
  260. 'wl_user' => $user->getId(),
  261. 'wl_title=rc_title',
  262. 'wl_namespace=rc_namespace'
  263. ] ];
  264. }
  265. // JOIN on page, used for 'last revision' filter highlight
  266. $tables[] = 'page';
  267. $fields[] = 'page_latest';
  268. $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
  269. $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
  270. ChangeTags::modifyDisplayQuery(
  271. $tables,
  272. $fields,
  273. $conds,
  274. $join_conds,
  275. $query_options,
  276. $tagFilter
  277. );
  278. if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
  279. $opts )
  280. ) {
  281. return false;
  282. }
  283. if ( $this->areFiltersInConflict() ) {
  284. return false;
  285. }
  286. $orderByAndLimit = [
  287. 'ORDER BY' => 'rc_timestamp DESC',
  288. 'LIMIT' => $opts['limit']
  289. ];
  290. if ( in_array( 'DISTINCT', $query_options ) ) {
  291. // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
  292. // In order to prevent DISTINCT from causing query performance problems,
  293. // we have to GROUP BY the primary key. This in turn requires us to add
  294. // the primary key to the end of the ORDER BY, and the old ORDER BY to the
  295. // start of the GROUP BY
  296. $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
  297. $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
  298. }
  299. // array_merge() is used intentionally here so that hooks can, should
  300. // they so desire, override the ORDER BY / LIMIT condition(s); prior to
  301. // MediaWiki 1.26 this used to use the plus operator instead, which meant
  302. // that extensions weren't able to change these conditions
  303. $query_options = array_merge( $orderByAndLimit, $query_options );
  304. $rows = $dbr->select(
  305. $tables,
  306. $fields,
  307. // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
  308. // knowledge to use an index merge if it wants (it may use some other index though).
  309. $conds + [ 'rc_new' => [ 0, 1 ] ],
  310. __METHOD__,
  311. $query_options,
  312. $join_conds
  313. );
  314. return $rows;
  315. }
  316. protected function getDB() {
  317. return wfGetDB( DB_REPLICA, 'recentchanges' );
  318. }
  319. public function outputFeedLinks() {
  320. $this->addFeedLinks( $this->getFeedQuery() );
  321. }
  322. /**
  323. * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
  324. *
  325. * @return array
  326. */
  327. protected function getFeedQuery() {
  328. $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
  329. // API handles empty parameters in a different way
  330. return $value !== '';
  331. } );
  332. $query['action'] = 'feedrecentchanges';
  333. $feedLimit = $this->getConfig()->get( 'FeedLimit' );
  334. if ( $query['limit'] > $feedLimit ) {
  335. $query['limit'] = $feedLimit;
  336. }
  337. return $query;
  338. }
  339. /**
  340. * Build and output the actual changes list.
  341. *
  342. * @param IResultWrapper $rows Database rows
  343. * @param FormOptions $opts
  344. */
  345. public function outputChangesList( $rows, $opts ) {
  346. $limit = $opts['limit'];
  347. $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
  348. && $this->getUser()->getOption( 'shownumberswatching' );
  349. $watcherCache = [];
  350. $counter = 1;
  351. $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
  352. $list->initChangesListRows( $rows );
  353. $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
  354. $rclistOutput = $list->beginRecentChangesList();
  355. if ( $this->isStructuredFilterUiEnabled() ) {
  356. $rclistOutput .= $this->makeLegend();
  357. }
  358. foreach ( $rows as $obj ) {
  359. if ( $limit == 0 ) {
  360. break;
  361. }
  362. $rc = RecentChange::newFromRow( $obj );
  363. # Skip CatWatch entries for hidden cats based on user preference
  364. if (
  365. $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
  366. !$userShowHiddenCats &&
  367. $rc->getParam( 'hidden-cat' )
  368. ) {
  369. continue;
  370. }
  371. $rc->counter = $counter++;
  372. # Check if the page has been updated since the last visit
  373. if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
  374. && !empty( $obj->wl_notificationtimestamp )
  375. ) {
  376. $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
  377. } else {
  378. $rc->notificationtimestamp = false; // Default
  379. }
  380. # Check the number of users watching the page
  381. $rc->numberofWatchingusers = 0; // Default
  382. if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
  383. if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
  384. $watcherCache[$obj->rc_namespace][$obj->rc_title] =
  385. MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers(
  386. new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
  387. );
  388. }
  389. $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
  390. }
  391. $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter );
  392. if ( $changeLine !== false ) {
  393. $rclistOutput .= $changeLine;
  394. --$limit;
  395. }
  396. }
  397. $rclistOutput .= $list->endRecentChangesList();
  398. if ( $rows->numRows() === 0 ) {
  399. $this->outputNoResults();
  400. if ( !$this->including() ) {
  401. $this->getOutput()->setStatusCode( 404 );
  402. }
  403. } else {
  404. $this->getOutput()->addHTML( $rclistOutput );
  405. }
  406. }
  407. /**
  408. * Set the text to be displayed above the changes
  409. *
  410. * @param FormOptions $opts
  411. * @param int $numRows Number of rows in the result to show after this header
  412. */
  413. public function doHeader( $opts, $numRows ) {
  414. $this->setTopText( $opts );
  415. $defaults = $opts->getAllValues();
  416. $nondefaults = $opts->getChangedValues();
  417. $panel = [];
  418. if ( !$this->isStructuredFilterUiEnabled() ) {
  419. $panel[] = $this->makeLegend();
  420. }
  421. $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
  422. $panel[] = '<hr />';
  423. $extraOpts = $this->getExtraOptions( $opts );
  424. $extraOptsCount = count( $extraOpts );
  425. $count = 0;
  426. $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
  427. $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
  428. foreach ( $extraOpts as $name => $optionRow ) {
  429. # Add submit button to the last row only
  430. ++$count;
  431. $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
  432. $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
  433. if ( is_array( $optionRow ) ) {
  434. $out .= Xml::tags(
  435. 'td',
  436. [ 'class' => 'mw-label mw-' . $name . '-label' ],
  437. $optionRow[0]
  438. );
  439. $out .= Xml::tags(
  440. 'td',
  441. [ 'class' => 'mw-input' ],
  442. $optionRow[1] . $addSubmit
  443. );
  444. } else {
  445. $out .= Xml::tags(
  446. 'td',
  447. [ 'class' => 'mw-input', 'colspan' => 2 ],
  448. $optionRow . $addSubmit
  449. );
  450. }
  451. $out .= Xml::closeElement( 'tr' );
  452. }
  453. $out .= Xml::closeElement( 'table' );
  454. $unconsumed = $opts->getUnconsumedValues();
  455. foreach ( $unconsumed as $key => $value ) {
  456. $out .= Html::hidden( $key, $value );
  457. }
  458. $t = $this->getPageTitle();
  459. $out .= Html::hidden( 'title', $t->getPrefixedText() );
  460. $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
  461. $panel[] = $form;
  462. $panelString = implode( "\n", $panel );
  463. $rcoptions = Xml::fieldset(
  464. $this->msg( 'recentchanges-legend' )->text(),
  465. $panelString,
  466. [ 'class' => 'rcoptions cloptions' ]
  467. );
  468. // Insert a placeholder for RCFilters
  469. if ( $this->isStructuredFilterUiEnabled() ) {
  470. $rcfilterContainer = Html::element(
  471. 'div',
  472. // TODO: Remove deprecated rcfilters-container class
  473. [ 'class' => 'rcfilters-container mw-rcfilters-container' ]
  474. );
  475. $loadingContainer = Html::rawElement(
  476. 'div',
  477. [ 'class' => 'mw-rcfilters-spinner' ],
  478. Html::element(
  479. 'div',
  480. [ 'class' => 'mw-rcfilters-spinner-bounce' ]
  481. )
  482. );
  483. // Wrap both with rcfilters-head
  484. $this->getOutput()->addHTML(
  485. Html::rawElement(
  486. 'div',
  487. // TODO: Remove deprecated rcfilters-head class
  488. [ 'class' => 'rcfilters-head mw-rcfilters-head' ],
  489. $rcfilterContainer . $rcoptions
  490. )
  491. );
  492. // Add spinner
  493. $this->getOutput()->addHTML( $loadingContainer );
  494. } else {
  495. $this->getOutput()->addHTML( $rcoptions );
  496. }
  497. $this->setBottomText( $opts );
  498. }
  499. /**
  500. * Send the text to be displayed above the options
  501. *
  502. * @param FormOptions $opts Unused
  503. */
  504. function setTopText( FormOptions $opts ) {
  505. $message = $this->msg( 'recentchangestext' )->inContentLanguage();
  506. if ( !$message->isDisabled() ) {
  507. $contLang = MediaWikiServices::getInstance()->getContentLanguage();
  508. // Parse the message in this weird ugly way to preserve the ability to include interlanguage
  509. // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
  510. // $message->parse() instead. This code is copied from Message::parseText().
  511. $parserOutput = MessageCache::singleton()->parse(
  512. $message->plain(),
  513. $this->getPageTitle(),
  514. /*linestart*/true,
  515. // Message class sets the interface flag to false when parsing in a language different than
  516. // user language, and this is wiki content language
  517. /*interface*/false,
  518. $contLang
  519. );
  520. $content = $parserOutput->getText( [
  521. 'enableSectionEditLinks' => false,
  522. ] );
  523. // Add only metadata here (including the language links), text is added below
  524. $this->getOutput()->addParserOutputMetadata( $parserOutput );
  525. $langAttributes = [
  526. 'lang' => $contLang->getHtmlCode(),
  527. 'dir' => $contLang->getDir(),
  528. ];
  529. $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
  530. if ( $this->isStructuredFilterUiEnabled() ) {
  531. // Check whether the widget is already collapsed or expanded
  532. $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
  533. // Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
  534. $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
  535. ' mw-recentchanges-toplinks-collapsed' : '';
  536. $this->getOutput()->enableOOUI();
  537. $contentTitle = new OOUI\ButtonWidget( [
  538. 'classes' => [ 'mw-recentchanges-toplinks-title' ],
  539. 'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
  540. 'framed' => false,
  541. 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
  542. 'flags' => [ 'progressive' ],
  543. ] );
  544. $contentWrapper = Html::rawElement( 'div',
  545. array_merge(
  546. [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
  547. $langAttributes
  548. ),
  549. $content
  550. );
  551. $content = $contentTitle . $contentWrapper;
  552. } else {
  553. // Language direction should be on the top div only
  554. // if the title is not there. If it is there, it's
  555. // interface direction, and the language/dir attributes
  556. // should be on the content itself
  557. $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
  558. }
  559. $this->getOutput()->addHTML(
  560. Html::rawElement( 'div', $topLinksAttributes, $content )
  561. );
  562. }
  563. }
  564. /**
  565. * Get options to be displayed in a form
  566. *
  567. * @param FormOptions $opts
  568. * @return array
  569. */
  570. function getExtraOptions( $opts ) {
  571. $opts->consumeValues( [
  572. 'namespace', 'invert', 'associated', 'tagfilter'
  573. ] );
  574. $extraOpts = [];
  575. $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
  576. $tagFilter = ChangeTags::buildTagFilterSelector(
  577. $opts['tagfilter'], false, $this->getContext() );
  578. if ( count( $tagFilter ) ) {
  579. $extraOpts['tagfilter'] = $tagFilter;
  580. }
  581. // Don't fire the hook for subclasses. (Or should we?)
  582. if ( $this->getName() === 'Recentchanges' ) {
  583. Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] );
  584. }
  585. return $extraOpts;
  586. }
  587. /**
  588. * Add page-specific modules.
  589. */
  590. protected function addModules() {
  591. parent::addModules();
  592. $out = $this->getOutput();
  593. $out->addModules( 'mediawiki.special.recentchanges' );
  594. }
  595. /**
  596. * Get last modified date, for client caching
  597. * Don't use this if we are using the patrol feature, patrol changes don't
  598. * update the timestamp
  599. *
  600. * @return string|bool
  601. */
  602. public function checkLastModified() {
  603. $dbr = $this->getDB();
  604. $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
  605. return $lastmod;
  606. }
  607. /**
  608. * Creates the choose namespace selection
  609. *
  610. * @param FormOptions $opts
  611. * @return string[]
  612. */
  613. protected function namespaceFilterForm( FormOptions $opts ) {
  614. $nsSelect = Html::namespaceSelector(
  615. [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
  616. [ 'name' => 'namespace', 'id' => 'namespace' ]
  617. );
  618. $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
  619. $attribs = [ 'class' => [ 'mw-input-with-label' ] ];
  620. // Hide the checkboxes when the namespace filter is set to 'all'.
  621. if ( $opts['namespace'] === '' ) {
  622. $attribs['class'][] = 'mw-input-hidden';
  623. }
  624. $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel(
  625. $this->msg( 'invert' )->text(), 'invert', 'nsinvert',
  626. $opts['invert'],
  627. [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
  628. ) );
  629. $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel(
  630. $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
  631. $opts['associated'],
  632. [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
  633. ) );
  634. return [ $nsLabel, "$nsSelect $invert $associated" ];
  635. }
  636. /**
  637. * Filter $rows by categories set in $opts
  638. *
  639. * @deprecated since 1.31
  640. *
  641. * @param IResultWrapper &$rows Database rows
  642. * @param FormOptions $opts
  643. */
  644. function filterByCategories( &$rows, FormOptions $opts ) {
  645. wfDeprecated( __METHOD__, '1.31' );
  646. $categories = array_map( 'trim', explode( '|', $opts['categories'] ) );
  647. if ( $categories === [] ) {
  648. return;
  649. }
  650. # Filter categories
  651. $cats = [];
  652. foreach ( $categories as $cat ) {
  653. $cat = trim( $cat );
  654. if ( $cat == '' ) {
  655. continue;
  656. }
  657. $cats[] = $cat;
  658. }
  659. # Filter articles
  660. $articles = [];
  661. $a2r = [];
  662. $rowsarr = [];
  663. foreach ( $rows as $k => $r ) {
  664. $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title );
  665. $id = $nt->getArticleID();
  666. if ( $id == 0 ) {
  667. continue; # Page might have been deleted...
  668. }
  669. if ( !in_array( $id, $articles ) ) {
  670. $articles[] = $id;
  671. }
  672. if ( !isset( $a2r[$id] ) ) {
  673. $a2r[$id] = [];
  674. }
  675. $a2r[$id][] = $k;
  676. $rowsarr[$k] = $r;
  677. }
  678. # Shortcut?
  679. if ( $articles === [] || $cats === [] ) {
  680. return;
  681. }
  682. # Look up
  683. $catFind = new CategoryFinder;
  684. $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' );
  685. $match = $catFind->run();
  686. # Filter
  687. $newrows = [];
  688. foreach ( $match as $id ) {
  689. foreach ( $a2r[$id] as $rev ) {
  690. $k = $rev;
  691. $newrows[$k] = $rowsarr[$k];
  692. }
  693. }
  694. $rows = new FakeResultWrapper( array_values( $newrows ) );
  695. }
  696. /**
  697. * Makes change an option link which carries all the other options
  698. *
  699. * @param string $title
  700. * @param array $override Options to override
  701. * @param array $options Current options
  702. * @param bool $active Whether to show the link in bold
  703. * @return string
  704. */
  705. function makeOptionsLink( $title, $override, $options, $active = false ) {
  706. $params = $this->convertParamsForLink( $override + $options );
  707. if ( $active ) {
  708. $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
  709. }
  710. return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
  711. 'data-params' => json_encode( $override ),
  712. 'data-keys' => implode( ',', array_keys( $override ) ),
  713. ], $params );
  714. }
  715. /**
  716. * Creates the options panel.
  717. *
  718. * @param array $defaults
  719. * @param array $nondefaults
  720. * @param int $numRows Number of rows in the result to show after this header
  721. * @return string
  722. */
  723. function optionsPanel( $defaults, $nondefaults, $numRows ) {
  724. $options = $nondefaults + $defaults;
  725. $note = '';
  726. $msg = $this->msg( 'rclegend' );
  727. if ( !$msg->isDisabled() ) {
  728. $note .= Html::rawElement(
  729. 'div',
  730. [ 'class' => 'mw-rclegend' ],
  731. $msg->parse()
  732. );
  733. }
  734. $lang = $this->getLanguage();
  735. $user = $this->getUser();
  736. $config = $this->getConfig();
  737. if ( $options['from'] ) {
  738. $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
  739. [ 'from' => '' ], $nondefaults );
  740. $noteFromMsg = $this->msg( 'rcnotefrom' )
  741. ->numParams( $options['limit'] )
  742. ->params(
  743. $lang->userTimeAndDate( $options['from'], $user ),
  744. $lang->userDate( $options['from'], $user ),
  745. $lang->userTime( $options['from'], $user )
  746. )
  747. ->numParams( $numRows );
  748. $note .= Html::rawElement(
  749. 'span',
  750. [ 'class' => 'rcnotefrom' ],
  751. $noteFromMsg->parse()
  752. ) .
  753. ' ' .
  754. Html::rawElement(
  755. 'span',
  756. [ 'class' => 'rcoptions-listfromreset' ],
  757. $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
  758. ) .
  759. '<br />';
  760. }
  761. # Sort data for display and make sure it's unique after we've added user data.
  762. $linkLimits = $config->get( 'RCLinkLimits' );
  763. $linkLimits[] = $options['limit'];
  764. sort( $linkLimits );
  765. $linkLimits = array_unique( $linkLimits );
  766. $linkDays = $this->getLinkDays();
  767. $linkDays[] = $options['days'];
  768. sort( $linkDays );
  769. $linkDays = array_unique( $linkDays );
  770. // limit links
  771. $cl = [];
  772. foreach ( $linkLimits as $value ) {
  773. $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
  774. [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
  775. }
  776. $cl = $lang->pipeList( $cl );
  777. // day links, reset 'from' to none
  778. $dl = [];
  779. foreach ( $linkDays as $value ) {
  780. $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
  781. [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
  782. }
  783. $dl = $lang->pipeList( $dl );
  784. $showhide = [ 'show', 'hide' ];
  785. $links = [];
  786. foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
  787. $msg = $filter->getShowHide();
  788. $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
  789. // Extensions can define additional filters, but don't need to define the corresponding
  790. // messages. If they don't exist, just fall back to 'show' and 'hide'.
  791. if ( !$linkMessage->exists() ) {
  792. $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
  793. }
  794. $link = $this->makeOptionsLink( $linkMessage->text(),
  795. [ $key => 1 - $options[$key] ], $nondefaults );
  796. $attribs = [
  797. 'class' => "$msg rcshowhideoption clshowhideoption",
  798. 'data-filter-name' => $filter->getName(),
  799. ];
  800. if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
  801. $attribs['data-feature-in-structured-ui'] = true;
  802. }
  803. $links[] = Html::rawElement(
  804. 'span',
  805. $attribs,
  806. $this->msg( $msg )->rawParams( $link )->parse()
  807. );
  808. }
  809. // show from this onward link
  810. $timestamp = wfTimestampNow();
  811. $now = $lang->userTimeAndDate( $timestamp, $user );
  812. $timenow = $lang->userTime( $timestamp, $user );
  813. $datenow = $lang->userDate( $timestamp, $user );
  814. $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
  815. $rclinks = Html::rawElement(
  816. 'span',
  817. [ 'class' => 'rclinks' ],
  818. $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
  819. );
  820. $rclistfrom = Html::rawElement(
  821. 'span',
  822. [ 'class' => 'rclistfrom' ],
  823. $this->makeOptionsLink(
  824. $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(),
  825. [ 'from' => $timestamp, 'fromFormatted' => $now ],
  826. $nondefaults
  827. )
  828. );
  829. return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
  830. }
  831. public function isIncludable() {
  832. return true;
  833. }
  834. protected function getCacheTTL() {
  835. return 60 * 5;
  836. }
  837. public function getDefaultLimit() {
  838. $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' );
  839. // Prefer the RCFilters-specific preference if RCFilters is enabled
  840. if ( $this->isStructuredFilterUiEnabled() ) {
  841. return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue );
  842. }
  843. // Otherwise, use the system rclimit preference value
  844. return $systemPrefValue;
  845. }
  846. }