Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.62% covered (success)
95.62%
612 / 640
66.67% covered (warning)
66.67%
14 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiParse
95.62% covered (success)
95.62%
612 / 640
66.67% covered (warning)
66.67%
14 / 21
176
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getPoolKey
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getContentParserOutput
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getUserForPreview
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getPageParserOutput
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 execute
97.89% covered (success)
97.89%
324 / 331
0.00% covered (danger)
0.00%
0 / 1
112
 makeParserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 tweakParserOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getParsedContent
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
15
 getSectionContent
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 formatSummary
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 formatLangLinks
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 formatCategoryLinks
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
7
 formatLinks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 formatIWLinks
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 formatHeadItems
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
2.75
 formatLimitReportData
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 setIndexedTagNames
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getAllowedParams
100.00% covered (success)
100.00%
97 / 97
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\Cache\LinkBatchFactory;
24use MediaWiki\Cache\LinkCache;
25use MediaWiki\CommentFormatter\CommentFormatter;
26use MediaWiki\Content\IContentHandlerFactory;
27use MediaWiki\Content\Renderer\ContentRenderer;
28use MediaWiki\Content\Transform\ContentTransformer;
29use MediaWiki\EditPage\EditPage;
30use MediaWiki\Language\RawMessage;
31use MediaWiki\Languages\LanguageNameUtils;
32use MediaWiki\Output\OutputPage;
33use MediaWiki\Page\PageReference;
34use MediaWiki\Page\WikiPageFactory;
35use MediaWiki\Parser\Parser;
36use MediaWiki\Parser\ParserOutput;
37use MediaWiki\Parser\ParserOutputFlags;
38use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
39use MediaWiki\Request\FauxRequest;
40use MediaWiki\Revision\RevisionLookup;
41use MediaWiki\Revision\RevisionRecord;
42use MediaWiki\Revision\SlotRecord;
43use MediaWiki\Title\Title;
44use MediaWiki\Title\TitleFormatter;
45use MediaWiki\User\TempUser\TempUserCreator;
46use MediaWiki\User\UserFactory;
47use MediaWiki\Utils\UrlUtils;
48use MediaWiki\WikiMap\WikiMap;
49use Wikimedia\ParamValidator\ParamValidator;
50use Wikimedia\ParamValidator\TypeDef\EnumDef;
51
52/**
53 * @ingroup API
54 */
55class ApiParse extends ApiBase {
56
57    /** @var string|false|null */
58    private $section = null;
59
60    /** @var Content|null */
61    private $content = null;
62
63    /** @var Content|null */
64    private $pstContent = null;
65
66    /** @var bool */
67    private $contentIsDeleted = false, $contentIsSuppressed = false;
68
69    private RevisionLookup $revisionLookup;
70    private SkinFactory $skinFactory;
71    private LanguageNameUtils $languageNameUtils;
72    private LinkBatchFactory $linkBatchFactory;
73    private LinkCache $linkCache;
74    private IContentHandlerFactory $contentHandlerFactory;
75    private ParserFactory $parserFactory;
76    private WikiPageFactory $wikiPageFactory;
77    private ContentTransformer $contentTransformer;
78    private CommentFormatter $commentFormatter;
79    private ContentRenderer $contentRenderer;
80    private TempUserCreator $tempUserCreator;
81    private UserFactory $userFactory;
82    private UrlUtils $urlUtils;
83    private TitleFormatter $titleFormatter;
84
85    /**
86     * @param ApiMain $main
87     * @param string $action
88     * @param RevisionLookup $revisionLookup
89     * @param SkinFactory $skinFactory
90     * @param LanguageNameUtils $languageNameUtils
91     * @param LinkBatchFactory $linkBatchFactory
92     * @param LinkCache $linkCache
93     * @param IContentHandlerFactory $contentHandlerFactory
94     * @param ParserFactory $parserFactory
95     * @param WikiPageFactory $wikiPageFactory
96     * @param ContentRenderer $contentRenderer
97     * @param ContentTransformer $contentTransformer
98     * @param CommentFormatter $commentFormatter
99     * @param TempUserCreator $tempUserCreator
100     * @param UserFactory $userFactory
101     * @param UrlUtils $urlUtils
102     * @param TitleFormatter $titleFormatter
103     */
104    public function __construct(
105        ApiMain $main,
106        $action,
107        RevisionLookup $revisionLookup,
108        SkinFactory $skinFactory,
109        LanguageNameUtils $languageNameUtils,
110        LinkBatchFactory $linkBatchFactory,
111        LinkCache $linkCache,
112        IContentHandlerFactory $contentHandlerFactory,
113        ParserFactory $parserFactory,
114        WikiPageFactory $wikiPageFactory,
115        ContentRenderer $contentRenderer,
116        ContentTransformer $contentTransformer,
117        CommentFormatter $commentFormatter,
118        TempUserCreator $tempUserCreator,
119        UserFactory $userFactory,
120        UrlUtils $urlUtils,
121        TitleFormatter $titleFormatter
122    ) {
123        parent::__construct( $main, $action );
124        $this->revisionLookup = $revisionLookup;
125        $this->skinFactory = $skinFactory;
126        $this->languageNameUtils = $languageNameUtils;
127        $this->linkBatchFactory = $linkBatchFactory;
128        $this->linkCache = $linkCache;
129        $this->contentHandlerFactory = $contentHandlerFactory;
130        $this->parserFactory = $parserFactory;
131        $this->wikiPageFactory = $wikiPageFactory;
132        $this->contentRenderer = $contentRenderer;
133        $this->contentTransformer = $contentTransformer;
134        $this->commentFormatter = $commentFormatter;
135        $this->tempUserCreator = $tempUserCreator;
136        $this->userFactory = $userFactory;
137        $this->urlUtils = $urlUtils;
138        $this->titleFormatter = $titleFormatter;
139    }
140
141    private function getPoolKey(): string {
142        $poolKey = WikiMap::getCurrentWikiDbDomain() . ':ApiParse:';
143        if ( !$this->getUser()->isRegistered() ) {
144            $poolKey .= 'a:' . $this->getUser()->getName();
145        } else {
146            $poolKey .= 'u:' . $this->getUser()->getId();
147        }
148        return $poolKey;
149    }
150
151    private function getContentParserOutput(
152        Content $content,
153        PageReference $page,
154        ?RevisionRecord $revision,
155        ParserOptions $popts
156    ) {
157        $worker = new PoolCounterWorkViaCallback( 'ApiParser', $this->getPoolKey(),
158            [
159                'doWork' => function () use ( $content, $page, $revision, $popts ) {
160                    return $this->contentRenderer->getParserOutput(
161                        $content, $page, $revision, $popts
162                    );
163                },
164                'error' => function () {
165                    $this->dieWithError( 'apierror-concurrency-limit' );
166                },
167            ]
168        );
169        return $worker->execute();
170    }
171
172    private function getUserForPreview() {
173        $user = $this->getUser();
174        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
175            return $this->userFactory->newUnsavedTempUser(
176                $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() )
177            );
178        }
179        return $user;
180    }
181
182    private function getPageParserOutput(
183        WikiPage $page,
184        $revId,
185        ParserOptions $popts,
186        bool $suppressCache
187    ) {
188        $worker = new PoolCounterWorkViaCallback( 'ApiParser', $this->getPoolKey(),
189            [
190                'doWork' => static function () use ( $page, $revId, $popts, $suppressCache ) {
191                    return $page->getParserOutput( $popts, $revId, $suppressCache );
192                },
193                'error' => function () {
194                    $this->dieWithError( 'apierror-concurrency-limit' );
195                },
196            ]
197        );
198        return $worker->execute();
199    }
200
201    public function execute() {
202        // The data is hot but user-dependent, like page views, so we set vary cookies
203        $this->getMain()->setCacheMode( 'anon-public-user-private' );
204
205        // Get parameters
206        $params = $this->extractRequestParams();
207
208        // No easy way to say that text and title or revid are allowed together
209        // while the rest aren't, so just do it in three calls.
210        $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'text' );
211        $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'title' );
212        $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'revid' );
213
214        $text = $params['text'];
215        $title = $params['title'];
216        if ( $title === null ) {
217            $titleProvided = false;
218            // A title is needed for parsing, so arbitrarily choose one
219            $title = 'API';
220        } else {
221            $titleProvided = true;
222        }
223
224        $page = $params['page'];
225        $pageid = $params['pageid'];
226        $oldid = $params['oldid'];
227
228        $prop = array_fill_keys( $params['prop'], true );
229
230        if ( isset( $params['section'] ) ) {
231            $this->section = $params['section'];
232            if ( !preg_match( '/^((T-)?\d+|new)$/', $this->section ) ) {
233                $this->dieWithError( 'apierror-invalidsection' );
234            }
235        } else {
236            $this->section = false;
237        }
238
239        // The parser needs $wgTitle to be set, apparently the
240        // $title parameter in Parser::parse isn't enough *sigh*
241        // TODO: Does this still need $wgTitle?
242        global $wgTitle;
243
244        $format = null;
245        $redirValues = null;
246
247        $needContent = isset( $prop['wikitext'] ) ||
248            isset( $prop['parsetree'] ) || $params['generatexml'];
249
250        // Return result
251        $result = $this->getResult();
252
253        if ( $oldid !== null || $pageid !== null || $page !== null ) {
254            if ( $this->section === 'new' ) {
255                $this->dieWithError( 'apierror-invalidparammix-parse-new-section', 'invalidparammix' );
256            }
257            if ( $oldid !== null ) {
258                // Don't use the parser cache
259                $rev = $this->revisionLookup->getRevisionById( $oldid );
260                if ( !$rev ) {
261                    $this->dieWithError( [ 'apierror-nosuchrevid', $oldid ] );
262                }
263
264                $this->checkTitleUserPermissions( $rev->getPage(), 'read' );
265
266                if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
267                    $this->dieWithError(
268                        [ 'apierror-permissiondenied', $this->msg( 'action-deletedtext' ) ]
269                    );
270                }
271
272                $revLinkTarget = $rev->getPageAsLinkTarget();
273                $titleObj = Title::newFromLinkTarget( $revLinkTarget );
274                $wgTitle = $titleObj;
275                $pageObj = $this->wikiPageFactory->newFromTitle( $titleObj );
276                [ $popts, $reset, $suppressCache ] = $this->makeParserOptions( $pageObj, $params );
277                $p_result = $this->getParsedContent(
278                    $pageObj, $popts, $suppressCache, $pageid, $rev, $needContent
279                );
280            } else { // Not $oldid, but $pageid or $page
281                if ( $params['redirects'] ) {
282                    $reqParams = [
283                        'redirects' => '',
284                    ];
285                    $pageParams = [];
286                    if ( $pageid !== null ) {
287                        $reqParams['pageids'] = $pageid;
288                        $pageParams['pageid'] = $pageid;
289                    } else { // $page
290                        $reqParams['titles'] = $page;
291                        $pageParams['title'] = $page;
292                    }
293                    $req = new FauxRequest( $reqParams );
294                    $main = new ApiMain( $req );
295                    $pageSet = new ApiPageSet( $main );
296                    $pageSet->execute();
297                    $redirValues = $pageSet->getRedirectTitlesAsResult( $this->getResult() );
298
299                    foreach ( $pageSet->getRedirectTargets() as $redirectTarget ) {
300                        $pageParams = [ 'title' => $this->titleFormatter->getFullText( $redirectTarget ) ];
301                    }
302                } elseif ( $pageid !== null ) {
303                    $pageParams = [ 'pageid' => $pageid ];
304                } else { // $page
305                    $pageParams = [ 'title' => $page ];
306                }
307
308                $pageObj = $this->getTitleOrPageId( $pageParams, 'fromdb' );
309                $titleObj = $pageObj->getTitle();
310                if ( !$titleObj->exists() ) {
311                    $this->dieWithError( 'apierror-missingtitle' );
312                }
313
314                $this->checkTitleUserPermissions( $titleObj, 'read' );
315                $wgTitle = $titleObj;
316
317                if ( isset( $prop['revid'] ) ) {
318                    $oldid = $pageObj->getLatest();
319                }
320
321                [ $popts, $reset, $suppressCache ] = $this->makeParserOptions( $pageObj, $params );
322                $p_result = $this->getParsedContent(
323                    $pageObj, $popts, $suppressCache, $pageid, null, $needContent
324                );
325            }
326        } else { // Not $oldid, $pageid, $page. Hence based on $text
327            $model = $params['contentmodel'];
328            $format = $params['contentformat'];
329
330            $titleObj = Title::newFromText( $title );
331            if ( !$titleObj || $titleObj->isExternal() ) {
332                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
333            }
334            $revid = $params['revid'];
335            $rev = null;
336            if ( $revid !== null ) {
337                $rev = $this->revisionLookup->getRevisionById( $revid );
338                if ( !$rev ) {
339                    $this->dieWithError( [ 'apierror-nosuchrevid', $revid ] );
340                }
341                $pTitleObj = $titleObj;
342                $titleObj = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
343                if ( $titleProvided ) {
344                    if ( !$titleObj->equals( $pTitleObj ) ) {
345                        $this->addWarning( [ 'apierror-revwrongpage', $rev->getId(),
346                            wfEscapeWikiText( $pTitleObj->getPrefixedText() ) ] );
347                    }
348                } else {
349                    // Consider the title derived from the revid as having
350                    // been provided.
351                    $titleProvided = true;
352                }
353            }
354            $wgTitle = $titleObj;
355            if ( $titleObj->canExist() ) {
356                $pageObj = $this->wikiPageFactory->newFromTitle( $titleObj );
357                [ $popts, $reset ] = $this->makeParserOptions( $pageObj, $params );
358            } else {
359                // Allow parsing wikitext in the context of special pages (T51477)
360                $pageObj = null;
361                $popts = ParserOptions::newFromContext( $this->getContext() );
362                [ $popts, $reset ] = $this->tweakParserOptions( $popts, $titleObj, $params );
363            }
364
365            $textProvided = $text !== null;
366
367            if ( !$textProvided ) {
368                if ( $titleProvided && ( $prop || $params['generatexml'] ) ) {
369                    if ( $revid !== null ) {
370                        $this->addWarning( 'apiwarn-parse-revidwithouttext' );
371                    } else {
372                        $this->addWarning( 'apiwarn-parse-titlewithouttext' );
373                    }
374                }
375                // Prevent warning from ContentHandler::makeContent()
376                $text = '';
377            }
378
379            // If we are parsing text, do not use the content model of the default
380            // API title, but default to wikitext to keep BC.
381            if ( $textProvided && !$titleProvided && $model === null ) {
382                $model = CONTENT_MODEL_WIKITEXT;
383                $this->addWarning( [ 'apiwarn-parse-nocontentmodel', $model ] );
384            } elseif ( $model === null ) {
385                $model = $titleObj->getContentModel();
386            }
387
388            $contentHandler = $this->contentHandlerFactory->getContentHandler( $model );
389            // Not in the default format, check supported or not
390            if ( $format && !$contentHandler->isSupportedFormat( $format ) ) {
391                $this->dieWithError( [ 'apierror-badformat-generic', $format, $model ] );
392            }
393
394            try {
395                $this->content = $contentHandler->unserializeContent( $text, $format );
396            } catch ( MWContentSerializationException $ex ) {
397                $this->dieWithException( $ex, [
398                    'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
399                ] );
400            }
401
402            if ( $this->section !== false ) {
403                if ( $this->section === 'new' ) {
404                    // Insert the section title above the content.
405                    if ( $params['sectiontitle'] !== null ) {
406                        $this->content = $this->content->addSectionHeader( $params['sectiontitle'] );
407                    }
408                } else {
409                    $this->content = $this->getSectionContent( $this->content, $titleObj->getPrefixedText() );
410                }
411            }
412
413            if ( $params['pst'] || $params['onlypst'] ) {
414                $this->pstContent = $this->contentTransformer->preSaveTransform(
415                    $this->content,
416                    $titleObj,
417                    $this->getUserForPreview(),
418                    $popts
419                );
420            }
421            if ( $params['onlypst'] ) {
422                // Build a result and bail out
423                $result_array = [];
424                $result_array['text'] = $this->pstContent->serialize( $format );
425                $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
426                if ( isset( $prop['wikitext'] ) ) {
427                    $result_array['wikitext'] = $this->content->serialize( $format );
428                    $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'wikitext';
429                }
430                if ( $params['summary'] !== null ||
431                    ( $params['sectiontitle'] !== null && $this->section === 'new' )
432                ) {
433                    $result_array['parsedsummary'] = $this->formatSummary( $titleObj, $params );
434                    $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsedsummary';
435                }
436
437                $result->addValue( null, $this->getModuleName(), $result_array );
438
439                return;
440            }
441
442            // Not cached (save or load)
443            if ( $params['pst'] ) {
444                $p_result = $this->getContentParserOutput( $this->pstContent, $titleObj, $rev, $popts );
445            } else {
446                $p_result = $this->getContentParserOutput( $this->content, $titleObj, $rev, $popts );
447            }
448        }
449
450        $result_array = [];
451
452        $result_array['title'] = $titleObj->getPrefixedText();
453        $result_array['pageid'] = $pageid ?: $titleObj->getArticleID();
454        if ( $this->contentIsDeleted ) {
455            $result_array['textdeleted'] = true;
456        }
457        if ( $this->contentIsSuppressed ) {
458            $result_array['textsuppressed'] = true;
459        }
460
461        if ( isset( $params['useskin'] ) ) {
462            $skin = $this->skinFactory->makeSkin( Skin::normalizeKey( $params['useskin'] ) );
463        } else {
464            $skin = null;
465        }
466
467        $outputPage = null;
468        $context = null;
469        if (
470            $skin || isset( $prop['subtitle'] ) || isset( $prop['headhtml'] ) || isset( $prop['categorieshtml'] ) ||
471            isset( $params['mobileformat'] )
472        ) {
473            // Enabling the skin via 'useskin', 'subtitle', 'headhtml', or 'categorieshtml'
474            // gets OutputPage and Skin involved, which (among others) applies
475            // these hooks:
476            // - Hook: LanguageLinks
477            // - Hook: SkinSubPageSubtitle
478            // - Hook: OutputPageParserOutput
479            // - Hook: OutputPageMakeCategoryLinks
480            // - Hook: OutputPageBeforeHTML
481            // HACK Adding the 'mobileformat' parameter *also* enables the skin, for compatibility with legacy
482            // apps. This behavior should be considered deprecated so new users should not rely on this and
483            // always use the "useskin" parameter to enable "skin mode".
484            // Ideally this would be done with another hook so that MobileFrontend could enable skin mode, but
485            // as this is just for a deprecated feature, we are hard-coding this param into core.
486            $context = new DerivativeContext( $this->getContext() );
487            $context->setTitle( $titleObj );
488
489            if ( $pageObj ) {
490                $context->setWikiPage( $pageObj );
491            }
492            // Some hooks only apply to pages when action=view, which this API
493            // call is simulating.
494            $context->setRequest( new FauxRequest( [ 'action' => 'view' ] ) );
495
496            if ( $skin ) {
497                // Use the skin specified by 'useskin'
498                $context->setSkin( $skin );
499                // Context clones the skin, refetch to stay in sync. (T166022)
500                $skin = $context->getSkin();
501            } else {
502                // Make sure the context's skin refers to the context. Without this,
503                // $outputPage->getSkin()->getOutput() !== $outputPage which
504                // confuses some of the output.
505                $context->setSkin( $context->getSkin() );
506            }
507
508            $outputPage = new OutputPage( $context );
509            // Required for subtitle to appear
510            $outputPage->setArticleFlag( true );
511
512            $outputPage->addParserOutputMetadata( $p_result );
513            if ( $this->content ) {
514                $outputPage->addContentOverride( $titleObj, $this->content );
515            }
516            $context->setOutput( $outputPage );
517
518            if ( $skin ) {
519                // Based on OutputPage::output()
520                $outputPage->loadSkinModules( $skin );
521            }
522
523            $this->getHookRunner()->onApiParseMakeOutputPage( $this, $outputPage );
524        }
525
526        if ( $oldid !== null ) {
527            $result_array['revid'] = (int)$oldid;
528        }
529
530        if ( $params['redirects'] && $redirValues !== null ) {
531            $result_array['redirects'] = $redirValues;
532        }
533
534        if ( isset( $prop['text'] ) ) {
535            $skin = $context ? $context->getSkin() : null;
536            $skinOptions = $skin ? $skin->getOptions() : [
537                'toc' => true,
538            ];
539            $result_array['text'] = $p_result->getText( [
540                'allowTOC' => !$params['disabletoc'],
541                'injectTOC' => $skinOptions['toc'],
542                'enableSectionEditLinks' => !$params['disableeditsection'],
543                'wrapperDivClass' => $params['wrapoutputclass'],
544                'deduplicateStyles' => !$params['disablestylededuplication'],
545                'userLang' => $context ? $context->getLanguage() : null,
546                'skin' => $skin,
547                'includeDebugInfo' => !$params['disablepp'] && !$params['disablelimitreport']
548            ] );
549            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
550            if ( $context ) {
551                $this->getHookRunner()->onOutputPageBeforeHTML( $context->getOutput(), $result_array['text'] );
552            }
553        }
554
555        if ( $params['summary'] !== null ||
556            ( $params['sectiontitle'] !== null && $this->section === 'new' )
557        ) {
558            $result_array['parsedsummary'] = $this->formatSummary( $titleObj, $params );
559            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsedsummary';
560        }
561
562        if ( isset( $prop['langlinks'] ) ) {
563            if ( $skin ) {
564                $langlinks = $outputPage->getLanguageLinks();
565            } else {
566                $langlinks = $p_result->getLanguageLinks();
567                // The deprecated 'effectivelanglinks' option depredates OutputPage
568                // support via 'useskin'. If not already applied, then run just this
569                // one hook of OutputPage::addParserOutputMetadata here.
570                if ( $params['effectivelanglinks'] ) {
571                    $linkFlags = [];
572                    $this->getHookRunner()->onLanguageLinks( $titleObj, $langlinks, $linkFlags );
573                }
574            }
575
576            $result_array['langlinks'] = $this->formatLangLinks( $langlinks );
577        }
578        if ( isset( $prop['categories'] ) ) {
579            $result_array['categories'] = $this->formatCategoryLinks( $p_result->getCategoryMap() );
580        }
581        if ( isset( $prop['categorieshtml'] ) ) {
582            $result_array['categorieshtml'] = $outputPage->getSkin()->getCategories();
583            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'categorieshtml';
584        }
585        if ( isset( $prop['links'] ) ) {
586            $result_array['links'] = $this->formatLinks( $p_result->getLinks() );
587        }
588        if ( isset( $prop['templates'] ) ) {
589            $result_array['templates'] = $this->formatLinks( $p_result->getTemplates() );
590        }
591        if ( isset( $prop['images'] ) ) {
592            // Cast image links to string since PHP coerces numeric string array keys to numbers
593            // (T346265).
594            $result_array['images'] = array_map(
595                fn ( $link ) => (string)$link,
596                array_keys( $p_result->getImages() )
597            );
598        }
599        if ( isset( $prop['externallinks'] ) ) {
600            $result_array['externallinks'] = array_keys( $p_result->getExternalLinks() );
601        }
602        if ( isset( $prop['sections'] ) ) {
603            $result_array['sections'] = $p_result->getSections();
604            $result_array['showtoc'] = $p_result->getOutputFlag( ParserOutputFlags::SHOW_TOC );
605        }
606        if ( isset( $prop['parsewarnings'] ) ) {
607            $result_array['parsewarnings'] = $p_result->getWarnings();
608        }
609        if ( isset( $prop['parsewarningshtml'] ) ) {
610            $warnings = $p_result->getWarnings();
611            $warningsHtml = array_map( static function ( $warning ) {
612                return ( new RawMessage( '$1', [ $warning ] ) )->parse();
613            }, $warnings );
614            $result_array['parsewarningshtml'] = $warningsHtml;
615        }
616
617        if ( isset( $prop['displaytitle'] ) ) {
618            $result_array['displaytitle'] = $p_result->getDisplayTitle() !== false
619                ? $p_result->getDisplayTitle()
620                : htmlspecialchars( $titleObj->getPrefixedText(), ENT_NOQUOTES );
621        }
622
623        if ( isset( $prop['subtitle'] ) ) {
624            // Get the subtitle without its container element to support UI refreshing
625            $result_array['subtitle'] = $context->getSkin()->prepareSubtitle( false );
626        }
627
628        if ( isset( $prop['headitems'] ) ) {
629            if ( $skin ) {
630                $result_array['headitems'] = $this->formatHeadItems( $outputPage->getHeadItemsArray() );
631            } else {
632                $result_array['headitems'] = $this->formatHeadItems( $p_result->getHeadItems() );
633            }
634        }
635
636        if ( isset( $prop['headhtml'] ) ) {
637            $result_array['headhtml'] = $outputPage->headElement( $context->getSkin() );
638            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'headhtml';
639        }
640
641        if ( isset( $prop['modules'] ) ) {
642            if ( $skin ) {
643                $result_array['modules'] = $outputPage->getModules();
644                // Deprecated since 1.32 (T188689)
645                $result_array['modulescripts'] = [];
646                $result_array['modulestyles'] = $outputPage->getModuleStyles();
647            } else {
648                $result_array['modules'] = array_values( array_unique( $p_result->getModules() ) );
649                // Deprecated since 1.32 (T188689)
650                $result_array['modulescripts'] = [];
651                $result_array['modulestyles'] = array_values( array_unique( $p_result->getModuleStyles() ) );
652            }
653        }
654
655        if ( isset( $prop['jsconfigvars'] ) ) {
656            $showStrategyKeys = (bool)( $params['showstrategykeys'] );
657            $jsconfigvars = $skin ? $outputPage->getJsConfigVars() : $p_result->getJsConfigVars( $showStrategyKeys );
658            $result_array['jsconfigvars'] = ApiResult::addMetadataToResultVars( $jsconfigvars );
659        }
660
661        if ( isset( $prop['encodedjsconfigvars'] ) ) {
662            $jsconfigvars = $skin ? $outputPage->getJsConfigVars() : $p_result->getJsConfigVars();
663            $result_array['encodedjsconfigvars'] = FormatJson::encode(
664                $jsconfigvars,
665                false,
666                FormatJson::ALL_OK
667            );
668            $result_array[ApiResult::META_SUBELEMENTS][] = 'encodedjsconfigvars';
669        }
670
671        if ( isset( $prop['modules'] ) &&
672            !isset( $prop['jsconfigvars'] ) && !isset( $prop['encodedjsconfigvars'] ) ) {
673            $this->addWarning( 'apiwarn-moduleswithoutvars' );
674        }
675
676        if ( isset( $prop['indicators'] ) ) {
677            if ( $skin ) {
678                $result_array['indicators'] = $outputPage->getIndicators();
679            } else {
680                $result_array['indicators'] = $p_result->getIndicators();
681            }
682            ApiResult::setArrayType( $result_array['indicators'], 'BCkvp', 'name' );
683        }
684
685        if ( isset( $prop['iwlinks'] ) ) {
686            $result_array['iwlinks'] = $this->formatIWLinks( $p_result->getInterwikiLinks() );
687        }
688
689        if ( isset( $prop['wikitext'] ) ) {
690            $result_array['wikitext'] = $this->content->serialize( $format );
691            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'wikitext';
692            // @phan-suppress-next-line PhanImpossibleTypeComparison
693            if ( $this->pstContent !== null ) {
694                $result_array['psttext'] = $this->pstContent->serialize( $format );
695                $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'psttext';
696            }
697        }
698        if ( isset( $prop['properties'] ) ) {
699            $result_array['properties'] = $p_result->getPageProperties();
700            ApiResult::setArrayType( $result_array['properties'], 'BCkvp', 'name' );
701        }
702
703        if ( isset( $prop['limitreportdata'] ) ) {
704            $result_array['limitreportdata'] =
705                $this->formatLimitReportData( $p_result->getLimitReportData() );
706        }
707        if ( isset( $prop['limitreporthtml'] ) ) {
708            $result_array['limitreporthtml'] = EditPage::getPreviewLimitReport( $p_result );
709            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'limitreporthtml';
710        }
711
712        if ( isset( $prop['parsetree'] ) || $params['generatexml'] ) {
713            if ( $this->content->getModel() != CONTENT_MODEL_WIKITEXT ) {
714                $this->dieWithError( 'apierror-parsetree-notwikitext', 'notwikitext' );
715            }
716
717            $parser = $this->parserFactory->getInstance();
718            $parser->startExternalParse( $titleObj, $popts, Parser::OT_PREPROCESS );
719            // @phan-suppress-next-line PhanUndeclaredMethod
720            $xml = $parser->preprocessToDom( $this->content->getText() )->__toString();
721            $result_array['parsetree'] = $xml;
722            $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree';
723        }
724
725        $result_mapping = [
726            'redirects' => 'r',
727            'langlinks' => 'll',
728            'categories' => 'cl',
729            'links' => 'pl',
730            'templates' => 'tl',
731            'images' => 'img',
732            'externallinks' => 'el',
733            'iwlinks' => 'iw',
734            'sections' => 's',
735            'headitems' => 'hi',
736            'modules' => 'm',
737            'indicators' => 'ind',
738            'modulescripts' => 'm',
739            'modulestyles' => 'm',
740            'properties' => 'pp',
741            'limitreportdata' => 'lr',
742            'parsewarnings' => 'pw',
743            'parsewarningshtml' => 'pw',
744        ];
745        $this->setIndexedTagNames( $result_array, $result_mapping );
746        $result->addValue( null, $this->getModuleName(), $result_array );
747    }
748
749    /**
750     * Constructs a ParserOptions object
751     *
752     * @param WikiPage $pageObj
753     * @param array $params
754     *
755     * @return array [ ParserOptions, ScopedCallback, bool $suppressCache ]
756     */
757    private function makeParserOptions( WikiPage $pageObj, array $params ) {
758        $popts = $pageObj->makeParserOptions( $this->getContext() );
759        $popts->setRenderReason( 'api-parse' );
760        return $this->tweakParserOptions( $popts, $pageObj->getTitle(), $params );
761    }
762
763    /**
764     * Tweaks a ParserOptions object
765     *
766     * @param ParserOptions $popts
767     * @param Title $title
768     * @param array $params
769     *
770     * @return array [ ParserOptions, ScopedCallback, bool $suppressCache ]
771     */
772    private function tweakParserOptions( ParserOptions $popts, Title $title, array $params ) {
773        $popts->setIsPreview( $params['preview'] || $params['sectionpreview'] );
774        $popts->setIsSectionPreview( $params['sectionpreview'] );
775
776        if ( $params['wrapoutputclass'] !== '' ) {
777            $popts->setWrapOutputClass( $params['wrapoutputclass'] );
778        }
779        if ( $params['parsoid'] ) {
780            $popts->setUseParsoid();
781        }
782
783        $reset = null;
784        $suppressCache = false;
785        $this->getHookRunner()->onApiMakeParserOptions( $popts, $title,
786            $params, $this, $reset, $suppressCache );
787
788        return [ $popts, $reset, $suppressCache ];
789    }
790
791    /**
792     * @param WikiPage $page
793     * @param ParserOptions $popts
794     * @param bool $suppressCache
795     * @param int $pageId
796     * @param RevisionRecord|null $rev
797     * @param bool $getContent
798     * @return ParserOutput
799     */
800    private function getParsedContent(
801        WikiPage $page, $popts, $suppressCache, $pageId, $rev, $getContent
802    ) {
803        $revId = $rev ? $rev->getId() : null;
804        $isDeleted = $rev && $rev->isDeleted( RevisionRecord::DELETED_TEXT );
805
806        if ( $getContent || $this->section !== false || $isDeleted ) {
807            if ( $rev ) {
808                $this->content = $rev->getContent(
809                    SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $this->getAuthority()
810                );
811                if ( !$this->content ) {
812                    $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ] );
813                }
814            } else {
815                $this->content = $page->getContent( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
816                if ( !$this->content ) {
817                    $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ] );
818                }
819            }
820            $this->contentIsDeleted = $isDeleted;
821            $this->contentIsSuppressed = $rev &&
822                $rev->isDeleted( RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_RESTRICTED );
823        }
824
825        if ( $this->section !== false ) {
826            $this->content = $this->getSectionContent(
827                $this->content,
828                $pageId === null ? $page->getTitle()->getPrefixedText() : $this->msg( 'pageid', $pageId )
829            );
830            return $this->getContentParserOutput(
831                $this->content, $page->getTitle(),
832                $rev,
833                $popts
834            );
835        }
836
837        if ( $isDeleted ) {
838            // getParserOutput can't do revdeled revisions
839
840            $pout = $this->getContentParserOutput(
841                $this->content, $page->getTitle(),
842                $rev,
843                $popts
844            );
845        } else {
846            // getParserOutput will save to Parser cache if able
847            $pout = $this->getPageParserOutput( $page, $revId, $popts, $suppressCache );
848        }
849        if ( !$pout ) {
850            // @codeCoverageIgnoreStart
851            $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] );
852            // @codeCoverageIgnoreEnd
853        }
854
855        return $pout;
856    }
857
858    /**
859     * Extract the requested section from the given Content
860     *
861     * @param Content $content
862     * @param string|Message $what Identifies the content in error messages, e.g. page title.
863     * @return Content
864     */
865    private function getSectionContent( Content $content, $what ) {
866        // Not cached (save or load)
867        $section = $content->getSection( $this->section );
868        if ( $section === false ) {
869            $this->dieWithError( [ 'apierror-nosuchsection-what', $this->section, $what ], 'nosuchsection' );
870        }
871        if ( $section === null ) {
872            $this->dieWithError( [ 'apierror-sectionsnotsupported-what', $what ], 'nosuchsection' );
873        }
874
875        // @phan-suppress-next-line PhanTypeMismatchReturnNullable T240141
876        return $section;
877    }
878
879    /**
880     * This mimics the behavior of EditPage in formatting a summary
881     *
882     * @param Title $title of the page being parsed
883     * @param array $params The API parameters of the request
884     * @return string HTML
885     */
886    private function formatSummary( $title, $params ) {
887        $summary = $params['summary'] ?? '';
888        $sectionTitle = $params['sectiontitle'] ?? '';
889
890        if ( $this->section === 'new' && ( $sectionTitle === '' || $summary === '' ) ) {
891            if ( $sectionTitle !== '' ) {
892                $summary = $params['sectiontitle'];
893            }
894            if ( $summary !== '' ) {
895                $summary = $this->msg( 'newsectionsummary' )
896                    ->rawParams( $this->parserFactory->getMainInstance()->stripSectionName( $summary ) )
897                    ->inContentLanguage()->text();
898            }
899        }
900        return $this->commentFormatter->format( $summary, $title, $this->section === 'new' );
901    }
902
903    private function formatLangLinks( $links ) {
904        $result = [];
905        foreach ( $links as $link ) {
906            $entry = [];
907            $bits = explode( ':', $link, 2 );
908            $title = Title::newFromText( $link );
909
910            $entry['lang'] = $bits[0];
911            if ( $title ) {
912                $entry['url'] = (string)$this->urlUtils->expand( $title->getFullURL(), PROTO_CURRENT );
913                // localised language name in 'uselang' language
914                $entry['langname'] = $this->languageNameUtils->getLanguageName(
915                    $title->getInterwiki(),
916                    $this->getLanguage()->getCode()
917                );
918
919                // native language name
920                $entry['autonym'] = $this->languageNameUtils->getLanguageName( $title->getInterwiki() );
921            }
922            ApiResult::setContentValue( $entry, 'title', $bits[1] );
923            $result[] = $entry;
924        }
925
926        return $result;
927    }
928
929    private function formatCategoryLinks( $links ) {
930        $result = [];
931
932        if ( !$links ) {
933            return $result;
934        }
935
936        // Fetch hiddencat property
937        $lb = $this->linkBatchFactory->newLinkBatch();
938        $lb->setArray( [ NS_CATEGORY => $links ] );
939        $db = $this->getDB();
940        $res = $db->newSelectQueryBuilder()
941            ->select( [ 'page_title', 'pp_propname' ] )
942            ->from( 'page' )
943            ->where( $lb->constructSet( 'page', $db ) )
944            ->leftJoin( 'page_props', null, [ 'pp_propname' => 'hiddencat', 'pp_page = page_id' ] )
945            ->caller( __METHOD__ )
946            ->fetchResultSet();
947        $hiddencats = [];
948        foreach ( $res as $row ) {
949            $hiddencats[$row->page_title] = isset( $row->pp_propname );
950        }
951
952        foreach ( $links as $link => $sortkey ) {
953            $entry = [];
954            $entry['sortkey'] = $sortkey;
955            // array keys will cast numeric category names to ints, so cast back to string
956            ApiResult::setContentValue( $entry, 'category', (string)$link );
957            if ( !isset( $hiddencats[$link] ) ) {
958                $entry['missing'] = true;
959
960                // We already know the link doesn't exist in the database, so
961                // tell LinkCache that before calling $title->isKnown().
962                $title = Title::makeTitle( NS_CATEGORY, $link );
963                $this->linkCache->addBadLinkObj( $title );
964                if ( $title->isKnown() ) {
965                    $entry['known'] = true;
966                }
967            } elseif ( $hiddencats[$link] ) {
968                $entry['hidden'] = true;
969            }
970            $result[] = $entry;
971        }
972
973        return $result;
974    }
975
976    private function formatLinks( $links ) {
977        $result = [];
978        foreach ( $links as $ns => $nslinks ) {
979            foreach ( $nslinks as $title => $id ) {
980                $entry = [];
981                $entry['ns'] = $ns;
982                ApiResult::setContentValue( $entry, 'title', Title::makeTitle( $ns, $title )->getFullText() );
983                $entry['exists'] = $id != 0;
984                $result[] = $entry;
985            }
986        }
987
988        return $result;
989    }
990
991    private function formatIWLinks( $iw ) {
992        $result = [];
993        foreach ( $iw as $prefix => $titles ) {
994            foreach ( $titles as $title => $_ ) {
995                $entry = [];
996                $entry['prefix'] = $prefix;
997
998                $title = Title::newFromText( "{$prefix}:{$title}" );
999                if ( $title ) {
1000                    $entry['url'] = (string)$this->urlUtils->expand( $title->getFullURL(), PROTO_CURRENT );
1001                }
1002
1003                ApiResult::setContentValue( $entry, 'title', $title->getFullText() );
1004                $result[] = $entry;
1005            }
1006        }
1007
1008        return $result;
1009    }
1010
1011    private function formatHeadItems( $headItems ) {
1012        $result = [];
1013        foreach ( $headItems as $tag => $content ) {
1014            $entry = [];
1015            $entry['tag'] = $tag;
1016            ApiResult::setContentValue( $entry, 'content', $content );
1017            $result[] = $entry;
1018        }
1019
1020        return $result;
1021    }
1022
1023    private function formatLimitReportData( $limitReportData ) {
1024        $result = [];
1025
1026        foreach ( $limitReportData as $name => $value ) {
1027            $entry = [];
1028            $entry['name'] = $name;
1029            if ( !is_array( $value ) ) {
1030                $value = [ $value ];
1031            }
1032            ApiResult::setIndexedTagNameRecursive( $value, 'param' );
1033            $entry = array_merge( $entry, $value );
1034            $result[] = $entry;
1035        }
1036
1037        return $result;
1038    }
1039
1040    private function setIndexedTagNames( &$array, $mapping ) {
1041        foreach ( $mapping as $key => $name ) {
1042            if ( isset( $array[$key] ) ) {
1043                ApiResult::setIndexedTagName( $array[$key], $name );
1044            }
1045        }
1046    }
1047
1048    public function getAllowedParams() {
1049        return [
1050            'title' => null,
1051            'text' => [
1052                ParamValidator::PARAM_TYPE => 'text',
1053            ],
1054            'revid' => [
1055                ParamValidator::PARAM_TYPE => 'integer',
1056            ],
1057            'summary' => null,
1058            'page' => null,
1059            'pageid' => [
1060                ParamValidator::PARAM_TYPE => 'integer',
1061            ],
1062            'redirects' => false,
1063            'oldid' => [
1064                ParamValidator::PARAM_TYPE => 'integer',
1065            ],
1066            'prop' => [
1067                ParamValidator::PARAM_DEFAULT => 'text|langlinks|categories|links|templates|' .
1068                    'images|externallinks|sections|revid|displaytitle|iwlinks|' .
1069                    'properties|parsewarnings',
1070                ParamValidator::PARAM_ISMULTI => true,
1071                ParamValidator::PARAM_TYPE => [
1072                    'text',
1073                    'langlinks',
1074                    'categories',
1075                    'categorieshtml',
1076                    'links',
1077                    'templates',
1078                    'images',
1079                    'externallinks',
1080                    'sections',
1081                    'revid',
1082                    'displaytitle',
1083                    'subtitle',
1084                    'headhtml',
1085                    'modules',
1086                    'jsconfigvars',
1087                    'encodedjsconfigvars',
1088                    'indicators',
1089                    'iwlinks',
1090                    'wikitext',
1091                    'properties',
1092                    'limitreportdata',
1093                    'limitreporthtml',
1094                    'parsetree',
1095                    'parsewarnings',
1096                    'parsewarningshtml',
1097                    'headitems',
1098                ],
1099                ApiBase::PARAM_HELP_MSG_PER_VALUE => [
1100                    'parsetree' => [ 'apihelp-parse-paramvalue-prop-parsetree', CONTENT_MODEL_WIKITEXT ],
1101                ],
1102                EnumDef::PARAM_DEPRECATED_VALUES => [
1103                    'headitems' => 'apiwarn-deprecation-parse-headitems',
1104                ],
1105            ],
1106            'wrapoutputclass' => 'mw-parser-output',
1107            'parsoid' => false, // since 1.41
1108            'pst' => false,
1109            'onlypst' => false,
1110            'effectivelanglinks' => [
1111                ParamValidator::PARAM_DEFAULT => false,
1112                ParamValidator::PARAM_DEPRECATED => true,
1113            ],
1114            'section' => null,
1115            'sectiontitle' => [
1116                ParamValidator::PARAM_TYPE => 'string',
1117            ],
1118            'disablepp' => [
1119                ParamValidator::PARAM_DEFAULT => false,
1120                ParamValidator::PARAM_DEPRECATED => true,
1121            ],
1122            'disablelimitreport' => false,
1123            'disableeditsection' => false,
1124            'disablestylededuplication' => false,
1125            'showstrategykeys' => false,
1126            'generatexml' => [
1127                ParamValidator::PARAM_DEFAULT => false,
1128                ApiBase::PARAM_HELP_MSG => [
1129                    'apihelp-parse-param-generatexml', CONTENT_MODEL_WIKITEXT
1130                ],
1131                ParamValidator::PARAM_DEPRECATED => true,
1132            ],
1133            'preview' => false,
1134            'sectionpreview' => false,
1135            'disabletoc' => false,
1136            'useskin' => [
1137                // T237856; We use all installed skins here to allow hidden (but usable) skins
1138                // to continue working correctly with some features such as Live Preview
1139                ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
1140            ],
1141            'contentformat' => [
1142                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(),
1143            ],
1144            'contentmodel' => [
1145                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(),
1146            ],
1147        ];
1148    }
1149
1150    protected function getExamplesMessages() {
1151        return [
1152            'action=parse&page=Project:Sandbox'
1153                => 'apihelp-parse-example-page',
1154            'action=parse&text={{Project:Sandbox}}&contentmodel=wikitext'
1155                => 'apihelp-parse-example-text',
1156            'action=parse&text={{PAGENAME}}&title=Test'
1157                => 'apihelp-parse-example-texttitle',
1158            'action=parse&summary=Some+[[link]]&prop='
1159                => 'apihelp-parse-example-summary',
1160        ];
1161    }
1162
1163    public function getHelpUrls() {
1164        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext';
1165    }
1166}