Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 361
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryViewer
0.00% covered (danger)
0.00%
0 / 360
0.00% covered (danger)
0.00%
0 / 22
6320
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 getHTML
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 clearCategoryState
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 addSubcategoryObject
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 generateLink
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getSubcategorySortChar
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 addImage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 addPage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 finaliseCategoryState
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 doCategoryQuery
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
132
 getCategoryTop
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSubcategorySection
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getPagesSection
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getImageSection
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getSectionPagingLinks
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getCategoryBottom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatList
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 columnList
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 shortList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pagingLinks
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 addFragmentToTitle
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getCountMessage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
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
23namespace MediaWiki\Category;
24
25use Collation;
26use DeprecationHelper;
27use HtmlArmor;
28use ILanguageConverter;
29use ImageGalleryBase;
30use ImageGalleryClassNotFoundException;
31use InvalidArgumentException;
32use MediaWiki\Cache\LinkCache;
33use MediaWiki\Context\ContextSource;
34use MediaWiki\Context\IContextSource;
35use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
36use MediaWiki\Html\Html;
37use MediaWiki\Linker\LinkTarget;
38use MediaWiki\MainConfigNames;
39use MediaWiki\MediaWikiServices;
40use MediaWiki\Page\PageIdentity;
41use MediaWiki\Page\PageReference;
42use MediaWiki\Title\Title;
43use MediaWiki\Title\TitleValue;
44use Wikimedia\Rdbms\SelectQueryBuilder;
45
46class 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()->mNoGallery;
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 */
844class_alias( CategoryViewer::class, 'CategoryViewer' );