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