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