Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 249
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 249
0.00% covered (danger)
0.00%
0 / 19
5112
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 inEventSample
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 doEventLogging
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
272
 doVisualEditorFeatureUseLogging
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 onEditPage__showEditForm_initial
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
182
 onEditPage__showEditForm_fields
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleDataSummary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getSignatureMessage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getMagicWords
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getEditingStatsId
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 onEditPage__attemptSave
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 onEditPage__attemptSave_after
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
56
 onEditPageGetPreviewContent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 registerTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onRecentChange_save
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Hooks for WikiEditor extension
4 *
5 * @file
6 * @ingroup Extensions
7 */
8
9namespace MediaWiki\Extension\WikiEditor;
10
11use ApiMessage;
12use Article;
13use Content;
14use ExtensionRegistry;
15use MediaWiki\Cache\CacheKeyHelper;
16use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
17use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
18use MediaWiki\Config\Config;
19use MediaWiki\Context\RequestContext;
20use MediaWiki\EditPage\EditPage;
21use MediaWiki\Extension\ConfirmEdit\Hooks as ConfirmEditHooks;
22use MediaWiki\Extension\DiscussionTools\Hooks as DiscussionToolsHooks;
23use MediaWiki\Extension\EventLogging\EventLogging;
24use MediaWiki\Hook\EditPage__attemptSave_afterHook;
25use MediaWiki\Hook\EditPage__attemptSaveHook;
26use MediaWiki\Hook\EditPage__showEditForm_fieldsHook;
27use MediaWiki\Hook\EditPage__showEditForm_initialHook;
28use MediaWiki\Hook\EditPageGetPreviewContentHook;
29use MediaWiki\Hook\RecentChange_saveHook;
30use MediaWiki\Html\Html;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Output\OutputPage;
33use MediaWiki\Preferences\Hook\GetPreferencesHook;
34use MediaWiki\Request\WebRequest;
35use MediaWiki\ResourceLoader as RL;
36use MediaWiki\Status\Status;
37use MediaWiki\User\Options\UserOptionsLookup;
38use MediaWiki\User\User;
39use MediaWiki\User\UserEditTracker;
40use MediaWiki\WikiMap\WikiMap;
41use MessageLocalizer;
42use MobileContext;
43use MWCryptRand;
44use RecentChange;
45use WikimediaEvents\WikimediaEventsHooks;
46
47/**
48 * @phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
49 */
50class Hooks implements
51    EditPage__showEditForm_initialHook,
52    EditPage__showEditForm_fieldsHook,
53    GetPreferencesHook,
54    EditPage__attemptSaveHook,
55    EditPage__attemptSave_afterHook,
56    EditPageGetPreviewContentHook,
57    ListDefinedTagsHook,
58    ChangeTagsListActiveHook,
59    RecentChange_saveHook
60{
61
62    /** @var string|bool ID used for grouping entries all of a session's entries together in EventLogging. */
63    private static $statsId = false;
64
65    /** @var string[] */
66    private static $tags = [ 'wikieditor' ];
67
68    private Config $config;
69    private UserEditTracker $userEditTracker;
70    private UserOptionsLookup $userOptionsLookup;
71    private ?MobileContext $mobileContext;
72
73    public function __construct(
74        Config $config,
75        UserEditTracker $userEditTracker,
76        UserOptionsLookup $userOptionsLookup,
77        ?MobileContext $mobileContext
78    ) {
79        $this->config = $config;
80        $this->userEditTracker = $userEditTracker;
81        $this->userOptionsLookup = $userOptionsLookup;
82        $this->mobileContext = $mobileContext;
83    }
84
85    /**
86     * Should the current session be sampled for EventLogging?
87     *
88     * @param string $sessionId
89     * @return bool Whether to sample the session
90     */
91    protected function inEventSample( string $sessionId ): bool {
92        // Sample 6.25%
93        $samplingRate = $this->config->has( 'WMESchemaEditAttemptStepSamplingRate' ) ?
94            $this->config->get( 'WMESchemaEditAttemptStepSamplingRate' ) : 0.0625;
95
96        // (T314896) Convert whatever we've been given to a string of hex, as that's what EL needs
97        $hexValue = hash( 'md5', $sessionId, false );
98
99        $inSample = EventLogging::sessionInSample(
100            (int)( 1 / $samplingRate ), $hexValue
101        );
102        return $inSample;
103    }
104
105    /**
106     * Log stuff to the eventlogging_EditAttemptStep stream in a shape that conforms to the
107     * analytics/legacy/editattemptstep schema.
108     *
109     * If the EventLogging extension is not loaded, then this is a NOP.
110     *
111     * @see https://meta.wikimedia.org/wiki/Schema:EditAttemptStep
112     *
113     * @param string $action
114     * @param Article $article Which article (with full context, page, title, etc.)
115     * @param array $data Data to log for this action
116     * @return void
117     */
118    public function doEventLogging(
119        string $action,
120        Article $article,
121        array $data = []
122    ): void {
123        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
124            return;
125        }
126
127        $extensionRegistry = ExtensionRegistry::getInstance();
128        if ( !$extensionRegistry->isLoaded( 'EventLogging' ) || !$extensionRegistry->isLoaded( 'WikimediaEvents' ) ) {
129            return;
130        }
131        if ( $extensionRegistry->isLoaded( 'MobileFrontend' ) && $this->mobileContext ) {
132            if ( $this->mobileContext->shouldDisplayMobileView() ) {
133                // on a MobileFrontend page the logging should be handled by it
134                return;
135            }
136        }
137        $inSample = $this->inEventSample( $data['editing_session_id'] );
138        $shouldOversample = WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $article->getContext() );
139
140        $user = $article->getContext()->getUser();
141        $page = $article->getPage();
142        $title = $article->getTitle();
143        $revisionRecord = $page->getRevisionRecord();
144        $skin = $article->getContext()->getSkin();
145
146        $data = [
147            'action' => $action,
148            'version' => 1,
149            'is_oversample' => !$inSample,
150            'editor_interface' => 'wikitext',
151            // @todo FIXME for other than 'desktop'. T249944
152            'platform' => 'desktop',
153            'integration' => 'page',
154            'page_id' => $page->getId(),
155            'page_title' => $title->getPrefixedText(),
156            'page_ns' => $title->getNamespace(),
157            'revision_id' => $revisionRecord ? $revisionRecord->getId() : 0,
158            'user_id' => $user->getId(),
159            'user_is_temp' => $user->isTemp(),
160            'user_editcount' => $this->userEditTracker->getUserEditCount( $user ) ?: 0,
161            'mw_version' => MW_VERSION,
162            'skin' => $skin ? $skin->getSkinName() : null,
163            'is_bot' => $user->isRegistered() && $user->isBot(),
164            'is_anon' => $user->isAnon(),
165            'wiki' => WikiMap::getCurrentWikiId(),
166        ] + $data;
167
168        $bucket = ExtensionRegistry::getInstance()->isLoaded( 'DiscussionTools' ) ?
169            // @phan-suppress-next-line PhanUndeclaredClassMethod
170            DiscussionToolsHooks\HookUtils::determineUserABTestBucket( $user ) : false;
171        if ( $bucket ) {
172            $data['bucket'] = $bucket;
173        }
174
175        if ( $user->isAnon() ) {
176            $data['user_class'] = 'IP';
177        }
178
179        if ( !$inSample && !$shouldOversample ) {
180            return;
181        }
182
183        EventLogging::submit(
184            'eventlogging_EditAttemptStep',
185            [
186                '$schema' => '/analytics/legacy/editattemptstep/2.0.2',
187                'event' => $data,
188            ]
189        );
190    }
191
192    /**
193     * Log stuff to EventLogging's Schema:VisualEditorFeatureUse -
194     * see https://meta.wikimedia.org/wiki/Schema:VisualEditorFeatureUse
195     * If you don't have EventLogging and WikimediaEvents installed, does nothing.
196     *
197     * @param string $feature
198     * @param string $action
199     * @param Article $article Which article (with full context, page, title, etc.)
200     * @param string $sessionId Session identifier
201     * @return bool Whether the event was logged or not.
202     */
203    public function doVisualEditorFeatureUseLogging(
204        string $feature,
205        string $action,
206        Article $article,
207        string $sessionId
208    ): bool {
209        $extensionRegistry = ExtensionRegistry::getInstance();
210        if ( !$extensionRegistry->isLoaded( 'EventLogging' ) || !$extensionRegistry->isLoaded( 'WikimediaEvents' ) ) {
211            return false;
212        }
213        $inSample = $this->inEventSample( $sessionId );
214        $shouldOversample = WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $article->getContext() );
215        if ( !$inSample && !$shouldOversample ) {
216            return false;
217        }
218
219        $user = $article->getContext()->getUser();
220        $editCount = $this->userEditTracker->getUserEditCount( $user );
221        $data = [
222            'feature' => $feature,
223            'action' => $action,
224            'editingSessionId' => $sessionId,
225            // @todo FIXME for other than 'desktop'. T249944
226            'platform' => 'desktop',
227            'integration' => 'page',
228            'editor_interface' => 'wikitext',
229            'user_id' => $user->getId(),
230            'user_is_temp' => $user->isTemp(),
231            'user_editcount' => $editCount ?: 0,
232        ];
233
234        $bucket = ExtensionRegistry::getInstance()->isLoaded( 'DiscussionTools' ) ?
235            // @phan-suppress-next-line PhanUndeclaredClassMethod
236            DiscussionToolsHooks\HookUtils::determineUserABTestBucket( $user ) : false;
237        if ( $bucket ) {
238            $data['bucket'] = $bucket;
239        }
240
241        // NOTE: The 'VisualEditorFeatureUse' event was migrated to the Event Platform and is no
242        //  longer using the legacy EventLogging schema from metawiki. $revId is actually
243        //  overridden by the EventLoggingSchemas extension attribute in
244        //  WikimediaEvents/extension.json.
245        return EventLogging::logEvent( 'VisualEditorFeatureUse', -1, $data );
246    }
247
248    /**
249     * EditPage::showEditForm:initial hook
250     *
251     * Adds the modules to the edit form
252     *
253     * @param EditPage $editPage the current EditPage object.
254     * @param OutputPage $outputPage object.
255     */
256    public function onEditPage__showEditForm_initial( $editPage, $outputPage ) {
257        if ( $editPage->contentModel !== CONTENT_MODEL_WIKITEXT ) {
258            return;
259        }
260
261        $article = $editPage->getArticle();
262        $request = $article->getContext()->getRequest();
263
264        // Add modules if enabled
265        $user = $article->getContext()->getUser();
266        if ( $this->userOptionsLookup->getBoolOption( $user, 'usebetatoolbar' ) ) {
267            $outputPage->addModuleStyles( 'ext.wikiEditor.styles' );
268            $outputPage->addModules( 'ext.wikiEditor' );
269            if ( $this->config->get( 'WikiEditorRealtimePreview' ) ) {
270                $outputPage->addModules( 'ext.wikiEditor.realtimepreview' );
271            }
272        }
273
274        // Don't run this if the request was posted - we don't want to log 'init' when the
275        // user just pressed 'Show preview' or 'Show changes', or switched from VE keeping
276        // changes.
277        if ( ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) && !$request->wasPosted() ) {
278            $data = [];
279            $data['editing_session_id'] = self::getEditingStatsId( $request );
280            $section = $request->getRawVal( 'section' );
281            if ( $section !== null ) {
282                $data['init_type'] = 'section';
283            } else {
284                $data['init_type'] = 'page';
285            }
286            if ( $request->getHeader( 'Referer' ) ) {
287                if (
288                    $section === 'new'
289                    || !$article->getPage()->exists()
290                ) {
291                    $data['init_mechanism'] = 'new';
292                } else {
293                    $data['init_mechanism'] = 'click';
294                }
295            } else {
296                if (
297                    $section === 'new'
298                    || !$article->getPage()->exists()
299                ) {
300                    $data['init_mechanism'] = 'url-new';
301                } else {
302                    $data['init_mechanism'] = 'url';
303                }
304            }
305            if ( $request->getRawVal( 'wvprov' ) === 'sticky-header' ) {
306                $data['init_mechanism'] .= '-sticky-header';
307            }
308
309            $this->doEventLogging( 'init', $article, $data );
310        }
311    }
312
313    /**
314     * EditPage::showEditForm:fields hook
315     *
316     * Adds the event fields to the edit form
317     *
318     * @param EditPage $editPage the current EditPage object.
319     * @param OutputPage $outputPage object.
320     */
321    public function onEditPage__showEditForm_fields( $editPage, $outputPage ) {
322        $outputPage->addHTML(
323            Html::hidden(
324                'wikieditorUsed',
325                '',
326                [ 'id' => 'wikieditorUsed' ]
327            )
328        );
329
330        if ( $editPage->contentModel !== CONTENT_MODEL_WIKITEXT
331            || !ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) ) {
332            return;
333        }
334
335        $req = $outputPage->getRequest();
336        $editingStatsId = self::getEditingStatsId( $req );
337
338        $shouldOversample = ExtensionRegistry::getInstance()->isLoaded( 'WikimediaEvents' ) &&
339            WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $outputPage->getContext() );
340
341        $outputPage->addHTML(
342            Html::hidden(
343                'editingStatsId',
344                $editingStatsId,
345                [ 'id' => 'editingStatsId' ]
346            )
347        );
348
349        if ( $shouldOversample ) {
350            $outputPage->addHTML(
351                Html::hidden(
352                    'editingStatsOversample',
353                    1,
354                    [ 'id' => 'editingStatsOversample' ]
355                )
356            );
357        }
358    }
359
360    /**
361     * GetPreferences hook
362     *
363     * Adds WikiEditor-related items to the preferences
364     *
365     * @param User $user current user
366     * @param array &$defaultPreferences list of default user preference controls
367     */
368    public function onGetPreferences( $user, &$defaultPreferences ) {
369        // Ideally this key would be 'wikieditor-toolbar'
370        $defaultPreferences['usebetatoolbar'] = [
371            'type' => 'toggle',
372            'label-message' => 'wikieditor-toolbar-preference',
373            'help-message' => 'wikieditor-toolbar-preference-help',
374            'section' => 'editing/editor',
375        ];
376        $defaultPreferences['wikieditor-realtimepreview'] = [
377            'type' => 'api',
378        ];
379    }
380
381    /**
382     * @param RL\Context $context
383     * @param Config $config
384     * @return array
385     */
386    public static function getModuleData( RL\Context $context, Config $config ): array {
387        return [
388            // expose magic words for use by the wikieditor toolbar
389            'magicWords' => self::getMagicWords(),
390            'signature' => self::getSignatureMessage( $context ),
391            'realtimeDebounce' => $config->get( 'WikiEditorRealtimePreviewDebounce' ),
392            'realtimeDisableDuration' => $config->get( 'WikiEditorRealtimeDisableDuration' ),
393        ];
394    }
395
396    /**
397     * @param RL\Context $context
398     * @param Config $config
399     * @return array
400     */
401    public static function getModuleDataSummary( RL\Context $context, Config $config ): array {
402        return [
403            'magicWords' => self::getMagicWords(),
404            'signature' => self::getSignatureMessage( $context, true ),
405            'realtimeDebounce' => $config->get( 'WikiEditorRealtimePreviewDebounce' ),
406            'realtimeDisableDuration' => $config->get( 'WikiEditorRealtimeDisableDuration' ),
407        ];
408    }
409
410    /**
411     * @param MessageLocalizer $ml
412     * @param bool $raw
413     * @return string
414     */
415    private static function getSignatureMessage( MessageLocalizer $ml, bool $raw = false ): string {
416        $msg = $ml->msg( 'sig-text' )->params( '~~~~' )->inContentLanguage();
417        return $raw ? $msg->plain() : $msg->text();
418    }
419
420    /**
421     * Expose useful magic words which are used by the wikieditor toolbar
422     * @return string[][]
423     */
424    private static function getMagicWords(): array {
425        $requiredMagicWords = [
426            'redirect',
427            'img_alt',
428            'img_right',
429            'img_left',
430            'img_none',
431            'img_center',
432            'img_thumbnail',
433            'img_framed',
434            'img_frameless',
435        ];
436        $magicWords = [];
437        $factory = MediaWikiServices::getInstance()->getMagicWordFactory();
438        foreach ( $requiredMagicWords as $name ) {
439            $magicWords[$name] = $factory->get( $name )->getSynonyms();
440        }
441        return $magicWords;
442    }
443
444    /**
445     * Gets a 32 character alphanumeric random string to be used for stats.
446     * @param WebRequest $request
447     * @return string
448     */
449    private static function getEditingStatsId( WebRequest $request ): string {
450        $fromRequest = $request->getRawVal( 'editingStatsId' );
451        if ( $fromRequest !== null ) {
452            return $fromRequest;
453        }
454        if ( !self::$statsId ) {
455            self::$statsId = MWCryptRand::generateHex( 32 );
456        }
457        return self::$statsId;
458    }
459
460    /**
461     * This is attached to the MediaWiki 'EditPage::attemptSave' hook.
462     *
463     * @param EditPage $editPage
464     */
465    public function onEditPage__attemptSave( $editPage ) {
466        $article = $editPage->getArticle();
467        $request = $article->getContext()->getRequest();
468        $statsId = $request->getRawVal( 'editingStatsId' );
469        if ( $statsId !== null ) {
470            $this->doEventLogging(
471                'saveAttempt',
472                $article,
473                [ 'editing_session_id' => $statsId ]
474            );
475        }
476    }
477
478    /**
479     * This is attached to the MediaWiki 'EditPage::attemptSave:after' hook.
480     *
481     * @param EditPage $editPage
482     * @param Status $status
483     * @param array $resultDetails
484     */
485    public function onEditPage__attemptSave_after( $editPage, $status, $resultDetails ) {
486        $article = $editPage->getArticle();
487        $request = $article->getContext()->getRequest();
488        $statsId = $request->getRawVal( 'editingStatsId' );
489        if ( $statsId !== null ) {
490            $data = [];
491            $data['editing_session_id'] = $statsId;
492
493            if ( $status->isOK() ) {
494                $action = 'saveSuccess';
495
496                if ( $request->getRawVal( 'wikieditorUsed' ) === 'yes' ) {
497                    $this->doVisualEditorFeatureUseLogging(
498                        'mwSave', 'source-has-js', $article, $statsId
499                    );
500                }
501            } else {
502                $action = 'saveFailure';
503
504                // Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor.
505                $typeMap = [
506                    'badtoken' => 'userBadToken',
507                    'assertanonfailed' => 'userNewUser',
508                    'assertuserfailed' => 'userNewUser',
509                    'assertnameduserfailed' => 'userNewUser',
510                    'abusefilter-disallowed' => 'extensionAbuseFilter',
511                    'abusefilter-warning' => 'extensionAbuseFilter',
512                    'captcha' => 'extensionCaptcha',
513                    'spamblacklist' => 'extensionSpamBlacklist',
514                    'titleblacklist-forbidden' => 'extensionTitleBlacklist',
515                    'pagedeleted' => 'editPageDeleted',
516                    'editconflict' => 'editConflict'
517                ];
518
519                $errors = $status->getErrorsArray();
520                // Replicate how the API generates error codes, in order to log data that is consistent with
521                // all other tools (which save changes via the API)
522                if ( isset( $errors[0] ) ) {
523                    $code = ApiMessage::create( $errors[0] )->getApiCode();
524                } else {
525                    $code = 'unknown';
526                }
527
528                $wikiPage = $editPage->getArticle()->getPage();
529
530                if ( ExtensionRegistry::getInstance()->isLoaded( 'ConfirmEdit' ) ) {
531                    $key = CacheKeyHelper::getKeyForPage( $wikiPage );
532                    /** @var SimpleCaptcha $captcha */
533                    $captcha = ConfirmEditHooks::getInstance();
534                    $activatedCaptchas = $captcha->getActivatedCaptchas();
535                    if ( isset( $activatedCaptchas[$key] ) ) {
536                        // TODO: :(
537                        $code = 'captcha';
538                    }
539                }
540
541                $data['save_failure_message'] = $code;
542                $data['save_failure_type'] = $typeMap[ $code ] ?? 'responseUnknown';
543            }
544
545            $this->doEventLogging( $action, $article, $data );
546        }
547    }
548
549    /**
550     * Log a 'preview-nonlive' action when a page is previewed via the non-ajax full-page preview.
551     *
552     * @param EditPage $editPage
553     * @param Content &$content Content object to be previewed (may be replaced by hook function)
554     * @return bool|void True or no return value to continue or false to abort
555     */
556    public function onEditPageGetPreviewContent( $editPage, &$content ) {
557        // This hook is only called for non-live previews, so we don't need to check the uselivepreview user option.
558        $editingStatsId = $editPage->getContext()->getRequest()->getRawVal( 'editingStatsId' );
559        if ( $editingStatsId !== null ) {
560            $article = $editPage->getArticle();
561            $this->doVisualEditorFeatureUseLogging( 'preview', 'preview-nonlive', $article, $editingStatsId );
562        }
563    }
564
565    /**
566     * @param string[] &$tags
567     * @return bool|void
568     */
569    public function onChangeTagsListActive( &$tags ) {
570        $this->registerTags( $tags );
571    }
572
573    /**
574     * @param string[] &$tags
575     * @return bool|void
576     */
577    public function onListDefinedTags( &$tags ) {
578        $this->registerTags( $tags );
579    }
580
581    /**
582     * @param string[] &$tags
583     */
584    protected function registerTags( array &$tags ): void {
585        $tags = array_merge( $tags, static::$tags );
586    }
587
588    /**
589     * @param RecentChange $recentChange
590     * @return bool|void
591     */
592    public function onRecentChange_save( $recentChange ) {
593        $request = RequestContext::getMain()->getRequest();
594        if ( $request->getRawVal( 'wikieditorUsed' ) === 'yes' ) {
595            $recentChange->addTags( 'wikieditor' );
596        }
597    }
598}