Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 126
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 / 126
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 / 10
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 ExtensionRegistry;
6use MediaWiki\CommentStore\CommentStoreComment;
7use MediaWiki\Config\Config;
8use MediaWiki\EditPage\EditPage;
9use MediaWiki\Extension\EventLogging\EventLogging;
10use MediaWiki\Hook\EditPage__showEditForm_fieldsHook;
11use MediaWiki\Hook\EditPage__showEditForm_initialHook;
12use MediaWiki\Hook\OutputPageBeforeHTMLHook;
13use MediaWiki\Hook\ParserFetchTemplateDataHook;
14use MediaWiki\Hook\ParserFirstCallInitHook;
15use MediaWiki\Html\Html;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Output\OutputPage;
18use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
19use MediaWiki\ResourceLoader\ResourceLoader;
20use MediaWiki\Revision\RenderedRevision;
21use MediaWiki\Revision\RevisionRecord;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Status\Status;
24use MediaWiki\Storage\Hook\MultiContentSaveHook;
25use MediaWiki\Title\Title;
26use MediaWiki\User\UserIdentity;
27use Parser;
28use PPFrame;
29use RequestContext;
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', [ __CLASS__, '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            $isTemplate = $output->getTitle()->inNamespace( NS_TEMPLATE );
182            if ( !$isTemplate ) {
183                // If we're outside the Template namespace, allow access to GUI
184                // if it's an existing page with <templatedate> (e.g. User template sandbox,
185                // or some other page that's intended to be transcluded for any reason).
186                $services = MediaWikiServices::getInstance();
187                $props = $services->getPageProps()->getProperties( $editPage->getTitle(), 'templatedata' );
188                $isTemplate = (bool)$props;
189            }
190            if ( $isTemplate ) {
191                $output->addModuleStyles( 'ext.templateDataGenerator.editTemplatePage.loading' );
192                $output->addHTML( '<div class="tdg-editscreen-placeholder"></div>' );
193                $output->addModules( 'ext.templateDataGenerator.editTemplatePage' );
194            }
195        }
196    }
197
198    /**
199     * Parser hook for <templatedata>.
200     * If there is any JSON provided, render the template documentation on the page.
201     *
202     * @param string|null $input The content of the tag.
203     * @param array $args The attributes of the tag.
204     * @param Parser $parser Parser instance available to render
205     *  wikitext into html, or parser methods.
206     * @param PPFrame $frame Can be used to see what template parameters ("{{{1}}}", etc.)
207     *  this hook was used with.
208     *
209     * @return string HTML to insert in the page.
210     */
211    public static function render( ?string $input, array $args, Parser $parser, PPFrame $frame ): string {
212        $parserOutput = $parser->getOutput();
213        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
214        $ti = TemplateDataBlob::newFromJSON( $dbr, $input ?? '' );
215
216        $status = $ti->getStatus();
217        if ( !$status->isOK() ) {
218            $parserOutput->setExtensionData( 'TemplateDataStatus', TemplateDataStatus::jsonSerialize( $status ) );
219            return Html::errorBox( $status->getHTML() );
220        }
221
222        // Store the blob as page property for retrieval by ApiTemplateData.
223        // But, don't store it if we're parsing a doc sub page,  because:
224        // - The doc subpage should not appear in ApiTemplateData as a documented
225        // template itself, which is confusing to users (T54448).
226        // - The doc subpage should not appear at Special:PagesWithProp.
227        // - Storing the blob twice in the database is inefficient (T52512).
228        $title = $parser->getTitle();
229        $docPage = wfMessage( 'templatedata-doc-subpage' )->inContentLanguage();
230        if ( !$title->isSubpage() || $title->getSubpageText() !== $docPage->plain() ) {
231            $parserOutput->setPageProperty( 'templatedata', $ti->getJSONForDatabase() );
232        }
233
234        $parserOutput->addModuleStyles( [
235            'ext.templateData',
236            'ext.templateData.images',
237            'jquery.tablesorter.styles',
238        ] );
239        $parserOutput->addModules( [ 'jquery.tablesorter' ] );
240        $parserOutput->setEnableOOUI( true );
241
242        $userLang = $parser->getOptions()->getUserLangObj();
243
244        // FIXME: this hard-codes default skin, but it is needed because
245        // ::getHtml() will need a theme singleton to be set.
246        OutputPage::setupOOUI( 'bogus', $userLang->getDir() );
247
248        $localizer = new TemplateDataMessageLocalizer( $userLang );
249        $formatter = new TemplateDataHtmlFormatter( $localizer, $userLang->getCode() );
250        return $formatter->getHtml( $ti, $frame->getTitle(), !$parser->getOptions()->getIsPreview() );
251    }
252
253    /**
254     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageBeforeHTML
255     *
256     * @param OutputPage $output
257     * @param string &$text
258     */
259    public function onOutputPageBeforeHTML( $output, &$text ) {
260        $services = MediaWikiServices::getInstance();
261        $props = $services->getPageProps()->getProperties( $output->getTitle(), 'templatedata' );
262        if ( $props ) {
263            $lang = $output->getLanguage();
264            $localizer = new TemplateDataMessageLocalizer( $lang );
265            $formatter = new TemplateDataHtmlFormatter( $localizer, $lang->getCode() );
266            $formatter->replaceEditLink( $text );
267        }
268    }
269
270    /**
271     * Fetch templatedata for an array of titles.
272     *
273     * @todo Document this hook
274     *
275     * The following questions are yet to be resolved.
276     * (a) Should we extend functionality to looking up an array of titles instead of one?
277     *     The signature allows for an array of titles to be passed in, but the
278     *     current implementation is not optimized for the multiple-title use case.
279     * (b) Should this be a lookup service instead of this faux hook?
280     *     This will be resolved separately.
281     *
282     * @param array $tplTitles
283     * @param \stdClass[] &$tplData
284     * @return bool
285     */
286    public function onParserFetchTemplateData( array $tplTitles, array &$tplData ): bool {
287        $tplData = [];
288
289        $services = MediaWikiServices::getInstance();
290        $pageProps = $services->getPageProps();
291        $wikiPageFactory = $services->getWikiPageFactory();
292        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
293
294        // This inefficient implementation is currently tuned for
295        // Parsoid's use case where it requests info for exactly one title.
296        // For a real batch use case, this code will need an overhaul.
297        foreach ( $tplTitles as $tplTitle ) {
298            $title = Title::newFromText( $tplTitle );
299            if ( !$title ) {
300                // Invalid title
301                $tplData[$tplTitle] = null;
302                continue;
303            }
304
305            if ( $title->isRedirect() ) {
306                $title = $wikiPageFactory->newFromTitle( $title )->getRedirectTarget();
307                if ( !$title ) {
308                    // Invalid redirecting title
309                    $tplData[$tplTitle] = null;
310                    continue;
311                }
312            }
313
314            if ( !$title->exists() ) {
315                $tplData[$tplTitle] = (object)[ 'missing' => true ];
316                continue;
317            }
318
319            // FIXME: PageProps returns takes titles but returns by page id.
320            // This means we need to do our own look up and hope it matches.
321            // Spoiler, sometimes it won't. When that happens, the user won't
322            // get any templatedata-based interfaces for that template.
323            // The fallback is to not serve data for that template, which
324            // the clients have to support anyway, so the impact is minimal.
325            // It is also expected that such race conditions resolve themselves
326            // after a few seconds so the old "try again later" should cover this.
327            $pageId = $title->getArticleID();
328            $props = $pageProps->getProperties( $title, 'templatedata' );
329            if ( !isset( $props[$pageId] ) ) {
330                // No templatedata
331                $tplData[$tplTitle] = (object)[ 'notemplatedata' => true ];
332                continue;
333            }
334
335            $tdb = TemplateDataBlob::newFromDatabase( $dbr, $props[$pageId] );
336            $status = $tdb->getStatus();
337            if ( !$status->isOK() ) {
338                // Invalid data. Parsoid has no use for the error.
339                $tplData[$tplTitle] = (object)[ 'notemplatedata' => true ];
340                continue;
341            }
342
343            $tplData[$tplTitle] = $tdb->getData();
344        }
345        return true;
346    }
347
348}