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