Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 321
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MockApiHelper
0.00% covered (danger)
0.00%
0 / 321
0.00% covered (danger)
0.00%
0 / 10
13110
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
 setApiPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addArticle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 makeRequest
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 transformHelper
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 imageInfo
0.00% covered (danger)
0.00%
0 / 131
0.00% covered (danger)
0.00%
0 / 1
2256
 processQuery
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 1
1722
 parse
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 preProcess
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 fetchTemplateData
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4// phpcs:disable Generic.Files.LineLength.TooLong
5
6namespace Wikimedia\Parsoid\ParserTests;
7
8use Error;
9use Wikimedia\Parsoid\Config\Api\ApiHelper;
10
11/**
12 * This class supports the implementation of Parser Tests in a standalone mode
13 * and without network access.
14 *
15 * In standalone mode, the config and data transformations needed by Parsoid
16 * cannot come from MediaWiki's database or its core classes.
17 *
18 * Without network access, we cannot fetch site configs or do data transformations
19 * on a remote wiki. This class supports this by intercepting network requests
20 * and returning mock responses based on cached site configs, hardcoded network
21 * responses and config,
22 *
23 * So, this API helper should be used with the Parsoid\Config\Api* set of config classes
24 * (and any subclasses derived from them).
25 *
26 * A lot of the responses here are tuned to what ParserTests needed. But, presumably
27 * this can be used by PHP Unit tests as long as the specific set of mocked responses
28 * satisfies the needs of those tests. Ideally, this class should NOT be updated for
29 * anything but the needs of running parser tests.
30 *
31 * Alternatively, PHP Unit tests could bypass the Api* classes altogether and use
32 * a (sub)set of mocked classes (Env, SiteConfig, PageConfig, DataAccess) if those
33 * classes and the data they provide satisfies the needs of those tests.
34 */
35class MockApiHelper extends ApiHelper {
36    // configuration to match PHP parserTests
37    private const IMAGE_BASE_URL = 'http://example.com/images';
38    private const IMAGE_DESC_URL = self::IMAGE_BASE_URL;
39    private const FILE_PROPS = [
40        'Foobar.jpg' => [
41            'size' => 7881,
42            'width' => 1941,
43            'height' => 220,
44            'bits' => 8,
45            'mime' => 'image/jpeg'
46        ],
47        'File_%26_file.jpg' => [
48            'size' => 7881,
49            'width' => 1941,
50            'height' => 220,
51            'bits' => 8,
52            'mime' => 'image/jpeg'
53        ],
54        'Thumb.png' => [
55            'size' => 22589,
56            'width' => 135,
57            'height' => 135,
58            'bits' => 8,
59            'mime' => 'image/png'
60        ],
61        'Foobar.svg' => [
62            'size' => 12345,
63            'width' => 240,
64            'height' => 180,
65            'bits' => 24,
66            'mime' => 'image/svg+xml'
67        ],
68        'Bad.jpg' => [
69            'size' => 12345,
70            'width' => 320,
71            'height' => 240,
72            'bits' => 24,
73            'mime' => 'image/jpeg',
74        ],
75        'LoremIpsum.djvu' => [
76            'size' => 3249,
77            'width' => 2480,
78            'height' => 3508,
79            'bits' => 8,
80            'mime' => 'image/vnd.djvu',
81            'mediatype' => 'OFFICE',
82            'pagecount' => 5,
83        ],
84        'Video.ogv' => [
85            'size' => 12345,
86            'width' => 320,
87            'height' => 240,
88            'bits' => 0,
89            # duration comes from
90            # TimedMediaHandler/tests/phpunit/mocks/MockOggHandler::getLength()
91            'duration' => 4.3666666666667,
92            'mime' => 'video/ogg; codecs="theora"',
93            'mediatype' => 'VIDEO',
94            # hacky way to get seek parameters to return the correct info
95            'extraParams' => [
96                'seek=1.2' => 'seek%3D1.2',
97                'seek=85' => 'seek%3D3.3666666666667', # hard limited by duration
98            ],
99        ],
100        'Transcode.webm' => [
101            'size' => 12345,
102            'width' => 492,
103            'height' => 360,
104            'bits' => 0,
105            'duration' => 4,
106            'mime' => 'video/webm; codecs="vp8, vorbis"',
107            'mediatype' => 'VIDEO',
108            'derivatives' => [
109                [
110                    'type' => 'video/webm; codecs="vp9, opus"',
111                    'transcodekey' => '240p.vp9.webm',
112                    'width' => 328,
113                    'height' => 240,
114                ],
115            ],
116        ],
117        'Audio.oga' => [
118            'size' => 12345,
119            'width' => 0,
120            'height' => 0,
121            'bits' => 0,
122            # duration comes from
123            # TimedMediaHandler/tests/phpunit/mocks/MockOggHandler::getLength()
124            'duration' => 0.99875,
125            'mime' => 'audio/ogg; codecs="vorbis"',
126            'mediatype' => 'AUDIO',
127        ],
128        'Hi-ho.jpg' => [
129            'size' => 7881,
130            'width' => 1941,
131            'height' => 220,
132            'bits' => 8,
133            'mime' => 'image/jpeg'
134        ],
135        'Tall.jpg' => [
136            'size' => 8888,
137            'width' => 400,
138            'height' => 600,
139            'bits' => 8,
140            'mime' => 'image/jpeg'
141        ],
142    ];
143
144    private array $articleCache = [];
145    private array $cachedConfigs = [];
146
147    private const MAIN_PAGE = [
148        'query' => [
149            'pages' => [
150                [
151                    'pageid' => 1,
152                    'ns' => 0,
153                    'title' => 'Main Page',
154                    'revisions' => [
155                        [
156                            'revid' => 1,
157                            'parentid' => 0,
158                            'slots' => [
159                                'main' => [
160                                    'contentmodel' => 'wikitext',
161                                    'contentformat' => 'text/x-wiki',
162                                    'content' => "<strong>MediaWiki has been successfully installed.</strong>\n\nConsult the [//meta.wikimedia.org/wiki/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
163                                ]
164                            ]
165                        ]
166                    ]
167                ]
168            ]
169        ]
170    ];
171
172    // Old response structure, pre-mcr
173    private const OLD_RESPONSE = [
174        'query' => [
175            'pages' => [
176                [
177                    'pageid' => 999,
178                    'ns' => 0,
179                    'title' => 'Old Response',
180                    'revisions' => [
181                        [
182                            'revid' => 999,
183                            'parentid' => 0,
184                            'contentmodel' => 'wikitext',
185                            'contentformat' => 'text/x-wiki',
186                            '*' => "<strong>MediaWiki was successfully installed.</strong>\n\nConsult the [//meta.wikimedia.org/wiki/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
187                        ]
188                    ]
189                ]
190            ]
191        ]
192    ];
193
194    private const JUNK_PAGE = [
195        'query' => [
196            'pages' => [
197                [
198                    'pageid' => 2,
199                    'ns' => 0,
200                    'title' => 'Junk Page',
201                    'revisions' => [
202                        [
203                            'revid' => 2,
204                            'parentid' => 0,
205                            'slots' => [
206                                'main' => [
207                                    'contentmodel' => 'wikitext',
208                                    'contentformat' => 'text/x-wiki',
209                                    'content' => '2. This is just some junk. See the comment above.'
210                                ]
211                            ]
212                        ]
213                    ]
214                ]
215            ]
216        ]
217    ];
218
219    private const LARGE_PAGE = [
220        'query' => [
221            'pages' => [
222                [
223                    'pageid' => 3,
224                    'ns' => 0,
225                    'title' => 'Large_Page',
226                    'revisions' => [
227                        [
228                            'revid' => 3,
229                            'parentid' => 0,
230                            'slots' => [
231                                'main' => [
232                                    'contentmodel' => 'wikitext',
233                                    'contentformat' => 'text/x-wiki',
234                                    /* content will be set separately */
235                                ]
236                            ]
237                        ]
238                    ]
239                ]
240            ]
241        ]
242    ];
243
244    private const REUSE_PAGE = [
245        'query' => [
246            'pages' => [
247                [
248                    'pageid' => 100,
249                    'ns' => 0,
250                    'title' => 'Reuse_Page',
251                    'revisions' => [
252                        [
253                            'revid' => 100,
254                            'parentid' => 0,
255                            'slots' => [
256                                'main' => [
257                                    'contentmodel' => 'wikitext',
258                                    'contentformat' => 'text/x-wiki',
259                                    'content' => '{{colours of the rainbow}}'
260                                ]
261                            ]
262                        ]
263                    ]
264                ]
265            ]
266        ]
267    ];
268
269    private const JSON_PAGE = [
270        'query' => [
271            'pages' => [
272                [
273                    'pageid' => 101,
274                    'ns' => 0,
275                    'title' => 'JSON_Page',
276                    'revisions' => [
277                        [
278                            'revid' => 101,
279                            'parentid' => 0,
280                            'slots' => [
281                                'main' => [
282                                    'contentmodel' => 'json',
283                                    'contentformat' => 'text/json',
284                                    'content' => '[1]'
285                                ]
286                            ]
287                        ]
288                    ]
289                ]
290            ]
291        ]
292    ];
293
294    private const LINT_PAGE = [
295        'query' => [
296            'pages' => [
297                [
298                    'pageid' => 102,
299                    'ns' => 0,
300                    'title' => 'Lint Page',
301                    'revisions' => [
302                        [
303                            'revid' => 102,
304                            'parentid' => 0,
305                            'slots' => [
306                                'main' => [
307                                    'contentmodel' => 'wikitext',
308                                    'contentformat' => 'text/x-wiki',
309                                    'content' => "{|\nhi\n|ho\n|}"
310                                ]
311                            ]
312                        ]
313                    ]
314                ]
315            ]
316        ]
317    ];
318
319    private const REDLINKS_PAGE = [
320        'query' => [
321            'pages' => [
322                [
323                    'pageid' => 103,
324                    'ns' => 0,
325                    'title' => 'Redlinks Page',
326                    'revisions' => [
327                        [
328                            'revid' => 103,
329                            'parentid' => 0,
330                            'slots' => [
331                                'main' => [
332                                    'contentmodel' => 'wikitext',
333                                    'contentformat' => 'text/x-wiki',
334                                    'content' => '[[Special:Version]] [[Doesnotexist]] [[Redirected]]'
335                                ]
336                            ]
337                        ]
338                    ]
339                ]
340            ]
341        ]
342    ];
343
344    private const VARIANT_PAGE = [
345        'query' => [
346            'pages' => [
347                [
348                    'pageid' => 104,
349                    'ns' => 0,
350                    'pagelanguage' => 'sr',
351                    'pagelanguagedir' => 'ltr',
352                    'title' => 'Variant Page',
353                    'revisions' => [
354                        [
355                            'revid' => 104,
356                            'parentid' => 0,
357                            'slots' => [
358                                'main' => [
359                                    'contentmodel' => 'wikitext',
360                                    'contentformat' => 'text/x-wiki',
361                                    'content' => "абвг abcd"
362                                ]
363                            ]
364                        ]
365                    ]
366                ]
367            ]
368        ]
369    ];
370
371    private const NOVARIANT_PAGE = [
372        'query' => [
373            'pages' => [
374                [
375                    'pageid' => 105,
376                    'ns' => 0,
377                    'pagelanguage' => 'sr',
378                    'pagelanguagedir' => 'ltr',
379                    'title' => 'No Variant Page',
380                    'revisions' => [
381                        [
382                            'revid' => 105,
383                            'parentid' => 0,
384                            'slots' => [
385                                'main' => [
386                                    'contentmodel' => 'wikitext',
387                                    'contentformat' => 'text/x-wiki',
388                                    'content' => "абвг abcd\n__NOCONTENTCONVERT__"
389                                ]
390                            ]
391                        ]
392                    ]
393                ]
394            ]
395        ]
396    ];
397
398    private const REVISION_PAGE = [
399        'query' => [
400            'pages' => [
401                [
402                    'pageid' => 63,
403                    'ns' => 0,
404                    'title' => 'Revision ID',
405                    'revisions' => [
406                        [
407                            'revid' => 63,
408                            'parentid' => 0,
409                            'slots' => [
410                                'main' => [
411                                    'contentmodel' => 'wikitext',
412                                    'contentformat' => 'text/x-wiki',
413                                    'content' => '{{REVISIONID}}'
414                                ]
415                            ]
416                        ]
417                    ]
418                ]
419            ]
420        ]
421    ];
422
423    private const MISSING_TITLES = [ 'Doesnotexist' ];
424    private const SPECIAL_TITLES = [
425        'Special:Version',
426        'Special:BookSources',
427        'Special:BookSources/isbn=4-00-026157-6',
428        'Special:BookSources/0978739256',
429    ];
430    private const REDIRECT_TITLES = [ 'Redirected' ];
431    private const DISAMBIG_TITLES = [ 'Disambiguation' ];
432
433    private const FNAMES = [
434        'Image:Foobar.jpg' => 'Foobar.jpg',
435        'Datei:Foobar.jpg' => 'Foobar.jpg',
436        'File:Foobar.jpg' => 'Foobar.jpg',
437        'File:File_&_file.jpg' => 'File_%26_file.jpg',
438        'Archivo:Foobar.jpg' => 'Foobar.jpg',
439        'Mynd:Foobar.jpg' => 'Foobar.jpg',
440        "Датотека:Foobar.jpg" => 'Foobar.jpg',
441        'Dosiero:Foobar.jpg' => 'Foobar.jpg',
442        'Image:Foobar.svg' => 'Foobar.svg',
443        'File:Foobar.svg' => 'Foobar.svg',
444        'Файл:Foobar.svg' => 'Foobar.svg',
445        'Datei:Foobar.svg' => 'Foobar.svg',
446        'Image:Thumb.png' => 'Thumb.png',
447        'File:Thumb.png' => 'Thumb.png',
448        'File:LoremIpsum.djvu' => 'LoremIpsum.djvu',
449        'File:Video.ogv' => 'Video.ogv',
450        'File:Transcode.webm' => 'Transcode.webm',
451        'File:Audio.oga' => 'Audio.oga',
452        'File:Bad.jpg' => 'Bad.jpg',
453        'File:Hi-ho.jpg' => 'Hi-ho.jpg',
454        'File:Tall.jpg' => 'Tall.jpg',
455    ];
456
457    private const PNAMES = [
458        'Image:Foobar.jpg' => 'File:Foobar.jpg',
459        'Image:Foobar.svg' => 'File:Foobar.svg',
460        'Image:Thumb.png' => 'File:Thumb.png'
461    ];
462
463    private const ENAMES = [
464        'File:File_&_file.jpg' => 'File_&_file.jpg',
465    ];
466
467    // FIXME: Get this info from pagelanguage of a revision for these pages
468    private const PAGELANGS = [
469        'Rupage' => 'ru',
470        'Depage' => 'de',
471    ];
472
473    // File is present in these langs
474    private const FILELANGS = [
475        'Foobar.svg' => [ 'en', 'ru' ],
476    ];
477
478    // This templatedata description only provides a subset of fields
479    // that mediawiki API returns. Parsoid only uses the format and
480    // paramOrder fields at this point, so keeping these lean.
481    private const TEMPLATE_DATA = [
482        'Template:NoFormatWithParamOrder' => [
483            'paramOrder' => [ 'f0', 'f1', 'unused2', 'f2', 'unused3' ]
484        ],
485        'Template:InlineTplNoParamOrder' => [
486            'format' => 'inline'
487        ],
488        'Template:BlockTplNoParamOrder' => [
489            'format' => 'block'
490        ],
491        'Template:InlineTplWithParamOrder' => [
492            'format' => 'inline',
493            'paramOrder' => [ 'f1', 'f2' ]
494        ],
495        'Template:BlockTplWithParamOrder' => [
496            'format' => 'block',
497            'paramOrder' => [ 'f1', 'f2' ]
498        ],
499        'Template:WithParamOrderAndAliases' => [
500            'params' => [
501                'f1' => [ 'aliases' => [ 'f4', 'f3' ] ]
502            ],
503            'paramOrder' => [ 'f1', 'f2' ]
504        ],
505        'Template:InlineFormattedTpl_1' => [
506            'format' => '{{_|_=_}}'
507        ],
508        'Template:InlineFormattedTpl_2' => [
509            'format' => "\n{{_ | _ = _}}"
510        ],
511        'Template:InlineFormattedTpl_3' => [
512            'format' => '{{_| _____ = _}}'
513        ],
514        'Template:BlockFormattedTpl_1' => [
515            'format' => "{{_\n| _ = _\n}}"
516        ],
517        'Template:BlockFormattedTpl_2' => [
518            'format' => "\n{{_\n| _ = _\n}}\n"
519        ],
520        'Template:BlockFormattedTpl_3' => [
521            'format' => "{{_|\n _____ = _}}"
522        ]
523    ];
524
525    /** @var string wiki prefix for which we are mocking the api access */
526    private $prefix = 'enwiki';
527
528    /** @var callable(string):string A helper to normalize titles. */
529    private $normalizeTitle = null;
530
531    /**
532     * @param ?string $prefix
533     * @param ?callable(string):string $normalizeTitleFunc
534     */
535    public function __construct( ?string $prefix = null, ?callable $normalizeTitleFunc = null ) {
536        $this->prefix = $prefix ?? $this->prefix;
537        $this->normalizeTitle = $normalizeTitleFunc ??
538            // poor man's normalization
539            ( static fn ( string $t ): string => str_replace( ' ', '_', $t ) );
540    }
541
542    /**
543     * Update prefix
544     * @param string $prefix
545     */
546    public function setApiPrefix( string $prefix ): void {
547        $this->prefix = $prefix;
548    }
549
550    /**
551     * Register an article defined in parsertests so that we can return
552     * the proper known/missing information about that title.
553     *
554     * @param string $key The normalized title of the article
555     * @param Article $article The contents of the article
556     * @return callable():void
557     */
558    public function addArticle( string $key, Article $article ): callable {
559        $oldVal = $this->articleCache[$key] ?? null;
560        $this->articleCache[$key] = $article;
561        return function () use ( $key, $oldVal ): void {
562            $this->articleCache[$key] = $oldVal;
563        };
564    }
565
566    public function makeRequest( array $params ): array {
567        switch ( $params['action'] ?? null ) {
568            case 'query':
569                return $this->processQuery( $params );
570
571            case 'parse':
572                return $this->parse( $params['text'], !empty( $params['onlypst'] ) );
573
574            case 'templatedata':
575                return $this->fetchTemplateData( $params );
576
577            case 'expandtemplates':
578                $ret = $this->preProcess( $params['titles'] ?? $params['title'], $params['text'], $params['revid'] ?? null ) ?? [ 'wikitext' => '' ];
579                if ( $ret ) {
580                    $ret += [
581                        'categories' => [],
582                        'modules' => [],
583                        'modulestyles' => []
584                    ];
585                }
586                return [ 'expandtemplates' => $ret ];
587
588            default:
589                return []; // FIXME: Maybe some error
590        }
591    }
592
593    /**
594     * Image scaling computation helper.
595     *
596     * Linker.php in core calls File::transform(...) for each dimension (1x,
597     * 2x) which then scales the image dimensions, using round/ceil/floor
598     * as appropriate to yield integer dimensions.  Note that the results
599     * may be unintuitive due to the conversion to integer: eg, a 442px width
600     * image may become 883px in 2x mode.  Resist the temptation to "optimize"
601     * this by computing the transformed size once and then scaling that;
602     * always scale the input dimensions instead.
603     *
604     * @see ImageHandler::normaliseParams, MediaHandler::fitBoxWidth,
605     * File::scaleHeight, etc, in core.
606     *
607     * Either $twidth or $theight or both will be set when called; both
608     * will be set when this function returns.
609     *
610     * @param int $width Original image width
611     * @param int $height Original image height
612     * @param int|float|null &$twidth Thumbnail width (inout parameter)
613     * @param int|float|null &$theight Thumbnail height (inout parameter)
614     */
615    public static function transformHelper( $width, $height, &$twidth, &$theight ): void {
616        if ( $width === 0 || $height === 0 ) {
617            $width = $twidth;
618            $height = $theight;
619        }
620        if ( $theight === null ) {
621            // File::scaleHeight in PHP
622            $theight = round( $height * $twidth / $width );
623        } elseif (
624            $twidth === null ||
625            // Match checks in ImageHandler.php::normaliseParams in core
626            ( $twidth * $height > $theight * $width )
627        ) {
628            // MediaHandler::fitBoxWidth in PHP
629            // This is crazy!
630            $idealWidth = $width * $theight / $height;
631            $roundedUp = ceil( $idealWidth );
632            if ( round( $roundedUp * $height / $width ) > $theight ) {
633                $twidth = floor( $idealWidth );
634            } else {
635                $twidth = $roundedUp;
636            }
637        } else {
638            if ( round( $height * $twidth / $width ) > $theight ) {
639                $twidth = ceil( $width * $theight / $height );
640            } else {
641                $theight = round( $height * $twidth / $width );
642            }
643        }
644    }
645
646    /**
647     * @param string $filename
648     * @param ?int $twidth
649     * @param ?int $theight
650     * @param ?string $extraParam optional iiurlparam, used for video/pdf/etc
651     * @param ?string $contexttitle optional iibadfilecontexttitle
652     * @return ?array
653     */
654    private function imageInfo(
655        string $filename, ?int $twidth, ?int $theight, ?string $extraParam,
656        ?string $contexttitle
657    ): ?array {
658        $normPageName = self::PNAMES[$filename] ?? $filename;
659        $normFileName = self::FNAMES[$filename] ?? $filename;
660        $encodedFileName = self::ENAMES[$filename] ?? $normFileName;
661        $props = self::FILE_PROPS[$normFileName] ?? null;
662        if ( $props === null ) {
663            // We don't have info for this file
664            return null;
665        }
666
667        $md5 = md5( $encodedFileName );
668        $md5prefix = $md5[0] . '/' . $md5[0] . $md5[1] . '/';
669        $baseurl = self::IMAGE_BASE_URL . '/' . $md5prefix . $normFileName;
670        $height = $props['height'];
671        $width = $props['width'];
672        $turl = self::IMAGE_BASE_URL . '/thumb/' . $md5prefix . $normFileName;
673        $durl = self::IMAGE_DESC_URL . '/' . $normFileName;
674        $mediatype = $props['mediatype'] ??
675            ( $props['mime'] === 'image/svg+xml' ? 'DRAWING' : 'BITMAP' );
676
677        $info = [
678            'size' => $props['size'],
679            'height' => $height,
680            'width' => $width,
681            'url' => $baseurl,
682            'descriptionurl' => $durl,
683            'mediatype' => $mediatype,
684            'mime' => $props['mime']
685        ];
686
687        if ( isset( $props['duration'] ) ) {
688            $info['duration'] = $props['duration'];
689        }
690        if ( isset( $props['pagecount'] ) ) {
691            $info['pagecount'] = $props['pagecount'];
692        }
693
694        if ( ( $mediatype === 'VIDEO' || $mediatype === 'DRAWING' ) && !$twidth && !$theight ) {
695            $twidth = $width;
696            $theight = $height;
697        }
698
699        preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/i', $extraParam ?? '', $matches );
700        $lang = $matches[1] ?? null;
701        $pagelang = self::PAGELANGS[$contexttitle] ?? 'en';
702        $filelangs = self::FILELANGS[$normFileName] ?? [ 'en' ];
703
704        // Set $lang based on the targetlang, if the file is present in that lang
705        if (
706            $lang === null &&
707            $mediatype === 'DRAWING' &&
708            $pagelang !== 'en' &&
709            in_array( $pagelang, $filelangs, true )
710        ) {
711            $lang = $pagelang;
712            $extraParam = "lang{$lang}-{$twidth}px";
713        }
714
715        if ( $theight || $twidth ) {
716
717            // Save $twidth and $theight
718            $origThumbHeight = $theight;
719            $origThumbWidth = $twidth;
720
721            // Set $twidth and $theight
722            self::transformHelper( $width, $height, $twidth, $theight );
723
724            $urlWidth = $twidth;
725            if ( $twidth > $width ) {
726                // The PHP api won't enlarge a bitmap ... but the batch api will.
727                // But, to match the PHP sections, don't scale.
728                if ( $mediatype !== 'DRAWING' ) {
729                    $urlWidth = $width;
730                }
731            }
732            $thumbBaseUrl = $turl;
733            $page = null;
734            if ( $urlWidth !== $width || $mediatype === 'AUDIO' || $mediatype === 'VIDEO' || $mediatype === 'OFFICE' || $mediatype === 'DRAWING' ) {
735                $turl .= '/';
736                if ( preg_match( '/^page(\d+)-(\d+)px$/', $extraParam ?? '', $matches ) ) {
737                    $turl .= $extraParam;
738                    $page = (int)$matches[1];
739                } elseif ( $mediatype === 'OFFICE' ) {
740                    $turl .= 'page1-' . $urlWidth . 'px';
741                    $page = 1;
742                } elseif ( $lang !== null ) {
743                    // Explicit English just gets the default path
744                    if ( $lang === 'en' ) {
745                        $turl .= $urlWidth . 'px';
746                        $lang = null;
747                    } else {
748                        $turl .= $extraParam;
749                    }
750                } else {
751                    $turl .= $urlWidth . 'px';
752                }
753                $turl .= '-';
754                if ( $mediatype === 'VIDEO' ) {
755                    // Hack in a 'seek' option, if provided (T258767)
756                    if ( str_starts_with( $extraParam ?? '', 'seek' ) ) {
757                        $turl .= $props['extraParams'][$extraParam] ?? '';
758                    }
759                    $turl .= '-';
760                }
761                $turl .= $normFileName;
762                switch ( $mediatype ) {
763                    case 'AUDIO':
764                        // No thumbs are generated for audio
765                        $turl = self::IMAGE_BASE_URL . '/w/resources/assets/file-type-icons/fileicon-ogg.png';
766                        break;
767                    case 'VIDEO':
768                    case 'OFFICE':
769                        $turl .= '.jpg';
770                        break;
771                    case 'DRAWING':
772                        $turl .= '.png';
773                        break;
774                }
775            } else {
776                $turl = $baseurl;
777            }
778            $info['thumbwidth'] = $twidth;
779            $info['thumbheight'] = $theight;
780            $info['thumburl'] = $turl;
781            // src set info; added to core API result as part of T226683
782            // See Linker.php::processResponsiveImages() in core
783            foreach ( [ 2 ] as $scale ) {
784                $stwidth = $stheight = null;
785                if ( $origThumbWidth !== null ) {
786                    $stwidth = round( $origThumbWidth * $scale );
787                }
788                if ( $origThumbHeight !== null ) {
789                    $stheight = round( $origThumbHeight * $scale );
790                }
791                self::transformHelper( $width, $height, $stwidth, $stheight );
792                $turl = $baseurl;
793                if (
794                    $stwidth < $width ||
795                    $mediatype === 'DRAWING' ||
796                    $mediatype === 'OFFICE'
797                ) {
798                    $turl = $thumbBaseUrl . '/';
799                    if ( $page !== null ) {
800                        $turl .= "page{$page}-";
801                    }
802                    if ( $lang !== null ) {
803                        $turl .= "lang{$lang}-";
804                    }
805                    $turl .= $stwidth . 'px-' . $normFileName;
806                    if ( $mediatype === 'VIDEO' || $mediatype === 'OFFICE' ) {
807                        $turl .= '.jpg';
808                    } elseif ( $mediatype === 'DRAWING' ) {
809                        $turl .= '.png';
810                    }
811                }
812                if ( $info['thumburl'] !== $turl && $mediatype !== 'AUDIO' ) {
813                    $info['responsiveUrls']["$scale"] = $turl;
814                }
815            }
816        }
817
818        if ( isset( $props['derivatives'] ) ) {
819            $info['derivatives'] = [
820                [
821                    'src' => $info['url'],
822                    'type' => $info['mime'],
823                    'width' => strval( $info['width'] ),
824                    'height' => strval( $info['height'] ),
825                ],
826            ];
827            foreach ( $props['derivatives'] as $derivative ) {
828                $info['derivatives'][] = [
829                    'src' => self::IMAGE_BASE_URL . '/transcoded/' .
830                        $md5prefix . $normFileName . '/' .
831                        $normFileName . '.' . $derivative['transcodekey'],
832                    'type' => $derivative['type'],
833                    'transcodekey' => $derivative['transcodekey'],
834                    // @phan-suppress-next-line PhanTypeInvalidDimOffset
835                    'width' => strval( $derivative['width'] ),
836                    'height' => strval( $derivative['height'] ),
837                ];
838            }
839        }
840
841        return [
842            'result' => $info,
843            'normPageName' => $normPageName
844        ];
845    }
846
847    private const TRACKING_CATEGORIES = [
848        'broken-file-category' => 'Pages with broken file links',
849        'magiclink-tracking-rfc' => 'Pages using RFC magic links',
850        'magiclink-tracking-isbn' => 'Pages using ISBN magic links',
851        'magiclink-tracking-pmid' => 'Pages using PMID magic links',
852        'hidden-category-category' => 'Hidden categories',
853    ];
854
855    private function processQuery( array $params ): array {
856        if ( ( $params['meta'] ?? null ) === 'siteinfo' ) {
857            if ( !isset( $this->cachedConfigs[$this->prefix] ) ) {
858                $this->cachedConfigs[$this->prefix] = json_decode(
859                    file_get_contents( __DIR__ . "/../../baseconfig/$this->prefix.json" ), true );
860            }
861            return $this->cachedConfigs[$this->prefix];
862        }
863
864        if ( ( $params['meta'] ?? null ) === 'allmessages' ) {
865            $allmessages = [];
866            if ( isset( self::TRACKING_CATEGORIES[$params['ammessages']] ) ) {
867                $allmessages[] = [
868                    'content' => self::TRACKING_CATEGORIES[$params['ammessages']]
869                ];
870            } else {
871                $allmessages[] = [ 'missing' => true ];
872            }
873            return [ 'query' => [ 'allmessages' => $allmessages ] ];
874        }
875
876        $revid = $params['revids'] ?? null;
877
878        if ( ( $params['prop'] ?? null ) === 'revisions' ) {
879            if ( $revid === '1' || $params['titles'] === 'Main_Page' ) {
880                return self::MAIN_PAGE;
881            } elseif ( $revid === '2' || $params['titles'] === 'Junk_Page' ) {
882                return self::JUNK_PAGE;
883            } elseif ( $revid === '3' || $params['titles'] === 'Large_Page' ) {
884                $largePage = self::LARGE_PAGE;
885                // PORT-FIXME: Need to get this value
886                // $wtSizeLimit = $parsoidOptions->limits->wt2html->maxWikitextSize;
887                $wtSizeLimit = 1000000;
888                $largePage['query']['pages'][0]['revisions'][0]['slots']['main']['content']
889                    = str_repeat( 'a', $wtSizeLimit + 1 );
890                return $largePage;
891            } elseif ( $revid === '63' || $params['titles'] === 'Revision_ID' ) {
892                return self::REVISION_PAGE;
893            } elseif ( $revid === '100' || $params['titles'] === 'Reuse_Page' ) {
894                return self::REUSE_PAGE;
895            } elseif ( $revid === '101' || $params['titles'] === 'JSON_Page' ) {
896                return self::JSON_PAGE;
897            } elseif ( $revid === '102' || $params['titles'] === 'Lint_Page' ) {
898                return self::LINT_PAGE;
899            } elseif ( $revid === '103' || $params['titles'] === 'Redlinks_Page' ) {
900                return self::REDLINKS_PAGE;
901            } elseif ( $revid === '104' || $params['titles'] === 'Variant_Page' ) {
902                return self::VARIANT_PAGE;
903            } elseif ( $revid === '105' || $params['titles'] === 'No_Variant_Page' ) {
904                return self::NOVARIANT_PAGE;
905            } elseif ( $revid === '999' || $params['titles'] === 'Old_Response' ) {
906                return self::OLD_RESPONSE;
907            } else {
908                return [ 'query' => [ 'pages' => [
909                            [
910                                'ns' => 6,
911                                'title' => json_encode( $params['titles'] ),
912                                'missing' => true,
913                                'imagerepository' => true
914                            ]
915                        ]
916                    ]
917                ];
918            }
919        }
920
921        if ( ( $params['prop'] ?? null ) === 'info' ) {
922            $ret = [];
923            $titles = preg_split( '/\|/', $params['titles'] );
924            foreach ( $titles as $t ) {
925                $props = [ 'title' => $t ];
926                $normalizeTitle = $this->normalizeTitle;
927                $key = $normalizeTitle( $t );
928                $definedInPt = isset( $this->articleCache[$key] );
929                if ( in_array( $t, self::MISSING_TITLES, true ) ||
930                     !$definedInPt ) {
931                    $props['missing'] = true;
932                }
933                if ( in_array( $t, self::SPECIAL_TITLES, true ) ) {
934                    $props['special'] = true;
935                    $props['missing'] = false;
936                }
937                if ( in_array( $t, self::REDIRECT_TITLES, true ) ) {
938                    $props['redirect'] = true;
939                    $props['missing'] = false;
940                }
941                if ( in_array( $t, self::DISAMBIG_TITLES, true ) ) {
942                    $props['linkclasses'] = [ 'mw-disambig' ];
943                    $props['missing'] = false;
944                }
945                $ret[] = $props;
946            }
947            return [ 'query' => [ 'pages' => $ret ] ];
948        }
949
950        if ( ( $params['prop'] ?? null ) === 'imageinfo' ) {
951            $response = [ 'query' => [] ];
952            $filename = $params['titles']; // assumes this is a single file
953            $tonum = static function ( $x ): ?int {
954                return $x ? (int)$x : null;
955            };
956            $ii = self::imageInfo(
957                $filename,
958                isset( $params['iiurlwidth'] ) ? $tonum( $params['iiurlwidth'] ) : null,
959                isset( $params['iiurlheight'] ) ? $tonum( $params['iiurlheight'] ) : null,
960                $params['iiurlparam'] ?? null,
961                $params['iibadfilecontexttitle'] ?? null
962            );
963            if ( $ii === null ) {
964                $p = [
965                    'ns' => 6,
966                    'title' => $filename,
967                    'imagerepository' => true,
968                    'imageinfo' => [ [
969                        'size' => 0,
970                        'width' => 0,
971                        'height' => 0,
972                        'filemissing' => true,
973                        'mime' => null,
974                        'mediatype' => null
975                    ] ]
976                ];
977                $p['missing'] = $p['imageinfo']['filemissing'] = true;
978                $p['badfile'] = false;
979            } else {
980                if ( $filename !== $ii['normPageName'] ) {
981                    $response['query']['normalized'] = [
982                        [ 'from' => $filename, 'to' => $ii['normPageName'] ]
983                    ];
984                }
985                $p = [
986                    'pageid' => 1,
987                    'ns' => 6,
988                    'title' => $ii['normPageName'],
989                    'imageinfo' => [ $ii['result'] ]
990                ];
991                $p['badfile'] = ( $filename === 'File:Bad.jpg' );
992            }
993            $response['query']['pages'] = [ $p ];
994
995            return $response;
996        }
997
998        return [ "error" => new Error( 'Uh oh!' ) ];
999    }
1000
1001    private function parse( string $text, bool $onlypst ): array {
1002        // We're performing a subst
1003        if ( $onlypst ) {
1004            return [ 'text' => preg_replace( '/\{\{subst:1x\|([^}]+)\}\}/', '$1', $text, 1 ) ];
1005        }
1006
1007        // Render to html the contents of known extension tags
1008        // These are the only known extensions (besides native extensions)
1009        // used in parser tests currently. This would need to be updated
1010        // as more templates are added OR we need to rely on true parsing.
1011        preg_match( '#<([A-Za-z][^\t\n\v />\0]*)#', $text, $match );
1012        $res = match ( $match[1] ?? '' ) {
1013            // FIXME: this isn't really used by the mocha tests
1014            // since some mocha tests hit the production db, but
1015            // when we fix that, they should go through this.
1016            'templatestyles' => "<style data-mw-deduplicate='TemplateStyles:r123456'>small { font-size: 120% } big { font-size: 80% }</style>",
1017            'translate' => $text,
1018            'indicator',
1019            'section' => '',
1020            default => throw new Error( 'Unhandled extension type encountered in: ' . $text )
1021        };
1022
1023        return [ 'parse' => [
1024            'text' => $res,
1025            'categories' => [],
1026            'modules' => [],
1027            'modulestyles' => []
1028        ] ];
1029    }
1030
1031    /**
1032     * @return ?array{wikitext: string}
1033     */
1034    private function preProcess(
1035        string $title, string $text, ?int $revid
1036    ): ?array {
1037        // These are the only known templates in current parser tests.
1038        // This would need to be updated as more templates are added OR we need
1039        // to rely on true (instead of mock) preprocessing.
1040        preg_match( '/{{1x\|(.*?)}}/', $text, $match );
1041        if ( $match ) {
1042            return [ 'wikitext' => $match[1] ];
1043        } elseif ( $text === '{{colours of the rainbow}}' ) {
1044            return [ 'wikitext' => 'purple' ];
1045        } elseif ( $text === '{{REVISIONID}}' ) {
1046            return [ 'wikitext' => (string)$revid ];
1047        } elseif ( !str_contains( $text, '{' ) ) {
1048            return [ 'wikitext' => $text ];
1049        } else {
1050            error_log( "UNKNOWN TEMPLATE: $text for $title\n" );
1051            return null;
1052        }
1053    }
1054
1055    private function fetchTemplateData( array $params ): array {
1056        return [
1057            // Assumes that titles is a single title
1058            // (which is how Parsoid uses this)
1059            'pages' => [
1060                '1' => self::TEMPLATE_DATA[$params['titles'] ?? ''] ?? []
1061            ]
1062        ];
1063    }
1064}