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