Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 127 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
Hooks | |
0.00% |
0 / 127 |
|
0.00% |
0 / 10 |
1190 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onEditPage__showEditForm_fields | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
onParserFirstCallInit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onResourceLoaderRegisterModules | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
onMultiContentSave | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
logChangeEvent | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
30 | |||
onEditPage__showEditForm_initial | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
render | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
onOutputPageBeforeHTML | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
onParserFetchTemplateData | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
72 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\TemplateData; |
4 | |
5 | use MediaWiki\CommentStore\CommentStoreComment; |
6 | use MediaWiki\Config\Config; |
7 | use MediaWiki\Context\RequestContext; |
8 | use MediaWiki\EditPage\EditPage; |
9 | use MediaWiki\Extension\EventLogging\EventLogging; |
10 | use MediaWiki\Hook\EditPage__showEditForm_fieldsHook; |
11 | use MediaWiki\Hook\EditPage__showEditForm_initialHook; |
12 | use MediaWiki\Hook\ParserFetchTemplateDataHook; |
13 | use MediaWiki\Hook\ParserFirstCallInitHook; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Output\Hook\OutputPageBeforeHTMLHook; |
17 | use MediaWiki\Output\OutputPage; |
18 | use MediaWiki\Parser\Parser; |
19 | use MediaWiki\Parser\PPFrame; |
20 | use MediaWiki\Registration\ExtensionRegistry; |
21 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook; |
22 | use MediaWiki\ResourceLoader\ResourceLoader; |
23 | use MediaWiki\Revision\RenderedRevision; |
24 | use MediaWiki\Revision\RevisionRecord; |
25 | use MediaWiki\Revision\SlotRecord; |
26 | use MediaWiki\Status\Status; |
27 | use MediaWiki\Storage\Hook\MultiContentSaveHook; |
28 | use MediaWiki\Title\Title; |
29 | use MediaWiki\User\UserIdentity; |
30 | |
31 | /** |
32 | * @license GPL-2.0-or-later |
33 | * phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName |
34 | */ |
35 | class 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 | } |