Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 10
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onEditPage__showEditForm_fields
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 onMultiContentSave
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 logChangeEvent
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 onEditPage__showEditForm_initial
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 render
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 onOutputPageBeforeHTML
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onParserFetchTemplateData
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace MediaWiki\Extension\TemplateData;
4
5use MediaWiki\CommentStore\CommentStoreComment;
6use MediaWiki\Config\Config;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\EditPage\EditPage;
9use MediaWiki\Extension\EventLogging\EventLogging;
10use MediaWiki\Hook\EditPage__showEditForm_fieldsHook;
11use MediaWiki\Hook\EditPage__showEditForm_initialHook;
12use MediaWiki\Hook\ParserFetchTemplateDataHook;
13use MediaWiki\Hook\ParserFirstCallInitHook;
14use MediaWiki\Html\Html;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Output\Hook\OutputPageBeforeHTMLHook;
17use MediaWiki\Output\OutputPage;
18use MediaWiki\Parser\Parser;
19use MediaWiki\Parser\PPFrame;
20use MediaWiki\Registration\ExtensionRegistry;
21use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
22use MediaWiki\ResourceLoader\ResourceLoader;
23use MediaWiki\Revision\RenderedRevision;
24use MediaWiki\Revision\RevisionRecord;
25use MediaWiki\Revision\SlotRecord;
26use MediaWiki\Status\Status;
27use MediaWiki\Storage\Hook\MultiContentSaveHook;
28use MediaWiki\Title\Title;
29use MediaWiki\User\UserIdentity;
30
31/**
32 * @license GPL-2.0-or-later
33 * phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName
34 */
35class Hooks implements
36    EditPage__showEditForm_fieldsHook,
37    ParserFirstCallInitHook,
38    MultiContentSaveHook,
39    ResourceLoaderRegisterModulesHook,
40    EditPage__showEditForm_initialHook,
41    ParserFetchTemplateDataHook,
42    OutputPageBeforeHTMLHook
43{
44
45    private Config $config;
46
47    public function __construct( Config $mainConfig ) {
48        $this->config = $mainConfig;
49    }
50
51    /**
52     * @param EditPage $editPage
53     * @param OutputPage $out
54     */
55    public function onEditPage__showEditForm_fields( $editPage, $out ) {
56        // TODO: Remove when not needed any more, see T267926
57        if ( $out->getRequest()->getBool( 'TemplateDataGeneratorUsed' ) ) {
58            // Recreate the dynamically created field after the user clicked "preview"
59            $out->addHTML( Html::hidden( 'TemplateDataGeneratorUsed', true ) );
60        }
61    }
62
63    /**
64     * Register parser hooks
65     * @param Parser $parser
66     */
67    public function onParserFirstCallInit( $parser ) {
68        $parser->setHook( 'templatedata', [ $this, 'render' ] );
69    }
70
71    /**
72     * Conditionally register the jquery.uls.data module, in case they've already been
73     * registered by the UniversalLanguageSelector extension or the VisualEditor extension.
74     *
75     * @param ResourceLoader $resourceLoader
76     */
77    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
78        $resourceModules = $resourceLoader->getConfig()->get( 'ResourceModules' );
79        $name = 'jquery.uls.data';
80        if ( !isset( $resourceModules[$name] ) && !$resourceLoader->isModuleRegistered( $name ) ) {
81            $resourceLoader->register( [
82                $name => [
83                    'localBasePath' => dirname( __DIR__ ),
84                    'remoteExtPath' => 'TemplateData',
85                    'scripts' => [
86                        'lib/jquery.uls/src/jquery.uls.data.js',
87                        'lib/jquery.uls/src/jquery.uls.data.utils.js',
88                    ],
89                ]
90            ] );
91        }
92    }
93
94    /**
95     * @param RenderedRevision $renderedRevision
96     * @param UserIdentity $user
97     * @param CommentStoreComment $summary
98     * @param int $flags
99     * @param Status $hookStatus
100     * @return bool
101     */
102    public function onMultiContentSave(
103        $renderedRevision, $user, $summary, $flags, $hookStatus
104    ) {
105        $revisionRecord = $renderedRevision->getRevision();
106        $contentModel = $revisionRecord
107            ->getContent( SlotRecord::MAIN )
108            ->getModel();
109
110        if ( $contentModel !== CONTENT_MODEL_WIKITEXT ) {
111            return true;
112        }
113
114        // Revision hasn't been parsed yet, so parse to know if self::render got a
115        // valid tag (via inclusion and transclusion) and abort save if it didn't
116        $parserOutput = $renderedRevision->getRevisionParserOutput( [ 'generate-html' => false ] );
117        $status = TemplateDataStatus::newFromJson( $parserOutput->getExtensionData( 'TemplateDataStatus' ) );
118        if ( $status && !$status->isOK() ) {
119            // Abort edit, show error message from TemplateDataBlob::getStatus
120            $hookStatus->merge( $status );
121            return false;
122        }
123
124        // TODO: Remove when not needed any more, see T267926
125        self::logChangeEvent( $revisionRecord, $parserOutput->getPageProperty( 'templatedata' ), $user );
126
127        return true;
128    }
129
130    private static function logChangeEvent(
131        RevisionRecord $revisionRecord,
132        ?string $newPageProperty,
133        UserIdentity $user
134    ): void {
135        if ( !ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) ) {
136            return;
137        }
138
139        $services = MediaWikiServices::getInstance();
140        $page = $revisionRecord->getPage();
141        $props = $services->getPageProps()->getProperties( $page, 'templatedata' );
142
143        $pageId = $page->getId();
144        // The JSON strings here are guaranteed to be normalized (and possibly compressed) the same
145        // way. No need to normalize them again for this comparison.
146        if ( $newPageProperty === ( $props[$pageId] ?? null ) ) {
147            return;
148        }
149
150        $generatorUsed = RequestContext::getMain()->getRequest()->getBool( 'TemplateDataGeneratorUsed' );
151        $userEditCount = $services->getUserEditTracker()->getUserEditCount( $user );
152        $userId = $services->getUserIdentityUtils()->isTemp( $user ) ? 0 : $user->getId();
153        // Note: We know that irrelevant changes (e.g. whitespace changes) aren't logged here
154        EventLogging::submit(
155            'eventlogging_TemplateDataEditor',
156            [
157                '$schema' => '/analytics/legacy/templatedataeditor/1.0.0',
158                'event' => [
159                    // Note: The "Done" button is disabled unless something changed, which means it's
160                    // very likely (but not guaranteed) the generator was used to make the changes
161                    'action' => $generatorUsed ? 'save-tag-edit-generator-used' : 'save-tag-edit-no-generator',
162                    'page_id' => $pageId,
163                    'page_namespace' => $page->getNamespace(),
164                    'page_title' => $page->getDBkey(),
165                    'rev_id' => $revisionRecord->getId() ?? 0,
166                    'user_edit_count' => $userEditCount ?? 0,
167                    'user_id' => $userId,
168                ],
169            ]
170        );
171    }
172
173    /**
174     * Hook to load the GUI module only on edit action.
175     *
176     * @param EditPage $editPage
177     * @param OutputPage $output
178     */
179    public function onEditPage__showEditForm_initial( $editPage, $output ) {
180        if ( $this->config->get( 'TemplateDataUseGUI' ) ) {
181            $editorNamespaces = $this->config->get( 'TemplateDataEditorNamespaces' );
182            $isEditorNamespace = $output->getTitle()->inNamespaces( $editorNamespaces );
183            if ( !$isEditorNamespace ) {
184                // If we're outside the editor namespaces, allow access to GUI
185                // if it's an existing page with <templatedate> (e.g. User template sandbox,
186                // or some other page that's intended to be transcluded for any reason).
187                $services = MediaWikiServices::getInstance();
188                $props = $services->getPageProps()->getProperties( $editPage->getTitle(), 'templatedata' );
189                $isEditorNamespace = (bool)$props;
190            }
191            if ( $isEditorNamespace ) {
192                $output->addModuleStyles( 'ext.templateDataGenerator.editTemplatePage.loading' );
193                $output->addHTML( '<div class="tdg-editscreen-placeholder"></div>' );
194                $output->addModules( 'ext.templateDataGenerator.editTemplatePage' );
195            }
196        }
197    }
198
199    /**
200     * Parser hook for <templatedata>.
201     * If there is any JSON provided, render the template documentation on the page.
202     *
203     * @param string|null $input The content of the tag.
204     * @param array $args The attributes of the tag.
205     * @param Parser $parser Parser instance available to render
206     *  wikitext into html, or parser methods.
207     * @param PPFrame $frame Can be used to see what template parameters ("{{{1}}}", etc.)
208     *  this hook was used with.
209     *
210     * @return string HTML to insert in the page.
211     */
212    public function render( ?string $input, array $args, Parser $parser, PPFrame $frame ): string {
213        $parserOutput = $parser->getOutput();
214        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
215        $ti = TemplateDataBlob::newFromJSON( $dbr, $input ?? '' );
216
217        $status = $ti->getStatus();
218        if ( !$status->isOK() ) {
219            $parserOutput->setExtensionData( 'TemplateDataStatus', TemplateDataStatus::jsonSerialize( $status ) );
220            return Html::errorBox( $status->getHTML() );
221        }
222
223        // Store the blob as page property for retrieval by ApiTemplateData.
224        // But, don't store it if we're parsing a doc sub page,  because:
225        // - The doc subpage should not appear in ApiTemplateData as a documented
226        // template itself, which is confusing to users (T54448).
227        // - The doc subpage should not appear at Special:PagesWithProp.
228        // - Storing the blob twice in the database is inefficient (T52512).
229        $title = $parser->getTitle();
230        $docPage = wfMessage( 'templatedata-doc-subpage' )->inContentLanguage();
231        if ( !$title->isSubpage() || $title->getSubpageText() !== $docPage->plain() ) {
232            $parserOutput->setPageProperty( 'templatedata', $ti->getJSONForDatabase() );
233        }
234
235        $parserOutput->addModuleStyles( [
236            'ext.templateData',
237            'ext.templateData.images',
238            'jquery.tablesorter.styles',
239        ] );
240        $parserOutput->addModules( [ 'jquery.tablesorter' ] );
241        $parserOutput->setEnableOOUI( true );
242
243        $userLang = $parser->getOptions()->getUserLangObj();
244
245        // FIXME: this hard-codes default skin, but it is needed because
246        // ::getHtml() will need a theme singleton to be set.
247        OutputPage::setupOOUI( 'bogus', $userLang->getDir() );
248
249        $localizer = new TemplateDataMessageLocalizer( $userLang );
250        $formatter = new TemplateDataHtmlFormatter( $this->config, $localizer, $userLang->getCode() );
251        return $formatter->getHtml( $ti, $frame->getTitle(), !$parser->getOptions()->getIsPreview() );
252    }
253
254    /**
255     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageBeforeHTML
256     *
257     * @param OutputPage $output
258     * @param string &$text
259     */
260    public function onOutputPageBeforeHTML( $output, &$text ) {
261        $services = MediaWikiServices::getInstance();
262        $props = $services->getPageProps()->getProperties( $output->getTitle(), 'templatedata' );
263        if ( $props ) {
264            $lang = $output->getLanguage();
265            $localizer = new TemplateDataMessageLocalizer( $lang );
266            $formatter = new TemplateDataHtmlFormatter( $this->config, $localizer, $lang->getCode() );
267            $formatter->replaceEditLink( $text );
268        }
269    }
270
271    /**
272     * Fetch templatedata for an array of titles.
273     *
274     * @todo Document this hook
275     *
276     * The following questions are yet to be resolved.
277     * (a) Should we extend functionality to looking up an array of titles instead of one?
278     *     The signature allows for an array of titles to be passed in, but the
279     *     current implementation is not optimized for the multiple-title use case.
280     * (b) Should this be a lookup service instead of this faux hook?
281     *     This will be resolved separately.
282     *
283     * @param array $tplTitles
284     * @param \stdClass[] &$tplData
285     * @return bool
286     */
287    public function onParserFetchTemplateData( array $tplTitles, array &$tplData ): bool {
288        $tplData = [];
289
290        $services = MediaWikiServices::getInstance();
291        $pageProps = $services->getPageProps();
292        $wikiPageFactory = $services->getWikiPageFactory();
293        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
294
295        // This inefficient implementation is currently tuned for
296        // Parsoid's use case where it requests info for exactly one title.
297        // For a real batch use case, this code will need an overhaul.
298        foreach ( $tplTitles as $tplTitle ) {
299            $title = Title::newFromText( $tplTitle );
300            if ( !$title ) {
301                // Invalid title
302                $tplData[$tplTitle] = null;
303                continue;
304            }
305
306            if ( $title->isRedirect() ) {
307                $title = $wikiPageFactory->newFromTitle( $title )->getRedirectTarget();
308                if ( !$title ) {
309                    // Invalid redirecting title
310                    $tplData[$tplTitle] = null;
311                    continue;
312                }
313            }
314
315            if ( !$title->exists() ) {
316                $tplData[$tplTitle] = (object)[ 'missing' => true ];
317                continue;
318            }
319
320            // FIXME: PageProps returns takes titles but returns by page id.
321            // This means we need to do our own look up and hope it matches.
322            // Spoiler, sometimes it won't. When that happens, the user won't
323            // get any templatedata-based interfaces for that template.
324            // The fallback is to not serve data for that template, which
325            // the clients have to support anyway, so the impact is minimal.
326            // It is also expected that such race conditions resolve themselves
327            // after a few seconds so the old "try again later" should cover this.
328            $pageId = $title->getArticleID();
329            $props = $pageProps->getProperties( $title, 'templatedata' );
330            if ( !isset( $props[$pageId] ) ) {
331                // No templatedata
332                $tplData[$tplTitle] = (object)[ 'notemplatedata' => true ];
333                continue;
334            }
335
336            $tdb = TemplateDataBlob::newFromDatabase( $dbr, $props[$pageId] );
337            $status = $tdb->getStatus();
338            if ( !$status->isOK() ) {
339                // Invalid data. Parsoid has no use for the error.
340                $tplData[$tplTitle] = (object)[ 'notemplatedata' => true ];
341                continue;
342            }
343
344            $tplData[$tplTitle] = $tdb->getData();
345        }
346        return true;
347    }
348
349}