QueryPage.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. <?php
  2. /**
  3. * Base code for "query" special pages.
  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\IDatabase;
  26. use Wikimedia\Rdbms\DBError;
  27. /**
  28. * This is a class for doing query pages; since they're almost all the same,
  29. * we factor out some of the functionality into a superclass, and let
  30. * subclasses derive from it.
  31. * @ingroup SpecialPage
  32. */
  33. abstract class QueryPage extends SpecialPage {
  34. /** @var bool Whether or not we want plain listoutput rather than an ordered list */
  35. protected $listoutput = false;
  36. /** @var int The offset and limit in use, as passed to the query() function */
  37. protected $offset = 0;
  38. /** @var int */
  39. protected $limit = 0;
  40. /**
  41. * The number of rows returned by the query. Reading this variable
  42. * only makes sense in functions that are run after the query has been
  43. * done, such as preprocessResults() and formatRow().
  44. */
  45. protected $numRows;
  46. protected $cachedTimestamp = null;
  47. /**
  48. * Whether to show prev/next links
  49. */
  50. protected $shownavigation = true;
  51. /**
  52. * Get a list of query page classes and their associated special pages,
  53. * for periodic updates.
  54. *
  55. * DO NOT CHANGE THIS LIST without testing that
  56. * maintenance/updateSpecialPages.php still works.
  57. * @return array
  58. */
  59. public static function getPages() {
  60. static $qp = null;
  61. if ( $qp === null ) {
  62. // QueryPage subclass, Special page name
  63. $qp = [
  64. [ AncientPagesPage::class, 'Ancientpages' ],
  65. [ BrokenRedirectsPage::class, 'BrokenRedirects' ],
  66. [ DeadendPagesPage::class, 'Deadendpages' ],
  67. [ DoubleRedirectsPage::class, 'DoubleRedirects' ],
  68. [ FileDuplicateSearchPage::class, 'FileDuplicateSearch' ],
  69. [ ListDuplicatedFilesPage::class, 'ListDuplicatedFiles' ],
  70. [ LinkSearchPage::class, 'LinkSearch' ],
  71. [ ListredirectsPage::class, 'Listredirects' ],
  72. [ LonelyPagesPage::class, 'Lonelypages' ],
  73. [ LongPagesPage::class, 'Longpages' ],
  74. [ MediaStatisticsPage::class, 'MediaStatistics' ],
  75. [ MIMEsearchPage::class, 'MIMEsearch' ],
  76. [ MostcategoriesPage::class, 'Mostcategories' ],
  77. [ MostimagesPage::class, 'Mostimages' ],
  78. [ MostinterwikisPage::class, 'Mostinterwikis' ],
  79. [ MostlinkedCategoriesPage::class, 'Mostlinkedcategories' ],
  80. [ MostlinkedTemplatesPage::class, 'Mostlinkedtemplates' ],
  81. [ MostlinkedPage::class, 'Mostlinked' ],
  82. [ MostrevisionsPage::class, 'Mostrevisions' ],
  83. [ FewestrevisionsPage::class, 'Fewestrevisions' ],
  84. [ ShortPagesPage::class, 'Shortpages' ],
  85. [ UncategorizedCategoriesPage::class, 'Uncategorizedcategories' ],
  86. [ UncategorizedPagesPage::class, 'Uncategorizedpages' ],
  87. [ UncategorizedImagesPage::class, 'Uncategorizedimages' ],
  88. [ UncategorizedTemplatesPage::class, 'Uncategorizedtemplates' ],
  89. [ UnusedCategoriesPage::class, 'Unusedcategories' ],
  90. [ UnusedimagesPage::class, 'Unusedimages' ],
  91. [ WantedCategoriesPage::class, 'Wantedcategories' ],
  92. [ WantedFilesPage::class, 'Wantedfiles' ],
  93. [ WantedPagesPage::class, 'Wantedpages' ],
  94. [ WantedTemplatesPage::class, 'Wantedtemplates' ],
  95. [ UnwatchedpagesPage::class, 'Unwatchedpages' ],
  96. [ UnusedtemplatesPage::class, 'Unusedtemplates' ],
  97. [ WithoutInterwikiPage::class, 'Withoutinterwiki' ],
  98. ];
  99. Hooks::run( 'wgQueryPages', [ &$qp ] );
  100. }
  101. return $qp;
  102. }
  103. /**
  104. * A mutator for $this->listoutput;
  105. *
  106. * @param bool $bool
  107. */
  108. function setListoutput( $bool ) {
  109. $this->listoutput = $bool;
  110. }
  111. /**
  112. * Subclasses return an SQL query here, formatted as an array with the
  113. * following keys:
  114. * tables => Table(s) for passing to Database::select()
  115. * fields => Field(s) for passing to Database::select(), may be *
  116. * conds => WHERE conditions
  117. * options => options
  118. * join_conds => JOIN conditions
  119. *
  120. * Note that the query itself should return the following three columns:
  121. * 'namespace', 'title', and 'value'. 'value' is used for sorting.
  122. *
  123. * These may be stored in the querycache table for expensive queries,
  124. * and that cached data will be returned sometimes, so the presence of
  125. * extra fields can't be relied upon. The cached 'value' column will be
  126. * an integer; non-numeric values are useful only for sorting the
  127. * initial query (except if they're timestamps, see usesTimestamps()).
  128. *
  129. * Don't include an ORDER or LIMIT clause, they will be added.
  130. *
  131. * If this function is not overridden or returns something other than
  132. * an array, getSQL() will be used instead. This is for backwards
  133. * compatibility only and is strongly deprecated.
  134. * @return array
  135. * @since 1.18
  136. */
  137. public function getQueryInfo() {
  138. return null;
  139. }
  140. /**
  141. * For back-compat, subclasses may return a raw SQL query here, as a string.
  142. * This is strongly deprecated; getQueryInfo() should be overridden instead.
  143. * @throws MWException
  144. * @return string
  145. */
  146. function getSQL() {
  147. /* Implement getQueryInfo() instead */
  148. throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
  149. . "getQuery() properly" );
  150. }
  151. /**
  152. * Subclasses return an array of fields to order by here. Don't append
  153. * DESC to the field names, that'll be done automatically if
  154. * sortDescending() returns true.
  155. * @return array
  156. * @since 1.18
  157. */
  158. function getOrderFields() {
  159. return [ 'value' ];
  160. }
  161. /**
  162. * Does this query return timestamps rather than integers in its
  163. * 'value' field? If true, this class will convert 'value' to a
  164. * UNIX timestamp for caching.
  165. * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
  166. * or TS_UNIX (querycache) format, so be sure to always run them
  167. * through wfTimestamp()
  168. * @return bool
  169. * @since 1.18
  170. */
  171. public function usesTimestamps() {
  172. return false;
  173. }
  174. /**
  175. * Override to sort by increasing values
  176. *
  177. * @return bool
  178. */
  179. function sortDescending() {
  180. return true;
  181. }
  182. /**
  183. * Is this query expensive (for some definition of expensive)? Then we
  184. * don't let it run in miser mode. $wgDisableQueryPages causes all query
  185. * pages to be declared expensive. Some query pages are always expensive.
  186. *
  187. * @return bool
  188. */
  189. public function isExpensive() {
  190. return $this->getConfig()->get( 'DisableQueryPages' );
  191. }
  192. /**
  193. * Is the output of this query cacheable? Non-cacheable expensive pages
  194. * will be disabled in miser mode and will not have their results written
  195. * to the querycache table.
  196. * @return bool
  197. * @since 1.18
  198. */
  199. public function isCacheable() {
  200. return true;
  201. }
  202. /**
  203. * Whether or not the output of the page in question is retrieved from
  204. * the database cache.
  205. *
  206. * @return bool
  207. */
  208. public function isCached() {
  209. return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
  210. }
  211. /**
  212. * Sometime we don't want to build rss / atom feeds.
  213. *
  214. * @return bool
  215. */
  216. function isSyndicated() {
  217. return true;
  218. }
  219. /**
  220. * Formats the results of the query for display. The skin is the current
  221. * skin; you can use it for making links. The result is a single row of
  222. * result data. You should be able to grab SQL results off of it.
  223. * If the function returns false, the line output will be skipped.
  224. * @param Skin $skin
  225. * @param object $result Result row
  226. * @return string|bool String or false to skip
  227. */
  228. abstract function formatResult( $skin, $result );
  229. /**
  230. * The content returned by this function will be output before any result
  231. *
  232. * @return string
  233. */
  234. function getPageHeader() {
  235. return '';
  236. }
  237. /**
  238. * Outputs some kind of an informative message (via OutputPage) to let the
  239. * user know that the query returned nothing and thus there's nothing to
  240. * show.
  241. *
  242. * @since 1.26
  243. */
  244. protected function showEmptyText() {
  245. $this->getOutput()->addWikiMsg( 'specialpage-empty' );
  246. }
  247. /**
  248. * If using extra form wheely-dealies, return a set of parameters here
  249. * as an associative array. They will be encoded and added to the paging
  250. * links (prev/next/lengths).
  251. *
  252. * @return array
  253. */
  254. function linkParameters() {
  255. return [];
  256. }
  257. /**
  258. * Some special pages (for example SpecialListusers used to) might not return the
  259. * current object formatted, but return the previous one instead.
  260. * Setting this to return true will ensure formatResult() is called
  261. * one more time to make sure that the very last result is formatted
  262. * as well.
  263. *
  264. * @deprecated since 1.27
  265. *
  266. * @return bool
  267. */
  268. function tryLastResult() {
  269. return false;
  270. }
  271. /**
  272. * Clear the cache and save new results
  273. *
  274. * @param int|bool $limit Limit for SQL statement
  275. * @param bool $ignoreErrors Whether to ignore database errors
  276. * @throws DBError|Exception
  277. * @return bool|int
  278. */
  279. public function recache( $limit, $ignoreErrors = true ) {
  280. if ( !$this->isCacheable() ) {
  281. return 0;
  282. }
  283. $fname = static::class . '::recache';
  284. $dbw = wfGetDB( DB_MASTER );
  285. if ( !$dbw ) {
  286. return false;
  287. }
  288. try {
  289. # Do query
  290. $res = $this->reallyDoQuery( $limit, false );
  291. $num = false;
  292. if ( $res ) {
  293. $num = $res->numRows();
  294. # Fetch results
  295. $vals = [];
  296. foreach ( $res as $row ) {
  297. if ( isset( $row->value ) ) {
  298. if ( $this->usesTimestamps() ) {
  299. $value = wfTimestamp( TS_UNIX,
  300. $row->value );
  301. } else {
  302. $value = intval( $row->value ); // T16414
  303. }
  304. } else {
  305. $value = 0;
  306. }
  307. $vals[] = [
  308. 'qc_type' => $this->getName(),
  309. 'qc_namespace' => $row->namespace,
  310. 'qc_title' => $row->title,
  311. 'qc_value' => $value
  312. ];
  313. }
  314. $dbw->doAtomicSection(
  315. __METHOD__,
  316. function ( IDatabase $dbw, $fname ) use ( $vals ) {
  317. # Clear out any old cached data
  318. $dbw->delete( 'querycache',
  319. [ 'qc_type' => $this->getName() ],
  320. $fname
  321. );
  322. # Save results into the querycache table on the master
  323. if ( count( $vals ) ) {
  324. $dbw->insert( 'querycache', $vals, $fname );
  325. }
  326. # Update the querycache_info record for the page
  327. $dbw->delete( 'querycache_info',
  328. [ 'qci_type' => $this->getName() ],
  329. $fname
  330. );
  331. $dbw->insert( 'querycache_info',
  332. [ 'qci_type' => $this->getName(),
  333. 'qci_timestamp' => $dbw->timestamp() ],
  334. $fname
  335. );
  336. }
  337. );
  338. }
  339. } catch ( DBError $e ) {
  340. if ( !$ignoreErrors ) {
  341. throw $e; // report query error
  342. }
  343. $num = false; // set result to false to indicate error
  344. }
  345. return $num;
  346. }
  347. /**
  348. * Get a DB connection to be used for slow recache queries
  349. * @return IDatabase
  350. */
  351. function getRecacheDB() {
  352. return wfGetDB( DB_REPLICA, [ $this->getName(), 'QueryPage::recache', 'vslow' ] );
  353. }
  354. /**
  355. * Run the query and return the result
  356. * @param int|bool $limit Numerical limit or false for no limit
  357. * @param int|bool $offset Numerical offset or false for no offset
  358. * @return IResultWrapper
  359. * @since 1.18
  360. */
  361. public function reallyDoQuery( $limit, $offset = false ) {
  362. $fname = static::class . '::reallyDoQuery';
  363. $dbr = $this->getRecacheDB();
  364. $query = $this->getQueryInfo();
  365. $order = $this->getOrderFields();
  366. if ( $this->sortDescending() ) {
  367. foreach ( $order as &$field ) {
  368. $field .= ' DESC';
  369. }
  370. }
  371. if ( is_array( $query ) ) {
  372. $tables = isset( $query['tables'] ) ? (array)$query['tables'] : [];
  373. $fields = isset( $query['fields'] ) ? (array)$query['fields'] : [];
  374. $conds = isset( $query['conds'] ) ? (array)$query['conds'] : [];
  375. $options = isset( $query['options'] ) ? (array)$query['options'] : [];
  376. $join_conds = isset( $query['join_conds'] ) ? (array)$query['join_conds'] : [];
  377. if ( $order ) {
  378. $options['ORDER BY'] = $order;
  379. }
  380. if ( $limit !== false ) {
  381. $options['LIMIT'] = intval( $limit );
  382. }
  383. if ( $offset !== false ) {
  384. $options['OFFSET'] = intval( $offset );
  385. }
  386. $res = $dbr->select( $tables, $fields, $conds, $fname,
  387. $options, $join_conds
  388. );
  389. } else {
  390. // Old-fashioned raw SQL style, deprecated
  391. $sql = $this->getSQL();
  392. $sql .= ' ORDER BY ' . implode( ', ', $order );
  393. $sql = $dbr->limitResult( $sql, $limit, $offset );
  394. $res = $dbr->query( $sql, $fname );
  395. }
  396. return $res;
  397. }
  398. /**
  399. * Somewhat deprecated, you probably want to be using execute()
  400. * @param int|bool $offset
  401. * @param int|bool $limit
  402. * @return IResultWrapper
  403. */
  404. public function doQuery( $offset = false, $limit = false ) {
  405. if ( $this->isCached() && $this->isCacheable() ) {
  406. return $this->fetchFromCache( $limit, $offset );
  407. } else {
  408. return $this->reallyDoQuery( $limit, $offset );
  409. }
  410. }
  411. /**
  412. * Fetch the query results from the query cache
  413. * @param int|bool $limit Numerical limit or false for no limit
  414. * @param int|bool $offset Numerical offset or false for no offset
  415. * @return IResultWrapper
  416. * @since 1.18
  417. */
  418. public function fetchFromCache( $limit, $offset = false ) {
  419. $dbr = wfGetDB( DB_REPLICA );
  420. $options = [];
  421. if ( $limit !== false ) {
  422. $options['LIMIT'] = intval( $limit );
  423. }
  424. if ( $offset !== false ) {
  425. $options['OFFSET'] = intval( $offset );
  426. }
  427. $order = $this->getCacheOrderFields();
  428. if ( $this->sortDescending() ) {
  429. foreach ( $order as &$field ) {
  430. $field .= " DESC";
  431. }
  432. }
  433. if ( $order ) {
  434. $options['ORDER BY'] = $order;
  435. }
  436. return $dbr->select( 'querycache',
  437. [ 'qc_type',
  438. 'namespace' => 'qc_namespace',
  439. 'title' => 'qc_title',
  440. 'value' => 'qc_value' ],
  441. [ 'qc_type' => $this->getName() ],
  442. __METHOD__,
  443. $options
  444. );
  445. }
  446. /**
  447. * Return the order fields for fetchFromCache. Default is to always use
  448. * "ORDER BY value" which was the default prior to this function.
  449. * @return array
  450. * @since 1.29
  451. */
  452. function getCacheOrderFields() {
  453. return [ 'value' ];
  454. }
  455. public function getCachedTimestamp() {
  456. if ( is_null( $this->cachedTimestamp ) ) {
  457. $dbr = wfGetDB( DB_REPLICA );
  458. $fname = static::class . '::getCachedTimestamp';
  459. $this->cachedTimestamp = $dbr->selectField( 'querycache_info', 'qci_timestamp',
  460. [ 'qci_type' => $this->getName() ], $fname );
  461. }
  462. return $this->cachedTimestamp;
  463. }
  464. /**
  465. * Returns limit and offset, as returned by $this->getRequest()->getLimitOffset().
  466. * Subclasses may override this to further restrict or modify limit and offset.
  467. *
  468. * @note Restricts the offset parameter, as most query pages have inefficient paging
  469. *
  470. * Its generally expected that the returned limit will not be 0, and the returned
  471. * offset will be less than the max results.
  472. *
  473. * @since 1.26
  474. * @return int[] list( $limit, $offset )
  475. */
  476. protected function getLimitOffset() {
  477. list( $limit, $offset ) = $this->getRequest()->getLimitOffset();
  478. if ( $this->getConfig()->get( 'MiserMode' ) ) {
  479. $maxResults = $this->getMaxResults();
  480. // Can't display more than max results on a page
  481. $limit = min( $limit, $maxResults );
  482. // Can't skip over more than the end of $maxResults
  483. $offset = min( $offset, $maxResults + 1 );
  484. }
  485. return [ $limit, $offset ];
  486. }
  487. /**
  488. * What is limit to fetch from DB
  489. *
  490. * Used to make it appear the DB stores less results then it actually does
  491. * @param int $uiLimit Limit from UI
  492. * @param int $uiOffset Offset from UI
  493. * @return int Limit to use for DB (not including extra row to see if at end)
  494. */
  495. protected function getDBLimit( $uiLimit, $uiOffset ) {
  496. $maxResults = $this->getMaxResults();
  497. if ( $this->getConfig()->get( 'MiserMode' ) ) {
  498. $limit = min( $uiLimit + 1, $maxResults - $uiOffset );
  499. return max( $limit, 0 );
  500. } else {
  501. return $uiLimit + 1;
  502. }
  503. }
  504. /**
  505. * Get max number of results we can return in miser mode.
  506. *
  507. * Most QueryPage subclasses use inefficient paging, so limit the max amount we return
  508. * This matters for uncached query pages that might otherwise accept an offset of 3 million
  509. *
  510. * @since 1.27
  511. * @return int
  512. */
  513. protected function getMaxResults() {
  514. // Max of 10000, unless we store more than 10000 in query cache.
  515. return max( $this->getConfig()->get( 'QueryCacheLimit' ), 10000 );
  516. }
  517. /**
  518. * This is the actual workhorse. It does everything needed to make a
  519. * real, honest-to-gosh query page.
  520. * @param string $par
  521. */
  522. public function execute( $par ) {
  523. $user = $this->getUser();
  524. if ( !$this->userCanExecute( $user ) ) {
  525. $this->displayRestrictionError();
  526. return;
  527. }
  528. $this->setHeaders();
  529. $this->outputHeader();
  530. $out = $this->getOutput();
  531. if ( $this->isCached() && !$this->isCacheable() ) {
  532. $out->addWikiMsg( 'querypage-disabled' );
  533. return;
  534. }
  535. $out->setSyndicated( $this->isSyndicated() );
  536. if ( $this->limit == 0 && $this->offset == 0 ) {
  537. list( $this->limit, $this->offset ) = $this->getLimitOffset();
  538. }
  539. $dbLimit = $this->getDBLimit( $this->limit, $this->offset );
  540. // @todo Use doQuery()
  541. if ( !$this->isCached() ) {
  542. # select one extra row for navigation
  543. $res = $this->reallyDoQuery( $dbLimit, $this->offset );
  544. } else {
  545. # Get the cached result, select one extra row for navigation
  546. $res = $this->fetchFromCache( $dbLimit, $this->offset );
  547. if ( !$this->listoutput ) {
  548. # Fetch the timestamp of this update
  549. $ts = $this->getCachedTimestamp();
  550. $lang = $this->getLanguage();
  551. $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
  552. if ( $ts ) {
  553. $updated = $lang->userTimeAndDate( $ts, $user );
  554. $updateddate = $lang->userDate( $ts, $user );
  555. $updatedtime = $lang->userTime( $ts, $user );
  556. $out->addMeta( 'Data-Cache-Time', $ts );
  557. $out->addJsConfigVars( 'dataCacheTime', $ts );
  558. $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
  559. } else {
  560. $out->addWikiMsg( 'perfcached', $maxResults );
  561. }
  562. # If updates on this page have been disabled, let the user know
  563. # that the data set won't be refreshed for now
  564. if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
  565. && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
  566. ) {
  567. $out->wrapWikiMsg(
  568. "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
  569. 'querypage-no-updates'
  570. );
  571. }
  572. }
  573. }
  574. $this->numRows = $res->numRows();
  575. $dbr = $this->getRecacheDB();
  576. $this->preprocessResults( $dbr, $res );
  577. $out->addHTML( Xml::openElement( 'div', [ 'class' => 'mw-spcontent' ] ) );
  578. # Top header and navigation
  579. if ( $this->shownavigation ) {
  580. $out->addHTML( $this->getPageHeader() );
  581. if ( $this->numRows > 0 ) {
  582. $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
  583. min( $this->numRows, $this->limit ), # do not show the one extra row, if exist
  584. $this->offset + 1, ( min( $this->numRows, $this->limit ) + $this->offset ) )->parseAsBlock() );
  585. # Disable the "next" link when we reach the end
  586. $miserMaxResults = $this->getConfig()->get( 'MiserMode' )
  587. && ( $this->offset + $this->limit >= $this->getMaxResults() );
  588. $atEnd = ( $this->numRows <= $this->limit ) || $miserMaxResults;
  589. $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset,
  590. $this->limit, $this->linkParameters(), $atEnd );
  591. $out->addHTML( '<p>' . $paging . '</p>' );
  592. } else {
  593. # No results to show, so don't bother with "showing X of Y" etc.
  594. # -- just let the user know and give up now
  595. $this->showEmptyText();
  596. $out->addHTML( Xml::closeElement( 'div' ) );
  597. return;
  598. }
  599. }
  600. # The actual results; specialist subclasses will want to handle this
  601. # with more than a straight list, so we hand them the info, plus
  602. # an OutputPage, and let them get on with it
  603. $this->outputResults( $out,
  604. $this->getSkin(),
  605. $dbr, # Should use a ResultWrapper for this
  606. $res,
  607. min( $this->numRows, $this->limit ), # do not format the one extra row, if exist
  608. $this->offset );
  609. # Repeat the paging links at the bottom
  610. if ( $this->shownavigation ) {
  611. $out->addHTML( '<p>' . $paging . '</p>' );
  612. }
  613. $out->addHTML( Xml::closeElement( 'div' ) );
  614. }
  615. /**
  616. * Format and output report results using the given information plus
  617. * OutputPage
  618. *
  619. * @param OutputPage $out OutputPage to print to
  620. * @param Skin $skin User skin to use
  621. * @param IDatabase $dbr Database (read) connection to use
  622. * @param IResultWrapper $res Result pointer
  623. * @param int $num Number of available result rows
  624. * @param int $offset Paging offset
  625. */
  626. protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
  627. if ( $num > 0 ) {
  628. $html = [];
  629. if ( !$this->listoutput ) {
  630. $html[] = $this->openList( $offset );
  631. }
  632. # $res might contain the whole 1,000 rows, so we read up to
  633. # $num [should update this to use a Pager]
  634. // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall
  635. for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++ ) {
  636. $line = $this->formatResult( $skin, $row );
  637. if ( $line ) {
  638. $html[] = $this->listoutput
  639. ? $line
  640. : "<li>{$line}</li>\n";
  641. }
  642. }
  643. # Flush the final result
  644. if ( $this->tryLastResult() ) {
  645. $row = null;
  646. $line = $this->formatResult( $skin, $row );
  647. if ( $line ) {
  648. $html[] = $this->listoutput
  649. ? $line
  650. : "<li>{$line}</li>\n";
  651. }
  652. }
  653. if ( !$this->listoutput ) {
  654. $html[] = $this->closeList();
  655. }
  656. $html = $this->listoutput
  657. ? MediaWikiServices::getInstance()->getContentLanguage()->listToText( $html )
  658. : implode( '', $html );
  659. $out->addHTML( $html );
  660. }
  661. }
  662. /**
  663. * @param int $offset
  664. * @return string
  665. */
  666. function openList( $offset ) {
  667. return "\n<ol start='" . ( $offset + 1 ) . "' class='special'>\n";
  668. }
  669. /**
  670. * @return string
  671. */
  672. function closeList() {
  673. return "</ol>\n";
  674. }
  675. /**
  676. * Do any necessary preprocessing of the result object.
  677. * @param IDatabase $db
  678. * @param IResultWrapper $res
  679. */
  680. function preprocessResults( $db, $res ) {
  681. }
  682. /**
  683. * Similar to above, but packaging in a syndicated feed instead of a web page
  684. * @param string $class
  685. * @param int $limit
  686. * @return bool
  687. */
  688. function doFeed( $class = '', $limit = 50 ) {
  689. if ( !$this->getConfig()->get( 'Feed' ) ) {
  690. $this->getOutput()->addWikiMsg( 'feed-unavailable' );
  691. return false;
  692. }
  693. $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
  694. $feedClasses = $this->getConfig()->get( 'FeedClasses' );
  695. if ( isset( $feedClasses[$class] ) ) {
  696. /** @var RSSFeed|AtomFeed $feed */
  697. $feed = new $feedClasses[$class](
  698. $this->feedTitle(),
  699. $this->feedDesc(),
  700. $this->feedUrl() );
  701. $feed->outHeader();
  702. $res = $this->reallyDoQuery( $limit, 0 );
  703. foreach ( $res as $obj ) {
  704. $item = $this->feedResult( $obj );
  705. if ( $item ) {
  706. $feed->outItem( $item );
  707. }
  708. }
  709. $feed->outFooter();
  710. return true;
  711. } else {
  712. return false;
  713. }
  714. }
  715. /**
  716. * Override for custom handling. If the titles/links are ok, just do
  717. * feedItemDesc()
  718. * @param object $row
  719. * @return FeedItem|null
  720. */
  721. function feedResult( $row ) {
  722. if ( !isset( $row->title ) ) {
  723. return null;
  724. }
  725. $title = Title::makeTitle( intval( $row->namespace ), $row->title );
  726. if ( $title ) {
  727. $date = $row->timestamp ?? '';
  728. $comments = '';
  729. if ( $title ) {
  730. $talkpage = $title->getTalkPage();
  731. $comments = $talkpage->getFullURL();
  732. }
  733. return new FeedItem(
  734. $title->getPrefixedText(),
  735. $this->feedItemDesc( $row ),
  736. $title->getFullURL(),
  737. $date,
  738. $this->feedItemAuthor( $row ),
  739. $comments );
  740. } else {
  741. return null;
  742. }
  743. }
  744. function feedItemDesc( $row ) {
  745. return isset( $row->comment ) ? htmlspecialchars( $row->comment ) : '';
  746. }
  747. function feedItemAuthor( $row ) {
  748. return $row->user_text ?? '';
  749. }
  750. function feedTitle() {
  751. $desc = $this->getDescription();
  752. $code = $this->getConfig()->get( 'LanguageCode' );
  753. $sitename = $this->getConfig()->get( 'Sitename' );
  754. return "$sitename - $desc [$code]";
  755. }
  756. function feedDesc() {
  757. return $this->msg( 'tagline' )->text();
  758. }
  759. function feedUrl() {
  760. return $this->getPageTitle()->getFullURL();
  761. }
  762. /**
  763. * Creates a new LinkBatch object, adds all pages from the passed ResultWrapper (MUST include
  764. * title and optional the namespace field) and executes the batch. This operation will pre-cache
  765. * LinkCache information like page existence and information for stub color and redirect hints.
  766. *
  767. * @param IResultWrapper $res The ResultWrapper object to process. Needs to include the title
  768. * field and namespace field, if the $ns parameter isn't set.
  769. * @param null $ns Use this namespace for the given titles in the ResultWrapper object,
  770. * instead of the namespace value of $res.
  771. */
  772. protected function executeLBFromResultWrapper( IResultWrapper $res, $ns = null ) {
  773. if ( !$res->numRows() ) {
  774. return;
  775. }
  776. $batch = new LinkBatch;
  777. foreach ( $res as $row ) {
  778. $batch->add( $ns ?? $row->namespace, $row->title );
  779. }
  780. $batch->execute();
  781. $res->seek( 0 );
  782. }
  783. }