Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubPageList3
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 10
6162
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
 renderSubpageList3
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 geterrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 options
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
1640
 render
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getTitles
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
 makeListItem
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 makeList
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
210
 parse
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\SubPageList3;
4
5use LogicException;
6use MediaWiki\Config\Config;
7use MediaWiki\Html\Html;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Title\Title;
10use Parser;
11use PPFrame;
12use Wikimedia\Rdbms\IExpression;
13use Wikimedia\Rdbms\LikeValue;
14
15/**
16 * SubPageList3 class
17 */
18class SubPageList3 {
19    /**
20     * @var Parser
21     */
22    private $parser;
23
24    /**
25     * @var PPFrame|bool
26     */
27    private $frame;
28
29    /**
30     * @var Title
31     */
32    private $title;
33
34    /**
35     * @var Title
36     */
37    private $ptitle;
38
39    /**
40     * @var string
41     */
42    private $namespace = '';
43
44    /**
45     * @var string token object
46     */
47    private $token = '*';
48
49    /**
50     * @var int error display on or off
51     * @default 0 hide errors
52     */
53    private $debug = 0;
54
55    /**
56     * contain the error messages
57     * @var array contain the errors messages
58     */
59    private $errors = [];
60
61    /**
62     * order type
63     * Can be:
64     *  - asc
65     *  - desc
66     * @var string order type
67     */
68    private $order = 'asc';
69
70    /**
71     * column that's used as order method
72     * Can be:
73     *  - title: alphabetic order of a page title
74     *  - lastedit: Timestamp numeric order of the last edit of a page
75     * @var string order method
76     * @private
77     */
78    private $ordermethod = 'title';
79
80    /**
81     * mode of the output
82     * Can be:
83     *  - unordered: UL list as output
84     *  - ordered: OL list as output
85     *  - bar: uses ยท as a delimiter producing a horizontal bar menu
86     * @var string mode of output
87     * @default unordered
88     */
89    private $mode = 'unordered';
90
91    /**
92     * parent of the listed pages
93     * Can be:
94     *  - -1: the current page title
95     *  - string: title of the specific title
96     * e.g. if you are in Mainpage/ it will list all subpages of Mainpage/
97     * @var mixed parent of listed pages
98     * @default -1 current
99     */
100    private $parent = -1;
101
102    /**
103     * style of the path (title)
104     * Can be:
105     *  - full: normal, e.g. Mainpage/Entry/Sub
106     *  - notparent: the path without the $parent item, e.g. Entry/Sub
107     *  - no: no path, only the page title, e.g. Sub
108     * @var string style of the path (title)
109     * @default normal
110     * @see $parent
111     */
112    private $showpath = 'no';
113
114    /**
115     * whether to show next sublevel only, or all sublevels
116     * Can be:
117     *  - 0 / no / false
118     *  - 1 / yes / true
119     * @var mixed show one sublevel only
120     * @default 0
121     * @see $parent
122     */
123    private $kidsonly = 0;
124
125    /**
126     * whether to show parent as the top item
127     * Can be:
128     *  - 0 / no / false
129     *  - 1 / yes / true
130     * @var mixed show one sublevel only
131     * @default 0
132     * @see $parent
133     */
134    private $showparent = 0;
135
136    /**
137     * Text to show when parent has no subpages to list
138     * when null (by default) shows default message
139     * @var string|null
140     * @default null
141     */
142    private $nosubpages = null;
143
144    /**
145     * Default limit of descendants
146     * @var int
147     * @default 200
148     */
149    private const DESCENDANTS_LIMIT_DEFAULT = 200;
150
151    /** @var Config */
152    private $config;
153
154    /**
155     * Constructor function of the class
156     * @param Parser $parser the parser object
157     * @param Config $config
158     * @param PPFrame|bool $frame
159     * @see SubpageList
160     */
161    private function __construct( Parser $parser, Config $config, $frame = false ) {
162        $this->parser = $parser;
163        $this->frame = $frame;
164        $this->title = $parser->getTitle();
165        $this->config = $config;
166    }
167
168    /**
169     * Function called by the Hook, returns the wiki text
170     *
171     * @param string $input
172     * @param array $args
173     * @param Parser $parser
174     * @param PPFrame $frame
175     * @return string
176     */
177    public static function renderSubpageList3( $input, array $args, Parser $parser, PPFrame $frame ) {
178        $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'SubPageList3' );
179        $list = new SubpageList3( $parser, $config, $frame );
180        $list->options( $args );
181
182        # $parser->disableCache();
183        return $list->render();
184    }
185
186    /**
187     * adds error to the $errors container
188     * but only if $debug is true or 1
189     * @param string $message the errors message
190     * @see $errors
191     * @see $debug
192     */
193    private function error( $message ) {
194        if ( $this->debug ) {
195            $this->errors[] = "<strong>Error [Subpage List 3]:</strong> $message";
196        }
197    }
198
199    /**
200     * returns all errors as a string
201     * @return string all errors separated by a newline
202     */
203    private function geterrors() {
204        return implode( "\n", $this->errors );
205    }
206
207    /**
208     * parse the options that the user has entered
209     * a bit long way, but because that it's easy to add alias
210     * @param array $options the options inserts by the user as array
211     * @see $debug
212     * @see $order
213     * @see $ordermethod
214     * @see $mode
215     * @see $parent
216     * @see $showpath
217     * @see $kidsonly
218     * @see $showparent
219     */
220    private function options( $options ) {
221        if ( isset( $options['debug'] ) ) {
222            if ( in_array( $options['debug'], [ 'true', 1, '1' ], true ) ) {
223                $this->debug = 1;
224            } elseif ( in_array( $options['debug'], [ 'false', 0, '0' ], true ) ) {
225                $this->debug = 0;
226            } else {
227                $this->error( wfMessage( 'spl3_debug', 'debug' )->escaped() );
228            }
229        }
230        if ( isset( $options['sort'] ) ) {
231            switch ( strtolower( $options['sort'] ) ) {
232                case 'asc':
233                    $this->order = 'asc';
234                    break;
235                case 'desc':
236                    $this->order = 'desc';
237                    break;
238                default:
239                    $this->error( wfMessage( 'spl3_debug', 'sort' )->escaped() );
240            }
241        }
242        if ( isset( $options['sortby'] ) ) {
243            switch ( strtolower( $options['sortby'] ) ) {
244                case 'title':
245                    $this->ordermethod = 'title';
246                    break;
247                case 'lastedit':
248                    $this->ordermethod = 'lastedit';
249                    break;
250                default:
251                    $this->error( wfMessage( 'spl3_debug', 'sortby' )->escaped() );
252            }
253        }
254        if ( isset( $options['liststyle'] ) ) {
255            switch ( strtolower( $options['liststyle'] ) ) {
256                case 'ordered':
257                    $this->mode = 'ordered';
258                    $this->token = '#';
259                    break;
260                case 'unordered':
261                    $this->mode = 'unordered';
262                    $this->token = '*';
263                    break;
264                case 'bar':
265                    $this->mode = 'bar';
266                    $this->token = '&#160;ยท ';
267                    break;
268                default:
269                    $this->error( wfMessage( 'spl3_debug', 'liststyle' )->escaped() );
270            }
271        }
272        if ( isset( $options['parent'] ) ) {
273            if ( intval( $options['parent'] ) == -1 ) {
274                $this->parent = -1;
275            } elseif ( is_string( $options['parent'] ) ) {
276                $this->parent = $this->parse( $options['parent'] );
277            } else {
278                $this->error( wfMessage( 'spl3_debug', 'parent' )->escaped() );
279            }
280        }
281        if ( isset( $options['showpath'] ) ) {
282            $showPath = strtolower( $options['showpath'] );
283            if ( $showPath === 'no' || $showPath === '0' || $showPath === 'false' ) {
284                $this->showpath = 'no';
285            } elseif ( $showPath === 'notparent' ) {
286                $this->showpath = 'notparent';
287            } elseif ( in_array( $showPath, [ 'full', 'yes', '1', 'true' ], true ) ) {
288                $this->showpath = 'full';
289            } else {
290                $this->error( wfMessage( 'spl3_debug', 'showpath' )->escaped() );
291            }
292        }
293        if ( isset( $options['kidsonly'] ) ) {
294            if ( $options['kidsonly'] == 'true' || $options['kidsonly'] == 'yes'
295                || intval( $options['kidsonly'] ) == 1
296            ) {
297                $this->kidsonly = 1;
298            } elseif ( $options['kidsonly'] == 'false' || $options['kidsonly'] == 'no'
299                || intval( $options['kidsonly'] ) == 0
300            ) {
301                $this->kidsonly = 0;
302            } else {
303                $this->error( wfMessage( 'spl3_debug', 'kidsonly' )->escaped() );
304            }
305        }
306        if ( isset( $options['showparent'] ) ) {
307            if ( $options['showparent'] == 'true' || $options['showparent'] == 'yes'
308                || intval( $options['showparent'] ) == 1
309            ) {
310                $this->showparent = 1;
311            } elseif ( $options['showparent'] == 'false' || $options['showparent'] == 'no'
312                || intval( $options['showparent'] ) == 0
313            ) {
314                $this->showparent = 0;
315            } else {
316                $this->error( wfMessage( 'spl3_debug', 'showparent' )->escaped() );
317            }
318        }
319
320        $this->nosubpages = $options['nosubpages'] ?? null;
321    }
322
323    /**
324     * produce output using this class
325     * @return string html output
326     */
327    private function render() {
328        $pages = $this->getTitles();
329        $class = 'subpagelist';
330        if ( $pages != null && count( $pages ) > 0 ) {
331            $list = $this->makeList( $pages );
332            $html = $this->parse( $list );
333        } else {
334            if ( $this->nosubpages !== null ) {
335                $out = $this->nosubpages;
336            } else {
337                $plink = "[[" . $this->parent . "]]";
338                $out = "''" . wfMessage( 'spl3_nosubpages', $plink )->text() . "''\n";
339            }
340            $html = $this->parse( $out );
341            $class .= ' subpagelist-empty';
342        }
343        $html = $this->geterrors() . $html;
344        return Html::rawElement( 'div', [ 'class' => $class ], $html );
345    }
346
347    /**
348     * return the page titles of the subpages in an array
349     * @return array|null all titles, null on failure
350     */
351    private function getTitles() {
352        if ( $this->parent !== -1 ) {
353            $this->ptitle = Title::newFromText( $this->parent );
354            $user = MediaWikiServices::getInstance()->getUserFactory()
355                ->newFromUserIdentity( $this->parser->getUserIdentity() );
356            // note that non-existent pages may nevertheless have valid subpages
357            // on the other hand, not checking that the page exists can let input
358            // through which causes database errors
359            if (
360                $this->ptitle instanceof Title &&
361                $this->ptitle->exists() &&
362                $user->definitelyCan( 'read', $this->ptitle )
363            ) {
364                $parent = $this->ptitle->getDBkey();
365                $this->parent = $parent;
366                $this->namespace = $this->ptitle->getNsText();
367                $nsi = $this->ptitle->getNamespace();
368            } else {
369                $this->error( wfMessage( 'spl3_debug', 'parent' )->escaped() );
370                return null;
371            }
372        } else {
373            $this->ptitle = $this->title;
374            $parent = $this->title->getDBkey();
375            $this->parent = $parent;
376            $this->namespace = $this->title->getNsText();
377            $nsi = $this->title->getNamespace();
378        }
379
380        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
381        $queryBuilder = $dbr->newSelectQueryBuilder()
382            ->select( [ 'page_namespace', 'page_title' ] )
383            ->from( 'page' )
384            // don't let lists cross namespaces or include redirects
385            ->where( [
386                'page_namespace' => $nsi,
387                'page_is_redirect' => 0,
388                $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( $parent . '/', $dbr->anyString() ) ),
389            ] )
390            ->caller( __METHOD__ );
391
392        $order = strtoupper( $this->order );
393        if ( $this->ordermethod == 'title' ) {
394            $queryBuilder->orderBy( 'page_title', $order );
395        } elseif ( $this->ordermethod == 'lastedit' ) {
396            $queryBuilder->orderBy( 'page_touched', $order );
397        }
398
399        $res = $queryBuilder->fetchResultSet();
400
401        $titles = [];
402        foreach ( $res as $row ) {
403            $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
404            if ( $title ) {
405                $titles[] = $title;
406            }
407        }
408
409        return $titles;
410    }
411
412    /**
413     * create one list item
414     * cases:
415     *  - full: full, e.g. Mainpage/Entry/Sub
416     *  - notparent: the path without the $parent item, e.g. Entry/Sub
417     *  - no: no path, only the page title, e.g. Sub
418     * @param Title $title the title of a page
419     * @return string the prepared string
420     * @see $showpath
421     */
422    private function makeListItem( $title ) {
423        switch ( $this->showpath ) {
424            case 'no':
425                $linktitle = substr( strrchr( $title->getText(), "/" ), 1 );
426                break;
427            case 'notparent':
428                $linktitle = substr( strstr( $title->getText(), "/" ), 1 );
429                break;
430            case 'full':
431                $linktitle = $title->getText();
432                break;
433            default:
434                throw new LogicException( "Can not happen" );
435        }
436        return ' [[' . $title->getPrefixedText() . '|' . $linktitle . ']]';
437    }
438
439    /**
440     * create whole list using makeListItem
441     * @param array $titles Array all page titles
442     * @return string the whole list
443     * @see SubPageList::makeListItem
444     */
445    private function makeList( $titles ) {
446        $descendantsLimitRaw = $this->config->get( 'SubPageListDescendantsLimit' );
447        $descendantsLimit = is_int( $descendantsLimitRaw ) ? $descendantsLimitRaw : self::DESCENDANTS_LIMIT_DEFAULT;
448        $c = 0;
449        $list = [];
450        # add parent item
451        if ( $this->showparent ) {
452            $pn = '[[' . $this->ptitle->getPrefixedText() . '|' . $this->ptitle->getText() . ']]';
453            if ( $this->mode != 'bar' ) {
454                $pn = $this->token . $pn;
455            }
456            $ss = trim( $pn );
457            $list[] = $ss;
458            // flag for bar token to be added on next item
459            $c++;
460        }
461        # add descendants
462        $parlv = substr_count( $this->ptitle->getPrefixedText(), '/' );
463        foreach ( $titles as $title ) {
464            $lv = substr_count( $title, '/' ) - $parlv;
465            if ( $this->kidsonly != 1 || $lv < 2 ) {
466                if ( $this->showparent ) {
467                    $lv++;
468                }
469                $ss = "";
470                if ( $this->mode == 'bar' ) {
471                    if ( $c > 0 ) {
472                        $ss .= $this->token;
473                    }
474                } else {
475                    for ( $i = 0; $i < $lv; $i++ ) {
476                        $ss .= $this->token;
477                    }
478                }
479                $ss .= $this->makeListItem( $title );
480                // make sure we don't get any <pre></pre> tags
481                $ss = trim( $ss );
482                $list[] = $ss;
483            }
484            $c++;
485            if ( $c > $descendantsLimit ) {
486                break;
487            }
488        }
489        $retval = '';
490        if ( count( $list ) > 0 ) {
491            $retval = implode( "\n", $list );
492            if ( $this->mode == 'bar' ) {
493                $retval = implode( "", $list );
494            }
495            // Workaround for bug where the first items */# in a list would remain unparsed
496            $retval = "\n" . $retval;
497        }
498
499        return $retval;
500    }
501
502    /**
503     * Wrapper function parse, call the other functions
504     * @param string $text the content
505     * @return string the parsed output
506     */
507    private function parse( $text ) {
508        return $this->parser->recursiveTagParse( $text, $this->frame );
509    }
510}