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