Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 361 |
|
0.00% |
0 / 22 |
CRAP | |
0.00% |
0 / 1 |
CategoryViewer | |
0.00% |
0 / 360 |
|
0.00% |
0 / 22 |
6320 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
2 | |||
getHTML | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
clearCategoryState | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
addSubcategoryObject | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
generateLink | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
getSubcategorySortChar | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
addImage | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
addPage | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
finaliseCategoryState | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
doCategoryQuery | |
0.00% |
0 / 77 |
|
0.00% |
0 / 1 |
132 | |||
getCategoryTop | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSubcategorySection | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
getPagesSection | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
getImageSection | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
getSectionPagingLinks | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
getCategoryBottom | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
formatList | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
columnList | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
shortList | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
pagingLinks | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
addFragmentToTitle | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
getCountMessage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
110 |
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 | namespace MediaWiki\Category; |
24 | |
25 | use Collation; |
26 | use HtmlArmor; |
27 | use ImageGalleryBase; |
28 | use ImageGalleryClassNotFoundException; |
29 | use InvalidArgumentException; |
30 | use MediaWiki\Cache\LinkCache; |
31 | use MediaWiki\Context\ContextSource; |
32 | use MediaWiki\Context\IContextSource; |
33 | use MediaWiki\Debug\DeprecationHelper; |
34 | use MediaWiki\HookContainer\ProtectedHookAccessorTrait; |
35 | use MediaWiki\Html\Html; |
36 | use MediaWiki\Language\ILanguageConverter; |
37 | use MediaWiki\Linker\LinkTarget; |
38 | use MediaWiki\MainConfigNames; |
39 | use MediaWiki\MediaWikiServices; |
40 | use MediaWiki\Page\PageIdentity; |
41 | use MediaWiki\Page\PageReference; |
42 | use MediaWiki\Title\Title; |
43 | use MediaWiki\Title\TitleValue; |
44 | use Wikimedia\Rdbms\SelectQueryBuilder; |
45 | |
46 | class CategoryViewer extends ContextSource { |
47 | use ProtectedHookAccessorTrait; |
48 | use DeprecationHelper; |
49 | |
50 | /** @var int */ |
51 | public $limit; |
52 | |
53 | /** @var array */ |
54 | public $from; |
55 | |
56 | /** @var array */ |
57 | public $until; |
58 | |
59 | /** @var string[] */ |
60 | public $articles; |
61 | |
62 | /** @var array */ |
63 | public $articles_start_char; |
64 | |
65 | /** @var array */ |
66 | public $children; |
67 | |
68 | /** @var array */ |
69 | public $children_start_char; |
70 | |
71 | /** @var bool */ |
72 | public $showGallery; |
73 | |
74 | /** @var array */ |
75 | public $imgsNoGallery_start_char; |
76 | |
77 | /** @var array */ |
78 | public $imgsNoGallery; |
79 | |
80 | /** @var array */ |
81 | public $nextPage; |
82 | |
83 | /** @var array */ |
84 | protected $prevPage; |
85 | |
86 | /** @var array */ |
87 | public $flip; |
88 | |
89 | /** @var PageIdentity */ |
90 | protected $page; |
91 | |
92 | /** @var Collation */ |
93 | public $collation; |
94 | |
95 | /** @var ImageGalleryBase */ |
96 | public $gallery; |
97 | |
98 | /** @var Category Category object for this page. */ |
99 | private $cat; |
100 | |
101 | /** @var array The original query array, to be used in generating paging links. */ |
102 | private $query; |
103 | |
104 | /** @var ILanguageConverter */ |
105 | private $languageConverter; |
106 | |
107 | /** |
108 | * @since 1.19 $context is a second, required parameter |
109 | * @param PageIdentity $page |
110 | * @param IContextSource $context |
111 | * @param array $from An array with keys page, subcat, |
112 | * and file for offset of results of each section (since 1.17) |
113 | * @param array $until An array with 3 keys for until of each section (since 1.17) |
114 | * @param array $query |
115 | */ |
116 | public function __construct( PageIdentity $page, IContextSource $context, array $from = [], |
117 | array $until = [], array $query = [] |
118 | ) { |
119 | $this->page = $page; |
120 | |
121 | $this->deprecatePublicPropertyFallback( |
122 | 'title', |
123 | '1.37', |
124 | function (): Title { |
125 | return Title::newFromPageIdentity( $this->page ); |
126 | }, |
127 | function ( PageIdentity $page ) { |
128 | $this->page = $page; |
129 | } |
130 | ); |
131 | |
132 | $this->setContext( $context ); |
133 | $this->getOutput()->addModuleStyles( [ |
134 | 'mediawiki.action.styles', |
135 | ] ); |
136 | $this->from = $from; |
137 | $this->until = $until; |
138 | $this->limit = $context->getConfig()->get( MainConfigNames::CategoryPagingLimit ); |
139 | $this->cat = Category::newFromTitle( $page ); |
140 | $this->query = $query; |
141 | $this->collation = MediaWikiServices::getInstance()->getCollationFactory()->getCategoryCollation(); |
142 | $this->languageConverter = MediaWikiServices::getInstance() |
143 | ->getLanguageConverterFactory()->getLanguageConverter(); |
144 | unset( $this->query['title'] ); |
145 | } |
146 | |
147 | /** |
148 | * Format the category data list. |
149 | * |
150 | * @return string HTML output |
151 | */ |
152 | public function getHTML() { |
153 | $this->showGallery = $this->getConfig()->get( MainConfigNames::CategoryMagicGallery ) |
154 | && !$this->getOutput()->getNoGallery(); |
155 | |
156 | $this->clearCategoryState(); |
157 | $this->doCategoryQuery(); |
158 | $this->finaliseCategoryState(); |
159 | |
160 | $r = $this->getSubcategorySection() . |
161 | $this->getPagesSection() . |
162 | $this->getImageSection(); |
163 | |
164 | if ( $r == '' ) { |
165 | // If there is no category content to display, only |
166 | // show the top part of the navigation links. |
167 | // @todo FIXME: Cannot be completely suppressed because it |
168 | // is unknown if 'until' or 'from' makes this |
169 | // give 0 results. |
170 | $r = $this->getCategoryTop(); |
171 | } else { |
172 | $r = $this->getCategoryTop() . |
173 | $r . |
174 | $this->getCategoryBottom(); |
175 | } |
176 | |
177 | // Give a proper message if category is empty |
178 | if ( $r == '' ) { |
179 | $r = $this->msg( 'category-empty' )->parseAsBlock(); |
180 | } |
181 | |
182 | $lang = $this->getLanguage(); |
183 | $attribs = [ |
184 | 'class' => 'mw-category-generated', |
185 | 'lang' => $lang->getHtmlCode(), |
186 | 'dir' => $lang->getDir() |
187 | ]; |
188 | # put a div around the headings which are in the user language |
189 | $r = Html::rawElement( 'div', $attribs, $r ); |
190 | |
191 | return $r; |
192 | } |
193 | |
194 | protected function clearCategoryState() { |
195 | $this->articles = []; |
196 | $this->articles_start_char = []; |
197 | $this->children = []; |
198 | $this->children_start_char = []; |
199 | if ( $this->showGallery ) { |
200 | // Note that null for mode is taken to mean use default. |
201 | $mode = $this->getRequest()->getVal( 'gallerymode', null ); |
202 | try { |
203 | $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() ); |
204 | } catch ( ImageGalleryClassNotFoundException $e ) { |
205 | // User specified something invalid, fallback to default. |
206 | $this->gallery = ImageGalleryBase::factory( false, $this->getContext() ); |
207 | } |
208 | |
209 | $this->gallery->setHideBadImages(); |
210 | } else { |
211 | $this->imgsNoGallery = []; |
212 | $this->imgsNoGallery_start_char = []; |
213 | } |
214 | } |
215 | |
216 | /** |
217 | * Add a subcategory to the internal lists, using a Category object |
218 | * @param Category $cat |
219 | * @param string $sortkey |
220 | * @param int $pageLength |
221 | */ |
222 | public function addSubcategoryObject( Category $cat, $sortkey, $pageLength ) { |
223 | $page = $cat->getPage(); |
224 | if ( !$page ) { |
225 | return; |
226 | } |
227 | |
228 | // Subcategory; strip the 'Category' namespace from the link text. |
229 | $pageRecord = MediaWikiServices::getInstance()->getPageStore() |
230 | ->getPageByReference( $page ); |
231 | if ( !$pageRecord ) { |
232 | return; |
233 | } |
234 | |
235 | $this->children[] = $this->generateLink( |
236 | 'subcat', |
237 | $pageRecord, |
238 | $pageRecord->isRedirect(), |
239 | htmlspecialchars( str_replace( '_', ' ', $pageRecord->getDBkey() ) ) |
240 | ); |
241 | |
242 | $this->children_start_char[] = |
243 | $this->getSubcategorySortChar( $page, $sortkey ); |
244 | } |
245 | |
246 | /** |
247 | * @param string $type |
248 | * @param PageReference $page |
249 | * @param bool $isRedirect |
250 | * @param string|null $html |
251 | * @return string |
252 | * Annotations needed to tell taint about HtmlArmor, |
253 | * due to the use of the hook it is not possible to avoid raw html handling here |
254 | * @param-taint $html tainted |
255 | * @return-taint escaped |
256 | */ |
257 | private function generateLink( |
258 | string $type, PageReference $page, bool $isRedirect, ?string $html = null |
259 | ): string { |
260 | $link = null; |
261 | $legacyTitle = MediaWikiServices::getInstance()->getTitleFactory() |
262 | ->newFromPageReference( $page ); |
263 | // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args |
264 | $this->getHookRunner()->onCategoryViewer__generateLink( $type, $legacyTitle, $html, $link ); |
265 | if ( $link === null ) { |
266 | $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
267 | if ( $html !== null ) { |
268 | $html = new HtmlArmor( $html ); |
269 | } |
270 | $link = $linkRenderer->makeLink( $page, $html ); |
271 | } |
272 | if ( $isRedirect ) { |
273 | $link = Html::rawElement( |
274 | 'span', |
275 | [ 'class' => 'redirect-in-category' ], |
276 | $link |
277 | ); |
278 | } |
279 | |
280 | return $link; |
281 | } |
282 | |
283 | /** |
284 | * Get the character to be used for sorting subcategories. |
285 | * If there's a link from Category:A to Category:B, the sortkey of the resulting |
286 | * entry in the categorylinks table is Category:A, not A, which it SHOULD be. |
287 | * Workaround: If sortkey == "Category:".$title, than use $title for sorting, |
288 | * else use sortkey... |
289 | * |
290 | * @param PageIdentity $page |
291 | * @param string $sortkey The human-readable sortkey (before transforming to icu or whatever). |
292 | * @return string |
293 | */ |
294 | public function getSubcategorySortChar( PageIdentity $page, string $sortkey ): string { |
295 | $titleText = MediaWikiServices::getInstance()->getTitleFormatter() |
296 | ->getPrefixedText( $page ); |
297 | if ( $titleText === $sortkey ) { |
298 | $word = $page->getDBkey(); |
299 | } else { |
300 | $word = $sortkey; |
301 | } |
302 | |
303 | $firstChar = $this->collation->getFirstLetter( $word ); |
304 | |
305 | return $this->languageConverter->convert( $firstChar ); |
306 | } |
307 | |
308 | /** |
309 | * Add a page in the image namespace |
310 | * @param PageReference $page |
311 | * @param string $sortkey |
312 | * @param int $pageLength |
313 | * @param bool $isRedirect |
314 | */ |
315 | public function addImage( |
316 | PageReference $page, string $sortkey, int $pageLength, bool $isRedirect = false |
317 | ): void { |
318 | $title = MediaWikiServices::getInstance()->getTitleFactory() |
319 | ->newFromPageReference( $page ); |
320 | if ( $this->showGallery ) { |
321 | $flip = $this->flip['file']; |
322 | if ( $flip ) { |
323 | $this->gallery->insert( $title, '', '', '', [], ImageGalleryBase::LOADING_LAZY ); |
324 | } else { |
325 | $this->gallery->add( $title, '', '', '', [], ImageGalleryBase::LOADING_LAZY ); |
326 | } |
327 | } else { |
328 | $this->imgsNoGallery[] = $this->generateLink( 'image', $page, $isRedirect ); |
329 | |
330 | $this->imgsNoGallery_start_char[] = |
331 | $this->languageConverter->convert( $this->collation->getFirstLetter( $sortkey ) ); |
332 | } |
333 | } |
334 | |
335 | /** |
336 | * Add a miscellaneous page |
337 | * @param PageReference $page |
338 | * @param string $sortkey |
339 | * @param int $pageLength |
340 | * @param bool $isRedirect |
341 | */ |
342 | public function addPage( |
343 | PageReference $page, |
344 | string $sortkey, |
345 | int $pageLength, |
346 | bool $isRedirect = false |
347 | ): void { |
348 | $this->articles[] = $this->generateLink( 'page', $page, $isRedirect ); |
349 | |
350 | $this->articles_start_char[] = |
351 | $this->languageConverter->convert( $this->collation->getFirstLetter( $sortkey ) ); |
352 | } |
353 | |
354 | protected function finaliseCategoryState() { |
355 | if ( $this->flip['subcat'] ) { |
356 | $this->children = array_reverse( $this->children ); |
357 | $this->children_start_char = array_reverse( $this->children_start_char ); |
358 | } |
359 | if ( $this->flip['page'] ) { |
360 | $this->articles = array_reverse( $this->articles ); |
361 | $this->articles_start_char = array_reverse( $this->articles_start_char ); |
362 | } |
363 | if ( !$this->showGallery && $this->flip['file'] ) { |
364 | $this->imgsNoGallery = array_reverse( $this->imgsNoGallery ); |
365 | $this->imgsNoGallery_start_char = array_reverse( $this->imgsNoGallery_start_char ); |
366 | } |
367 | } |
368 | |
369 | protected function doCategoryQuery() { |
370 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
371 | |
372 | $this->nextPage = [ |
373 | 'page' => null, |
374 | 'subcat' => null, |
375 | 'file' => null, |
376 | ]; |
377 | $this->prevPage = [ |
378 | 'page' => null, |
379 | 'subcat' => null, |
380 | 'file' => null, |
381 | ]; |
382 | |
383 | $this->flip = [ 'page' => false, 'subcat' => false, 'file' => false ]; |
384 | |
385 | foreach ( [ 'page', 'subcat', 'file' ] as $type ) { |
386 | # Get the sortkeys for start/end, if applicable. Note that if |
387 | # the collation in the database differs from the one |
388 | # set in $wgCategoryCollation, pagination might go totally haywire. |
389 | $extraConds = [ 'cl_type' => $type ]; |
390 | if ( isset( $this->from[$type] ) ) { |
391 | $extraConds[] = $dbr->expr( |
392 | 'cl_sortkey', |
393 | '>=', |
394 | $this->collation->getSortKey( $this->from[$type] ) |
395 | ); |
396 | } elseif ( isset( $this->until[$type] ) ) { |
397 | $extraConds[] = $dbr->expr( |
398 | 'cl_sortkey', |
399 | '<', |
400 | $this->collation->getSortKey( $this->until[$type] ) |
401 | ); |
402 | $this->flip[$type] = true; |
403 | } |
404 | |
405 | $queryBuilder = $dbr->newSelectQueryBuilder(); |
406 | $queryBuilder->select( array_merge( |
407 | LinkCache::getSelectFields(), |
408 | [ |
409 | 'cl_sortkey', |
410 | 'cat_id', |
411 | 'cat_title', |
412 | 'cat_subcats', |
413 | 'cat_pages', |
414 | 'cat_files', |
415 | 'cl_sortkey_prefix', |
416 | 'cl_collation' |
417 | ] |
418 | ) ) |
419 | ->from( 'page' ) |
420 | ->where( [ 'cl_to' => $this->page->getDBkey() ] ) |
421 | ->andWhere( $extraConds ) |
422 | ->useIndex( [ 'categorylinks' => 'cl_sortkey' ] ); |
423 | |
424 | if ( $this->flip[$type] ) { |
425 | $queryBuilder->orderBy( 'cl_sortkey', SelectQueryBuilder::SORT_DESC ); |
426 | } else { |
427 | $queryBuilder->orderBy( 'cl_sortkey' ); |
428 | } |
429 | |
430 | $queryBuilder |
431 | ->join( 'categorylinks', null, [ 'cl_from = page_id' ] ) |
432 | ->leftJoin( 'category', null, [ |
433 | 'cat_title = page_title', |
434 | 'page_namespace' => NS_CATEGORY |
435 | ] ) |
436 | ->limit( $this->limit + 1 ) |
437 | ->caller( __METHOD__ ); |
438 | |
439 | $res = $queryBuilder->fetchResultSet(); |
440 | |
441 | $this->getHookRunner()->onCategoryViewer__doCategoryQuery( $type, $res ); |
442 | $linkCache = MediaWikiServices::getInstance()->getLinkCache(); |
443 | |
444 | $count = 0; |
445 | foreach ( $res as $row ) { |
446 | $title = Title::newFromRow( $row ); |
447 | $linkCache->addGoodLinkObjFromRow( $title, $row ); |
448 | |
449 | if ( $row->cl_collation === '' ) { |
450 | // Hack to make sure that while updating from 1.16 schema |
451 | // and db is inconsistent, that the sky doesn't fall. |
452 | // See r83544. Could perhaps be removed in a couple decades... |
453 | $humanSortkey = $row->cl_sortkey; |
454 | } else { |
455 | $humanSortkey = $title->getCategorySortkey( $row->cl_sortkey_prefix ); |
456 | } |
457 | |
458 | if ( ++$count > $this->limit ) { |
459 | # We've reached the one extra which shows that there |
460 | # are additional pages to be had. Stop here... |
461 | $this->nextPage[$type] = $humanSortkey; |
462 | break; |
463 | } |
464 | if ( $count == $this->limit ) { |
465 | $this->prevPage[$type] = $humanSortkey; |
466 | } |
467 | |
468 | if ( $title->getNamespace() === NS_CATEGORY ) { |
469 | $cat = Category::newFromRow( $row, $title ); |
470 | $this->addSubcategoryObject( $cat, $humanSortkey, $row->page_len ); |
471 | } elseif ( $title->getNamespace() === NS_FILE ) { |
472 | $this->addImage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect ); |
473 | } else { |
474 | $this->addPage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect ); |
475 | } |
476 | } |
477 | } |
478 | } |
479 | |
480 | /** |
481 | * @return string |
482 | */ |
483 | protected function getCategoryTop() { |
484 | $r = $this->getCategoryBottom(); |
485 | return $r === '' |
486 | ? $r |
487 | : "<br style=\"clear:both;\"/>\n" . $r; |
488 | } |
489 | |
490 | /** |
491 | * @return string |
492 | */ |
493 | protected function getSubcategorySection() { |
494 | # Don't show subcategories section if there are none. |
495 | $r = ''; |
496 | $rescnt = count( $this->children ); |
497 | $dbcnt = $this->cat->getSubcatCount(); |
498 | // This function should be called even if the result isn't used, it has side-effects |
499 | $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'subcat' ); |
500 | |
501 | if ( $rescnt > 0 ) { |
502 | # Showing subcategories |
503 | $r .= Html::openElement( 'div', [ 'id' => 'mw-subcategories' ] ) . "\n"; |
504 | $r .= Html::rawElement( 'h2', [], $this->msg( 'subcategories' )->parse() ) . "\n"; |
505 | $r .= $countmsg; |
506 | $r .= $this->getSectionPagingLinks( 'subcat' ); |
507 | $r .= $this->formatList( $this->children, $this->children_start_char ); |
508 | $r .= $this->getSectionPagingLinks( 'subcat' ); |
509 | $r .= "\n" . Html::closeElement( 'div' ); |
510 | } |
511 | return $r; |
512 | } |
513 | |
514 | /** |
515 | * @return string |
516 | */ |
517 | protected function getPagesSection() { |
518 | $name = $this->getOutput()->getUnprefixedDisplayTitle(); |
519 | # Don't show articles section if there are none. |
520 | $r = ''; |
521 | |
522 | # @todo FIXME: Here and in the other two sections: we don't need to bother |
523 | # with this rigmarole if the entire category contents fit on one page |
524 | # and have already been retrieved. We can just use $rescnt in that |
525 | # case and save a query and some logic. |
526 | $dbcnt = $this->cat->getPageCount( Category::COUNT_CONTENT_PAGES ); |
527 | $rescnt = count( $this->articles ); |
528 | // This function should be called even if the result isn't used, it has side-effects |
529 | $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'article' ); |
530 | |
531 | if ( $rescnt > 0 ) { |
532 | $r .= Html::openElement( 'div', [ 'id' => 'mw-pages' ] ) . "\n"; |
533 | $r .= Html::rawElement( |
534 | 'h2', |
535 | [], |
536 | $this->msg( 'category_header' )->rawParams( $name )->parse() |
537 | ) . "\n"; |
538 | $r .= $countmsg; |
539 | $r .= $this->getSectionPagingLinks( 'page' ); |
540 | $r .= $this->formatList( $this->articles, $this->articles_start_char ); |
541 | $r .= $this->getSectionPagingLinks( 'page' ); |
542 | $r .= "\n" . Html::closeElement( 'div' ); |
543 | } |
544 | return $r; |
545 | } |
546 | |
547 | /** |
548 | * @return string |
549 | */ |
550 | protected function getImageSection() { |
551 | $name = $this->getOutput()->getUnprefixedDisplayTitle(); |
552 | $r = ''; |
553 | $rescnt = $this->showGallery ? |
554 | $this->gallery->count() : |
555 | count( $this->imgsNoGallery ?? [] ); |
556 | $dbcnt = $this->cat->getFileCount(); |
557 | // This function should be called even if the result isn't used, it has side-effects |
558 | $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'file' ); |
559 | |
560 | if ( $rescnt > 0 ) { |
561 | $r .= Html::openElement( 'div', [ 'id' => 'mw-category-media' ] ) . "\n"; |
562 | $r .= Html::rawElement( |
563 | 'h2', |
564 | [], |
565 | $this->msg( 'category-media-header' )->rawParams( $name )->parse() |
566 | ) . "\n"; |
567 | $r .= $countmsg; |
568 | $r .= $this->getSectionPagingLinks( 'file' ); |
569 | if ( $this->showGallery ) { |
570 | $r .= $this->gallery->toHTML(); |
571 | } else { |
572 | $r .= $this->formatList( $this->imgsNoGallery, $this->imgsNoGallery_start_char ); |
573 | } |
574 | $r .= $this->getSectionPagingLinks( 'file' ); |
575 | $r .= "\n" . Html::closeElement( 'div' ); |
576 | } |
577 | return $r; |
578 | } |
579 | |
580 | /** |
581 | * Get the paging links for a section (subcats/pages/files), to go at the top and bottom |
582 | * of the output. |
583 | * |
584 | * @param string $type 'page', 'subcat', or 'file' |
585 | * @return string HTML output, possibly empty if there are no other pages |
586 | */ |
587 | private function getSectionPagingLinks( $type ) { |
588 | if ( isset( $this->until[$type] ) ) { |
589 | // The new value for the until parameter should be pointing to the first |
590 | // result displayed on the page which is the second last result retrieved |
591 | // from the database.The next link should have a from parameter pointing |
592 | // to the until parameter of the current page. |
593 | if ( $this->nextPage[$type] !== null ) { |
594 | return $this->pagingLinks( |
595 | $this->prevPage[$type] ?? '', |
596 | $this->until[$type], |
597 | $type |
598 | ); |
599 | } |
600 | |
601 | // If the nextPage variable is null, it means that we have reached the first page |
602 | // and therefore the previous link should be disabled. |
603 | return $this->pagingLinks( |
604 | '', |
605 | $this->until[$type], |
606 | $type |
607 | ); |
608 | } elseif ( $this->nextPage[$type] !== null || isset( $this->from[$type] ) ) { |
609 | return $this->pagingLinks( |
610 | $this->from[$type] ?? '', |
611 | $this->nextPage[$type], |
612 | $type |
613 | ); |
614 | } |
615 | |
616 | return ''; |
617 | } |
618 | |
619 | /** |
620 | * @return string |
621 | */ |
622 | protected function getCategoryBottom() { |
623 | return ''; |
624 | } |
625 | |
626 | /** |
627 | * Format a list of articles chunked by letter, either as a |
628 | * bullet list or a columnar format, depending on the length. |
629 | * |
630 | * @param array $articles |
631 | * @param array $articles_start_char |
632 | * @param int $cutoff |
633 | * @return string |
634 | * @internal |
635 | */ |
636 | private function formatList( $articles, $articles_start_char, $cutoff = 6 ) { |
637 | $list = ''; |
638 | if ( count( $articles ) > $cutoff ) { |
639 | $list = self::columnList( $articles, $articles_start_char ); |
640 | } elseif ( count( $articles ) > 0 ) { |
641 | // for short lists of articles in categories. |
642 | $list = self::shortList( $articles, $articles_start_char ); |
643 | } |
644 | |
645 | $pageLang = MediaWikiServices::getInstance()->getTitleFactory() |
646 | ->newFromPageIdentity( $this->page ) |
647 | ->getPageLanguage(); |
648 | $attribs = [ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(), |
649 | 'class' => 'mw-content-' . $pageLang->getDir() ]; |
650 | $list = Html::rawElement( 'div', $attribs, $list ); |
651 | |
652 | return $list; |
653 | } |
654 | |
655 | /** |
656 | * Format a list of articles chunked by letter in a three-column list, ordered |
657 | * vertically. This is used for categories with a significant number of pages. |
658 | * |
659 | * @param string[] $articles HTML links to each article |
660 | * @param string[] $articles_start_char The header characters for each article |
661 | * @param string $cssClasses CSS classes for the wrapper element |
662 | * @return string HTML to output |
663 | * @internal |
664 | */ |
665 | public static function columnList( |
666 | $articles, |
667 | $articles_start_char, |
668 | $cssClasses = 'mw-category mw-category-columns' |
669 | ) { |
670 | $columns = array_combine( $articles, $articles_start_char ); |
671 | |
672 | $ret = Html::openElement( 'div', [ 'class' => $cssClasses ] ); |
673 | |
674 | $colContents = []; |
675 | |
676 | # Kind of like array_flip() here, but we keep duplicates in an |
677 | # array instead of dropping them. |
678 | foreach ( $columns as $article => $char ) { |
679 | $colContents[$char][] = $article; |
680 | } |
681 | |
682 | foreach ( $colContents as $char => $articles ) { |
683 | # Change space to non-breaking space to keep headers aligned |
684 | $h3char = $char === ' ' ? "\u{00A0}" : htmlspecialchars( $char ); |
685 | |
686 | $ret .= Html::openElement( 'div', [ 'class' => 'mw-category-group' ] ); |
687 | $ret .= Html::rawElement( 'h3', [], $h3char ) . "\n"; |
688 | $ret .= Html::openElement( 'ul' ); |
689 | $ret .= implode( |
690 | "\n", |
691 | array_map( |
692 | static function ( $article ) { |
693 | return Html::rawElement( 'li', [], $article ); |
694 | }, |
695 | $articles |
696 | ) |
697 | ); |
698 | $ret .= Html::closeElement( 'ul' ) . Html::closeElement( 'div' ); |
699 | |
700 | } |
701 | |
702 | $ret .= Html::closeElement( 'div' ); |
703 | return $ret; |
704 | } |
705 | |
706 | /** |
707 | * Format a list of articles chunked by letter in a bullet list. This is used |
708 | * for categories with a small number of pages (when columns aren't needed). |
709 | * @param string[] $articles HTML links to each article |
710 | * @param string[] $articles_start_char The header characters for each article |
711 | * @return string HTML to output |
712 | * @internal |
713 | */ |
714 | public static function shortList( $articles, $articles_start_char ) { |
715 | return self::columnList( $articles, $articles_start_char, 'mw-category' ); |
716 | } |
717 | |
718 | /** |
719 | * Create paging links, as a helper method to getSectionPagingLinks(). |
720 | * |
721 | * @param string $first The 'until' parameter for the generated URL |
722 | * @param string $last The 'from' parameter for the generated URL |
723 | * @param string $type A prefix for parameters, 'page' or 'subcat' or |
724 | * 'file' |
725 | * @return string HTML |
726 | */ |
727 | private function pagingLinks( $first, $last, $type = '' ) { |
728 | $prevLink = $this->msg( 'prev-page' )->escaped(); |
729 | |
730 | $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
731 | if ( $first != '' ) { |
732 | $prevQuery = $this->query; |
733 | $prevQuery["{$type}until"] = $first; |
734 | unset( $prevQuery["{$type}from"] ); |
735 | $prevLink = $linkRenderer->makeKnownLink( |
736 | $this->addFragmentToTitle( $this->page, $type ), |
737 | new HtmlArmor( $prevLink ), |
738 | [], |
739 | $prevQuery |
740 | ); |
741 | } |
742 | |
743 | $nextLink = $this->msg( 'next-page' )->escaped(); |
744 | |
745 | if ( $last != '' ) { |
746 | $lastQuery = $this->query; |
747 | $lastQuery["{$type}from"] = $last; |
748 | unset( $lastQuery["{$type}until"] ); |
749 | $nextLink = $linkRenderer->makeKnownLink( |
750 | $this->addFragmentToTitle( $this->page, $type ), |
751 | new HtmlArmor( $nextLink ), |
752 | [], |
753 | $lastQuery |
754 | ); |
755 | } |
756 | |
757 | return $this->msg( 'categoryviewer-pagedlinks' )->rawParams( $prevLink, $nextLink )->escaped(); |
758 | } |
759 | |
760 | /** |
761 | * Takes a title, and adds the fragment identifier that |
762 | * corresponds to the correct segment of the category. |
763 | * |
764 | * @param PageReference $page The title (usually $this->title) |
765 | * @param string $section Which section |
766 | * @return LinkTarget |
767 | */ |
768 | private function addFragmentToTitle( PageReference $page, string $section ): LinkTarget { |
769 | switch ( $section ) { |
770 | case 'page': |
771 | $fragment = 'mw-pages'; |
772 | break; |
773 | case 'subcat': |
774 | $fragment = 'mw-subcategories'; |
775 | break; |
776 | case 'file': |
777 | $fragment = 'mw-category-media'; |
778 | break; |
779 | default: |
780 | throw new InvalidArgumentException( __METHOD__ . |
781 | " Invalid section $section." ); |
782 | } |
783 | |
784 | return new TitleValue( $page->getNamespace(), |
785 | $page->getDBkey(), $fragment ); |
786 | } |
787 | |
788 | /** |
789 | * What to do if the category table conflicts with the number of results |
790 | * returned? This function says what. Each type is considered independently |
791 | * of the other types. |
792 | * |
793 | * @param int $rescnt The number of items returned by our database query. |
794 | * @param int $dbcnt The number of items according to the category table. |
795 | * @param string $type 'subcat', 'article', or 'file' |
796 | * @return string A message giving the number of items, to output to HTML. |
797 | */ |
798 | private function getCountMessage( $rescnt, $dbcnt, $type ) { |
799 | // There are three cases: |
800 | // 1) The category table figure seems good. It might be wrong, but |
801 | // we can't do anything about it if we don't recalculate it on ev- |
802 | // ery category view. |
803 | // 2) The category table figure isn't good, like it's smaller than the |
804 | // number of actual results, *but* the number of results is less |
805 | // than $this->limit and there's no offset. In this case we still |
806 | // know the right figure. |
807 | // 3) We have no idea. |
808 | |
809 | // Check if there's a "from" or "until" for anything |
810 | |
811 | // This is a little ugly, but we seem to use different names |
812 | // for the paging types then for the messages. |
813 | if ( $type === 'article' ) { |
814 | $pagingType = 'page'; |
815 | } else { |
816 | $pagingType = $type; |
817 | } |
818 | |
819 | $fromOrUntil = false; |
820 | if ( isset( $this->from[$pagingType] ) || isset( $this->until[$pagingType] ) ) { |
821 | $fromOrUntil = true; |
822 | } |
823 | |
824 | if ( $dbcnt == $rescnt || |
825 | ( ( $rescnt == $this->limit || $fromOrUntil ) && $dbcnt > $rescnt ) |
826 | ) { |
827 | // Case 1: seems good. |
828 | $totalcnt = $dbcnt; |
829 | } elseif ( $rescnt < $this->limit && !$fromOrUntil ) { |
830 | // Case 2: not good, but salvageable. Use the number of results. |
831 | $totalcnt = $rescnt; |
832 | } else { |
833 | // Case 3: hopeless. Don't give a total count at all. |
834 | // Messages: category-subcat-count-limited, category-article-count-limited, |
835 | // category-file-count-limited |
836 | return $this->msg( "category-$type-count-limited" )->numParams( $rescnt )->parseAsBlock(); |
837 | } |
838 | // Messages: category-subcat-count, category-article-count, category-file-count |
839 | return $this->msg( "category-$type-count" )->numParams( $rescnt, $totalcnt )->parseAsBlock(); |
840 | } |
841 | } |
842 | |
843 | /** @deprecated class alias since 1.40 */ |
844 | class_alias( CategoryViewer::class, 'CategoryViewer' ); |