Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PagesTagParser
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 5
9506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 render
0.00% covered (danger)
0.00%
0 / 200
0.00% covered (danger)
0.00%
0 / 1
6806
 parseNumList
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 getTableOfContentLinks
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 formatError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace ProofreadPage\Parser;
4
5use MediaWiki\Title\Title;
6use OutOfBoundsException;
7use Parser;
8use ParserOptions;
9use ProofreadPage\Context;
10use ProofreadPage\Index\IndexTemplateStyles;
11use ProofreadPage\Index\WikitextLinksExtractor;
12use ProofreadPage\Link;
13use ProofreadPage\Page\PageLevel;
14use ProofreadPage\Pagination\FilePagination;
15use ProofreadPage\Pagination\PageNumber;
16
17/**
18 * @license GPL-2.0-or-later
19 *
20 * Parser for the <pages> tag
21 */
22class PagesTagParser {
23
24    /**
25     * @var Parser
26     */
27    private $parser;
28
29    /**
30     * @var Context
31     */
32    private $context;
33
34    /**
35     * @param Parser $parser
36     * @param Context $context
37     */
38    public function __construct( Parser $parser, Context $context ) {
39        $this->parser = $parser;
40        $this->context = $context;
41    }
42
43    /**
44     * Render a <pages> tag
45     *
46     * @param array $args tags arguments
47     * @return string
48     */
49    public function render( array $args ) {
50        // abort if this is nested <pages> call
51        // FIXME: remove this: T362664
52        if ( $this->parser->proofreadRenderingPages ?? false ) {
53            return '';
54        }
55
56        $index = $args['index'] ?? null;
57        $from = $args['from'] ?? null;
58        $to = $args['to'] ?? null;
59        $include = $args['include'] ?? null;
60        $exclude = $args['exclude'] ?? null;
61        $step = $args['step'] ?? null;
62        $header = $args['header'] ?? null;
63        $tosection = $args['tosection'] ?? null;
64        $fromsection = $args['fromsection'] ?? null;
65        $onlysection = $args['onlysection'] ?? null;
66
67        $pageTitle = $this->parser->getTitle();
68
69        // abort if the tag is on an index or a page page
70        if (
71            $pageTitle->inNamespace( $this->context->getIndexNamespaceId() ) ||
72            $pageTitle->inNamespace( $this->context->getPageNamespaceId() )
73        ) {
74            return $this->formatError( 'proofreadpage_pagesnotallowed' );
75        }
76        // ignore fromsection and tosection arguments if onlysection is specified
77        if ( $onlysection !== null ) {
78            $fromsection = null;
79            $tosection = null;
80        }
81
82        if ( !$index ) {
83            return $this->formatError( 'proofreadpage_index_expected' );
84        }
85
86        $indexTitle = Title::makeTitleSafe( $this->context->getIndexNamespaceId(), $index );
87        if ( $indexTitle === null || !$indexTitle->exists() ) {
88            $this->parser->addTrackingCategory( 'proofreadpage_nosuch_index_category' );
89            return $this->formatError( 'proofreadpage_nosuch_index' );
90        }
91        $pagination = $this->context->getPaginationFactory()->getPaginationForIndexTitle( $indexTitle );
92        $outputWrapperClass = 'prp-pages-output';
93        $language = $this->parser->getTargetLanguage();
94        $this->parser->getOutput()->addTemplate(
95            $indexTitle, $indexTitle->getArticleID(), $indexTitle->getLatestRevID()
96        );
97        $out = '';
98
99        $separator = $this->context->getConfig()->get( 'ProofreadPagePageSeparator' );
100        $joiner = $this->context->getConfig()->get( 'ProofreadPagePageJoiner' );
101        $placeholder = $this->context->getConfig()->get( 'ProofreadPagePageSeparatorPlaceholder' );
102
103        $contentLang = null;
104
105        if ( $from || $to || $include ) {
106            $pages = [];
107
108            if ( $pagination instanceof FilePagination ) {
109                $from = ( $from === null ) ? null : $language->parseFormattedNumber( $from );
110                $to = ( $to === null ) ? null : $language->parseFormattedNumber( $to );
111                $step = ( $step === null ) ? null : $language->parseFormattedNumber( $step );
112
113                $count = $pagination->getNumberOfPages();
114                if ( $count === 0 ) {
115                    return $this->formatError( 'proofreadpage_nosuch_file' );
116                }
117
118                if ( !$step ) {
119                    $step = 1;
120                } elseif ( !is_numeric( $step ) || $step < 1 ) {
121                    return $this->formatError( 'proofreadpage_number_expected' );
122                } else {
123                    $step = (int)$step;
124                }
125
126                $pagenums = [];
127
128                // add page selected with $include in pagenums
129                if ( $include ) {
130                    $pagenums = $this->parseNumList( $include );
131                    if ( !$pagenums ) {
132                        return $this->formatError( 'proofreadpage_invalid_interval' );
133                    }
134                }
135
136                // ad pages selected with from and to in pagenums
137                if ( $from || $to ) {
138                    $from = $from ?: '1';
139                    $to = $to ?: (string)$count;
140
141                    if ( !ctype_digit( $from ) || !ctype_digit( $to ) ) {
142                        return $this->formatError( 'proofreadpage_number_expected' );
143                    }
144                    $from = (int)$from;
145                    $to = (int)$to;
146
147                    if ( $from === 0 || $from > $to || $to > $count ) {
148                        return $this->formatError( 'proofreadpage_invalid_interval' );
149                    }
150
151                    for ( $i = $from; $i <= $to; $i++ ) {
152                        $pagenums[$i] = $i;
153                    }
154                }
155
156                // remove excluded pages form $pagenums
157                if ( $exclude ) {
158                    $excluded = $this->parseNumList( $exclude );
159                    if ( $excluded == null ) {
160                        return $this->formatError( 'proofreadpage_invalid_interval' );
161                    }
162                    $pagenums = array_diff( $pagenums, $excluded );
163                }
164
165                if ( count( $pagenums ) / $step > 1000 ) {
166                    return $this->formatError( 'proofreadpage_interval_too_large' );
167                }
168
169                // we must sort the array even if the numerical keys are in a good order.
170                ksort( $pagenums );
171                if ( end( $pagenums ) > $count ) {
172                    return $this->formatError( 'proofreadpage_invalid_interval' );
173                }
174
175                // Create the list of pages to translude.
176                // the step system start with the smaller pagenum
177                $mod = reset( $pagenums ) % $step;
178                foreach ( $pagenums as $num ) {
179                    if ( $step == 1 || $num % $step == $mod ) {
180                        $pageNumber = $pagination->getDisplayedPageNumber( $num );
181                        $pages[] = [ $pagination->getPageTitle( $num ), $pageNumber ];
182                    }
183                }
184
185            } else {
186                $fromTitle = null;
187                if ( $from ) {
188                    $fromTitle = Title::makeTitleSafe(
189                        $this->context->getPageNamespaceId(), $from
190                    );
191                }
192
193                $toTitle = null;
194                if ( $to ) {
195                    $toTitle = Title::makeTitleSafe( $this->context->getPageNamespaceId(), $to );
196                }
197
198                $adding = ( $fromTitle === null );
199                $i = 1;
200                foreach ( $pagination as $link ) {
201                    if ( $fromTitle !== null && $fromTitle->equals( $link ) ) {
202                        $adding = true;
203                    }
204                    if ( $adding ) {
205                        $pageNumber = $pagination->getDisplayedPageNumber( $i );
206                        $pages[] = [ $link, $pageNumber ];
207                    }
208                    if ( $toTitle !== null && $toTitle->equals( $link ) ) {
209                        $adding = false;
210                    }
211                    $i++;
212                }
213            }
214
215            /** @var PageNumber $from_pagenum */
216            [ $from_page, $from_pagenum ] = reset( $pages );
217            /** @var PageNumber $to_pagenum */
218            [ $to_page, $to_pagenum ] = end( $pages );
219
220            $pageQualityLevelLookup = $this->context->getPageQualityLevelLookup();
221            $pageQualityLevelLookup->prefetchQualityLevelForTitles( array_column( $pages, 0 ) );
222
223            $indexTs = new IndexTemplateStyles( $indexTitle );
224            $out .= $indexTs->getIndexTemplateStyles( ".$outputWrapperClass" );
225
226            // write the output
227            /** @var Title $page */
228            foreach ( $pages as [ $page, $pageNumber ] ) {
229                if ( $contentLang !== 'mixed' ) {
230                    $pageLang = $page->getPageLanguage()->getHtmlCode();
231                    if ( !$contentLang ) {
232                        $contentLang = $pageLang;
233                    } elseif ( $contentLang !== $pageLang ) {
234                        $contentLang = 'mixed';
235                    }
236                }
237                $qualityLevel = $pageQualityLevelLookup->getQualityLevelForPageTitle( $page );
238                $text = $page->getPrefixedText();
239                if ( $qualityLevel !== PageLevel::WITHOUT_TEXT ) {
240                    $pagenum = $pageNumber->getRawPageNumber( $language );
241                    $formattedNum = $pageNumber->getFormattedPageNumber( $language );
242                    $out .= '<span>{{:MediaWiki:Proofreadpage_pagenum_template|page=' . $text .
243                        "|num=$pagenum|formatted=$formattedNum|quality=$qualityLevel}}</span>";
244                }
245                if ( $from_page !== null && $page->equals( $from_page ) && $fromsection !== null ) {
246                    $ts = '';
247                    // Check if it is single page transclusion
248                    if ( $to_page !== null && $page->equals( $to_page ) && $tosection !== null ) {
249                        $ts = $tosection;
250                    }
251                    $out .= '{{#lst:' . $text . '|' . $fromsection . '|' . $ts . '}}';
252                } elseif ( $to_page !== null && $page->equals( $to_page ) && $tosection !== null ) {
253                    $out .= '{{#lst:' . $text . '||' . $tosection . '}}';
254                } elseif ( $onlysection !== null ) {
255                    $out .= '{{#lst:' . $text . '|' . $onlysection . '}}';
256                } else {
257                    $out .= '{{:' . $text . '}}';
258                }
259                if ( $qualityLevel !== PageLevel::WITHOUT_TEXT ) {
260                    $out .= $placeholder;
261                }
262            }
263        } else {
264            /* table of Contents */
265            $header = 'toc';
266            try {
267                $firstpage = $pagination->getPageTitle( 1 );
268                $this->parser->getOutput()->addTemplate(
269                    $firstpage,
270                    $firstpage->getArticleID(),
271                    $firstpage->getLatestRevID()
272                );
273            } catch ( OutOfBoundsException $e ) {
274                // if the first page does not exist
275            }
276        }
277
278        if ( $header ) {
279            if ( $header == 'toc' ) {
280                $this->parser->getOutput()
281                    ->setExtensionData( 'proofreadpage_is_toc', true );
282            }
283
284            $indexLinks = $this->getTableOfContentLinks( $indexTitle );
285            $pageTitle = $this->parser->getTitle();
286            $h_out = '{{:MediaWiki:Proofreadpage_header_template';
287            $h_out .= "|value=$header";
288            // find next and previous pages in list
289            $indexLinksCount = count( $indexLinks );
290            for ( $i = 0; $i < $indexLinksCount; $i++ ) {
291                if ( $pageTitle->equals( $indexLinks[$i]->getTarget() ) ) {
292                    $current = '[[' . $indexLinks[$i]->getTarget()->getFullText() . '|' .
293                        $indexLinks[$i]->getLabel() . ']]';
294                    break;
295                }
296            }
297            if ( isset( $current ) ) {
298                if ( $i > 0 ) {
299                    $prev = '[[' . $indexLinks[$i - 1]->getTarget()->getFullText() . '|' .
300                        $indexLinks[$i - 1]->getLabel() . ']]';
301                }
302                if ( $i + 1 < $indexLinksCount ) {
303                    $next = '[[' . $indexLinks[$i + 1]->getTarget()->getFullText() . '|' .
304                        $indexLinks[$i + 1]->getLabel() . ']]';
305                }
306            }
307            if ( isset( $args['current'] ) ) {
308                $current = $args['current'];
309            }
310            if ( isset( $args['prev'] ) ) {
311                $prev = $args['prev'];
312            }
313            if ( isset( $args['next'] ) ) {
314                $next = $args['next'];
315            }
316            if ( isset( $current ) ) {
317                $h_out .= "|current=$current";
318            }
319            if ( isset( $prev ) ) {
320                $h_out .= "|prev=$prev";
321            }
322            if ( isset( $next ) ) {
323                $h_out .= "|next=$next";
324            }
325            if ( isset( $from_pagenum ) ) {
326                $formattedFrom = $from_pagenum->getFormattedPageNumber( $language );
327                $h_out .= "|from=$formattedFrom";
328            }
329            if ( isset( $to_pagenum ) ) {
330                $formattedTo = $to_pagenum->getFormattedPageNumber( $language );
331                $h_out .= "|to=$formattedTo";
332            }
333            $indexContent = $this->context->getIndexContentLookup()->getIndexContentForTitle( $indexTitle );
334            $attributes = $this->context->getCustomIndexFieldsParser()
335                ->parseCustomIndexFieldsForHeader( $indexContent );
336            foreach ( $attributes as $attribute ) {
337                $key = strtolower( $attribute->getKey() );
338                if ( array_key_exists( $key, $args ) ) {
339                    $val = $args[$key];
340                } else {
341                    $val = $attribute->getStringValue();
342                }
343                $h_out .= "|$key=$val";
344            }
345            $h_out .= '}}';
346            $out = $h_out . $out;
347        }
348
349        // wrap the output in a div, to prevent the parser from inserting paragraphs
350        // and to set the content language
351        $langAttr = $contentLang && $contentLang !== 'mixed'
352            ? " lang=\"$contentLang\""
353            : "";
354        $out = "<div class=\"$outputWrapperClass\"$langAttr>\n$out\n</div>";
355        $this->parser->proofreadRenderingPages = true;
356        $out = $this->parser->recursiveTagParse( $out );
357
358        // remove separator after the word-joiner character
359        $out = str_replace( $joiner . $placeholder, '', $out );
360        $out = str_replace( $placeholder, $separator, $out );
361
362        $this->parser->proofreadRenderingPages = false;
363        return $out;
364    }
365
366    /**
367     * Parse a comma-separated list of pages. A dash indicates an interval of pages
368     * example: 1-10,23,38
369     *
370     * @param string $input
371     * @return int[]|null an array of pages, or null if the input does not comply to the syntax
372     */
373    public function parseNumList( $input ) {
374        $input = str_replace( [ ' ', '\t', '\n' ], '', $input );
375        $list = explode( ',', $input );
376        $nums = [];
377        foreach ( $list as $item ) {
378            if ( ctype_digit( $item ) ) {
379                if ( $item < 1 ) {
380                    return null;
381                }
382                $nums[$item] = $item;
383            } else {
384                $interval = explode( '-', $item );
385                if ( count( $interval ) != 2
386                    || !ctype_digit( $interval[0] )
387                    || !ctype_digit( $interval[1] )
388                    || $interval[0] < 1
389                    || $interval[1] < $interval[0]
390                ) {
391                    return null;
392                }
393                for ( $i = $interval[0]; $i <= $interval[1]; $i += 1 ) {
394                    $nums[$i] = $i;
395                }
396            }
397        }
398        return $nums;
399    }
400
401    /**
402     * Fetches all the ns0 links from the "toc" field if it exists or considers all fields and skips the first link.
403     *
404     * @param Title $indexTitle
405     * @return Link[]
406     */
407    private function getTableOfContentLinks( Title $indexTitle ): array {
408        $indexContent = $this->context->getIndexContentLookup()->getIndexContentForTitle( $indexTitle );
409        $linksExtractor = new WikitextLinksExtractor();
410        // @phan-suppress-next-line PhanUndeclaredMethod
411        $parser = $indexContent->getContentHandler()->getParser();
412        $parserOptions = ParserOptions::newFromAnon();
413        try {
414            $toc = $this->context->getCustomIndexFieldsParser()->getCustomIndexFieldForDataKey( $indexContent, 'toc' );
415            $wikitext = $parser->preprocess(
416                $toc->getStringValue(),
417                $indexTitle, $parserOptions
418            );
419            return $linksExtractor->getLinksToNamespace( $wikitext, NS_MAIN );
420        } catch ( OutOfBoundsException $e ) {
421            $links = [];
422            foreach ( $indexContent->getFields() as $field ) {
423                $wikitext = $parser->preprocess(
424                    $field->serialize( CONTENT_FORMAT_WIKITEXT ),
425                    $indexTitle, $parserOptions
426                );
427                $links = array_merge(
428                    $links,
429                    $linksExtractor->getLinksToNamespace( $wikitext, NS_MAIN )
430                );
431            }
432
433            // Hack to ignore the book title
434            array_shift( $links );
435
436            return $links;
437        }
438    }
439
440    /**
441     * @param string $errorMsg
442     * @return string
443     */
444    private function formatError( $errorMsg ) {
445        return '<strong class="error">' . wfMessage( $errorMsg )->inContentLanguage()->escaped() .
446            '</strong>';
447    }
448}