1<?php
2
3use MediaWiki\MediaWikiServices;
4
5/**
6 * Hooks for TemplateData extension
7 *
8 * @file
9 * @ingroup Extensions
10 */
11
12class TemplateDataHooks {
13
14	/**
15	 * @param EditPage $editPage
16	 * @param OutputPage $out
17	 */
18	public static function onEditPageShowEditFormFields( EditPage $editPage, OutputPage $out ) {
19		// TODO: Remove when not needed any more, see T267926
20		if ( $out->getRequest()->getBool( 'TemplateDataGeneratorUsed' ) ) {
21			// Recreate the dynamically created field after the user clicked "preview"
22			$out->addHTML( Html::hidden( 'TemplateDataGeneratorUsed', true ) );
23		}
24	}
25
26	/**
27	 * Register parser hooks
28	 * @param Parser $parser
29	 */
30	public static function onParserFirstCallInit( Parser $parser ) {
31		$parser->setHook( 'templatedata', [ __CLASS__, 'render' ] );
32	}
33
34	/**
35	 * Conditionally register the jquery.uls.data module, in case they've already been
36	 * registered by the UniversalLanguageSelector extension or the VisualEditor extension.
37	 *
38	 * @param ResourceLoader &$resourceLoader
39	 */
40	public static function onResourceLoaderRegisterModules( ResourceLoader &$resourceLoader ) {
41		$resourceModules = $resourceLoader->getConfig()->get( 'ResourceModules' );
42		$name = 'jquery.uls.data';
43		if ( !isset( $resourceModules[$name] ) && !$resourceLoader->isModuleRegistered( $name ) ) {
44			$resourceLoader->register( [
45				'jquery.uls.data' => [
46					'localBasePath' => dirname( __DIR__ ),
47					'remoteExtPath' => 'TemplateData',
48					'scripts' => [
49						'lib/jquery.uls/src/jquery.uls.data.js',
50						'lib/jquery.uls/src/jquery.uls.data.utils.js',
51					],
52					'targets' => [ 'desktop', 'mobile' ],
53				]
54			] );
55		}
56	}
57
58	/**
59	 * @param WikiPage &$page
60	 * @param User &$user
61	 * @param Content &$content
62	 * @param string &$summary
63	 * @param bool $minor
64	 * @param bool|null $watchthis
65	 * @param string $sectionanchor
66	 * @param int &$flags
67	 * @param Status &$status
68	 * @return bool
69	 */
70	public static function onPageContentSave( WikiPage &$page, &$user, &$content, &$summary, $minor,
71		$watchthis, $sectionanchor, &$flags, &$status
72	) {
73		if ( $page->getContentModel() !== CONTENT_MODEL_WIKITEXT ) {
74			return true;
75		}
76
77		// The PageContentSave hook provides raw $text, but not $parser because at this stage
78		// the page is not actually parsed yet. Which means we can't know whether self::render()
79		// got a valid tag or not. Looking at $text directly is not a solution either as
80		// it may not be in the current page (it can be transcluded).
81		// Since there is no later hook that allows aborting the save and showing an error,
82		// we will have to trigger the parser ourselves.
83		// Fortunately this causes no overhead since the below (copied from WikiPage::doEditContent,
84		// right after this hook is ran) has guards that lazy-init and return early if called again
85		// later by the real WikiPage.
86
87		// Specify format the same way the API and EditPage do to avoid extra parsing
88		$format = $content->getContentHandler()->getDefaultFormat();
89		$editInfo = $page->prepareContentForEdit( $content, null, $user, $format );
90		$parserOutput = $editInfo->getOutput();
91
92		$templateDataStatus = self::getStatusFromParserOutput( $parserOutput );
93		if ( $templateDataStatus instanceof Status && !$templateDataStatus->isOK() ) {
94			// Abort edit, show error message from TemplateDataBlob::getStatus
95			$status->merge( $templateDataStatus );
96			return false;
97		}
98
99		// TODO: Remove when not needed any more, see T267926
100		self::logChangeEvent( $page, $parserOutput->getProperty( 'templatedata' ), $user );
101
102		return true;
103	}
104
105	/**
106	 * @param WikiPage $page
107	 * @param string|false $newPageProperty
108	 * @param User $user
109	 */
110	private static function logChangeEvent( WikiPage $page, $newPageProperty, User $user ) {
111		if ( !ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) ) {
112			return;
113		}
114
115		$services = MediaWikiServices::getInstance();
116		$title = $page->getTitle();
117		$pageId = $page->getId();
118		$props = $services->getPageProps()->getProperties( $title, 'templatedata' );
119		// The JSON strings here are guaranteed to be normalized (and possibly compressed) the same
120		// way. No need to normalize them again for this comparison.
121		if ( $newPageProperty === ( $props[$pageId] ?? false ) ) {
122			return;
123		}
124
125		$generatorUsed = RequestContext::getMain()->getRequest()->getBool( 'TemplateDataGeneratorUsed' );
126		$revision = $page->getRevisionRecord();
127
128		// Note: We know that irrelevant changes (e.g. whitespace changes) aren't logged here
129		EventLogging::logEvent(
130			'TemplateDataEditor',
131			-1,
132			[
133				// Note: The "Done" button is disabled unless something changed, which means it's
134				// very likely (but not guaranteed) the generator was used to make the changes
135				'action' => $generatorUsed ? 'save-tag-edit-generator-used' : 'save-tag-edit-no-generator',
136				'page_id' => $pageId,
137				'page_namespace' => $title->getNamespace(),
138				'page_title' => $title->getText(),
139				'rev_id' => $revision ? $revision->getId() : 0,
140				'user_edit_count' => $user->getEditCount() ?? 0,
141				'user_id' => $user->getId(),
142			]
143		);
144	}
145
146	/**
147	 * Parser hook registering the GUI module only in edit pages.
148	 *
149	 * @param EditPage $editPage
150	 * @param OutputPage $output
151	 */
152	public static function onEditPage( EditPage $editPage, OutputPage $output ) {
153		global $wgTemplateDataUseGUI;
154		if ( $wgTemplateDataUseGUI ) {
155			if ( $output->getTitle()->inNamespace( NS_TEMPLATE ) ) {
156				$output->addModules( 'ext.templateDataGenerator.editTemplatePage' );
157			}
158		}
159	}
160
161	/**
162	 * Include config when appropriate.
163	 *
164	 * @param array &$vars
165	 * @param OutputPage $output
166	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
167	 */
168	public static function onMakeGlobalVariablesScript( array &$vars, OutputPage $output ) {
169		if ( $output->getTitle()->inNamespace( NS_TEMPLATE ) ) {
170			$vars['wgTemplateDataSuggestedValuesEditor'] =
171				$output->getConfig()->get( 'TemplateDataSuggestedValuesEditor' );
172		}
173	}
174
175	/**
176	 * Parser hook for <templatedata>.
177	 * If there is any JSON provided, render the template documentation on the page.
178	 *
179	 * @param string|null $input The content of the tag.
180	 * @param array $args The attributes of the tag.
181	 * @param Parser $parser Parser instance available to render
182	 *  wikitext into html, or parser methods.
183	 * @param PPFrame $frame Can be used to see what template parameters ("{{{1}}}", etc.)
184	 *  this hook was used with.
185	 *
186	 * @return string HTML to insert in the page.
187	 */
188	public static function render( $input, $args, Parser $parser, $frame ) {
189		$ti = TemplateDataBlob::newFromJSON( wfGetDB( DB_REPLICA ), $input ?? '' );
190
191		$status = $ti->getStatus();
192		if ( !$status->isOK() ) {
193			self::setStatusToParserOutput( $parser->getOutput(), $status );
194			return '<div class="errorbox">' . $status->getHTML() . '</div>';
195		}
196
197		// Store the blob as page property for retrieval by ApiTemplateData.
198		// But, don't store it if we're parsing a doc sub page,  because:
199		// - The doc subpage should not appear in ApiTemplateData as a documented
200		// template itself, which is confusing to users (T54448).
201		// - The doc subpage should not appear at Special:PagesWithProp.
202		// - Storing the blob twice in the database is inefficient (T52512).
203		$title = $parser->getTitle();
204		$docPage = wfMessage( 'templatedata-doc-subpage' )->inContentLanguage();
205		if ( !$title->isSubpage() || $title->getSubpageText() !== $docPage->plain() ) {
206			$parser->getOutput()->setProperty( 'templatedata', $ti->getJSONForDatabase() );
207		}
208
209		$parser->getOutput()->addModuleStyles( [
210			'ext.templateData',
211			'ext.templateData.images',
212			'jquery.tablesorter.styles',
213		] );
214		$parser->getOutput()->addModules( 'jquery.tablesorter' );
215		$parser->getOutput()->setEnableOOUI( true );
216
217		$userLang = $parser->getOptions()->getUserLangObj();
218
219		// FIXME: this hard-codes default skin, but it is needed because
220		// ::getHtml() will need a theme singleton to be set.
221		OutputPage::setupOOUI( 'bogus', $userLang->getDir() );
222		return $ti->getHtml( $userLang );
223	}
224
225	/**
226	 * Fetch templatedata for an array of titles.
227	 *
228	 * @todo Document this hook
229	 *
230	 * The following questions are yet to be resolved.
231	 * (a) Should we extend functionality to looking up an array of titles instead of one?
232	 *     The signature allows for an array of titles to be passed in, but the
233	 *     current implementation is not optimized for the multiple-title use case.
234	 * (b) Should this be a lookup service instead of this faux hook?
235	 *     This will be resolved separately.
236	 *
237	 * @param array $tplTitles
238	 * @param stdclass[] &$tplData
239	 */
240	public static function onParserFetchTemplateData( array $tplTitles, array &$tplData ): void {
241		$tplData = [];
242
243		// This inefficient implementation is currently tuned for
244		// Parsoid's use case where it requests info for exactly one title.
245		// For a real batch use case, this code will need an overhaul.
246		foreach ( $tplTitles as $tplTitle ) {
247			$title = Title::newFromText( $tplTitle );
248			if ( !$title ) {
249				// Invalid title
250				$tplData[$tplTitle] = null;
251				continue;
252			}
253
254			if ( $title->isRedirect() ) {
255				$title = ( new WikiPage( $title ) )->getRedirectTarget();
256				if ( !$title ) {
257					// Invalid redirecting title
258					$tplData[$tplTitle] = null;
259					continue;
260				}
261			}
262
263			if ( !$title->exists() ) {
264				$tplData[$tplTitle] = (object)[ "missing" => true ];
265				continue;
266			}
267
268			// FIXME: PageProps returns takes titles but returns by page id.
269			// This means we need to do our own look up and hope it matches.
270			// Spoiler, sometimes it won't. When that happens, the user won't
271			// get any templatedata-based interfaces for that template.
272			// The fallback is to not serve data for that template, which
273			// the clients have to support anyway, so the impact is minimal.
274			// It is also expected that such race conditions resolve themselves
275			// after a few seconds so the old "try again later" should cover this.
276			$pageId = $title->getArticleID();
277			$props = PageProps::getInstance()->getProperties( $title, 'templatedata' );
278			if ( !isset( $props[$pageId] ) ) {
279				// No templatedata
280				$tplData[$tplTitle] = (object)[ "notemplatedata" => true ];
281				continue;
282			}
283
284			$tdb = TemplateDataBlob::newFromDatabase( wfGetDB( DB_REPLICA ), $props[$pageId] );
285			$status = $tdb->getStatus();
286			if ( !$status->isOK() ) {
287				// Invalid data. Parsoid has no use for the error.
288				$tplData[$tplTitle] = (object)[ "notemplatedata" => true ];
289				continue;
290			}
291
292			$tplData[$tplTitle] = $tdb->getData();
293		}
294	}
295
296	/**
297	 * Write the status to ParserOutput object.
298	 * @param ParserOutput $parserOutput
299	 * @param Status $status
300	 */
301	public static function setStatusToParserOutput( ParserOutput $parserOutput, Status $status ) {
302		$parserOutput->setExtensionData( 'TemplateDataStatus',
303			self::jsonSerializeStatus( $status ) );
304	}
305
306	/**
307	 * @param ParserOutput $parserOutput
308	 * @return Status|null
309	 */
310	public static function getStatusFromParserOutput( ParserOutput $parserOutput ) {
311		$status = $parserOutput->getExtensionData( 'TemplateDataStatus' );
312		if ( is_array( $status ) ) {
313			return self::newStatusFromJson( $status );
314		}
315		return $status;
316	}
317
318	/**
319	 * @param array $status contains StatusValue ok and errors fields (does not serialize value)
320	 * @return Status
321	 */
322	public static function newStatusFromJson( array $status ): Status {
323		if ( $status['ok'] ) {
324			return Status::newGood();
325		} else {
326			$statusObj = new Status();
327			$errors = $status['errors'];
328			foreach ( $errors as $error ) {
329				$statusObj->fatal( $error['message'], ...$error['params'] );
330			}
331			$warnings = $status['warnings'];
332			foreach ( $warnings as $warning ) {
333				$statusObj->warning( $warning['message'], ...$warning['params'] );
334			}
335			return $statusObj;
336		}
337	}
338
339	/**
340	 * @param Status $status
341	 * @return array contains StatusValue ok and errors fields (does not serialize value)
342	 */
343	public static function jsonSerializeStatus( Status $status ): array {
344		if ( $status->isOK() ) {
345			return [
346				'ok' => true
347			];
348		} else {
349			list( $errorsOnlyStatus, $warningsOnlyStatus ) = $status->splitByErrorType();
350			// note that non-scalar values are not supported in errors or warnings
351			return [
352				'ok' => false,
353				'errors' => $errorsOnlyStatus->getErrors(),
354				'warnings' => $warningsOnlyStatus->getErrors()
355			];
356		}
357	}
358}
359