Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 245 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
PagesTagParser | |
0.00% |
0 / 245 |
|
0.00% |
0 / 5 |
9506 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
render | |
0.00% |
0 / 200 |
|
0.00% |
0 / 1 |
6806 | |||
parseNumList | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
110 | |||
getTableOfContentLinks | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
formatError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace ProofreadPage\Parser; |
4 | |
5 | use MediaWiki\Title\Title; |
6 | use OutOfBoundsException; |
7 | use Parser; |
8 | use ParserOptions; |
9 | use ProofreadPage\Context; |
10 | use ProofreadPage\Index\IndexTemplateStyles; |
11 | use ProofreadPage\Index\WikitextLinksExtractor; |
12 | use ProofreadPage\Link; |
13 | use ProofreadPage\Page\PageLevel; |
14 | use ProofreadPage\Pagination\FilePagination; |
15 | use ProofreadPage\Pagination\PageNumber; |
16 | |
17 | /** |
18 | * @license GPL-2.0-or-later |
19 | * |
20 | * Parser for the <pages> tag |
21 | */ |
22 | class 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 | } |