1<?php 2/** 3 * Hooks for WikiEditor extension 4 * 5 * @file 6 * @ingroup Extensions 7 */ 8 9use MediaWiki\MediaWikiServices; 10use WikimediaEvents\WikimediaEventsHooks; 11 12class WikiEditorHooks { 13 // ID used for grouping entries all of a session's entries together in 14 // EventLogging. 15 private static $statsId = false; 16 17 /* Static Methods */ 18 19 /** 20 * Should the current session be sampled for EventLogging? 21 * 22 * @param string $sessionId 23 * @return bool Whether to sample the session 24 */ 25 protected static function inEventSample( $sessionId ) { 26 global $wgWMESchemaEditAttemptStepSamplingRate; 27 // Sample 6.25% 28 $samplingRate = $wgWMESchemaEditAttemptStepSamplingRate ?? 0.0625; 29 $inSample = EventLogging::sessionInSample( 30 (int)( 1 / $samplingRate ), $sessionId 31 ); 32 return $inSample; 33 } 34 35 /** 36 * Log stuff to EventLogging's Schema:EditAttemptStep - 37 * see https://meta.wikimedia.org/wiki/Schema:EditAttemptStep 38 * If you don't have EventLogging installed, does nothing. 39 * 40 * @param string $action 41 * @param Article $article Which article (with full context, page, title, etc.) 42 * @param array $data Data to log for this action 43 * @return bool Whether the event was logged or not. 44 */ 45 public static function doEventLogging( $action, $article, $data = [] ) { 46 $extensionRegistry = ExtensionRegistry::getInstance(); 47 if ( !$extensionRegistry->isLoaded( 'EventLogging' ) ) { 48 return false; 49 } 50 $inSample = self::inEventSample( $data['editing_session_id'] ); 51 $shouldOversample = $extensionRegistry->isLoaded( 'WikimediaEvents' ) && 52 WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $article->getContext() ); 53 if ( !$inSample && !$shouldOversample ) { 54 return false; 55 } 56 57 $user = $article->getContext()->getUser(); 58 $page = $article->getPage(); 59 $title = $article->getTitle(); 60 $revisionRecord = $page->getRevisionRecord(); 61 62 $data = [ 63 'action' => $action, 64 'version' => 1, 65 'is_oversample' => !$inSample, 66 'editor_interface' => 'wikitext', 67 'platform' => 'desktop', // FIXME 68 'integration' => 'page', 69 'page_id' => $page->getId(), 70 'page_title' => $title->getPrefixedText(), 71 'page_ns' => $title->getNamespace(), 72 'revision_id' => $revisionRecord ? $revisionRecord->getId() : 0, 73 'user_id' => $user->getId(), 74 'user_editcount' => $user->getEditCount() ?: 0, 75 'mw_version' => MW_VERSION, 76 ] + $data; 77 78 if ( $user->getOption( 'discussiontools-abtest' ) ) { 79 $data['bucket'] = $user->getOption( 'discussiontools-abtest' ); 80 } 81 82 if ( $user->isAnon() ) { 83 $data['user_class'] = 'IP'; 84 } 85 86 return EventLogging::logEvent( 'EditAttemptStep', 18530416, $data ); 87 } 88 89 /** 90 * Log stuff to EventLogging's Schema:VisualEditorFeatureUse - 91 * see https://meta.wikimedia.org/wiki/Schema:VisualEditorFeatureUse 92 * If you don't have EventLogging installed, does nothing. 93 * 94 * @param string $feature 95 * @param string $action 96 * @param Article $article Which article (with full context, page, title, etc.) 97 * @param string $sessionId Session identifier 98 * @return bool Whether the event was logged or not. 99 */ 100 public static function doVisualEditorFeatureUseLogging( $feature, $action, $article, $sessionId ) { 101 $extensionRegistry = ExtensionRegistry::getInstance(); 102 if ( !$extensionRegistry->isLoaded( 'EventLogging' ) ) { 103 return false; 104 } 105 $inSample = self::inEventSample( $sessionId ); 106 $shouldOversample = $extensionRegistry->isLoaded( 'WikimediaEvents' ) && 107 WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $article->getContext() ); 108 if ( !$inSample && !$shouldOversample ) { 109 return false; 110 } 111 112 $user = $article->getContext()->getUser(); 113 114 $data = [ 115 'feature' => $feature, 116 'action' => $action, 117 'editingSessionId' => $sessionId, 118 'platform' => 'desktop', // FIXME T249944 119 'integration' => 'page', 120 'editor_interface' => 'wikitext', 121 'user_id' => $user->getId(), 122 'user_editcount' => $user->getEditCount() ?: 0, 123 ]; 124 125 if ( $user->getOption( 'discussiontools-abtest' ) ) { 126 $data['bucket'] = $user->getOption( 'discussiontools-abtest' ); 127 } 128 129 return EventLogging::logEvent( 'VisualEditorFeatureUse', 21199762, $data ); 130 } 131 132 /** 133 * EditPage::showEditForm:initial hook 134 * 135 * Adds the modules to the edit form 136 * 137 * @param EditPage $editPage the current EditPage object. 138 * @param OutputPage $outputPage object. 139 */ 140 public static function editPageShowEditFormInitial( EditPage $editPage, OutputPage $outputPage ) { 141 if ( $editPage->contentModel !== CONTENT_MODEL_WIKITEXT ) { 142 return; 143 } 144 145 $article = $editPage->getArticle(); 146 $request = $article->getContext()->getRequest(); 147 148 // Add modules if enabled 149 $user = $article->getContext()->getUser(); 150 if ( $user->getOption( 'usebetatoolbar' ) ) { 151 $outputPage->addModuleStyles( 'ext.wikiEditor.styles' ); 152 $outputPage->addModules( 'ext.wikiEditor' ); 153 } 154 155 // Don't run this if the request was posted - we don't want to log 'init' when the 156 // user just pressed 'Show preview' or 'Show changes', or switched from VE keeping 157 // changes. 158 if ( ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) && !$request->wasPosted() ) { 159 $data = []; 160 $data['editing_session_id'] = self::getEditingStatsId( $request ); 161 if ( $request->getVal( 'section' ) ) { 162 $data['init_type'] = 'section'; 163 } else { 164 $data['init_type'] = 'page'; 165 } 166 if ( $request->getHeader( 'Referer' ) ) { 167 if ( 168 $request->getVal( 'section' ) === 'new' 169 || !$article->getPage()->exists() 170 ) { 171 $data['init_mechanism'] = 'new'; 172 } else { 173 $data['init_mechanism'] = 'click'; 174 } 175 } else { 176 if ( 177 $request->getVal( 'section' ) === 'new' 178 || !$article->getPage()->exists() 179 ) { 180 $data['init_mechanism'] = 'url-new'; 181 } else { 182 $data['init_mechanism'] = 'url'; 183 } 184 } 185 186 self::doEventLogging( 'init', $article, $data ); 187 } 188 } 189 190 /** 191 * EditPage::showEditForm:fields hook 192 * 193 * Adds the event fields to the edit form 194 * 195 * @param EditPage $editPage the current EditPage object. 196 * @param OutputPage $outputPage object. 197 */ 198 public static function editPageShowEditFormFields( EditPage $editPage, OutputPage $outputPage ) { 199 if ( $editPage->contentModel !== CONTENT_MODEL_WIKITEXT ) { 200 return; 201 } 202 203 $req = $outputPage->getRequest(); 204 $editingStatsId = self::getEditingStatsId( $req ); 205 206 $shouldOversample = ExtensionRegistry::getInstance()->isLoaded( 'WikimediaEvents' ) && 207 WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $outputPage->getContext() ); 208 209 $outputPage->addHTML( 210 Xml::element( 211 'input', 212 [ 213 'type' => 'hidden', 214 'name' => 'editingStatsId', 215 'id' => 'editingStatsId', 216 'value' => $editingStatsId 217 ] 218 ) 219 ); 220 221 if ( $shouldOversample ) { 222 $outputPage->addHTML( 223 Xml::element( 224 'input', 225 [ 226 'type' => 'hidden', 227 'name' => 'editingStatsOversample', 228 'id' => 'editingStatsOversample', 229 'value' => 1 230 ] 231 ) 232 ); 233 } 234 235 $outputPage->addHTML( 236 Xml::element( 237 'input', 238 [ 239 'type' => 'hidden', 240 'name' => 'wikieditorJavascriptSupport', 241 'id' => 'wikieditorJavascriptSupport', 242 'value' => '' 243 ] 244 ) 245 ); 246 } 247 248 /** 249 * GetPreferences hook 250 * 251 * Adds WikiEditor-related items to the preferences 252 * 253 * @param User $user current user 254 * @param array &$defaultPreferences list of default user preference controls 255 */ 256 public static function getPreferences( $user, &$defaultPreferences ) { 257 // Ideally this key would be 'wikieditor-toolbar' 258 $defaultPreferences['usebetatoolbar'] = [ 259 'type' => 'toggle', 260 'label-message' => 'wikieditor-toolbar-preference', 261 'help-message' => 'wikieditor-toolbar-preference-help', 262 'section' => 'editing/editor', 263 ]; 264 } 265 266 /** 267 * @param ResourceLoaderContext $context 268 * @param Config $config 269 * @return array 270 */ 271 public static function getModuleData( ResourceLoaderContext $context, Config $config ) { 272 return [ 273 // expose magic words for use by the wikieditor toolbar 274 'magicWords' => self::getMagicWords(), 275 'signature' => self::getSignatureMessage( $context ) 276 ]; 277 } 278 279 /** 280 * @param ResourceLoaderContext $context 281 * @param Config $config 282 * @return array 283 */ 284 public static function getModuleDataSummary( ResourceLoaderContext $context, Config $config ) { 285 return [ 286 'magicWords' => self::getMagicWords(), 287 'signature' => self::getSignatureMessage( $context, true ) 288 ]; 289 } 290 291 private static function getSignatureMessage( MessageLocalizer $ml, $raw = false ) { 292 $msg = $ml->msg( 'sig-text' )->params( '~~~~' )->inContentLanguage(); 293 return $raw ? $msg->plain() : $msg->text(); 294 } 295 296 /** 297 * Expose useful magic words which are used by the wikieditor toolbar 298 * @return string[][] 299 */ 300 private static function getMagicWords() { 301 $requiredMagicWords = [ 302 'redirect', 303 'img_alt', 304 'img_right', 305 'img_left', 306 'img_none', 307 'img_center', 308 'img_thumbnail', 309 'img_framed', 310 'img_frameless', 311 ]; 312 $magicWords = []; 313 $factory = MediaWikiServices::getInstance()->getMagicWordFactory(); 314 foreach ( $requiredMagicWords as $name ) { 315 $magicWords[$name] = $factory->get( $name )->getSynonyms(); 316 } 317 return $magicWords; 318 } 319 320 /** 321 * Gets a 32 character alphanumeric random string to be used for stats. 322 * @param WebRequest $request 323 * @return string 324 */ 325 private static function getEditingStatsId( WebRequest $request ) { 326 $fromRequest = $request->getVal( 'editingStatsId' ); 327 if ( $fromRequest ) { 328 return $fromRequest; 329 } 330 if ( !self::$statsId ) { 331 self::$statsId = MWCryptRand::generateHex( 32 ); 332 } 333 return self::$statsId; 334 } 335 336 /** 337 * This is attached to the MediaWiki 'EditPage::attemptSave' hook. 338 * 339 * @param EditPage $editPage 340 */ 341 public static function editPageAttemptSave( EditPage $editPage ) { 342 $article = $editPage->getArticle(); 343 $request = $article->getContext()->getRequest(); 344 if ( $request->getVal( 'editingStatsId' ) ) { 345 self::doEventLogging( 346 'saveAttempt', 347 $article, 348 [ 'editing_session_id' => $request->getVal( 'editingStatsId' ) ] 349 ); 350 } 351 } 352 353 /** 354 * This is attached to the MediaWiki 'EditPage::attemptSave:after' hook. 355 * 356 * @param EditPage $editPage 357 * @param Status $status 358 */ 359 public static function editPageAttemptSaveAfter( EditPage $editPage, Status $status ) { 360 $article = $editPage->getArticle(); 361 $request = $article->getContext()->getRequest(); 362 if ( $request->getVal( 'editingStatsId' ) ) { 363 $data = []; 364 $data['editing_session_id'] = $request->getVal( 'editingStatsId' ); 365 366 if ( $status->isOK() ) { 367 $action = 'saveSuccess'; 368 369 if ( $request->getVal( 'wikieditorJavascriptSupport' ) === 'yes' ) { 370 self::doVisualEditorFeatureUseLogging( 371 'mwSave', 'source-has-js', $article, $request->getVal( 'editingStatsId' ) 372 ); 373 } 374 } else { 375 $action = 'saveFailure'; 376 377 // Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor. 378 $typeMap = [ 379 'badtoken' => 'userBadToken', 380 'assertanonfailed' => 'userNewUser', 381 'assertuserfailed' => 'userNewUser', 382 'assertnameduserfailed' => 'userNewUser', 383 'abusefilter-disallowed' => 'extensionAbuseFilter', 384 'abusefilter-warning' => 'extensionAbuseFilter', 385 'captcha' => 'extensionCaptcha', 386 'spamblacklist' => 'extensionSpamBlacklist', 387 'titleblacklist-forbidden' => 'extensionTitleBlacklist', 388 'pagedeleted' => 'editPageDeleted', 389 'editconflict' => 'editConflict' 390 ]; 391 392 $errors = $status->getErrorsArray(); 393 // Replicate how the API generates error codes, in order to log data that is consistent with 394 // all other tools (which save changes via the API) 395 if ( isset( $errors[0] ) ) { 396 $code = ApiMessage::create( $errors[0] )->getApiCode(); 397 } else { 398 $code = 'unknown'; 399 } 400 401 $wikiPage = $editPage->getArticle()->getPage(); 402 if ( isset( $wikiPage->ConfirmEdit_ActivateCaptcha ) ) { 403 // TODO: :( 404 $code = 'captcha'; 405 } 406 407 $data['save_failure_message'] = $code; 408 $data['save_failure_type'] = $typeMap[ $code ] ?? 'responseUnknown'; 409 } 410 411 self::doEventLogging( $action, $article, $data ); 412 } 413 } 414} 415