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