3011a884ddbf4a2de896a1e9c5ca6adcf64df882
[lhc/web/wiklou.git] / includes / CategoryPage.php
1 <?php
2 /**
3 * Special handling for category description pages.
4 * Modelled after ImagePage.php.
5 *
6 * @file
7 */
8
9 if ( !defined( 'MEDIAWIKI' ) )
10 die( 1 );
11
12 /**
13 * Special handling for category description pages, showing pages,
14 * subcategories and file that belong to the category
15 */
16 class CategoryPage extends Article {
17 # Subclasses can change this to override the viewer class.
18 protected $mCategoryViewerClass = 'CategoryViewer';
19
20 function view() {
21 global $wgRequest, $wgUser;
22
23 $diff = $wgRequest->getVal( 'diff' );
24 $diffOnly = $wgRequest->getBool( 'diffonly', $wgUser->getOption( 'diffonly' ) );
25
26 if ( isset( $diff ) && $diffOnly ) {
27 return parent::view();
28 }
29
30 if ( !wfRunHooks( 'CategoryPageView', array( &$this ) ) ) {
31 return;
32 }
33
34 if ( NS_CATEGORY == $this->mTitle->getNamespace() ) {
35 $this->openShowCategory();
36 }
37
38 parent::view();
39
40 if ( NS_CATEGORY == $this->mTitle->getNamespace() ) {
41 $this->closeShowCategory();
42 }
43 }
44
45 /**
46 * Don't return a 404 for categories in use.
47 * In use defined as: either the actual page exists
48 * or the category currently has members.
49 */
50 function hasViewableContent() {
51 if ( parent::hasViewableContent() ) {
52 return true;
53 } else {
54 $cat = Category::newFromTitle( $this->mTitle );
55 // If any of these are not 0, then has members
56 if ( $cat->getPageCount()
57 || $cat->getSubcatCount()
58 || $cat->getFileCount()
59 ) {
60 return true;
61 }
62 }
63 return false;
64 }
65
66 function openShowCategory() {
67 # For overloading
68 }
69
70 function closeShowCategory() {
71 global $wgOut;
72
73 $from = $until = array();
74 foreach ( array( 'page', 'subcat', 'file' ) as $type ) {
75 # Use $_GET instead of $wgRequest, because the latter helpfully
76 # normalizes Unicode, which removes nulls. TODO: do something
77 # smarter than passing nulls in URLs. :/
78 $from[$type] = isset( $_GET["{$type}from"] ) ? $_GET["{$type}from"] : null;
79 $until[$type] = isset( $_GET["{$type}until"] ) ? $_GET["{$type}until"] : null;
80 }
81
82 $viewer = new $this->mCategoryViewerClass( $this->mTitle, $from, $until, $_GET );
83 $wgOut->addHTML( $viewer->getHTML() );
84 }
85 }
86
87 class CategoryViewer {
88 var $title, $limit, $from, $until,
89 $articles, $articles_start_char,
90 $children, $children_start_char,
91 $showGallery, $gallery,
92 $imgsNoGalley, $imgsNoGallery_start_char,
93 $skin, $collation;
94 # Category object for this page
95 private $cat;
96 # The original query array, to be used in generating paging links.
97 private $query;
98
99 function __construct( $title, $from = '', $until = '', $query = array() ) {
100 global $wgCategoryPagingLimit;
101 $this->title = $title;
102 $this->from = $from;
103 $this->until = $until;
104 $this->limit = $wgCategoryPagingLimit;
105 $this->cat = Category::newFromTitle( $title );
106 $this->query = $query;
107 $this->collation = Collation::singleton();
108 unset( $this->query['title'] );
109 }
110
111 /**
112 * Format the category data list.
113 *
114 * @return string HTML output
115 */
116 public function getHTML() {
117 global $wgOut, $wgCategoryMagicGallery, $wgContLang;
118 wfProfileIn( __METHOD__ );
119
120 $this->showGallery = $wgCategoryMagicGallery && !$wgOut->mNoGallery;
121
122 $this->clearCategoryState();
123 $this->doCategoryQuery();
124 $this->finaliseCategoryState();
125
126 $r = $this->getSubcategorySection() .
127 $this->getPagesSection() .
128 $this->getImageSection();
129
130 if ( $r == '' ) {
131 // If there is no category content to display, only
132 // show the top part of the navigation links.
133 // FIXME: cannot be completely suppressed because it
134 // is unknown if 'until' or 'from' makes this
135 // give 0 results.
136 $r = $r . $this->getCategoryTop();
137 } else {
138 $r = $this->getCategoryTop() .
139 $r .
140 $this->getCategoryBottom();
141 }
142
143 // Give a proper message if category is empty
144 if ( $r == '' ) {
145 $r = wfMsgExt( 'category-empty', array( 'parse' ) );
146 }
147
148 wfProfileOut( __METHOD__ );
149 return $wgContLang->convert( $r );
150 }
151
152 function clearCategoryState() {
153 $this->articles = array();
154 $this->articles_start_char = array();
155 $this->children = array();
156 $this->children_start_char = array();
157 if ( $this->showGallery ) {
158 $this->gallery = new ImageGallery();
159 $this->gallery->setHideBadImages();
160 } else {
161 $this->imgsNoGallery = array();
162 $this->imgsNoGallery_start_char = array();
163 }
164 }
165
166 function getSkin() {
167 if ( !$this->skin ) {
168 global $wgUser;
169 $this->skin = $wgUser->getSkin();
170 }
171 return $this->skin;
172 }
173
174 /**
175 * Add a subcategory to the internal lists, using a Category object
176 */
177 function addSubcategoryObject( Category $cat, $sortkey, $pageLength ) {
178 // Subcategory; strip the 'Category' namespace from the link text.
179 $title = $cat->getTitle();
180 $this->children[] = $this->getSkin()->link(
181 $title,
182 $title->getText(),
183 array(),
184 array(),
185 array( 'known', 'noclasses' )
186 );
187
188 $this->children_start_char[] =
189 $this->getSubcategorySortChar( $cat->getTitle(), $sortkey );
190 }
191
192 /**
193 * Add a subcategory to the internal lists, using a title object
194 * @deprecated kept for compatibility, please use addSubcategoryObject instead
195 */
196 function addSubcategory( Title $title, $sortkey, $pageLength ) {
197 $this->addSubcategoryObject( Category::newFromTitle( $title ), $sortkey, $pageLength );
198 }
199
200 /**
201 * Get the character to be used for sorting subcategories.
202 * If there's a link from Category:A to Category:B, the sortkey of the resulting
203 * entry in the categorylinks table is Category:A, not A, which it SHOULD be.
204 * Workaround: If sortkey == "Category:".$title, than use $title for sorting,
205 * else use sortkey...
206 */
207 function getSubcategorySortChar( $title, $sortkey ) {
208 global $wgContLang;
209
210 if ( $title->getPrefixedText() == $sortkey ) {
211 $word = $title->getDBkey();
212 } else {
213 $word = $sortkey;
214 }
215
216 $firstChar = $this->collation->getFirstLetter( $word );
217
218 return $wgContLang->convert( $firstChar );
219 }
220
221 /**
222 * Add a page in the image namespace
223 */
224 function addImage( Title $title, $sortkey, $pageLength, $isRedirect = false ) {
225 global $wgContLang;
226 if ( $this->showGallery ) {
227 $flip = $this->flip['file'];
228 if ( $flip ) {
229 $this->gallery->insert( $title );
230 } else {
231 $this->gallery->add( $title );
232 }
233 } else {
234 $this->imgsNoGallery[] = $isRedirect
235 ? '<span class="redirect-in-category">' .
236 $this->getSkin()->link(
237 $title,
238 null,
239 array(),
240 array(),
241 array( 'known', 'noclasses' )
242 ) . '</span>'
243 : $this->getSkin()->link( $title );
244
245 $this->imgsNoGallery_start_char[] = $wgContLang->convert(
246 $this->collation->getFirstLetter( $sortkey ) );
247 }
248 }
249
250 /**
251 * Add a miscellaneous page
252 */
253 function addPage( $title, $sortkey, $pageLength, $isRedirect = false ) {
254 global $wgContLang;
255 $this->articles[] = $isRedirect
256 ? '<span class="redirect-in-category">' .
257 $this->getSkin()->link(
258 $title,
259 null,
260 array(),
261 array(),
262 array( 'known', 'noclasses' )
263 ) . '</span>'
264 : $this->getSkin()->link( $title );
265
266 $this->articles_start_char[] = $wgContLang->convert(
267 $this->collation->getFirstLetter( $sortkey ) );
268 }
269
270 function finaliseCategoryState() {
271 if ( $this->flip['subcat'] ) {
272 $this->children = array_reverse( $this->children );
273 $this->children_start_char = array_reverse( $this->children_start_char );
274 }
275 if ( $this->flip['page'] ) {
276 $this->articles = array_reverse( $this->articles );
277 $this->articles_start_char = array_reverse( $this->articles_start_char );
278 }
279 if ( !$this->showGallery && $this->flip['file'] ) {
280 $this->imgsNoGallery = array_reverse( $this->imgsNoGallery );
281 $this->imgsNoGallery_start_char = array_reverse( $this->imgsNoGallery_start_char );
282 }
283 }
284
285 function doCategoryQuery() {
286 $dbr = wfGetDB( DB_SLAVE, 'category' );
287
288 $this->nextPage = array(
289 'page' => null,
290 'subcat' => null,
291 'file' => null,
292 );
293 $this->flip = array( 'page' => false, 'subcat' => false, 'file' => false );
294
295 foreach ( array( 'page', 'subcat', 'file' ) as $type ) {
296 # Get the sortkeys for start/end, if applicable. Note that if
297 # the collation in the database differs from the one
298 # set in $wgCategoryCollation, pagination might go totally haywire.
299 $extraConds = array( 'cl_type' => $type );
300 if ( $this->from[$type] !== null ) {
301 $extraConds[] = 'cl_sortkey >= '
302 . $dbr->addQuotes( $this->collation->getSortKey( $this->from[$type] ) );
303 } elseif ( $this->until[$type] !== null ) {
304 $extraConds[] = 'cl_sortkey < '
305 . $dbr->addQuotes( $this->collation->getSortKey( $this->until[$type] ) );
306 $this->flip[$type] = true;
307 }
308
309 $res = $dbr->select(
310 array( 'page', 'categorylinks', 'category' ),
311 array( 'page_id', 'page_title', 'page_namespace', 'page_len',
312 'page_is_redirect', 'cl_sortkey', 'cat_id', 'cat_title',
313 'cat_subcats', 'cat_pages', 'cat_files', 'cl_sortkey_prefix' ),
314 array( 'cl_to' => $this->title->getDBkey() ) + $extraConds,
315 __METHOD__,
316 array(
317 'USE INDEX' => array( 'categorylinks' => 'cl_sortkey' ),
318 'LIMIT' => $this->limit + 1,
319 'ORDER BY' => $this->flip[$type] ? 'cl_sortkey DESC' : 'cl_sortkey',
320 ),
321 array(
322 'categorylinks' => array( 'INNER JOIN', 'cl_from = page_id' ),
323 'category' => array( 'LEFT JOIN', 'cat_title = page_title AND page_namespace = ' . NS_CATEGORY )
324 )
325 );
326
327 $count = 0;
328 foreach ( $res as $row ) {
329 $title = Title::newFromRow( $row );
330 $rawSortkey = $title->getCategorySortkey( $row->cl_sortkey_prefix );
331
332 if ( ++$count > $this->limit ) {
333 # We've reached the one extra which shows that there
334 # are additional pages to be had. Stop here...
335 $this->nextPage[$type] = $rawSortkey;
336 break;
337 }
338
339 if ( $title->getNamespace() == NS_CATEGORY ) {
340 $cat = Category::newFromRow( $row, $title );
341 $this->addSubcategoryObject( $cat, $rawSortkey, $row->page_len );
342 } elseif ( $title->getNamespace() == NS_FILE ) {
343 $this->addImage( $title, $rawSortkey, $row->page_len, $row->page_is_redirect );
344 } else {
345 $this->addPage( $title, $rawSortkey, $row->page_len, $row->page_is_redirect );
346 }
347 }
348 }
349 }
350
351 function getCategoryTop() {
352 $r = $this->getCategoryBottom();
353 return $r === ''
354 ? $r
355 : "<br style=\"clear:both;\"/>\n" . $r;
356 }
357
358 function getSubcategorySection() {
359 # Don't show subcategories section if there are none.
360 $r = '';
361 $rescnt = count( $this->children );
362 $dbcnt = $this->cat->getSubcatCount();
363 $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'subcat' );
364
365 if ( $rescnt > 0 ) {
366 # Showing subcategories
367 $r .= "<div id=\"mw-subcategories\">\n";
368 $r .= '<h2>' . wfMsg( 'subcategories' ) . "</h2>\n";
369 $r .= $countmsg;
370 $r .= $this->getSectionPagingLinks( 'subcat' );
371 $r .= $this->formatList( $this->children, $this->children_start_char );
372 $r .= $this->getSectionPagingLinks( 'subcat' );
373 $r .= "\n</div>";
374 }
375 return $r;
376 }
377
378 function getPagesSection() {
379 $ti = htmlspecialchars( $this->title->getText() );
380 # Don't show articles section if there are none.
381 $r = '';
382
383 # FIXME, here and in the other two sections: we don't need to bother
384 # with this rigamarole if the entire category contents fit on one page
385 # and have already been retrieved. We can just use $rescnt in that
386 # case and save a query and some logic.
387 $dbcnt = $this->cat->getPageCount() - $this->cat->getSubcatCount()
388 - $this->cat->getFileCount();
389 $rescnt = count( $this->articles );
390 $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'article' );
391
392 if ( $rescnt > 0 ) {
393 $r = "<div id=\"mw-pages\">\n";
394 $r .= '<h2>' . wfMsg( 'category_header', $ti ) . "</h2>\n";
395 $r .= $countmsg;
396 $r .= $this->getSectionPagingLinks( 'page' );
397 $r .= $this->formatList( $this->articles, $this->articles_start_char );
398 $r .= $this->getSectionPagingLinks( 'page' );
399 $r .= "\n</div>";
400 }
401 return $r;
402 }
403
404 function getImageSection() {
405 $r = '';
406 $rescnt = $this->showGallery ? $this->gallery->count() : count( $this->imgsNoGallery );
407 if ( $rescnt > 0 ) {
408 $dbcnt = $this->cat->getFileCount();
409 $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'file' );
410
411 $r .= "<div id=\"mw-category-media\">\n";
412 $r .= '<h2>' . wfMsg( 'category-media-header', htmlspecialchars( $this->title->getText() ) ) . "</h2>\n";
413 $r .= $countmsg;
414 $r .= $this->getSectionPagingLinks( 'file' );
415 if ( $this->showGallery ) {
416 $r .= $this->gallery->toHTML();
417 } else {
418 $r .= $this->formatList( $this->imgsNoGallery, $this->imgsNoGallery_start_char );
419 }
420 $r .= $this->getSectionPagingLinks( 'file' );
421 $r .= "\n</div>";
422 }
423 return $r;
424 }
425
426 /**
427 * Get the paging links for a section (subcats/pages/files), to go at the top and bottom
428 * of the output.
429 *
430 * @param $type String: 'page', 'subcat', or 'file'
431 * @return String: HTML output, possibly empty if there are no other pages
432 */
433 private function getSectionPagingLinks( $type ) {
434 if ( $this->until[$type] !== null ) {
435 return $this->pagingLinks( $this->nextPage[$type], $this->until[$type], $type );
436 } elseif ( $this->nextPage[$type] !== null || $this->from[$type] !== null ) {
437 return $this->pagingLinks( $this->from[$type], $this->nextPage[$type], $type );
438 } else {
439 return '';
440 }
441 }
442
443 function getCategoryBottom() {
444 return '';
445 }
446
447 /**
448 * Format a list of articles chunked by letter, either as a
449 * bullet list or a columnar format, depending on the length.
450 *
451 * @param $articles Array
452 * @param $articles_start_char Array
453 * @param $cutoff Int
454 * @return String
455 * @private
456 */
457 function formatList( $articles, $articles_start_char, $cutoff = 6 ) {
458 if ( count ( $articles ) > $cutoff ) {
459 return self::columnList( $articles, $articles_start_char );
460 } elseif ( count( $articles ) > 0 ) {
461 // for short lists of articles in categories.
462 return self::shortList( $articles, $articles_start_char );
463 }
464 return '';
465 }
466
467 /**
468 * Format a list of articles chunked by letter in a three-column
469 * list, ordered vertically.
470 *
471 * TODO: Take the headers into account when creating columns, so they're
472 * more visually equal.
473 *
474 * More distant TODO: Scrap this and use CSS columns, whenever IE finally
475 * supports those.
476 *
477 * @param $articles Array
478 * @param $articles_start_char Array
479 * @return String
480 * @private
481 */
482 static function columnList( $articles, $articles_start_char ) {
483 $columns = array_combine( $articles, $articles_start_char );
484 # Split into three columns
485 $columns = array_chunk( $columns, ceil( count( $columns ) / 3 ), true /* preserve keys */ );
486
487 $ret = '<table width="100%"><tr valign="top"><td>';
488 $prevchar = null;
489
490 foreach ( $columns as $column ) {
491 $colContents = array();
492
493 # Kind of like array_flip() here, but we keep duplicates in an
494 # array instead of dropping them.
495 foreach ( $column as $article => $char ) {
496 if ( !isset( $colContents[$char] ) ) {
497 $colContents[$char] = array();
498 }
499 $colContents[$char][] = $article;
500 }
501
502 $first = true;
503 foreach ( $colContents as $char => $articles ) {
504 $ret .= '<h3>' . htmlspecialchars( $char );
505 if ( $first && $char === $prevchar ) {
506 # We're continuing a previous chunk at the top of a new
507 # column, so add " cont." after the letter.
508 $ret .= ' ' . wfMsgHtml( 'listingcontinuesabbrev' );
509 }
510 $ret .= "</h3>\n";
511
512 $ret .= '<ul><li>';
513 $ret .= implode( "</li>\n<li>", $articles );
514 $ret .= '</li></ul>';
515
516 $first = false;
517 $prevchar = $char;
518 }
519
520 $ret .= "</td>\n<td>";
521 }
522
523 $ret .= '</td></tr></table>';
524 return $ret;
525 }
526
527 /**
528 * Format a list of articles chunked by letter in a bullet list.
529 * @param $articles Array
530 * @param $articles_start_char Array
531 * @return String
532 * @private
533 */
534 static function shortList( $articles, $articles_start_char ) {
535 $r = '<h3>' . htmlspecialchars( $articles_start_char[0] ) . "</h3>\n";
536 $r .= '<ul><li>' . $articles[0] . '</li>';
537 for ( $index = 1; $index < count( $articles ); $index++ )
538 {
539 if ( $articles_start_char[$index] != $articles_start_char[$index - 1] )
540 {
541 $r .= "</ul><h3>" . htmlspecialchars( $articles_start_char[$index] ) . "</h3>\n<ul>";
542 }
543
544 $r .= "<li>{$articles[$index]}</li>";
545 }
546 $r .= '</ul>';
547 return $r;
548 }
549
550 /**
551 * Create paging links, as a helper method to getSectionPagingLinks().
552 *
553 * @param $first String The 'until' parameter for the generated URL
554 * @param $last String The 'from' parameter for the genererated URL
555 * @param $type String A prefix for parameters, 'page' or 'subcat' or
556 * 'file'
557 * @return String HTML
558 */
559 private function pagingLinks( $first, $last, $type = '' ) {
560 global $wgLang;
561 $sk = $this->getSkin();
562 $limitText = $wgLang->formatNum( $this->limit );
563
564 $prevLink = wfMsgExt( 'prevn', array( 'escape', 'parsemag' ), $limitText );
565
566 if ( $first != '' ) {
567 $prevQuery = $this->query;
568 $prevQuery["{$type}until"] = $first;
569 unset( $prevQuery["{$type}from"] );
570 $prevLink = $sk->linkKnown(
571 $this->title,
572 $prevLink,
573 array(),
574 $prevQuery
575 );
576 }
577
578 $nextLink = wfMsgExt( 'nextn', array( 'escape', 'parsemag' ), $limitText );
579
580 if ( $last != '' ) {
581 $lastQuery = $this->query;
582 $lastQuery["{$type}from"] = $last;
583 unset( $lastQuery["{$type}until"] );
584 $nextLink = $sk->linkKnown(
585 $this->title,
586 $nextLink,
587 array(),
588 $lastQuery
589 );
590 }
591
592 return "($prevLink) ($nextLink)";
593 }
594
595 /**
596 * What to do if the category table conflicts with the number of results
597 * returned? This function says what. Each type is considered independently
598 * of the other types.
599 *
600 * Note for grepping: uses the messages category-article-count,
601 * category-article-count-limited, category-subcat-count,
602 * category-subcat-count-limited, category-file-count,
603 * category-file-count-limited.
604 *
605 * @param $rescnt Int: The number of items returned by our database query.
606 * @param $dbcnt Int: The number of items according to the category table.
607 * @param $type String: 'subcat', 'article', or 'file'
608 * @return String: A message giving the number of items, to output to HTML.
609 */
610 private function getCountMessage( $rescnt, $dbcnt, $type ) {
611 global $wgLang;
612 # There are three cases:
613 # 1) The category table figure seems sane. It might be wrong, but
614 # we can't do anything about it if we don't recalculate it on ev-
615 # ery category view.
616 # 2) The category table figure isn't sane, like it's smaller than the
617 # number of actual results, *but* the number of results is less
618 # than $this->limit and there's no offset. In this case we still
619 # know the right figure.
620 # 3) We have no idea.
621
622 # Check if there's a "from" or "until" for anything
623
624 // This is a little ugly, but we seem to use different names
625 // for the paging types then for the messages.
626 if ( $type === 'article' ) {
627 $pagingType = 'page';
628 } else {
629 $pagingType = $type;
630 }
631
632 $fromOrUntil = false;
633 if ( $this->from[$pagingType] !== null || $this->until[$pagingType] !== null ) {
634 $fromOrUntil = true;
635 }
636
637 if ( $dbcnt == $rescnt || ( ( $rescnt == $this->limit || $fromOrUntil )
638 && $dbcnt > $rescnt ) )
639 {
640 # Case 1: seems sane.
641 $totalcnt = $dbcnt;
642 } elseif ( $rescnt < $this->limit && !$fromOrUntil ) {
643 # Case 2: not sane, but salvageable. Use the number of results.
644 # Since there are fewer than 200, we can also take this opportunity
645 # to refresh the incorrect category table entry -- which should be
646 # quick due to the small number of entries.
647 $totalcnt = $rescnt;
648 $this->cat->refreshCounts();
649 } else {
650 # Case 3: hopeless. Don't give a total count at all.
651 return wfMsgExt( "category-$type-count-limited", 'parse',
652 $wgLang->formatNum( $rescnt ) );
653 }
654 return wfMsgExt(
655 "category-$type-count",
656 'parse',
657 $wgLang->formatNum( $rescnt ),
658 $wgLang->formatNum( $totalcnt )
659 );
660 }
661 }