Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryTree
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 9
4970
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setHeaders
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTag
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 renderChildren
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
506
 renderParents
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 renderNode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 renderNodeInfo
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
462
 createCountString
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 makeTitle
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * © 2006-2007 Daniel Kinzler
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 * @ingroup Extensions
22 * @author Daniel Kinzler, brightbyte.de
23 */
24
25namespace MediaWiki\Extension\CategoryTree;
26
27use MediaWiki\Category\Category;
28use MediaWiki\Config\Config;
29use MediaWiki\Context\IContextSource;
30use MediaWiki\Context\RequestContext;
31use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
32use MediaWiki\Html\Html;
33use MediaWiki\Linker\LinkRenderer;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\Output\OutputPage;
36use MediaWiki\Registration\ExtensionRegistry;
37use MediaWiki\SpecialPage\SpecialPage;
38use MediaWiki\Title\Title;
39use Wikimedia\Rdbms\IConnectionProvider;
40
41/**
42 * Core functions for the CategoryTree extension, which displays the category structure of a wiki
43 */
44class CategoryTree {
45    public OptionManager $optionManager;
46    private Config $config;
47    private IConnectionProvider $dbProvider;
48    private LinkRenderer $linkRenderer;
49
50    public function __construct(
51        array $options,
52        Config $config,
53        IConnectionProvider $dbProvider,
54        LinkRenderer $linkRenderer
55    ) {
56        $this->optionManager = new OptionManager( $options, $config );
57        $this->config = $config;
58        $this->dbProvider = $dbProvider;
59        $this->linkRenderer = $linkRenderer;
60    }
61
62    /**
63     * Add ResourceLoader modules to the OutputPage object
64     */
65    public static function setHeaders( OutputPage $outputPage ): void {
66        # Add the modules
67        $outputPage->addModuleStyles( 'ext.categoryTree.styles' );
68        $outputPage->addModules( 'ext.categoryTree' );
69    }
70
71    /**
72     * Custom tag implementation. This is called by Hooks::parserHook, which is used to
73     * load CategoryTree on demand.
74     * @param string $category
75     * @param bool $hideroot
76     * @param array $attr
77     * @param int $depth
78     * @return bool|string
79     */
80    public function getTag( string $category, bool $hideroot = false, array $attr = [],
81        int $depth = 1
82    ) {
83        $title = self::makeTitle( $category );
84        if ( !$title ) {
85            return false;
86        }
87
88        if ( isset( $attr['class'] ) ) {
89            $attr['class'] .= ' CategoryTreeTag';
90        } else {
91            $attr['class'] = 'CategoryTreeTag';
92        }
93
94        $attr['data-ct-options'] = $this->optionManager->getOptionsAsJsStructure();
95
96        if ( !$title->getArticleID() ) {
97            $html = Html::rawElement( 'span', [ 'class' => 'CategoryTreeNotice' ],
98                wfMessage( 'categorytree-not-found' )
99                    ->plaintextParams( $category )
100                    ->parse()
101            );
102        } else {
103            if ( !$hideroot ) {
104                $html = $this->renderNode( $title, $depth );
105            } else {
106                $html = $this->renderChildren( $title, $depth );
107            }
108        }
109
110        return Html::rawElement( 'div', $attr, $html );
111    }
112
113    /**
114     * Returns a string with an HTML representation of the children of the given category.
115     * @suppress PhanUndeclaredClassMethod,PhanUndeclaredClassInstanceof
116     */
117    public function renderChildren( Title $title, int $depth = 1 ): string {
118        if ( !$title->inNamespace( NS_CATEGORY ) ) {
119            // Non-categories can't have children. :)
120            return '';
121        }
122
123        $dbr = $this->dbProvider->getReplicaDatabase();
124
125        $inverse = $this->optionManager->isInverse();
126        $mode = $this->optionManager->getOption( 'mode' );
127        $namespaces = $this->optionManager->getOption( 'namespaces' );
128
129        $queryBuilder = $dbr->newSelectQueryBuilder()
130            ->select( [
131                'page_id', 'page_namespace', 'page_title',
132                'page_is_redirect', 'page_len', 'page_latest', 'cl_to', 'cl_from'
133            ] )
134            ->orderBy( [ 'cl_type', 'cl_sortkey' ] )
135            ->limit( $this->config->get( 'CategoryTreeMaxChildren' ) )
136            ->caller( __METHOD__ );
137
138        if ( $inverse ) {
139            $queryBuilder
140                ->from( 'categorylinks' )
141                ->leftJoin( 'page', null, [
142                    'cl_to = page_title', 'page_namespace' => NS_CATEGORY
143                ] )
144                ->where( [ 'cl_from' => $title->getArticleID() ] );
145        } else {
146            $queryBuilder
147                ->from( 'page' )
148                ->join( 'categorylinks', null, 'cl_from = page_id' )
149                ->where( [ 'cl_to' => $title->getDBkey() ] )
150                ->useIndex( 'cl_sortkey' );
151
152            # namespace filter.
153            if ( $namespaces ) {
154                // NOTE: we assume that the $namespaces array contains only integers!
155                // decodeNamepsaces makes it so.
156                $queryBuilder->where( [ 'page_namespace' => $namespaces ] );
157            } elseif ( $mode !== CategoryTreeMode::ALL ) {
158                if ( $mode === CategoryTreeMode::PAGES ) {
159                    $queryBuilder->where( [ 'cl_type' => [ 'page', 'subcat' ] ] );
160                } else {
161                    $queryBuilder->where( [ 'cl_type' => 'subcat' ] );
162                }
163            }
164        }
165
166        # fetch member count if possible
167        $doCount = !$inverse && $this->config->get( 'CategoryTreeUseCategoryTable' );
168
169        if ( $doCount ) {
170            $queryBuilder
171                ->leftJoin( 'category', null, [ 'cat_title = page_title', 'page_namespace' => NS_CATEGORY ] )
172                ->fields( [ 'cat_id', 'cat_title', 'cat_subcats', 'cat_pages', 'cat_files' ] );
173        }
174
175        $res = $queryBuilder->fetchResultSet();
176
177        # collect categories separately from other pages
178        $categories = '';
179        $other = '';
180        $suppressTranslations = OptionManager::decodeBoolean(
181            $this->optionManager->getOption( 'notranslations' )
182        ) && ExtensionRegistry::getInstance()->isLoaded( 'Translate' );
183
184        if ( $suppressTranslations ) {
185            $lb = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
186            foreach ( $res as $row ) {
187                $title = Title::newFromText( $row->page_title, $row->page_namespace );
188                // Page name could have slashes, check the subpage for valid language built-in codes
189                if ( $title !== null && $title->getSubpageText() ) {
190                    $lb->addObj( $title->getBaseTitle() );
191                }
192            }
193
194            $lb->execute();
195        }
196
197        foreach ( $res as $row ) {
198            if ( $suppressTranslations ) {
199                $title = Title::newFromRow( $row );
200                $baseTitle = $title->getBaseTitle();
201                $page = TranslatablePage::isTranslationPage( $title );
202
203                if ( ( $page instanceof TranslatablePage ) && $baseTitle->exists() ) {
204                    // T229265: Render only the default pages created and ignore their
205                    // translations.
206                    continue;
207                }
208            }
209
210            # NOTE: in inverse mode, the page record may be null, because we use a right join.
211            #      happens for categories with no category page (red cat links)
212            if ( $inverse && $row->page_title === null ) {
213                $t = Title::makeTitle( NS_CATEGORY, $row->cl_to );
214            } else {
215                # TODO: translation support; ideally added to Title object
216                $t = Title::newFromRow( $row );
217            }
218
219            $cat = null;
220
221            if ( $doCount && (int)$row->page_namespace === NS_CATEGORY ) {
222                $cat = Category::newFromRow( $row, $t );
223            }
224
225            $s = $this->renderNodeInfo( $t, $cat, $depth - 1 );
226
227            if ( (int)$row->page_namespace === NS_CATEGORY ) {
228                $categories .= $s;
229            } else {
230                $other .= $s;
231            }
232        }
233
234        return $categories . $other;
235    }
236
237    /**
238     * Returns a string with an HTML representation of the parents of the given category.
239     */
240    public function renderParents( Title $title ): string {
241        $dbr = $this->dbProvider->getReplicaDatabase();
242
243        $res = $dbr->newSelectQueryBuilder()
244            ->select( 'cl_to' )
245            ->from( 'categorylinks' )
246            ->where( [ 'cl_from' => $title->getArticleID() ] )
247            ->limit( $this->config->get( 'CategoryTreeMaxChildren' ) )
248            ->orderBy( 'cl_to' )
249            ->caller( __METHOD__ )
250            ->fetchResultSet();
251
252        $special = SpecialPage::getTitleFor( 'CategoryTree' );
253
254        $s = [];
255
256        foreach ( $res as $row ) {
257            $t = Title::makeTitle( NS_CATEGORY, $row->cl_to );
258
259            $s[] = Html::rawElement( 'span', [ 'class' => 'CategoryTreeItem' ],
260                $this->linkRenderer->makeLink(
261                    $special,
262                    $t->getText(),
263                    [ 'class' => 'CategoryTreeLabel' ],
264                    [ 'target' => $t->getDBkey() ] + $this->optionManager->getOptions()
265                )
266            );
267        }
268
269        return implode( wfMessage( 'pipe-separator' )->escaped(), $s );
270    }
271
272    /**
273     * Returns a string with a HTML represenation of the given page.
274     * @param Title $title
275     * @param int $children
276     * @return string
277     */
278    public function renderNode( Title $title, int $children = 0 ): string {
279        if ( $this->config->get( 'CategoryTreeUseCategoryTable' )
280            && $title->inNamespace( NS_CATEGORY )
281            && !$this->optionManager->isInverse()
282        ) {
283            $cat = Category::newFromTitle( $title );
284        } else {
285            $cat = null;
286        }
287
288        return $this->renderNodeInfo( $title, $cat, $children );
289    }
290
291    /**
292     * Returns a string with a HTML represenation of the given page.
293     * $info must be an associative array, containing at least a Title object under the 'title' key.
294     */
295    public function renderNodeInfo( Title $title, ?Category $cat = null, int $children = 0 ): string {
296        $mode = $this->optionManager->getOption( 'mode' );
297
298        $isInCatNS = $title->inNamespace( NS_CATEGORY );
299        $key = $title->getDBkey();
300
301        $hideprefix = $this->optionManager->getOption( 'hideprefix' );
302
303        if ( $hideprefix === CategoryTreeHidePrefix::ALWAYS ) {
304            $hideprefix = true;
305        } elseif ( $hideprefix === CategoryTreeHidePrefix::AUTO ) {
306            $hideprefix = ( $mode === CategoryTreeMode::CATEGORIES );
307        } elseif ( $hideprefix === CategoryTreeHidePrefix::CATEGORIES ) {
308            $hideprefix = $isInCatNS;
309        } else {
310            $hideprefix = true;
311        }
312
313        // when showing only categories, omit namespace in label unless we explicitely defined the
314        // configuration setting
315        // patch contributed by Manuel Schneider <manuel.schneider@wikimedia.ch>, Bug 8011
316        if ( $hideprefix ) {
317            $label = $title->getText();
318        } else {
319            $label = $title->getPrefixedText();
320        }
321
322        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
323        $link = Html::rawElement( 'bdi', [ 'dir' => $contLang->getDir() ],
324            $this->linkRenderer->makeLink( $title, $label ) );
325
326        $count = false;
327        $s = '';
328
329        # NOTE: things in CategoryTree.js rely on the exact order of tags!
330        #      Specifically, the CategoryTreeChildren div must be the first
331        #      sibling with nodeName = DIV of the grandparent of the expland link.
332
333        $s .= Html::openElement( 'div', [ 'class' => 'CategoryTreeSection' ] );
334        $s .= Html::openElement( 'div', [ 'class' => 'CategoryTreeItem' ] );
335
336        $attr = [ 'class' => 'CategoryTreeBullet' ];
337
338        if ( $isInCatNS ) {
339            if ( $cat ) {
340                if ( $mode === CategoryTreeMode::CATEGORIES ) {
341                    $count = $cat->getSubcatCount();
342                } elseif ( $mode === CategoryTreeMode::PAGES ) {
343                    $count = $cat->getMemberCount() - $cat->getFileCount();
344                } else {
345                    $count = $cat->getMemberCount();
346                }
347            }
348            if ( $count === 0 ) {
349                $bullet = '';
350                $attr['class'] = 'CategoryTreeEmptyBullet';
351            } else {
352                $linkattr = [
353                    'class' => 'CategoryTreeToggle',
354                    'data-ct-title' => $key,
355                ];
356
357                if ( $children === 0 ) {
358                    $linkattr['aria-expanded'] = 'false';
359                } else {
360                    $linkattr['data-ct-loaded'] = true;
361                    $linkattr['aria-expanded'] = 'true';
362                }
363
364                $bullet = Html::element( 'a', $linkattr ) . ' ';
365            }
366        } else {
367            $bullet = '';
368            $attr['class'] = 'CategoryTreePageBullet';
369        }
370        $s .= Html::rawElement( 'span', $attr, $bullet ) . ' ';
371
372        $s .= $link;
373
374        if ( $count !== false && $this->optionManager->getOption( 'showcount' ) ) {
375            $s .= self::createCountString( RequestContext::getMain(), $cat, $count );
376        }
377
378        $s .= Html::closeElement( 'div' );
379        $s .= Html::openElement(
380            'div',
381            [
382                'class' => 'CategoryTreeChildren',
383                'style' => $children === 0 ? 'display:none' : null
384            ]
385        );
386
387        if ( $isInCatNS && $children > 0 ) {
388            $children = $this->renderChildren( $title, $children );
389            if ( $children === '' ) {
390                switch ( $mode ) {
391                    case CategoryTreeMode::CATEGORIES:
392                        $msg = 'categorytree-no-subcategories';
393                        break;
394                    case CategoryTreeMode::PAGES:
395                        $msg = 'categorytree-no-pages';
396                        break;
397                    case CategoryTreeMode::PARENTS:
398                        $msg = 'categorytree-no-parent-categories';
399                        break;
400                    default:
401                        $msg = 'categorytree-nothing-found';
402                        break;
403                }
404                $children = Html::element( 'i', [ 'class' => 'CategoryTreeNotice' ],
405                    wfMessage( $msg )->text()
406                );
407            }
408            $s .= $children;
409        }
410
411        $s .= Html::closeElement( 'div' ) . Html::closeElement( 'div' );
412
413        return $s;
414    }
415
416    /**
417     * Create a string which format the page, subcat and file counts of a category
418     */
419    public static function createCountString( IContextSource $context, ?Category $cat,
420        int $countMode
421    ): string {
422        $allCount = $cat ? $cat->getMemberCount() : 0;
423        $subcatCount = $cat ? $cat->getSubcatCount() : 0;
424        $fileCount = $cat ? $cat->getFileCount() : 0;
425        $pages = $cat ? $cat->getPageCount( Category::COUNT_CONTENT_PAGES ) : 0;
426
427        $attr = [
428            'title' => $context->msg( 'categorytree-member-counts' )
429                ->numParams( $subcatCount, $pages, $fileCount, $allCount, $countMode )->text(),
430            # numbers and commas get messed up in a mixed dir env
431            'dir' => $context->getLanguage()->getDir()
432        ];
433
434        # Create a list of category members with only non-zero member counts
435        $memberNums = [];
436        if ( $subcatCount ) {
437            $memberNums[] = $context->msg( 'categorytree-num-categories' )
438                ->numParams( $subcatCount )->text();
439        }
440        if ( $pages ) {
441            $memberNums[] = $context->msg( 'categorytree-num-pages' )->numParams( $pages )->text();
442        }
443        if ( $fileCount ) {
444            $memberNums[] = $context->msg( 'categorytree-num-files' )
445                ->numParams( $fileCount )->text();
446        }
447        $memberNumsShort = $memberNums
448            ? $context->getLanguage()->commaList( $memberNums )
449            : $context->msg( 'categorytree-num-empty' )->text();
450
451        # Only $5 is actually used in the default message.
452        # Other arguments can be used in a customized message.
453        $s = ' ' . Html::rawElement(
454            'span',
455            $attr,
456            $context->msg( 'categorytree-member-num' )
457                // Do not use numParams on params 1-4, as they are only used for customisation.
458                ->params( $subcatCount, $pages, $fileCount, $allCount, $memberNumsShort )
459                ->escaped()
460        );
461
462        return $s;
463    }
464
465    /**
466     * Creates a Title object from a user provided (and thus unsafe) string
467     */
468    public static function makeTitle( string $title ): ?Title {
469        $title = trim( $title );
470
471        if ( $title === '' ) {
472            return null;
473        }
474
475        # The title must be in the category namespace
476        # Ignore a leading Category: if there is one
477        $t = Title::newFromText( $title, NS_CATEGORY );
478        if ( !$t || !$t->inNamespace( NS_CATEGORY ) || $t->isExternal() ) {
479            // If we were given something like "Wikipedia:Foo" or "Template:",
480            // try it again but forced.
481            $title = "Category:$title";
482            $t = Title::newFromText( $title );
483        }
484        return $t;
485    }
486}