MediaWiki  master
CategoryViewer.php
Go to the documentation of this file.
1 <?php
23 
26  public $limit;
27 
29  public $from;
30 
32  public $until;
33 
35  public $articles;
36 
39 
41  public $children;
42 
45 
47  public $showGallery;
48 
51 
54 
56  public $nextPage;
57 
59  protected $prevPage;
60 
62  public $flip;
63 
65  public $title;
66 
68  public $collation;
69 
71  public $gallery;
72 
74  private $cat;
75 
77  private $query;
78 
89  $until = [], $query = []
90  ) {
91  $this->title = $title;
92  $this->setContext( $context );
93  $this->getOutput()->addModuleStyles( [
94  'mediawiki.action.view.categoryPage.styles'
95  ] );
96  $this->from = $from;
97  $this->until = $until;
98  $this->limit = $context->getConfig()->get( 'CategoryPagingLimit' );
99  $this->cat = Category::newFromTitle( $title );
100  $this->query = $query;
101  $this->collation = Collation::singleton();
102  unset( $this->query['title'] );
103  }
104 
110  public function getHTML() {
111  $this->showGallery = $this->getConfig()->get( 'CategoryMagicGallery' )
112  && !$this->getOutput()->mNoGallery;
113 
114  $this->clearCategoryState();
115  $this->doCategoryQuery();
116  $this->finaliseCategoryState();
117 
118  $r = $this->getSubcategorySection() .
119  $this->getPagesSection() .
120  $this->getImageSection();
121 
122  if ( $r == '' ) {
123  // If there is no category content to display, only
124  // show the top part of the navigation links.
125  // @todo FIXME: Cannot be completely suppressed because it
126  // is unknown if 'until' or 'from' makes this
127  // give 0 results.
128  $r = $this->getCategoryTop();
129  } else {
130  $r = $this->getCategoryTop() .
131  $r .
132  $this->getCategoryBottom();
133  }
134 
135  // Give a proper message if category is empty
136  if ( $r == '' ) {
137  $r = $this->msg( 'category-empty' )->parseAsBlock();
138  }
139 
140  $lang = $this->getLanguage();
141  $attribs = [
142  'class' => 'mw-category-generated',
143  'lang' => $lang->getHtmlCode(),
144  'dir' => $lang->getDir()
145  ];
146  # put a div around the headings which are in the user language
147  $r = Html::openElement( 'div', $attribs ) . $r . '</div>';
148 
149  return $r;
150  }
151 
152  function clearCategoryState() {
153  $this->articles = [];
154  $this->articles_start_char = [];
155  $this->children = [];
156  $this->children_start_char = [];
157  if ( $this->showGallery ) {
158  // Note that null for mode is taken to mean use default.
159  $mode = $this->getRequest()->getVal( 'gallerymode', null );
160  try {
161  $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() );
162  } catch ( Exception $e ) {
163  // User specified something invalid, fallback to default.
164  $this->gallery = ImageGalleryBase::factory( false, $this->getContext() );
165  }
166 
167  $this->gallery->setHideBadImages();
168  } else {
169  $this->imgsNoGallery = [];
170  $this->imgsNoGallery_start_char = [];
171  }
172  }
173 
180  function addSubcategoryObject( Category $cat, $sortkey, $pageLength ) {
181  // Subcategory; strip the 'Category' namespace from the link text.
182  $title = $cat->getTitle();
183 
184  $this->children[] = $this->generateLink(
185  'subcat',
186  $title,
187  $title->isRedirect(),
188  htmlspecialchars( $title->getText() )
189  );
190 
191  $this->children_start_char[] =
192  $this->getSubcategorySortChar( $cat->getTitle(), $sortkey );
193  }
194 
195  function generateLink( $type, Title $title, $isRedirect, $html = null ) {
196  $link = null;
197  Hooks::run( 'CategoryViewer::generateLink', [ $type, $title, $html, &$link ] );
198  if ( $link === null ) {
199  $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
200  if ( $html !== null ) {
201  $html = new HtmlArmor( $html );
202  }
203  $link = $linkRenderer->makeLink( $title, $html );
204  }
205  if ( $isRedirect ) {
206  $link = '<span class="redirect-in-category">' . $link . '</span>';
207  }
208 
209  return $link;
210  }
211 
223  function getSubcategorySortChar( $title, $sortkey ) {
224  if ( $title->getPrefixedText() == $sortkey ) {
225  $word = $title->getDBkey();
226  } else {
227  $word = $sortkey;
228  }
229 
230  $firstChar = $this->collation->getFirstLetter( $word );
231 
232  return MediaWikiServices::getInstance()->getContentLanguage()->convert( $firstChar );
233  }
234 
242  function addImage( Title $title, $sortkey, $pageLength, $isRedirect = false ) {
243  if ( $this->showGallery ) {
244  $flip = $this->flip['file'];
245  if ( $flip ) {
246  $this->gallery->insert( $title );
247  } else {
248  $this->gallery->add( $title );
249  }
250  } else {
251  $this->imgsNoGallery[] = $this->generateLink( 'image', $title, $isRedirect );
252 
253  $this->imgsNoGallery_start_char[] = MediaWikiServices::getInstance()->
254  getContentLanguage()->convert( $this->collation->getFirstLetter( $sortkey ) );
255  }
256  }
257 
265  function addPage( $title, $sortkey, $pageLength, $isRedirect = false ) {
266  $this->articles[] = $this->generateLink( 'page', $title, $isRedirect );
267 
268  $this->articles_start_char[] = MediaWikiServices::getInstance()->
269  getContentLanguage()->convert( $this->collation->getFirstLetter( $sortkey ) );
270  }
271 
273  if ( $this->flip['subcat'] ) {
274  $this->children = array_reverse( $this->children );
275  $this->children_start_char = array_reverse( $this->children_start_char );
276  }
277  if ( $this->flip['page'] ) {
278  $this->articles = array_reverse( $this->articles );
279  $this->articles_start_char = array_reverse( $this->articles_start_char );
280  }
281  if ( !$this->showGallery && $this->flip['file'] ) {
282  $this->imgsNoGallery = array_reverse( $this->imgsNoGallery );
283  $this->imgsNoGallery_start_char = array_reverse( $this->imgsNoGallery_start_char );
284  }
285  }
286 
287  function doCategoryQuery() {
288  $dbr = wfGetDB( DB_REPLICA, 'category' );
289 
290  $this->nextPage = [
291  'page' => null,
292  'subcat' => null,
293  'file' => null,
294  ];
295  $this->prevPage = [
296  'page' => null,
297  'subcat' => null,
298  'file' => null,
299  ];
300 
301  $this->flip = [ 'page' => false, 'subcat' => false, 'file' => false ];
302 
303  foreach ( [ 'page', 'subcat', 'file' ] as $type ) {
304  # Get the sortkeys for start/end, if applicable. Note that if
305  # the collation in the database differs from the one
306  # set in $wgCategoryCollation, pagination might go totally haywire.
307  $extraConds = [ 'cl_type' => $type ];
308  if ( isset( $this->from[$type] ) && $this->from[$type] !== null ) {
309  $extraConds[] = 'cl_sortkey >= '
310  . $dbr->addQuotes( $this->collation->getSortKey( $this->from[$type] ) );
311  } elseif ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
312  $extraConds[] = 'cl_sortkey < '
313  . $dbr->addQuotes( $this->collation->getSortKey( $this->until[$type] ) );
314  $this->flip[$type] = true;
315  }
316 
317  $res = $dbr->select(
318  [ 'page', 'categorylinks', 'category' ],
319  array_merge(
321  [
322  'page_namespace',
323  'page_title',
324  'cl_sortkey',
325  'cat_id',
326  'cat_title',
327  'cat_subcats',
328  'cat_pages',
329  'cat_files',
330  'cl_sortkey_prefix',
331  'cl_collation'
332  ]
333  ),
334  array_merge( [ 'cl_to' => $this->title->getDBkey() ], $extraConds ),
335  __METHOD__,
336  [
337  'USE INDEX' => [ 'categorylinks' => 'cl_sortkey' ],
338  'LIMIT' => $this->limit + 1,
339  'ORDER BY' => $this->flip[$type] ? 'cl_sortkey DESC' : 'cl_sortkey',
340  ],
341  [
342  'categorylinks' => [ 'JOIN', 'cl_from = page_id' ],
343  'category' => [ 'LEFT JOIN', [
344  'cat_title = page_title',
345  'page_namespace' => NS_CATEGORY
346  ] ]
347  ]
348  );
349 
350  Hooks::run( 'CategoryViewer::doCategoryQuery', [ $type, $res ] );
351  $linkCache = MediaWikiServices::getInstance()->getLinkCache();
352 
353  $count = 0;
354  foreach ( $res as $row ) {
355  $title = Title::newFromRow( $row );
356  $linkCache->addGoodLinkObjFromRow( $title, $row );
357 
358  if ( $row->cl_collation === '' ) {
359  // Hack to make sure that while updating from 1.16 schema
360  // and db is inconsistent, that the sky doesn't fall.
361  // See r83544. Could perhaps be removed in a couple decades...
362  $humanSortkey = $row->cl_sortkey;
363  } else {
364  $humanSortkey = $title->getCategorySortkey( $row->cl_sortkey_prefix );
365  }
366 
367  if ( ++$count > $this->limit ) {
368  # We've reached the one extra which shows that there
369  # are additional pages to be had. Stop here...
370  $this->nextPage[$type] = $humanSortkey;
371  break;
372  }
373  if ( $count == $this->limit ) {
374  $this->prevPage[$type] = $humanSortkey;
375  }
376 
377  if ( $title->getNamespace() == NS_CATEGORY ) {
378  $cat = Category::newFromRow( $row, $title );
379  $this->addSubcategoryObject( $cat, $humanSortkey, $row->page_len );
380  } elseif ( $title->getNamespace() == NS_FILE ) {
381  $this->addImage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
382  } else {
383  $this->addPage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
384  }
385  }
386  }
387  }
388 
392  function getCategoryTop() {
393  $r = $this->getCategoryBottom();
394  return $r === ''
395  ? $r
396  : "<br style=\"clear:both;\"/>\n" . $r;
397  }
398 
403  # Don't show subcategories section if there are none.
404  $r = '';
405  $rescnt = count( $this->children );
406  $dbcnt = $this->cat->getSubcatCount();
407  // This function should be called even if the result isn't used, it has side-effects
408  $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'subcat' );
409 
410  if ( $rescnt > 0 ) {
411  # Showing subcategories
412  $r .= "<div id=\"mw-subcategories\">\n";
413  $r .= '<h2>' . $this->msg( 'subcategories' )->parse() . "</h2>\n";
414  $r .= $countmsg;
415  $r .= $this->getSectionPagingLinks( 'subcat' );
416  $r .= $this->formatList( $this->children, $this->children_start_char );
417  $r .= $this->getSectionPagingLinks( 'subcat' );
418  $r .= "\n</div>";
419  }
420  return $r;
421  }
422 
426  function getPagesSection() {
427  $name = $this->getOutput()->getUnprefixedDisplayTitle();
428  # Don't show articles section if there are none.
429  $r = '';
430 
431  # @todo FIXME: Here and in the other two sections: we don't need to bother
432  # with this rigmarole if the entire category contents fit on one page
433  # and have already been retrieved. We can just use $rescnt in that
434  # case and save a query and some logic.
435  $dbcnt = $this->cat->getPageCount() - $this->cat->getSubcatCount()
436  - $this->cat->getFileCount();
437  $rescnt = count( $this->articles );
438  // This function should be called even if the result isn't used, it has side-effects
439  $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'article' );
440 
441  if ( $rescnt > 0 ) {
442  $r = "<div id=\"mw-pages\">\n";
443  $r .= '<h2>' . $this->msg( 'category_header' )->rawParams( $name )->parse() . "</h2>\n";
444  $r .= $countmsg;
445  $r .= $this->getSectionPagingLinks( 'page' );
446  $r .= $this->formatList( $this->articles, $this->articles_start_char );
447  $r .= $this->getSectionPagingLinks( 'page' );
448  $r .= "\n</div>";
449  }
450  return $r;
451  }
452 
456  function getImageSection() {
457  $name = $this->getOutput()->getUnprefixedDisplayTitle();
458  $r = '';
459  $rescnt = $this->showGallery ? $this->gallery->count() : count( $this->imgsNoGallery );
460  $dbcnt = $this->cat->getFileCount();
461  // This function should be called even if the result isn't used, it has side-effects
462  $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'file' );
463 
464  if ( $rescnt > 0 ) {
465  $r .= "<div id=\"mw-category-media\">\n";
466  $r .= '<h2>' .
467  $this->msg( 'category-media-header' )->rawParams( $name )->parse() .
468  "</h2>\n";
469  $r .= $countmsg;
470  $r .= $this->getSectionPagingLinks( 'file' );
471  if ( $this->showGallery ) {
472  $r .= $this->gallery->toHTML();
473  } else {
474  $r .= $this->formatList( $this->imgsNoGallery, $this->imgsNoGallery_start_char );
475  }
476  $r .= $this->getSectionPagingLinks( 'file' );
477  $r .= "\n</div>";
478  }
479  return $r;
480  }
481 
489  private function getSectionPagingLinks( $type ) {
490  if ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
491  // The new value for the until parameter should be pointing to the first
492  // result displayed on the page which is the second last result retrieved
493  // from the database.The next link should have a from parameter pointing
494  // to the until parameter of the current page.
495  if ( $this->nextPage[$type] !== null ) {
496  return $this->pagingLinks( $this->prevPage[$type], $this->until[$type], $type );
497  } else {
498  // If the nextPage variable is null, it means that we have reached the first page
499  // and therefore the previous link should be disabled.
500  return $this->pagingLinks( null, $this->until[$type], $type );
501  }
502  } elseif ( $this->nextPage[$type] !== null
503  || ( isset( $this->from[$type] ) && $this->from[$type] !== null )
504  ) {
505  return $this->pagingLinks( $this->from[$type], $this->nextPage[$type], $type );
506  } else {
507  return '';
508  }
509  }
510 
514  function getCategoryBottom() {
515  return '';
516  }
517 
528  function formatList( $articles, $articles_start_char, $cutoff = 6 ) {
529  $list = '';
530  if ( count( $articles ) > $cutoff ) {
531  $list = self::columnList( $articles, $articles_start_char );
532  } elseif ( count( $articles ) > 0 ) {
533  // for short lists of articles in categories.
534  $list = self::shortList( $articles, $articles_start_char );
535  }
536 
537  $pageLang = $this->title->getPageLanguage();
538  $attribs = [ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(),
539  'class' => 'mw-content-' . $pageLang->getDir() ];
540  $list = Html::rawElement( 'div', $attribs, $list );
541 
542  return $list;
543  }
544 
560  $columns = array_combine( $articles, $articles_start_char );
561 
562  $ret = Html::openElement( 'div', [ 'class' => 'mw-category' ] );
563 
564  $colContents = [];
565 
566  # Kind of like array_flip() here, but we keep duplicates in an
567  # array instead of dropping them.
568  foreach ( $columns as $article => $char ) {
569  if ( !isset( $colContents[$char] ) ) {
570  $colContents[$char] = [];
571  }
572  $colContents[$char][] = $article;
573  }
574 
575  foreach ( $colContents as $char => $articles ) {
576  # Change space to non-breaking space to keep headers aligned
577  $h3char = $char === ' ' ? "\u{00A0}" : htmlspecialchars( $char );
578 
579  $ret .= '<div class="mw-category-group"><h3>' . $h3char;
580  $ret .= "</h3>\n";
581 
582  $ret .= '<ul><li>';
583  $ret .= implode( "</li>\n<li>", $articles );
584  $ret .= '</li></ul></div>';
585 
586  }
587 
588  $ret .= Html::closeElement( 'div' );
589  return $ret;
590  }
591 
601  $r = '<h3>' . htmlspecialchars( $articles_start_char[0] ) . "</h3>\n";
602  $r .= '<ul><li>' . $articles[0] . '</li>';
603  $articleCount = count( $articles );
604  for ( $index = 1; $index < $articleCount; $index++ ) {
605  if ( $articles_start_char[$index] != $articles_start_char[$index - 1] ) {
606  $r .= "</ul><h3>" . htmlspecialchars( $articles_start_char[$index] ) . "</h3>\n<ul>";
607  }
608 
609  $r .= "<li>{$articles[$index]}</li>";
610  }
611  $r .= '</ul>';
612  return $r;
613  }
614 
624  private function pagingLinks( $first, $last, $type = '' ) {
625  $prevLink = $this->msg( 'prev-page' )->escaped();
626 
627  $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
628  if ( $first != '' ) {
629  $prevQuery = $this->query;
630  $prevQuery["{$type}until"] = $first;
631  unset( $prevQuery["{$type}from"] );
632  $prevLink = $linkRenderer->makeKnownLink(
633  $this->addFragmentToTitle( $this->title, $type ),
634  new HtmlArmor( $prevLink ),
635  [],
636  $prevQuery
637  );
638  }
639 
640  $nextLink = $this->msg( 'next-page' )->escaped();
641 
642  if ( $last != '' ) {
643  $lastQuery = $this->query;
644  $lastQuery["{$type}from"] = $last;
645  unset( $lastQuery["{$type}until"] );
646  $nextLink = $linkRenderer->makeKnownLink(
647  $this->addFragmentToTitle( $this->title, $type ),
648  new HtmlArmor( $nextLink ),
649  [],
650  $lastQuery
651  );
652  }
653 
654  return $this->msg( 'categoryviewer-pagedlinks' )->rawParams( $prevLink, $nextLink )->escaped();
655  }
656 
666  private function addFragmentToTitle( $title, $section ) {
667  switch ( $section ) {
668  case 'page':
669  $fragment = 'mw-pages';
670  break;
671  case 'subcat':
672  $fragment = 'mw-subcategories';
673  break;
674  case 'file':
675  $fragment = 'mw-category-media';
676  break;
677  default:
678  throw new MWException( __METHOD__ .
679  " Invalid section $section." );
680  }
681 
683  $title->getDBkey(), $fragment );
684  }
685 
696  private function getCountMessage( $rescnt, $dbcnt, $type ) {
697  // There are three cases:
698  // 1) The category table figure seems sane. It might be wrong, but
699  // we can't do anything about it if we don't recalculate it on ev-
700  // ery category view.
701  // 2) The category table figure isn't sane, like it's smaller than the
702  // number of actual results, *but* the number of results is less
703  // than $this->limit and there's no offset. In this case we still
704  // know the right figure.
705  // 3) We have no idea.
706 
707  // Check if there's a "from" or "until" for anything
708 
709  // This is a little ugly, but we seem to use different names
710  // for the paging types then for the messages.
711  if ( $type === 'article' ) {
712  $pagingType = 'page';
713  } else {
714  $pagingType = $type;
715  }
716 
717  $fromOrUntil = false;
718  if ( ( isset( $this->from[$pagingType] ) && $this->from[$pagingType] !== null ) ||
719  ( isset( $this->until[$pagingType] ) && $this->until[$pagingType] !== null )
720  ) {
721  $fromOrUntil = true;
722  }
723 
724  if ( $dbcnt == $rescnt ||
725  ( ( $rescnt == $this->limit || $fromOrUntil ) && $dbcnt > $rescnt )
726  ) {
727  // Case 1: seems sane.
728  $totalcnt = $dbcnt;
729  } elseif ( $rescnt < $this->limit && !$fromOrUntil ) {
730  // Case 2: not sane, but salvageable. Use the number of results.
731  $totalcnt = $rescnt;
732  } else {
733  // Case 3: hopeless. Don't give a total count at all.
734  // Messages: category-subcat-count-limited, category-article-count-limited,
735  // category-file-count-limited
736  return $this->msg( "category-$type-count-limited" )->numParams( $rescnt )->parseAsBlock();
737  }
738  // Messages: category-subcat-count, category-article-count, category-file-count
739  return $this->msg( "category-$type-count" )->numParams( $rescnt, $totalcnt )->parseAsBlock();
740  }
741 }
setContext(IContextSource $context)
pagingLinks( $first, $last, $type='')
Create paging links, as a helper method to getSectionPagingLinks().
array $articles_start_char
static newFromRow( $row, $title=null)
Factory function, for constructing a Category object from a result set.
Definition: Category.php:179
addFragmentToTitle( $title, $section)
Takes a title, and adds the fragment identifier that corresponds to the correct segment of the catego...
getSubcategorySortChar( $title, $sortkey)
Get the character to be used for sorting subcategories.
getText()
Get the text form (spaces not underscores) of the main part.
Definition: Title.php:998
static newFromTitle( $title)
Factory function.
Definition: Category.php:146
The simplest way of implementing IContextSource is to hold a RequestContext as a member variable and ...
ImageGalleryBase $gallery
static singleton()
Definition: Collation.php:36
if(!isset( $args[0])) $lang
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing &#39;/&#39;...
Definition: Html.php:251
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
array $children_start_char
static factory( $mode=false, IContextSource $context=null)
Get a new image gallery.
getPrefixedText()
Get the prefixed title with spaces.
Definition: Title.php:1858
static newFromRow( $row)
Make a Title object from a DB row.
Definition: Title.php:518
getCategorySortkey( $prefix='')
Returns the raw sort key to be used for categories, with the specified prefix.
Definition: Title.php:4580
IContextSource $context
$last
getConfig()
Get the site configuration.
getTitle()
Definition: Category.php:251
getDBkey()
Get the main part with underscores.
Definition: Title.php:1016
Collation $collation
getContext()
Get the base IContextSource object.
const NS_CATEGORY
Definition: Defines.php:74
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:315
getCountMessage( $rescnt, $dbcnt, $type)
What to do if the category table conflicts with the number of results returned? This function says wh...
array $query
The original query array, to be used in generating paging links.
addImage(Title $title, $sortkey, $pageLength, $isRedirect=false)
Add a page in the image namespace.
formatList( $articles, $articles_start_char, $cutoff=6)
Format a list of articles chunked by letter, either as a bullet list or a columnar format...
getNamespace()
Get the namespace index, i.e.
Definition: Title.php:1040
static shortList( $articles, $articles_start_char)
Format a list of articles chunked by letter in a bullet list.
const NS_FILE
Definition: Defines.php:66
generateLink( $type, Title $title, $isRedirect, $html=null)
array $imgsNoGallery_start_char
static getSelectFields()
Fields that LinkCache needs to select.
Definition: LinkCache.php:219
addSubcategoryObject(Category $cat, $sortkey, $pageLength)
Add a subcategory to the internal lists, using a Category object.
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:586
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
getSectionPagingLinks( $type)
Get the paging links for a section (subcats/pages/files), to go at the top and bottom of the output...
__construct( $title, IContextSource $context, $from=[], $until=[], $query=[])
isRedirect( $flags=0)
Is this an article that is a redirect page? Uses link cache, adding it if necessary.
Definition: Title.php:3195
const DB_REPLICA
Definition: defines.php:25
addPage( $title, $sortkey, $pageLength, $isRedirect=false)
Add a miscellaneous page.
string [] $articles
getHTML()
Format the category data list.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
static columnList( $articles, $articles_start_char)
Format a list of articles chunked by letter in a three-column list, ordered vertically.
Category $cat
Category object for this page.