Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.58% covered (danger)
15.58%
79 / 507
6.25% covered (danger)
6.25%
2 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
15.58% covered (danger)
15.58%
79 / 507
6.25% covered (danger)
6.25%
2 / 32
16344.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 getDataForDesktopArticleTargetInitModule
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 loadDiffModules
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onDifferenceEngineViewHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onTextSlotDiffRendererTablePrefix
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 isSupportedEditPage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
 isWikitextAvailable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 deferredSetUserOption
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onCustomEditor
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
72
 getEditPageEditor
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 getPreferredEditor
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 getLastEditor
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 1
1806
 onEditPage__showEditForm_fields
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onRecentChange_save
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onSkinEditSectionLinks
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
272
 onArticleParserOptions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 onOutputPageBodyAttributes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
42
 onPreferencesFormPreSave
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 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
 onMakeGlobalVariablesScript
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderGetConfigVars
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
1 / 1
5
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 onParserTestGlobals
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onRedirectSpecialArticleRedirectParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforeInitialize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onUserLoggedIn
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 onParserOptionsDefaults
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * VisualEditor extension hooks
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2011-2020 VisualEditor Team and others; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\VisualEditor;
12
13use MediaWiki\Actions\ActionEntryPoint;
14use MediaWiki\Actions\Hook\CustomEditorHook;
15use MediaWiki\Auth\Hook\UserLoggedInHook;
16use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
17use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
18use MediaWiki\Config\Config;
19use MediaWiki\Config\ConfigFactory;
20use MediaWiki\Context\IContextSource;
21use MediaWiki\Context\RequestContext;
22use MediaWiki\Deferred\DeferredUpdates;
23use MediaWiki\Diff\Hook\DifferenceEngineViewHeaderHook;
24use MediaWiki\Diff\Hook\TextSlotDiffRendererTablePrefixHook;
25use MediaWiki\Diff\TextSlotDiffRenderer;
26use MediaWiki\EditPage\EditPage;
27use MediaWiki\Extension\VisualEditor\EditCheck\ApiEditCheckReferenceUrl;
28use MediaWiki\Extension\VisualEditor\Services\VisualEditorAvailabilityLookup;
29use MediaWiki\Hook\BeforeInitializeHook;
30use MediaWiki\Hook\EditPage__showEditForm_fieldsHook;
31use MediaWiki\Hook\ParserTestGlobalsHook;
32use MediaWiki\HookContainer\HookContainer;
33use MediaWiki\Html\Html;
34use MediaWiki\HTMLForm\HTMLForm;
35use MediaWiki\Language\Language;
36use MediaWiki\MediaWikiServices;
37use MediaWiki\Output\Hook\BeforePageDisplayHook;
38use MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook;
39use MediaWiki\Output\Hook\OutputPageBodyAttributesHook;
40use MediaWiki\Output\OutputPage;
41use MediaWiki\Page\Article;
42use MediaWiki\Page\Hook\ArticleParserOptionsHook;
43use MediaWiki\Parser\Hook\ParserOptionsDefaultsHook;
44use MediaWiki\Parser\ParserOptions;
45use MediaWiki\Preferences\Hook\GetPreferencesHook;
46use MediaWiki\Preferences\Hook\PreferencesFormPreSaveHook;
47use MediaWiki\RecentChanges\Hook\RecentChange_saveHook;
48use MediaWiki\RecentChanges\RecentChange;
49use MediaWiki\Registration\ExtensionRegistry;
50use MediaWiki\Request\WebRequest;
51use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
52use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
53use MediaWiki\ResourceLoader\ResourceLoader;
54use MediaWiki\Skin\Hook\SkinEditSectionLinksHook;
55use MediaWiki\Skin\Hook\SkinTemplateNavigation__UniversalHook;
56use MediaWiki\Skin\Skin;
57use MediaWiki\Skin\SkinTemplate;
58use MediaWiki\SpecialPage\Hook\RedirectSpecialArticleRedirectParamsHook;
59use MediaWiki\SpecialPage\SpecialPage;
60use MediaWiki\Title\Title;
61use MediaWiki\User\Options\UserOptionsLookup;
62use MediaWiki\User\User;
63use MediaWiki\User\UserIdentity;
64use MobileContext;
65use OOUI\ButtonGroupWidget;
66use OOUI\ButtonWidget;
67
68class Hooks implements
69    TextSlotDiffRendererTablePrefixHook,
70    BeforeInitializeHook,
71    BeforePageDisplayHook,
72    ChangeTagsListActiveHook,
73    CustomEditorHook,
74    DifferenceEngineViewHeaderHook,
75    EditPage__showEditForm_fieldsHook,
76    GetPreferencesHook,
77    ListDefinedTagsHook,
78    MakeGlobalVariablesScriptHook,
79    OutputPageBodyAttributesHook,
80    ParserOptionsDefaultsHook,
81    ParserTestGlobalsHook,
82    ArticleParserOptionsHook,
83    PreferencesFormPreSaveHook,
84    RecentChange_saveHook,
85    RedirectSpecialArticleRedirectParamsHook,
86    ResourceLoaderGetConfigVarsHook,
87    ResourceLoaderRegisterModulesHook,
88    SkinEditSectionLinksHook,
89    SkinTemplateNavigation__UniversalHook,
90    UserLoggedInHook
91{
92
93    // Known parameters that VE does not handle
94    // TODO: Other params too?
95    // Known-good parameters: edit, veaction, section, oldid, lintid, preload, preloadparams, editintro
96    // Partially-good: preloadtitle (source-mode only)
97    private const UNSUPPORTED_EDIT_PARAMS = [
98        'undo',
99        'undoafter',
100        // Only for WTE. This parameter is not supported right now, and NWE has a very different design
101        // for previews, so we might not want to support this at all.
102        'preview',
103    ];
104
105    private const TAGS = [
106        'visualeditor',
107        'visualeditor-wikitext',
108        // Edit check
109        'editcheck-references',
110        'editcheck-references-shown',
111        'editcheck-newcontent',
112        'editcheck-newreference',
113        'editcheck-tone',
114        'editcheck-tone-shown',
115        'editcheck-paste-shown',
116        'editsuggestion-seen',
117        'editsuggestion-used',
118        // No longer in active use:
119        'editcheck-references-activated',
120        'editcheck-reference-decline-common-knowledge',
121        'editcheck-reference-decline-irrelevant',
122        'editcheck-reference-decline-uncertain',
123        'editcheck-reference-decline-other',
124        'visualeditor-needcheck',
125        'visualeditor-switched'
126    ];
127
128    private readonly Config $veConfig;
129    private readonly VisualEditorHookRunner $hookRunner;
130
131    public function __construct(
132        private readonly ExtensionRegistry $extensionRegistry,
133        private readonly VisualEditorAvailabilityLookup $visualEditorAvailabilityLookup,
134        private readonly UserOptionsLookup $userOptionsLookup,
135        HookContainer $hookContainer,
136        ConfigFactory $configFactory,
137        private readonly ?MobileContext $mobileFrontendContext,
138    ) {
139        $this->veConfig = $configFactory->makeConfig( 'visualeditor' );
140        $this->hookRunner = new VisualEditorHookRunner( $hookContainer );
141    }
142
143    /**
144     * Initialise the 'VisualEditorAvailableNamespaces' setting, and add content
145     * namespaces to it. This will run after LocalSettings.php is processed.
146     * Also ensure Parsoid extension is loaded when necessary.
147     */
148    public static function onRegistration(): void {
149        global $wgVisualEditorAvailableNamespaces, $wgContentNamespaces;
150
151        foreach ( $wgContentNamespaces as $contentNamespace ) {
152            if ( !isset( $wgVisualEditorAvailableNamespaces[$contentNamespace] ) ) {
153                $wgVisualEditorAvailableNamespaces[$contentNamespace] = true;
154            }
155        }
156    }
157
158    /**
159     * Adds VisualEditor JS to the output.
160     *
161     * This is attached to the MediaWiki 'BeforePageDisplay' hook.
162     *
163     * @param OutputPage $output The page view.
164     * @param Skin $skin The skin that's going to build the UI.
165     */
166    public function onBeforePageDisplay( $output, $skin ): void {
167        $services = MediaWikiServices::getInstance();
168        if ( !$this->hookRunner->onVisualEditorBeforeEditor( $output, $skin ) ) {
169            $output->addJsConfigVars( 'wgVisualEditorDisabledByHook', true );
170            return;
171        }
172
173        if ( $output->getTitle() && $output->getTitle()->isSpecial( 'EditCheckHeadless' ) ) {
174            // Special:EditCheckHeadless is intentionally minimal and self-contained.
175            // TODO: Shortcut on all special pages except allowed ones (e.g. Special:CollabPad)
176            return;
177        }
178
179        if ( !(
180            $this->extensionRegistry->isLoaded( 'MobileFrontend' ) &&
181            $services->getService( 'MobileFrontend.Context' )
182                ->shouldDisplayMobileView()
183        ) ) {
184            $output->addModules( 'ext.visualEditor.desktopArticleTarget.init' );
185            $output->addModuleStyles( [ 'ext.visualEditor.desktopArticleTarget.noscript' ] );
186        } else {
187            $output->addModules( 'ext.visualEditor.targetLoader' );
188        }
189
190        // add scroll offset js variable to output
191        $skinsToolbarScrollOffset = $this->veConfig->get( 'VisualEditorSkinToolbarScrollOffset' );
192        $toolbarScrollOffset = 0;
193        $skinName = $skin->getSkinName();
194        if ( isset( $skinsToolbarScrollOffset[$skinName] ) ) {
195            $toolbarScrollOffset = $skinsToolbarScrollOffset[$skinName];
196        }
197        // T220158: Don't add this unless it's non-default
198        // TODO: Move this to packageFiles as it's not relevant to the HTML request.
199        if ( $toolbarScrollOffset !== 0 ) {
200            $output->addJsConfigVars( 'wgVisualEditorToolbarScrollOffset', $toolbarScrollOffset );
201        }
202
203        $output->addJsConfigVars(
204            'wgEditSubmitButtonLabelPublish',
205            $this->veConfig->get( 'EditSubmitButtonLabelPublish' )
206        );
207
208        // TODO: Move to EditCheck
209        $isDisambiguation = $output->getTitle()->exists() &&
210            $services->getPageProps()->getProperties( $output->getTitle(), 'disambiguation' ) !== [];
211        $output->addJsConfigVars( 'wgVisualEditorPageIsDisambiguation', $isDisambiguation );
212
213        // Don't index VE edit pages (T319124)
214        if ( $output->getRequest()->getVal( 'veaction' ) ) {
215            $output->setRobotPolicy( 'noindex,nofollow' );
216        }
217    }
218
219    /**
220     * @internal For internal use in extension.json only.
221     */
222    public static function getDataForDesktopArticleTargetInitModule(): array {
223        return [
224            'unsupportedEditParams' => self::UNSUPPORTED_EDIT_PARAMS,
225        ];
226    }
227
228    /**
229     * Load modules required for a diff page
230     *
231     * @param OutputPage $output Output page
232     */
233    private function loadDiffModules( OutputPage $output ) {
234        $output->addModuleStyles( [
235            'ext.visualEditor.diffPage.init.styles',
236            'oojs-ui.styles.icons-accessibility',
237            'oojs-ui.styles.icons-editing-advanced'
238        ] );
239        $output->addModules( 'ext.visualEditor.diffPage.init' );
240        $output->enableOOUI();
241    }
242
243    /** @inheritDoc */
244    public function onDifferenceEngineViewHeader( $differenceEngine ) {
245        // T344596: Must load this module unconditionally. The TextSlotDiffRendererTablePrefix hook
246        // below doesn't run when the diff is e.g. a log entry with no change to the content.
247        $this->loadDiffModules( $differenceEngine->getContext()->getOutput() );
248    }
249
250    /**
251     * Handler for the DifferenceEngineViewHeader hook, to add visual diffs code as configured
252     *
253     * @param TextSlotDiffRenderer $textSlotDiffRenderer
254     * @param IContextSource $context
255     * @param string[] &$parts
256     * @return void
257     */
258    public function onTextSlotDiffRendererTablePrefix(
259        TextSlotDiffRenderer $textSlotDiffRenderer,
260        IContextSource $context,
261        array &$parts
262    ) {
263        $output = $context->getOutput();
264
265        // Return early if not viewing a diff of an allowed type.
266        if ( !$this->visualEditorAvailabilityLookup->isAllowedContentType( $textSlotDiffRenderer->getContentModel() )
267            || $output->getActionName() !== 'view'
268        ) {
269            return;
270        }
271
272        // onDifferenceEngineViewHeader may not run, so load modules here as well for styling (T361775)
273        $this->loadDiffModules( $output );
274
275        $parts['50_ve-init-mw-diffPage-diffMode'] = '<div class="ve-init-mw-diffPage-diffMode">' .
276            // Will be replaced by a ButtonSelectWidget in JS
277            new ButtonGroupWidget( [
278                'items' => [
279                    new ButtonWidget( [
280                        'data' => 'visual',
281                        'icon' => 'eye',
282                        'disabled' => true,
283                        'label' => $output->msg( 'visualeditor-savedialog-review-visual' )->plain()
284                    ] ),
285                    new ButtonWidget( [
286                        'data' => 'source',
287                        'icon' => 'wikiText',
288                        'active' => true,
289                        'label' => $output->msg( 'visualeditor-savedialog-review-wikitext' )->plain()
290                    ] )
291                ]
292            ] ) .
293            '</div>';
294    }
295
296    /**
297     * @param Title $title
298     * @param User $user
299     * @param WebRequest $req
300     * @return bool
301     */
302    private function isSupportedEditPage( Title $title, User $user, WebRequest $req ): bool {
303        if (
304            $req->getVal( 'action' ) !== 'edit' ||
305            !MediaWikiServices::getInstance()->getPermissionManager()->quickUserCan( 'edit', $user, $title )
306        ) {
307            return false;
308        }
309
310        // Suppress the takeover for this page view only.
311        // Duplicated in ve.init.mw.DesktopArticleTarget.init.js#getEditModeFromUrl
312        if ( $req->getCheck( 'venoautoload' ) ) {
313            return false;
314        }
315
316        foreach ( self::UNSUPPORTED_EDIT_PARAMS as $param ) {
317            if ( $req->getVal( $param ) !== null ) {
318                return false;
319            }
320        }
321
322        switch ( $this->getEditPageEditor( $user, $req ) ) {
323            case 'visualeditor':
324                return $this->visualEditorAvailabilityLookup->isAvailable( $title, $req, $user ) ||
325                    $this->isWikitextAvailable( $title, $user );
326            case 'wikitext':
327            default:
328                return $this->isWikitextAvailable( $title, $user );
329        }
330    }
331
332    /**
333     * @param Title $title
334     * @param UserIdentity $user
335     * @return bool
336     */
337    private function isWikitextAvailable( Title $title, UserIdentity $user ): bool {
338        return $this->userOptionsLookup->getOption( $user, 'visualeditor-newwikitext' ) &&
339            $title->getContentModel() === 'wikitext';
340    }
341
342    /**
343     * @param UserIdentity $user
344     * @param string $key
345     * @param string $value
346     */
347    private static function deferredSetUserOption( UserIdentity $user, string $key, string $value ): void {
348        DeferredUpdates::addCallableUpdate( static function () use ( $user, $key, $value ) {
349            $services = MediaWikiServices::getInstance();
350            if ( $services->getReadOnlyMode()->isReadOnly() ) {
351                return;
352            }
353            $userOptionsManager = $services->getUserOptionsManager();
354            $userOptionsManager->setOption( $user, $key, $value );
355            $userOptionsManager->saveOptions( $user );
356        } );
357    }
358
359    /**
360     * Decide whether to bother showing the wikitext editor at all.
361     * If not, we expect the VE initialisation JS to activate.
362     *
363     * @param Article $article The article being viewed.
364     * @param User $user The user-specific settings.
365     * @return bool Whether to show the wikitext editor or not.
366     */
367    public function onCustomEditor( $article, $user ) {
368        $req = $article->getContext()->getRequest();
369        $services = MediaWikiServices::getInstance();
370        $urlUtils = $services->getUrlUtils();
371
372        if ( $this->extensionRegistry->isLoaded( 'MobileFrontend' ) ) {
373            // If mobilefrontend is involved it can make its own decisions about this
374            $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
375            if ( $mobFrontContext->shouldDisplayMobileView() ) {
376                return true;
377            }
378        }
379
380        if ( !$this->visualEditorAvailabilityLookup->isEnabledForUser( $user ) ) {
381            return true;
382        }
383
384        $title = $article->getTitle();
385
386        if ( $req->getVal( 'venoscript' ) ) {
387            $req->response()->setCookie( 'VEE', 'wikitext', 0, [ 'prefix' => '' ] );
388            if ( $user->isNamed() ) {
389                self::deferredSetUserOption( $user, 'visualeditor-editor', 'wikitext' );
390            }
391            return true;
392        }
393
394        if ( $this->isSupportedEditPage( $title, $user, $req ) ) {
395            $params = $req->getValues();
396            $params['venoscript'] = '1';
397            $url = wfScript() . '?' . wfArrayToCgi( $params );
398
399            $out = $article->getContext()->getOutput();
400            $titleMsg = $title->exists() ? 'editing' : 'creating';
401            $out->setPageTitleMsg( wfMessage( $titleMsg, $title->getPrefixedText() ) );
402            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Only null for invalid URL, shouldn't happen
403            $out->showPendingTakeover( $url, 'visualeditor-toload', $urlUtils->expand( $url ) );
404
405            $out->setRevisionId( $req->getInt( 'oldid', $article->getRevIdFetched() ) );
406            return false;
407        }
408        return true;
409    }
410
411    /**
412     * @param User $user
413     * @param WebRequest $req
414     * @return string 'wikitext' or 'visual'
415     */
416    private function getEditPageEditor( User $user, WebRequest $req ): string {
417        if ( $this->veConfig->get( 'VisualEditorDisableForAnons' ) && !$user->isRegistered() ) {
418            return 'wikitext';
419        }
420        $isRedLink = $req->getBool( 'redlink' );
421        // On dual-edit-tab wikis, the edit page must mean the user wants wikitext,
422        // unless following a redlink
423        if ( !$this->veConfig->get( 'VisualEditorUseSingleEditTab' ) && !$isRedLink ) {
424            return 'wikitext';
425        }
426        // Adding a new section is not supported in visual mode
427        if ( $req->getRawVal( 'section' ) === 'new' ) {
428            return 'wikitext';
429        }
430        // Force switched from VE
431        if ( $req->getVal( 'veswitched' ) !== null ) {
432            return 'wikitext';
433        }
434        return self::getPreferredEditor( $user, $req, !$isRedLink );
435    }
436
437    /**
438     * @param User $user
439     * @param WebRequest $req
440     * @param bool $useWikitextInMultiTab
441     * @return string 'wikitext' or 'visual'
442     */
443    public static function getPreferredEditor(
444        User $user, WebRequest $req, bool $useWikitextInMultiTab = false
445    ): string {
446        // VisualEditor shouldn't even call this method when it's disabled, but it is a public API for
447        // other extensions (e.g. DiscussionTools), and the editor preferences might have surprising
448        // values if the user has tried VisualEditor in the past and then disabled it. (T257234)
449        $services = MediaWikiServices::getInstance();
450        /** @var VisualEditorAvailabilityLookup $visualEditorAvailabilityLookup */
451        $visualEditorAvailabilityLookup = $services->get( VisualEditorAvailabilityLookup::SERVICE_NAME );
452        if ( !$visualEditorAvailabilityLookup->isEnabledForUser( $user ) ) {
453            return 'wikitext';
454        }
455
456        $userOptionsLookup = $services->getUserOptionsLookup();
457
458        switch ( $userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) ) {
459            case 'prefer-ve':
460                return 'visualeditor';
461            case 'prefer-wt':
462                return 'wikitext';
463            case 'multi-tab':
464                // May have got here by switching from VE
465                // TODO: Make such an action explicitly request wikitext
466                // so we can use getLastEditor here instead.
467                return $useWikitextInMultiTab ?
468                    'wikitext' :
469                    self::getLastEditor( $user, $req );
470            case 'remember-last':
471            default:
472                return self::getLastEditor( $user, $req );
473        }
474    }
475
476    /**
477     * @param User $user
478     * @param WebRequest $req
479     * @return string
480     */
481    private static function getLastEditor( User $user, WebRequest $req ): string {
482        // This logic matches getLastEditor in:
483        // modules/ve-mw/init/targets/ve.init.mw.DesktopArticleTarget.init.js
484        $editor = $req->getCookie( 'VEE', '' );
485        // Set editor to user's preference or site's default (ignore the cookie) if â€¦
486        if (
487            // â€¦ user is logged in,
488            $user->isNamed() ||
489            // â€¦ no cookie is set, or
490            !$editor ||
491            // value is invalid.
492            !( $editor === 'visualeditor' || $editor === 'wikitext' )
493        ) {
494            $services = MediaWikiServices::getInstance();
495            $userOptionsLookup = $services->getUserOptionsLookup();
496            $editor = $userOptionsLookup->getOption( $user, 'visualeditor-editor' );
497        }
498        return $editor;
499    }
500
501    /**
502     * Changes the Edit tab and adds the VisualEditor tab.
503     *
504     * This is attached to the MediaWiki 'SkinTemplateNavigation::Universal' hook.
505     *
506     * @param SkinTemplate $skin The skin template on which the UI is built.
507     * @param array &$links Navigation links.
508     */
509    public function onSkinTemplateNavigation__Universal( $skin, &$links ): void {
510        $services = MediaWikiServices::getInstance();
511        if (
512            $this->extensionRegistry->isLoaded( 'MobileFrontend' ) &&
513            $services->getService( 'MobileFrontend.Context' )->shouldDisplayMobileView()
514        ) {
515            return;
516        }
517
518        // Exit if there's no edit link for whatever reason (e.g. protected page)
519        if ( !isset( $links['views']['edit'] ) ) {
520            return;
521        }
522
523        if ( !$this->hookRunner->onVisualEditorBeforeEditor( $skin->getOutput(), $skin ) ) {
524            return;
525        }
526
527        $user = $skin->getUser();
528        if (
529            $this->veConfig->get( 'VisualEditorUseSingleEditTab' ) &&
530            $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'prefer-wt'
531        ) {
532            return;
533        }
534
535        if (
536            $this->veConfig->get( 'VisualEditorUseSingleEditTab' ) &&
537            wfTimestampNow() < $this->veConfig->get( 'VisualEditorSingleEditTabSwitchTimeEnd' ) &&
538            $user->isNamed() &&
539            $this->visualEditorAvailabilityLookup->isEnabledForUser( $user ) &&
540            !$this->userOptionsLookup->getOption( $user, 'visualeditor-hidetabdialog' ) &&
541            $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'remember-last'
542        ) {
543            // Check if the user has made any edits before the SET switch time
544            $dbr = $services->getConnectionProvider()->getReplicaDatabase();
545            $revExists = $dbr->newSelectQueryBuilder()
546                ->from( 'revision' )
547                ->field( '1' )
548                ->where( [
549                    'rev_actor' => $user->getActorId(),
550                    $dbr->expr( 'rev_timestamp', '<', $dbr->timestamp(
551                        $this->veConfig->get( 'VisualEditorSingleEditTabSwitchTime' )
552                    ) )
553                ] )
554                ->caller( __METHOD__ )
555                ->fetchField();
556            if ( $revExists ) {
557                $links['views']['edit']['class'] .= ' visualeditor-showtabdialog';
558            }
559        }
560
561        // Exit if the user doesn't have VE enabled
562        if (
563            !$this->visualEditorAvailabilityLookup->isEnabledForUser( $user ) ||
564            // T253941: This option does not actually disable the editor, only leaves the tabs/links unchanged
565            ( $this->veConfig->get( 'VisualEditorDisableForAnons' ) && !$user->isRegistered() )
566        ) {
567            return;
568        }
569
570        $title = $skin->getRelevantTitle();
571        // Don't exit if this page isn't VE-enabled, since we should still
572        // change "Edit" to "Edit source".
573        $isAvailable = $this->visualEditorAvailabilityLookup->isAvailable( $title, $skin->getRequest(), $user );
574
575        $tabMessages = $this->veConfig->get( 'VisualEditorTabMessages' );
576        // Rebuild the $links['views'] array and inject the VisualEditor tab before or after
577        // the edit tab as appropriate. We have to rebuild the array because PHP doesn't allow
578        // us to splice into the middle of an associative array.
579        $newViews = [];
580        $wikiPageFactory = $services->getWikiPageFactory();
581        if ( $title->inNamespace( NS_SPECIAL ) ) {
582            // @see https://phabricator.wikimedia.org/T376487
583            // The WikiPageFactory call would fatal if $title points to a special page, but sometimes,
584            // as unusual as it might be, a special page *can* call the relevant hooks to add an "edit"
585            // tab to itself. Luckily most special pages are smarter than that.
586            $isRemote = false;
587        } else {
588            $isRemote = !$wikiPageFactory->newFromTitle( $title )->isLocal();
589        }
590
591        $skinHasEditIcons = in_array(
592            $skin->getSkinName(),
593            $this->extensionRegistry->getAttribute( 'VisualEditorIconSkins' )
594        );
595
596        foreach ( $links['views'] as $action => $data ) {
597            if ( $action === 'edit' ) {
598                $shadowPage = $services->getShadowPageLoader()->get( $title );
599                // Build the VisualEditor tab
600                $existing = $title->exists() || $shadowPage?->existsForEdit();
601                $action = $existing ? 'edit' : 'create';
602                $veParams = $skin->editUrlOptions();
603                // Remove action=edit
604                unset( $veParams['action'] );
605                // Set veaction=edit
606                $veParams['veaction'] = 'edit';
607                $veTabMessage = $tabMessages[$action];
608                $veTabText = $veTabMessage === null ? ( $data['text'] ?? '' ) :
609                    $skin->msg( $veTabMessage )->text();
610                if ( $isRemote ) {
611                    // The following messages can be used here:
612                    // * tooltip-ca-ve-edit-local
613                    // * tooltip-ca-ve-create-local
614                    // The following messages can be generated upstream:
615                    // * accesskey-ca-ve-edit-local
616                    // * accesskey-ca-ve-create-local
617                    $veTooltip = 'ca-ve-' . $action . '-local';
618                } else {
619                    // The following messages can be used here:
620                    // * tooltip-ca-ve-edit
621                    // * tooltip-ca-ve-create
622                    // The following messages can be generated upstream:
623                    // * accesskey-ca-ve-edit
624                    // * accesskey-ca-ve-create
625                    $veTooltip = 'ca-ve-' . $action;
626                }
627                $veTab = [
628                    'href' => $title->getLocalURL( $veParams ),
629                    'text' => $veTabText,
630                    'single-id' => $veTooltip,
631                    'primary' => true,
632                    'icon' => $skinHasEditIcons ? 'edit' : null,
633                    'class' => '',
634                ];
635
636                // Alter the edit tab
637                $editTab = $data;
638                if ( $isRemote ) {
639                    // The following messages can be used here:
640                    // * visualeditor-ca-editlocaldescriptionsource
641                    // * visualeditor-ca-createlocaldescriptionsource
642                    $editTabMessage = $tabMessages[$action . 'localdescriptionsource'];
643                    // The following messages can be used here:
644                    // * tooltip-ca-editsource-local
645                    // * tooltip-ca-createsource-local
646                    // The following messages can be generated upstream:
647                    // * accesskey-ca-editsource-local
648                    // * accesskey-ca-createsource-local
649                    $editTabTooltip = 'ca-' . $action . 'source-local';
650                } else {
651                    // The following messages can be used here:
652                    // * visualeditor-ca-editsource
653                    // * visualeditor-ca-createsource
654                    $editTabMessage = $tabMessages[$action . 'source'];
655                    // The following messages can be used here:
656                    // * tooltip-ca-editsource
657                    // * tooltip-ca-createsource
658                    // The following messages can be generated upstream:
659                    // * accesskey-ca-editsource
660                    // * accesskey-ca-createsource
661                    $editTabTooltip = 'ca-' . $action . 'source';
662                }
663
664                if ( $editTabMessage !== null ) {
665                    $editTab['text'] = $skin->msg( $editTabMessage )->text();
666                    $editTab['single-id'] = $editTabTooltip;
667                }
668
669                $editor = self::getLastEditor( $user, $skin->getRequest() );
670                if (
671                    $isAvailable &&
672                    $this->veConfig->get( 'VisualEditorUseSingleEditTab' ) &&
673                    (
674                        $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'prefer-ve' ||
675                        (
676                            $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'remember-last' &&
677                            $editor === 'visualeditor'
678                        )
679                    )
680                ) {
681                    $editTab['text'] = $veTabText;
682                    $newViews['edit'] = $editTab;
683                } elseif (
684                    $isAvailable &&
685                    (
686                        !$this->veConfig->get( 'VisualEditorUseSingleEditTab' ) ||
687                        $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'multi-tab'
688                    )
689                ) {
690                    // Change icon
691                    $editTab['icon'] = $skinHasEditIcons ? 'wikiText' : null;
692                    // Inject the VE tab before or after the edit tab
693                    if ( $this->veConfig->get( 'VisualEditorTabPosition' ) === 'before' ) {
694                        $editTab['class'] .= ' collapsible';
695                        $newViews['ve-edit'] = $veTab;
696                        $newViews['edit'] = $editTab;
697                    } else {
698                        $veTab['class'] .= ' collapsible';
699                        $newViews['edit'] = $editTab;
700                        $newViews['ve-edit'] = $veTab;
701                    }
702                } elseif (
703                    !$this->veConfig->get( 'VisualEditorUseSingleEditTab' ) ||
704                    !$isAvailable ||
705                    $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'multi-tab' ||
706                    (
707                        $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'remember-last' &&
708                        $editor === 'wikitext'
709                    )
710                ) {
711                    // Don't add ve-edit, but do update the edit tab (e.g. "Edit source").
712                    $newViews['edit'] = $editTab;
713                } else {
714                    // This should not happen.
715                }
716            } else {
717                // Just pass through
718                $newViews[$action] = $data;
719            }
720        }
721        $links['views'] = $newViews;
722    }
723
724    /**
725     * Called when the normal wikitext editor is shown.
726     * Inserts a 'veswitched' hidden field if requested by the client
727     *
728     * @param EditPage $editPage The edit page view.
729     * @param OutputPage $output The page view.
730     */
731    public function onEditPage__showEditForm_fields( $editPage, $output ) {
732        $request = $output->getRequest();
733        if ( $request->getBool( 'veswitched' ) ) {
734            $output->addHTML( Html::hidden( 'veswitched', '1' ) );
735        }
736    }
737
738    /**
739     * Called when an edit is saved
740     * Adds 'visualeditor-switched' tag to the edit if requested
741     * Adds whatever tags from static::TAGS are present in the vetags parameter
742     *
743     * @param RecentChange $rc The new RC entry.
744     */
745    public function onRecentChange_save( $rc ) {
746        $request = RequestContext::getMain()->getRequest();
747        if ( $request->getBool( 'veswitched' ) && $rc->getAttribute( 'rc_this_oldid' ) ) {
748            $rc->addTags( 'visualeditor-switched' );
749        }
750
751        $tags = explode( ',', $request->getVal( 'vetags' ) ?? '' );
752        $tags = array_values( array_intersect( $tags, static::TAGS ) );
753        if ( $tags ) {
754            $rc->addTags( $tags );
755        }
756    }
757
758    /**
759     * Changes the section edit links to add a VE edit link.
760     *
761     * This is attached to the MediaWiki 'SkinEditSectionLinks' hook.
762     *
763     * @param Skin $skin Skin being used to render the UI
764     * @param Title $title Title being used for request
765     * @param string $section The name of the section being pointed to.
766     * @param string $tooltip The default tooltip.
767     * @param array &$result All link detail arrays.
768     * @phan-param array{editsection:array{text:string,targetTitle:Title,attribs:array,query:array}} $result
769     * @param Language $lang The user interface language.
770     */
771    public function onSkinEditSectionLinks( $skin, $title, $section,
772        $tooltip, &$result, $lang
773    ) {
774        // Exit if we're in parserTests
775        if ( isset( $GLOBALS[ 'wgVisualEditorInParserTests' ] ) ) {
776            return;
777        }
778
779        if ( $this->mobileFrontendContext?->shouldDisplayMobileView() ) {
780            return;
781        }
782
783        $user = $skin->getUser();
784        // Exit if the user doesn't have VE enabled
785        if (
786            !$this->visualEditorAvailabilityLookup->isEnabledForUser( $user ) ||
787            // T253941: This option does not actually disable the editor, only leaves the tabs/links unchanged
788            ( $this->veConfig->get( 'VisualEditorDisableForAnons' ) && !$user->isRegistered() )
789        ) {
790            return;
791        }
792
793        // Exit if we're on a foreign file description page
794        $services = MediaWikiServices::getInstance();
795        if (
796            $title->inNamespace( NS_FILE ) &&
797            !$services->getWikiPageFactory()->newFromTitle( $title )->isLocal()
798        ) {
799            return;
800        }
801
802        $linkBehaviour = $skin->getParserOptions()?->getOption( 'visibleLinks' ) ?? SectionLinkBehaviour::LINK_BOTH;
803
804        if ( $linkBehaviour === SectionLinkBehaviour::LINK_WT || $linkBehaviour === SectionLinkBehaviour::LINK_BOTH ) {
805            // Don't add ve-edit, but do update the edit tab (e.g. "Edit source").
806            $tabMessages = $this->veConfig->get( 'VisualEditorTabMessages' );
807            // The following messages can be used here:
808            // * visualeditor-ca-editsource-section
809            $sourceEditSection = $tabMessages['editsectionsource'];
810            $result['editsection']['text'] = $skin->msg( $sourceEditSection )->inLanguage( $lang )->text();
811            // The following messages can be used here:
812            // * visualeditor-ca-editsource-section-hint
813            $sourceEditSectionHint = $tabMessages['editsectionsourcehint'];
814            $result['editsection']['attribs']['title'] = $skin->msg( $sourceEditSectionHint )
815                ->plaintextParams( $tooltip )
816                ->inLanguage( $lang )->text();
817        }
818
819        // Exit if we're using the single edit tab.
820        if ( $linkBehaviour !== SectionLinkBehaviour::LINK_BOTH ) {
821            return;
822        }
823
824        $skinHasEditIcons = in_array(
825            $skin->getSkinName(),
826            $this->extensionRegistry->getAttribute( 'VisualEditorIconSkins' )
827        );
828
829        // add VE edit section in VE available namespaces
830        if ( $this->visualEditorAvailabilityLookup->isAvailable( $title, $skin->getRequest(), $user ) ) {
831            // The following messages can be used here:
832            // * editsection
833            $veEditSection = $tabMessages['editsection'];
834            // The following messages can be used here:
835            // * editsectionhint
836            $veEditSectionHint = $tabMessages['editsectionhint'];
837
838            $attribs = $result['editsection']['attribs'];
839            // class goes to SkinComponentLink which will accept a string or
840            // an array, and either might be provided at this point.
841            $class = $attribs['class'] ?? '';
842            if ( is_array( $class ) ) {
843                $class[] = 'mw-editsection-visualeditor';
844            } else {
845                $class .= ' mw-editsection-visualeditor';
846            }
847            $attribs['class'] = $class;
848            $attribs['title'] = $skin->msg( $veEditSectionHint )
849                ->plaintextParams( $tooltip )
850                ->inLanguage( $lang )->text();
851
852            $veLink = [
853                'text' => $skin->msg( $veEditSection )->inLanguage( $lang )->text(),
854                'icon' => $skinHasEditIcons ? 'edit' : null,
855                'targetTitle' => $title,
856                'attribs' => $attribs,
857                'query' => [ 'veaction' => 'edit', 'section' => $section ],
858                'options' => [ 'noclasses', 'known' ]
859            ];
860            // Change icon
861            $result['editsection']['icon'] = $skinHasEditIcons ? 'wikiText' : null;
862
863            $result['veeditsection'] = $veLink;
864            if ( $this->veConfig->get( 'VisualEditorTabPosition' ) === 'before' ) {
865                krsort( $result );
866                // TODO: This will probably cause weird ordering if any other extensions added something
867                // already.
868                // ... wfArrayInsertBefore?
869            }
870        }
871    }
872
873    public function onArticleParserOptions( Article $article, ParserOptions $popts ) {
874        $popts->setOption( 'visibleLinks', SectionLinkBehaviour::LINK_SINGLE );
875
876        $user = $article->getContext()->getUser();
877        $request = $article->getContext()->getRequest();
878
879        $editor = self::getLastEditor( $user, $request );
880        if (
881                $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'remember-last' &&
882                $editor === 'wikitext'
883        ) {
884            $popts->setOption( 'visibleLinks', SectionLinkBehaviour::LINK_WT );
885        }
886
887        if (
888            !$this->veConfig->get( 'VisualEditorUseSingleEditTab' ) ||
889            $this->userOptionsLookup->getOption( $user, 'visualeditor-tabs' ) === 'multi-tab'
890        ) {
891            if ( $this->visualEditorAvailabilityLookup->isAvailable( $article->getTitle(), $request, $user ) ) {
892                $popts->setOption( 'visibleLinks', SectionLinkBehaviour::LINK_BOTH );
893            } else {
894                $popts->setOption( 'visibleLinks', SectionLinkBehaviour::LINK_WT );
895            }
896        }
897    }
898
899    /**
900     * @param OutputPage $out
901     * @param Skin $sk
902     * @param string[] &$bodyAttrs
903     */
904    public function onOutputPageBodyAttributes( $out, $sk, &$bodyAttrs ): void {
905        $specialTitle = $sk->getTitle();
906
907        // HACK: Replace classes generated by Skin::getPageClasses as if an article title
908        // was passed in, instead of a special page.
909        if ( $specialTitle && $specialTitle->isSpecial( 'CollabPad' ) ) {
910            $articleTitle = Title::newFromText( 'DummyPage' );
911
912            $specialClasses = $sk->getPageClasses( $specialTitle );
913            $articleClasses = $sk->getPageClasses( $articleTitle );
914
915            $bodyAttrs['class'] = str_replace( $specialClasses, $articleClasses, $bodyAttrs['class'] );
916        }
917    }
918
919    /**
920     * Handler for the GetPreferences hook, to add and hide user preferences as configured
921     *
922     * @param User $user
923     * @param array &$preferences Their preferences object
924     */
925    public function onGetPreferences( $user, &$preferences ) {
926        $isBeta = $this->veConfig->get( 'VisualEditorEnableBetaFeature' );
927
928        // Use the old preference keys to avoid having to migrate data for now.
929        // (One day we might write and run a maintenance script to update the
930        // entries in the database and make this unnecessary.) (T344762)
931        if ( $isBeta ) {
932            $preferences['visualeditor-enable'] = [
933                'type' => 'toggle',
934                'label-message' => 'visualeditor-preference-visualeditor',
935                'section' => 'editing/editor',
936            ];
937        } else {
938            $preferences['visualeditor-betatempdisable'] = [
939                'invert' => true,
940                'type' => 'toggle',
941                'label-message' => 'visualeditor-preference-visualeditor',
942                'section' => 'editing/editor',
943                'default' => $this->userOptionsLookup->getOption( $user, 'visualeditor-betatempdisable' ) ||
944                    $this->userOptionsLookup->getOption( $user, 'visualeditor-autodisable' )
945            ];
946        }
947
948        if ( $this->veConfig->get( 'VisualEditorEnableWikitext' ) ) {
949            $preferences['visualeditor-newwikitext'] = [
950                'type' => 'toggle',
951                'label-message' => 'visualeditor-preference-newwikitexteditor-enable',
952                'help-message' => 'visualeditor-preference-newwikitexteditor-help',
953                'section' => 'editing/editor'
954            ];
955        }
956
957        // Config option for Single Edit Tab
958        if (
959            $this->veConfig->get( 'VisualEditorUseSingleEditTab' ) &&
960            $this->visualEditorAvailabilityLookup->isEnabledForUser( $user )
961        ) {
962            $preferences['visualeditor-tabs'] = [
963                'type' => 'select',
964                'label-message' => 'visualeditor-preference-tabs',
965                'section' => 'editing/editor',
966                'options-messages' => [
967                    'visualeditor-preference-tabs-remember-last' => 'remember-last',
968                    'visualeditor-preference-tabs-prefer-ve' => 'prefer-ve',
969                    'visualeditor-preference-tabs-prefer-wt' => 'prefer-wt',
970                    'visualeditor-preference-tabs-multi-tab' => 'multi-tab'
971                ]
972            ];
973        }
974
975        $api = [ 'type' => 'api' ];
976        // The "autodisable" preference records whether the user has explicitly opted out of VE.
977        // This is saved even when VE is off by default, which allows changing it to be on by default
978        // without affecting the users who opted out. There's also a maintenance script to silently
979        // opt-out existing users en masse before changing the default, thus only affecting new users.
980        // (This option is no longer set to 'true' anywhere, but we can still encounter old true
981        // values until they are migrated: T344760.)
982        $preferences['visualeditor-autodisable'] = $api;
983        // The diff mode is persisted for each editor mode separately,
984        // e.g. use visual diffs for visual mode only.
985        $preferences['visualeditor-diffmode-source'] = $api;
986        $preferences['visualeditor-diffmode-visual'] = $api;
987        $preferences['visualeditor-diffmode-historical'] = $api;
988        $preferences['visualeditor-editor'] = $api;
989        $preferences['visualeditor-hidebetawelcome'] = $api;
990        $preferences['visualeditor-hidetabdialog'] = $api;
991        $preferences['visualeditor-hidesourceswitchpopup'] = $api;
992        $preferences['visualeditor-hidevisualswitchpopup'] = $api;
993        $preferences['visualeditor-hideusered'] = $api;
994        $preferences['visualeditor-findAndReplace-diacritic'] = $api;
995        $preferences['visualeditor-findAndReplace-findText'] = $api;
996        $preferences['visualeditor-findAndReplace-replaceText'] = $api;
997        $preferences['visualeditor-findAndReplace-regex'] = $api;
998        $preferences['visualeditor-findAndReplace-matchCase'] = $api;
999        $preferences['visualeditor-findAndReplace-word'] = $api;
1000        $preferences['visualeditor-symbolList-recentlyUsed-specialCharacters'] = $api;
1001    }
1002
1003    /**
1004     * Implements the PreferencesFormPreSave hook, to remove the 'autodisable' flag
1005     * when the user it was set on explicitly enables VE.
1006     *
1007     * @param array $data User-submitted data
1008     * @param HTMLForm $form A ContextSource
1009     * @param User $user User with new preferences already set
1010     * @param bool &$result Success or failure
1011     * @param array $oldUserOptions
1012     */
1013    public function onPreferencesFormPreSave( $data, $form, $user, &$result, $oldUserOptions ) {
1014        $services = MediaWikiServices::getInstance();
1015        $userOptionsManager = $services->getUserOptionsManager();
1016        $isBeta = $this->veConfig->get( 'VisualEditorEnableBetaFeature' );
1017
1018        // The "autodisable" preference records whether the user has explicitly opted out of VE
1019        // while it was in beta (which would otherwise not be saved, since it's the same as default).
1020
1021        if (
1022            // When the user enables VE, clear the preference.
1023            $userOptionsManager->getOption( $user, 'visualeditor-autodisable' ) &&
1024            ( $isBeta ?
1025                $userOptionsManager->getOption( $user, 'visualeditor-enable' ) :
1026                !$userOptionsManager->getOption( $user, 'visualeditor-betatempdisable' ) )
1027        ) {
1028            $userOptionsManager->setOption( $user, 'visualeditor-autodisable', false );
1029        }
1030    }
1031
1032    /**
1033     * @param array &$tags
1034     */
1035    public function onChangeTagsListActive( &$tags ) {
1036        $this->onListDefinedTags( $tags );
1037    }
1038
1039    /**
1040     * Implements the ListDefinedTags and ChangeTagsListActive hooks, to
1041     * populate core Special:Tags with the change tags in use by VisualEditor.
1042     *
1043     * @param array &$tags Available change tags.
1044     */
1045    public function onListDefinedTags( &$tags ) {
1046        $tags = array_merge( $tags, static::TAGS );
1047    }
1048
1049    /**
1050     * Adds extra variables to the page config.
1051     *
1052     * @param array &$vars Global variables object
1053     * @param OutputPage $out The page view.
1054     */
1055    public function onMakeGlobalVariablesScript( &$vars, $out ): void {
1056        $pageLanguage = ApiVisualEditor::getPageLanguage( $out->getTitle() );
1057        $converter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
1058            ->getLanguageConverter( $pageLanguage );
1059
1060        $fallbacks = $converter->getVariantFallbacks( $converter->getPreferredVariant() );
1061
1062        $vars['wgVisualEditor'] = [
1063            'pageLanguageCode' => $pageLanguage->getHtmlCode(),
1064            'pageLanguageDir' => $pageLanguage->getDir(),
1065            'pageVariantFallbacks' => $fallbacks,
1066        ];
1067    }
1068
1069    /**
1070     * Adds extra variables to the global config
1071     *
1072     * @param array &$vars Global variables object
1073     * @param string $skin
1074     * @param Config $config
1075     */
1076    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
1077        $coreConfig = RequestContext::getMain()->getConfig();
1078        $services = MediaWikiServices::getInstance();
1079
1080        $availableNamespaces = $this->visualEditorAvailabilityLookup->getAvailableNamespaceIds();
1081        sort( $availableNamespaces );
1082
1083        $availableContentModels = array_filter(
1084            array_merge(
1085                $this->extensionRegistry->getAttribute( 'VisualEditorAvailableContentModels' ),
1086                $this->veConfig->get( 'VisualEditorAvailableContentModels' )
1087            )
1088        );
1089
1090        $namespacesWithSubpages = $coreConfig->get( 'NamespacesWithSubpages' );
1091        // Export as a list of namespaces where subpages are enabled instead of an object
1092        // mapping namespaces to if subpages are enabled or not, so filter out disabled
1093        // namespaces and then just use the keys. See T291729.
1094        $namespacesWithSubpages = array_filter( $namespacesWithSubpages );
1095        $namespacesWithSubpagesEnabled = array_keys( $namespacesWithSubpages );
1096        // $wgNamespacesWithSubpages can include namespaces that don't exist, no need
1097        // to include those in the JavaScript data. See T291727.
1098        // Run this filtering after the filter for subpages being enabled, to reduce
1099        // the number of calls needed to namespace info.
1100        $nsInfo = $services->getNamespaceInfo();
1101        $namespacesWithSubpagesEnabled = array_values( array_filter(
1102            $namespacesWithSubpagesEnabled,
1103            [ $nsInfo, 'exists' ]
1104        ) );
1105
1106        $defaultSortPrefix = $services->getMagicWordFactory()->get( 'defaultsort' )->getSynonym( 0 );
1107        // Sanitize trailing colon. /languages/messages/*.php are not consistent but the
1108        // presence or absence of a trailing colon in the message makes no difference.
1109        $defaultSortPrefix = preg_replace( '/:$/', '', $defaultSortPrefix );
1110
1111        $displayTitlePrefix = $services->getMagicWordFactory()->get( 'displaytitle' )->getSynonym( 0 );
1112        $displayTitlePrefix = preg_replace( '/:$/', '', $displayTitlePrefix );
1113
1114        $vars['wgVisualEditorConfig'] = [
1115            'usePageImages' => $this->extensionRegistry->isLoaded( 'PageImages' ),
1116            'usePageDescriptions' => $this->extensionRegistry->isLoaded( 'WikibaseClient' ),
1117            'namespaces' => $availableNamespaces,
1118            'contentModels' => $availableContentModels,
1119            'pluginModules' => array_merge(
1120                $this->extensionRegistry->getAttribute( 'VisualEditorPluginModules' ),
1121                // @todo deprecate the global setting
1122                $this->veConfig->get( 'VisualEditorPluginModules' )
1123            ),
1124            'thumbLimits' => $coreConfig->get( 'ThumbLimits' ),
1125            'galleryOptions' => $coreConfig->get( 'GalleryOptions' ),
1126            'editCheckReliabilityAvailable' => ApiEditCheckReferenceUrl::isAvailable(),
1127            'namespacesWithSubpages' => $namespacesWithSubpagesEnabled,
1128            'specialBooksources' => urldecode( SpecialPage::getTitleFor( 'Booksources' )->getPrefixedURL() ),
1129            'cirrusSearchLookup' => $this->extensionRegistry->isLoaded( 'CirrusSearch' ),
1130            'defaultSortPrefix' => $defaultSortPrefix,
1131            'displayTitlePrefix' => $displayTitlePrefix,
1132        ];
1133
1134        // VisualEditor config keys, automatically mapped to config vars:
1135        //   VisualEditorAllowExternalLinkPaste -> allowExternalLinkPaste
1136        $veConfigKeys = [
1137            'VisualEditorDisableForAnons',
1138            'VisualEditorEnableBetaFeature',
1139            'VisualEditorPreloadModules',
1140            'VisualEditorTabPosition',
1141            'VisualEditorTabMessages',
1142            'VisualEditorUseSingleEditTab',
1143            'VisualEditorEnableVisualSectionEditing',
1144            'VisualEditorShowBetaWelcome',
1145            'VisualEditorAllowExternalLinkPaste',
1146            'VisualEditorEnableHelpCompletion',
1147            'VisualEditorEnableWikitext',
1148            'VisualEditorRebaserURL',
1149            'VisualEditorFeedbackAPIURL',
1150            'VisualEditorSuggestionFeedbackAPIURL',
1151            'VisualEditorTextMatchFeedbackAPIURL',
1152            'VisualEditorUseChangeTagging',
1153            'VisualEditorEditCheckTagging',
1154            'VisualEditorEditCheck',
1155            'VisualEditorEditCheckABTest',
1156            'VisualEditorEnableEditCheckExperimental',
1157            'VisualEditorEnableEditCheckSuggestionsBeta',
1158            'VisualEditorFeedbackTitle',
1159            'VisualEditorSourceFeedbackTitle',
1160            'VisualEditorSuggestionFeedbackTitle',
1161            'VisualEditorTextMatchFeedbackTitle',
1162            'VisualEditorMobileInsertMenu',
1163        ];
1164
1165        foreach ( $veConfigKeys as $key ) {
1166            $jsKey = lcfirst( preg_replace( '/^VisualEditor/', '', $key ) );
1167            $value = $this->veConfig->get( $key );
1168            if ( $key === 'VisualEditorTabMessages' ) {
1169                $value = array_filter( $value );
1170            }
1171            $vars['wgVisualEditorConfig'][$jsKey] = $value;
1172        }
1173
1174        // This can be removed and the module added in TemplateData's extension.json
1175        // after the feature flag has been removed. T377976.
1176        if ( $this->extensionRegistry->isLoaded( 'TemplateData' )
1177            && $coreConfig->get( 'TemplateDataEnableDiscovery' )
1178        ) {
1179            $vars['wgVisualEditorConfig']['pluginModules'][] = 'ext.templateData.templateDiscovery';
1180        }
1181    }
1182
1183    /**
1184     * Conditionally register the jquery.uls.data and jquery.i18n modules, in case they've already
1185     * been registered by the UniversalLanguageSelector extension or the TemplateData extension.
1186     *
1187     * @param ResourceLoader $resourceLoader Client-side code and assets to be loaded.
1188     */
1189    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
1190        $veResourceTemplate = [
1191            'localBasePath' => dirname( __DIR__ ),
1192            'remoteExtPath' => 'VisualEditor',
1193        ];
1194
1195        // Only register VisualEditor core's local version of jquery.uls.data if it hasn't been
1196        // installed locally already (presumably, by the UniversalLanguageSelector extension).
1197        if ( !$resourceLoader->isModuleRegistered( 'jquery.uls.data' ) ) {
1198            $resourceLoader->register( [
1199                'jquery.uls.data' => $veResourceTemplate + [
1200                    'scripts' => [
1201                        'lib/ve/lib/jquery.uls/src/jquery.uls.data.js',
1202                        'lib/ve/lib/jquery.uls/src/jquery.uls.data.utils.js',
1203                    ],
1204                ] ] );
1205        }
1206    }
1207
1208    /**
1209     * Ensures that we know whether we're running inside a parser test.
1210     *
1211     * @param array &$settings The settings with which MediaWiki is being run.
1212     */
1213    public function onParserTestGlobals( &$settings ) {
1214        $settings['wgVisualEditorInParserTests'] = true;
1215    }
1216
1217    /**
1218     * @param array &$redirectParams Parameters preserved on special page redirects
1219     *   to wiki pages
1220     */
1221    public function onRedirectSpecialArticleRedirectParams( &$redirectParams ) {
1222        $redirectParams[] = 'veaction';
1223    }
1224
1225    /**
1226     * If the user has specified that they want to edit the page with VE, suppress any redirect.
1227     *
1228     * @param Title $title Title being used for request
1229     * @param Article|null $article The page being viewed.
1230     * @param OutputPage $output The page view.
1231     * @param User $user The user-specific settings.
1232     * @param WebRequest $request
1233     * @param ActionEntryPoint $mediaWiki Helper class.
1234     */
1235    public function onBeforeInitialize(
1236        $title, $article, $output, $user, $request, $mediaWiki
1237    ) {
1238        if ( $request->getVal( 'veaction' ) ) {
1239            $request->setVal( 'redirect', 'no' );
1240        }
1241    }
1242
1243    /**
1244     * On login, if user has a VEE cookie, set their preference equal to it.
1245     *
1246     * @param User $user The user-specific settings.
1247     */
1248    public function onUserLoggedIn( $user ) {
1249        $cookie = RequestContext::getMain()->getRequest()->getCookie( 'VEE', '' );
1250        if ( $user->isNamed() && ( $cookie === 'visualeditor' || $cookie === 'wikitext' ) ) {
1251            self::deferredSetUserOption( $user, 'visualeditor-editor', $cookie );
1252        }
1253    }
1254
1255    /**
1256     * @inheritDoc
1257     *
1258     * @param array $postprocOpts array of options that are only for post-processing
1259     */
1260    public function onParserOptionsDefaults( &$defaults, &$inCacheKey, &$lazyLoad, &$postprocOpts = [] ) {
1261        $defaults[ 'visibleLinks'] = SectionLinkBehaviour::LINK_BOTH;
1262        $inCacheKey[ 'visibleLinks' ] = true;
1263        $postprocOpts[] = 'visibleLinks';
1264    }
1265}