FullSearchResultWidget.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. <?php
  2. namespace MediaWiki\Widget\Search;
  3. use Category;
  4. use Hooks;
  5. use HtmlArmor;
  6. use MediaWiki\Linker\LinkRenderer;
  7. use MediaWiki\MediaWikiServices;
  8. use SearchResult;
  9. use SpecialSearch;
  10. use Title;
  11. /**
  12. * Renders a 'full' multi-line search result with metadata.
  13. *
  14. * The Title
  15. * some *highlighted* *text* about the search result
  16. * 5KB (651 words) - 12:40, 6 Aug 2016
  17. */
  18. class FullSearchResultWidget implements SearchResultWidget {
  19. /** @var SpecialSearch */
  20. protected $specialPage;
  21. /** @var LinkRenderer */
  22. protected $linkRenderer;
  23. public function __construct( SpecialSearch $specialPage, LinkRenderer $linkRenderer ) {
  24. $this->specialPage = $specialPage;
  25. $this->linkRenderer = $linkRenderer;
  26. }
  27. /**
  28. * @param SearchResult $result The result to render
  29. * @param int $position The result position, including offset
  30. * @return string HTML
  31. */
  32. public function render( SearchResult $result, $position ) {
  33. // If the page doesn't *exist*... our search index is out of date.
  34. // The least confusing at this point is to drop the result.
  35. // You may get less results, but... on well. :P
  36. if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
  37. return '';
  38. }
  39. $link = $this->generateMainLinkHtml( $result, $position );
  40. // If page content is not readable, just return ths title.
  41. // This is not quite safe, but better than showing excerpts from
  42. // non-readable pages. Note that hiding the entry entirely would
  43. // screw up paging (really?).
  44. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  45. if ( !$permissionManager->userCan(
  46. 'read', $this->specialPage->getUser(), $result->getTitle()
  47. ) ) {
  48. return "<li>{$link}</li>";
  49. }
  50. $redirect = $this->generateRedirectHtml( $result );
  51. $section = $this->generateSectionHtml( $result );
  52. $category = $this->generateCategoryHtml( $result );
  53. $date = $this->specialPage->getLanguage()->userTimeAndDate(
  54. $result->getTimestamp(),
  55. $this->specialPage->getUser()
  56. );
  57. list( $file, $desc, $thumb ) = $this->generateFileHtml( $result );
  58. $snippet = $result->getTextSnippet();
  59. if ( $snippet ) {
  60. $extract = "<div class='searchresult'>$snippet</div>";
  61. } else {
  62. $extract = '';
  63. }
  64. if ( $thumb === null ) {
  65. // If no thumb, then the description is about size
  66. $desc = $this->generateSizeHtml( $result );
  67. // Let hooks do their own final construction if desired.
  68. // FIXME: Not sure why this is only for results without thumbnails,
  69. // but keeping it as-is for now to prevent breaking hook consumers.
  70. $html = null;
  71. $score = '';
  72. $related = '';
  73. // TODO: remove this instanceof and always pass [], let implementors do the cast if
  74. // they want to be SearchDatabase specific
  75. $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : [];
  76. if ( !Hooks::run( 'ShowSearchHit', [
  77. $this->specialPage, $result, $terms,
  78. &$link, &$redirect, &$section, &$extract,
  79. &$score, &$desc, &$date, &$related, &$html
  80. ] ) ) {
  81. return $html;
  82. }
  83. }
  84. // All the pieces have been collected. Now generate the final HTML
  85. $joined = "{$link} {$redirect} {$category} {$section} {$file}";
  86. $meta = $this->buildMeta( $desc, $date );
  87. if ( $thumb === null ) {
  88. $html =
  89. "<div class='mw-search-result-heading'>{$joined}</div>" .
  90. "{$extract} {$meta}";
  91. } else {
  92. $html =
  93. "<table class='searchResultImage'>" .
  94. "<tr>" .
  95. "<td style='width: 120px; text-align: center; vertical-align: top'>" .
  96. $thumb .
  97. "</td>" .
  98. "<td style='vertical-align: top'>" .
  99. "{$joined} {$extract} {$meta}" .
  100. "</td>" .
  101. "</tr>" .
  102. "</table>";
  103. }
  104. return "<li class='mw-search-result'>{$html}</li>";
  105. }
  106. /**
  107. * Generates HTML for the primary call to action. It is
  108. * typically the article title, but the search engine can
  109. * return an exact snippet to use (typically the article
  110. * title with highlighted words).
  111. *
  112. * @param SearchResult $result
  113. * @param int $position
  114. * @return string HTML
  115. */
  116. protected function generateMainLinkHtml( SearchResult $result, $position ) {
  117. $snippet = $result->getTitleSnippet();
  118. if ( $snippet === '' ) {
  119. $snippet = null;
  120. } else {
  121. $snippet = new HtmlArmor( $snippet );
  122. }
  123. // clone to prevent hook from changing the title stored inside $result
  124. $title = clone $result->getTitle();
  125. $query = [];
  126. $attributes = [ 'data-serp-pos' => $position ];
  127. Hooks::run( 'ShowSearchHitTitle',
  128. [ &$title, &$snippet, $result,
  129. $result instanceof \SqlSearchResult ? $result->getTermMatches() : [],
  130. $this->specialPage, &$query, &$attributes ] );
  131. $link = $this->linkRenderer->makeLink(
  132. $title,
  133. $snippet,
  134. $attributes,
  135. $query
  136. );
  137. return $link;
  138. }
  139. /**
  140. * Generates an alternate title link, such as (redirect from <a>Foo</a>).
  141. *
  142. * @param string $msgKey i18n message used to wrap title
  143. * @param Title|null $title The title to link to, or null to generate
  144. * the message without a link. In that case $text must be non-null.
  145. * @param string|null $text The text snippet to display, or null
  146. * to use the title
  147. * @return string HTML
  148. */
  149. protected function generateAltTitleHtml( $msgKey, Title $title = null, $text ) {
  150. $inner = $title === null
  151. ? $text
  152. : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
  153. return "<span class='searchalttitle'>" .
  154. $this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
  155. . "</span>";
  156. }
  157. /**
  158. * @param SearchResult $result
  159. * @return string HTML
  160. */
  161. protected function generateRedirectHtml( SearchResult $result ) {
  162. $title = $result->getRedirectTitle();
  163. return $title === null
  164. ? ''
  165. : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
  166. }
  167. /**
  168. * @param SearchResult $result
  169. * @return string HTML
  170. */
  171. protected function generateSectionHtml( SearchResult $result ) {
  172. $title = $result->getSectionTitle();
  173. return $title === null
  174. ? ''
  175. : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
  176. }
  177. /**
  178. * @param SearchResult $result
  179. * @return string HTML
  180. */
  181. protected function generateCategoryHtml( SearchResult $result ) {
  182. $snippet = $result->getCategorySnippet();
  183. return $snippet
  184. ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
  185. : '';
  186. }
  187. /**
  188. * @param SearchResult $result
  189. * @return string HTML
  190. */
  191. protected function generateSizeHtml( SearchResult $result ) {
  192. $title = $result->getTitle();
  193. if ( $title->getNamespace() === NS_CATEGORY ) {
  194. $cat = Category::newFromTitle( $title );
  195. return $this->specialPage->msg( 'search-result-category-size' )
  196. ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() )
  197. ->escaped();
  198. // TODO: This is a bit odd...but requires changing the i18n message to fix
  199. } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
  200. $lang = $this->specialPage->getLanguage();
  201. $bytes = $lang->formatSize( $result->getByteSize() );
  202. $words = $result->getWordCount();
  203. return $this->specialPage->msg( 'search-result-size', $bytes )
  204. ->numParams( $words )
  205. ->escaped();
  206. }
  207. return '';
  208. }
  209. /**
  210. * @param SearchResult $result
  211. * @return array Three element array containing the main file html,
  212. * a text description of the file, and finally the thumbnail html.
  213. * If no thumbnail is available the second and third will be null.
  214. */
  215. protected function generateFileHtml( SearchResult $result ) {
  216. $title = $result->getTitle();
  217. if ( $title->getNamespace() !== NS_FILE ) {
  218. return [ '', null, null ];
  219. }
  220. if ( $result->isFileMatch() ) {
  221. $html = "<span class='searchalttitle'>" .
  222. $this->specialPage->msg( 'search-file-match' )->escaped() .
  223. "</span>";
  224. } else {
  225. $html = '';
  226. }
  227. $descHtml = null;
  228. $thumbHtml = null;
  229. $img = $result->getFile() ?: MediaWikiServices::getInstance()->getRepoGroup()
  230. ->findFile( $title );
  231. if ( $img ) {
  232. $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] );
  233. if ( $thumb ) {
  234. $descHtml = $this->specialPage->msg( 'parentheses' )
  235. ->rawParams( $img->getShortDesc() )
  236. ->escaped();
  237. $thumbHtml = $thumb->toHtml( [ 'desc-link' => true ] );
  238. }
  239. }
  240. return [ $html, $descHtml, $thumbHtml ];
  241. }
  242. /**
  243. * @param string $desc HTML description of result, ex: size in bytes, or empty string
  244. * @param string $date HTML representation of last edit date, or empty string
  245. * @return string HTML A div combining $desc and $date with a separator in a <div>.
  246. * If either is missing only one will be represented. If both are missing an empty
  247. * string will be returned.
  248. */
  249. protected function buildMeta( $desc, $date ) {
  250. if ( $desc && $date ) {
  251. $meta = "{$desc} - {$date}";
  252. } elseif ( $desc ) {
  253. $meta = $desc;
  254. } elseif ( $date ) {
  255. $meta = $date;
  256. } else {
  257. return '';
  258. }
  259. return "<div class='mw-search-result-data'>{$meta}</div>";
  260. }
  261. }