Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.32% covered (danger)
44.32%
191 / 431
25.93% covered (danger)
25.93%
7 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookHandler
44.32% covered (danger)
44.32%
191 / 431
25.93% covered (danger)
25.93%
7 / 27
1553.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setupTranslate
57.44% covered (warning)
57.44%
112 / 195
0.00% covered (danger)
0.00%
0 / 1
17.71
 registerHookHandlers
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 onUserGetReservedNames
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onAbuseFilterAlterVariables
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 onAbuseFilterComputeVariable
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 onAbuseFilterBuilder
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setupParserHooks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onPageContentLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 translateMessageDocumentationLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 searchProfile
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
2.01
 searchProfileForm
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 searchProfileSetupEngine
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 preventCategorization
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 showFakeCategories
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 addConfig
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 onAdminLinks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onMergeAccountFromTo
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 onDeleteAccount
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 onAbortEmailNotificationReview
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onTitleIsAlwaysKnown
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 onParserFirstCallInit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateRenderParserFunction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 validateMessage
84.85% covered (warning)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
10.35
 onRevisionRecordInserted
13.04% covered (danger)
13.04%
3 / 23
0.00% covered (danger)
0.00%
0 / 1
21.44
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types=1 );
3
4namespace MediaWiki\Extension\Translate;
5
6use ALItem;
7use ALTree;
8use ApiRawMessage;
9use Config;
10use ConfigException;
11use Content;
12use ExtensionRegistry;
13use IContextSource;
14use Language;
15use LogFormatter;
16use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
17use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
18use MediaWiki\Config\ServiceOptions;
19use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
20use MediaWiki\Extension\Translate\LogFormatter as TranslateLogFormatter;
21use MediaWiki\Extension\Translate\MessageGroupProcessing\DeleteTranslatableBundleJob;
22use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscriptionHookHandler;
23use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscriptionNotificationJob;
24use MediaWiki\Extension\Translate\MessageGroupProcessing\MoveTranslatableBundleJob;
25use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
26use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleLogFormatter;
27use MediaWiki\Extension\Translate\MessageLoading\FatMessage;
28use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
29use MediaWiki\Extension\Translate\PageTranslation\DeleteTranslatableBundleSpecialPage;
30use MediaWiki\Extension\Translate\PageTranslation\Hooks;
31use MediaWiki\Extension\Translate\PageTranslation\MigrateTranslatablePageSpecialPage;
32use MediaWiki\Extension\Translate\PageTranslation\PageTranslationSpecialPage;
33use MediaWiki\Extension\Translate\PageTranslation\PrepareTranslatablePageSpecialPage;
34use MediaWiki\Extension\Translate\PageTranslation\RenderTranslationPageJob;
35use MediaWiki\Extension\Translate\PageTranslation\UpdateTranslatablePageJob;
36use MediaWiki\Extension\Translate\Statistics\RebuildMessageGroupStatsJob;
37use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
38use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager;
39use MediaWiki\Extension\Translate\TranslatorInterface\TranslateEditAddons;
40use MediaWiki\Extension\Translate\TranslatorSandbox\ManageTranslatorSandboxSpecialPage;
41use MediaWiki\Extension\Translate\TranslatorSandbox\TranslateSandboxEmailJob;
42use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashActionApi;
43use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashSpecialPage;
44use MediaWiki\Extension\Translate\TranslatorSandbox\TranslatorSandboxActionApi;
45use MediaWiki\Extension\Translate\TtmServer\SearchableTtmServer;
46use MediaWiki\Extension\Translate\Utilities\Utilities;
47use MediaWiki\Hook\ParserFirstCallInitHook;
48use MediaWiki\Html\Html;
49use MediaWiki\Languages\LanguageNameUtils;
50use MediaWiki\MediaWikiServices;
51use MediaWiki\Revision\Hook\RevisionRecordInsertedHook;
52use MediaWiki\Revision\RevisionLookup;
53use MediaWiki\Settings\SettingsBuilder;
54use MediaWiki\StubObject\StubUserLang;
55use MediaWiki\Title\Title;
56use MediaWiki\User\Hook\UserGetReservedNamesHook;
57use OutputPage;
58use Parser;
59use ParserOutput;
60use RecentChange;
61use SearchEngine;
62use SpecialPage;
63use SpecialSearch;
64use Status;
65use TextContent;
66use TitleValue;
67use User;
68use Wikimedia\Rdbms\ILoadBalancer;
69use Xml;
70use XmlSelect;
71
72/**
73 * Hooks for Translate extension.
74 * Contains class with basic non-feature specific hooks.
75 * Main subsystems, like page translation, should have their own hook handler. *
76 * Most of the hooks on this class are still old style static functions, but new new hooks should
77 * use the new style hook handlers with interfaces.
78 *
79 * @author Niklas Laxström
80 * @license GPL-2.0-or-later
81 */
82class HookHandler implements
83    ChangeTagsListActiveHook,
84    ListDefinedTagsHook,
85    ParserFirstCallInitHook,
86    RevisionRecordInsertedHook,
87    UserGetReservedNamesHook
88{
89    /**
90     * Any user of this list should make sure that the tables
91     * actually exist, since they may be optional
92     */
93    private const USER_MERGE_TABLES = [
94        'translate_stash' => 'ts_user',
95        'translate_reviews' => 'trr_user',
96    ];
97    private RevisionLookup $revisionLookup;
98    private ILoadBalancer $loadBalancer;
99    private Config $config;
100    private LanguageNameUtils $languageNameUtils;
101
102    public function __construct(
103        RevisionLookup $revisionLookup,
104        ILoadBalancer $loadBalancer,
105        Config $config,
106        LanguageNameUtils $languageNameUtils
107    ) {
108        $this->revisionLookup = $revisionLookup;
109        $this->loadBalancer = $loadBalancer;
110        $this->config = $config;
111        $this->languageNameUtils = $languageNameUtils;
112    }
113
114    /** Do late setup that depends on configuration. */
115    public static function setupTranslate(): void {
116        global $wgTranslateYamlLibrary;
117        $hooks = [];
118
119        /*
120         * Text that will be shown in translations if the translation is outdated.
121         * Must be something that does not conflict with actual content.
122         */
123        if ( !defined( 'TRANSLATE_FUZZY' ) ) {
124            define( 'TRANSLATE_FUZZY', '!!FUZZY!!' );
125        }
126
127        if ( $wgTranslateYamlLibrary === null ) {
128            $wgTranslateYamlLibrary = function_exists( 'yaml_parse' ) ? 'phpyaml' : 'spyc';
129        }
130
131        $hooks['PageSaveComplete'][] = [ TranslateEditAddons::class, 'onSaveComplete' ];
132
133        // TODO Remove after MLEB 2024.01 release
134        global $wgJobClasses;
135        $wgJobClasses['MessageGroupStatsRebuildJob'] = RebuildMessageGroupStatsJob::class;
136
137        // Page translation setup check and init if enabled.
138        global $wgEnablePageTranslation;
139        if ( $wgEnablePageTranslation ) {
140            // Special page and the right to use it
141            global $wgSpecialPages, $wgAvailableRights;
142            $wgSpecialPages['PageTranslation'] = [
143                'class' => PageTranslationSpecialPage::class,
144                'services' => [
145                    'LanguageFactory',
146                    'LinkBatchFactory',
147                    'JobQueueGroup',
148                    'Translate:TranslatablePageMarker',
149                    'Translate:TranslatablePageParser',
150                    'Translate:MessageGroupMetadata'
151                ]
152            ];
153            $wgSpecialPages['PageTranslationDeletePage'] = [
154                'class' => DeleteTranslatableBundleSpecialPage::class,
155                'services' => [
156                    'PermissionManager',
157                    'Translate:TranslatableBundleDeleter',
158                    'Translate:TranslatableBundleFactory',
159                ]
160            ];
161
162            // right-pagetranslation action-pagetranslation
163            $wgAvailableRights[] = 'pagetranslation';
164
165            $wgSpecialPages['PageMigration'] = MigrateTranslatablePageSpecialPage::class;
166            $wgSpecialPages['PagePreparation'] = PrepareTranslatablePageSpecialPage::class;
167
168            global $wgActionFilteredLogs, $wgLogActionsHandlers, $wgLogTypes;
169
170            // log-description-pagetranslation log-name-pagetranslation logentry-pagetranslation-mark
171            // logentry-pagetranslation-unmark logentry-pagetranslation-moveok
172            // logentry-pagetranslation-movenok logentry-pagetranslation-deletefok
173            // logentry-pagetranslation-deletefnok logentry-pagetranslation-deletelok
174            // logentry-pagetranslation-deletelnok logentry-pagetranslation-encourage
175            // logentry-pagetranslation-discourage logentry-pagetranslation-prioritylanguages
176            // logentry-pagetranslation-associate logentry-pagetranslation-dissociate
177            $wgLogTypes[] = 'pagetranslation';
178            $wgLogActionsHandlers['pagetranslation/mark'] = TranslatableBundleLogFormatter::class;
179            $wgLogActionsHandlers['pagetranslation/unmark'] = TranslatableBundleLogFormatter::class;
180            $wgLogActionsHandlers['pagetranslation/moveok'] = TranslatableBundleLogFormatter::class;
181            $wgLogActionsHandlers['pagetranslation/movenok'] = TranslatableBundleLogFormatter::class;
182            $wgLogActionsHandlers['pagetranslation/deletelok'] = TranslatableBundleLogFormatter::class;
183            $wgLogActionsHandlers['pagetranslation/deletefok'] = TranslatableBundleLogFormatter::class;
184            $wgLogActionsHandlers['pagetranslation/deletelnok'] = TranslatableBundleLogFormatter::class;
185            $wgLogActionsHandlers['pagetranslation/deletefnok'] = TranslatableBundleLogFormatter::class;
186            $wgLogActionsHandlers['pagetranslation/encourage'] = TranslatableBundleLogFormatter::class;
187            $wgLogActionsHandlers['pagetranslation/discourage'] = TranslatableBundleLogFormatter::class;
188            $wgLogActionsHandlers['pagetranslation/prioritylanguages'] = TranslatableBundleLogFormatter::class;
189            $wgLogActionsHandlers['pagetranslation/associate'] = TranslatableBundleLogFormatter::class;
190            $wgLogActionsHandlers['pagetranslation/dissociate'] = TranslatableBundleLogFormatter::class;
191            $wgActionFilteredLogs['pagetranslation'] = [
192                'mark' => [ 'mark' ],
193                'unmark' => [ 'unmark' ],
194                'move' => [ 'moveok', 'movenok' ],
195                'delete' => [ 'deletefok', 'deletefnok', 'deletelok', 'deletelnok' ],
196                'encourage' => [ 'encourage' ],
197                'discourage' => [ 'discourage' ],
198                'prioritylanguages' => [ 'prioritylanguages' ],
199                'aggregategroups' => [ 'associate', 'dissociate' ],
200            ];
201
202            $wgLogTypes[] = 'messagebundle';
203            $wgLogActionsHandlers['messagebundle/moveok'] = TranslatableBundleLogFormatter::class;
204            $wgLogActionsHandlers['messagebundle/movenok'] = TranslatableBundleLogFormatter::class;
205            $wgLogActionsHandlers['messagebundle/deletefok'] = TranslatableBundleLogFormatter::class;
206            $wgLogActionsHandlers['messagebundle/deletefnok'] = TranslatableBundleLogFormatter::class;
207            $wgActionFilteredLogs['messagebundle'] = [
208                'move' => [ 'moveok', 'movenok' ],
209                'delete' => [ 'deletefok', 'deletefnok' ],
210            ];
211
212            $wgLogActionsHandlers['import/translatable-bundle'] = TranslatableBundleLogFormatter::class;
213
214            $wgJobClasses['RenderTranslationPageJob'] = RenderTranslationPageJob::class;
215            $wgJobClasses['NonPrioritizedRenderTranslationPageJob'] = RenderTranslationPageJob::class;
216            $wgJobClasses['MoveTranslatableBundleJob'] = MoveTranslatableBundleJob::class;
217            $wgJobClasses['DeleteTranslatableBundleJob'] = DeleteTranslatableBundleJob::class;
218            $wgJobClasses['UpdateTranslatablePageJob'] = UpdateTranslatablePageJob::class;
219
220            // Namespaces
221            global $wgNamespacesWithSubpages, $wgNamespaceProtection;
222            global $wgTranslateMessageNamespaces;
223
224            $wgNamespacesWithSubpages[NS_TRANSLATIONS] = true;
225            $wgNamespacesWithSubpages[NS_TRANSLATIONS_TALK] = true;
226
227            // Standard protection and register it for filtering
228            $wgNamespaceProtection[NS_TRANSLATIONS] = [ 'translate' ];
229            $wgTranslateMessageNamespaces[] = NS_TRANSLATIONS;
230
231            /// Page translation hooks
232
233            /// Register our CSS and metadata
234            $hooks['BeforePageDisplay'][] = [ Hooks::class, 'onBeforePageDisplay' ];
235
236            // Disable VE
237            $hooks['VisualEditorBeforeEditor'][] = [ Hooks::class, 'onVisualEditorBeforeEditor' ];
238
239            // Check syntax for \<translate>
240            $hooks['MultiContentSave'][] = [ Hooks::class, 'tpSyntaxCheck' ];
241            $hooks['EditFilterMergedContent'][] =
242                [ Hooks::class, 'tpSyntaxCheckForEditContent' ];
243
244            // Add transtag to page props for discovery
245            $hooks['PageSaveComplete'][] = [ Hooks::class, 'addTranstagAfterSave' ];
246
247            $hooks['RevisionRecordInserted'][] = [ Hooks::class, 'updateTranstagOnNullRevisions' ];
248
249            // Register different ways to show language links
250            $hooks['ParserFirstCallInit'][] = [ self::class, 'setupParserHooks' ];
251            $hooks['LanguageLinks'][] = [ Hooks::class, 'addLanguageLinks' ];
252            $hooks['SkinTemplateGetLanguageLink'][] = [ Hooks::class, 'formatLanguageLink' ];
253
254            // Allow templates to query whether they are transcluded in a translatable/translated page
255            $hooks['GetMagicVariableIDs'][] = [ Hooks::class, 'onGetMagicVariableIDs' ];
256            $hooks['ParserGetVariableValueSwitch'][] = [ Hooks::class, 'onParserGetVariableValueSwitch' ];
257
258            // Strip \<translate> tags etc. from source pages when rendering
259            $hooks['ParserBeforeInternalParse'][] = [ Hooks::class, 'renderTagPage' ];
260            // Strip \<translate> tags etc. from source pages when preprocessing
261            $hooks['ParserBeforePreprocess'][] = [ Hooks::class, 'preprocessTagPage' ];
262            $hooks['ParserOutputPostCacheTransform'][] =
263                [ Hooks::class, 'onParserOutputPostCacheTransform' ];
264
265            $hooks['BeforeParserFetchTemplateRevisionRecord'][] =
266                [ Hooks::class, 'fetchTranslatableTemplateAndTitle' ];
267
268            // Set the page content language
269            $hooks['PageContentLanguage'][] = [ Hooks::class, 'onPageContentLanguage' ];
270
271            // Prevent editing of certain pages in translations namespace
272            $hooks['getUserPermissionsErrorsExpensive'][] =
273                [ Hooks::class, 'onGetUserPermissionsErrorsExpensive' ];
274            // Prevent editing of translation pages directly
275            $hooks['getUserPermissionsErrorsExpensive'][] =
276                [ Hooks::class, 'preventDirectEditing' ];
277
278            // Our custom header for translation pages
279            $hooks['ArticleViewHeader'][] = [ Hooks::class, 'translatablePageHeader' ];
280
281            // Edit notice shown on translatable pages
282            $hooks['TitleGetEditNotices'][] = [ Hooks::class, 'onTitleGetEditNotices' ];
283
284            // Custom move page that can move all the associated pages too
285            $hooks['SpecialPage_initList'][] = [ Hooks::class, 'replaceMovePage' ];
286            // Locking during page moves
287            $hooks['getUserPermissionsErrorsExpensive'][] =
288                [ Hooks::class, 'lockedPagesCheck' ];
289            // Disable action=delete
290            $hooks['ArticleConfirmDelete'][] = [ Hooks::class, 'disableDelete' ];
291
292            // Replace subpage logic behavior
293            $hooks['SkinSubPageSubtitle'][] = [ Hooks::class, 'replaceSubtitle' ];
294
295            // Replaced edit tab with translation tab for translation pages
296            $hooks['SkinTemplateNavigation::Universal'][] = [ Hooks::class, 'translateTab' ];
297
298            // Update translated page when translation unit is moved
299            $hooks['PageMoveComplete'][] = [ Hooks::class, 'onMovePageTranslationUnits' ];
300
301            // Update translated page when translation unit is deleted
302            $hooks['ArticleDeleteComplete'][] = [ Hooks::class, 'onDeleteTranslationUnit' ];
303
304            // Prevent editing of translation pages.
305            $hooks['ReplaceTextFilterPageTitlesForEdit'][] = [ Hooks::class, 'onReplaceTextFilterPageTitlesForEdit' ];
306            // Prevent renaming of translatable pages and their translation and translation units
307            $hooks['ReplaceTextFilterPageTitlesForRename'][] =
308                [ Hooks::class, 'onReplaceTextFilterPageTitlesForRename' ];
309        }
310
311        global $wgTranslateUseSandbox;
312        if ( $wgTranslateUseSandbox ) {
313            global $wgSpecialPages, $wgAvailableRights, $wgDefaultUserOptions;
314
315            $wgSpecialPages['ManageTranslatorSandbox'] = [
316                'class' => ManageTranslatorSandboxSpecialPage::class,
317                'services' => [
318                    'Translate:TranslationStashReader',
319                    'UserOptionsLookup',
320                    'Translate:TranslateSandbox',
321                ],
322                'args' => [
323                    static function () {
324                        return new ServiceOptions(
325                            ManageTranslatorSandboxSpecialPage::CONSTRUCTOR_OPTIONS,
326                            MediaWikiServices::getInstance()->getMainConfig()
327                        );
328                    }
329                ]
330            ];
331            $wgSpecialPages['TranslationStash'] = [
332                'class' => TranslationStashSpecialPage::class,
333                'services' => [
334                    'LanguageNameUtils',
335                    'Translate:TranslationStashReader',
336                    'UserOptionsLookup',
337                    'LanguageFactory',
338                ],
339                'args' => [
340                    static function () {
341                        return new ServiceOptions(
342                            TranslationStashSpecialPage::CONSTRUCTOR_OPTIONS,
343                            MediaWikiServices::getInstance()->getMainConfig()
344                        );
345                    }
346                ]
347            ];
348            $wgDefaultUserOptions['translate-sandbox'] = '';
349            // right-translate-sandboxmanage action-translate-sandboxmanage
350            $wgAvailableRights[] = 'translate-sandboxmanage';
351
352            global $wgLogTypes, $wgLogActionsHandlers;
353            // log-name-translatorsandbox log-description-translatorsandbox
354            $wgLogTypes[] = 'translatorsandbox';
355            // logentry-translatorsandbox-promoted logentry-translatorsandbox-rejected
356            $wgLogActionsHandlers['translatorsandbox/promoted'] = TranslateLogFormatter::class;
357            $wgLogActionsHandlers['translatorsandbox/rejected'] = TranslateLogFormatter::class;
358
359            // This is no longer used for new entries since 2016.07.
360            // logentry-newusers-tsbpromoted
361            $wgLogActionsHandlers['newusers/tsbpromoted'] = LogFormatter::class;
362
363            global $wgJobClasses;
364            $wgJobClasses['TranslateSandboxEmailJob'] = TranslateSandboxEmailJob::class;
365
366            global $wgAPIModules;
367            $wgAPIModules['translationstash'] = [
368                'class' => TranslationStashActionApi::class,
369                'services' => [
370                    'DBLoadBalancer',
371                    'UserFactory'
372                ]
373            ];
374            $wgAPIModules['translatesandbox'] = [
375                'class' => TranslatorSandboxActionApi::class,
376                'services' => [
377                    'UserFactory',
378                    'UserNameUtils',
379                    'UserOptionsManager',
380                    'WikiPageFactory',
381                    'UserOptionsLookup',
382                    'Translate:TranslateSandbox',
383                ],
384                'args' => [
385                    static function () {
386                        return new ServiceOptions(
387                            TranslatorSandboxActionApi::CONSTRUCTOR_OPTIONS,
388                            MediaWikiServices::getInstance()->getMainConfig()
389                        );
390                    }
391                ]
392            ];
393        }
394
395        global $wgNamespaceRobotPolicies;
396        $wgNamespaceRobotPolicies[NS_TRANSLATIONS] = 'noindex';
397
398        // If no service has been configured, we use a built-in fallback.
399        global $wgTranslateTranslationDefaultService,
400               $wgTranslateTranslationServices;
401        if ( $wgTranslateTranslationDefaultService === true ) {
402            $wgTranslateTranslationDefaultService = 'TTMServer';
403            if ( !isset( $wgTranslateTranslationServices['TTMServer'] ) ) {
404                $wgTranslateTranslationServices['TTMServer'] = [
405                    'database' => false,
406                    'cutoff' => 0.75,
407                    'type' => 'ttmserver',
408                    'public' => false,
409                ];
410            }
411        }
412
413        global $wgTranslateEnableMessageGroupSubscription;
414        if ( $wgTranslateEnableMessageGroupSubscription ) {
415            if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
416                throw new ConfigException(
417                    'Translate: Message group subscriptions (TranslateEnableMessageGroupSubscription) are ' .
418                    'enabled but Echo extension is not installed'
419                );
420            }
421            MessageGroupSubscriptionHookHandler::registerHooks( $hooks );
422            $wgJobClasses['MessageGroupSubscriptionNotificationJob'] = MessageGroupSubscriptionNotificationJob::class;
423        }
424
425        static::registerHookHandlers( $hooks );
426    }
427
428    private static function registerHookHandlers( array $hooks ): void {
429        if ( defined( 'MW_PHPUNIT_TEST' ) && MediaWikiServices::hasInstance() ) {
430            // When called from a test case's setUp() method,
431            // we can use HookContainer, but we cannot use SettingsBuilder.
432            $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
433            foreach ( $hooks as $name => $handlers ) {
434                foreach ( $handlers as $h ) {
435                    $hookContainer->register( $name, $h );
436                }
437            }
438        } else {
439            $settingsBuilder = SettingsBuilder::getInstance();
440            $settingsBuilder->registerHookHandlers( $hooks );
441        }
442    }
443
444    /**
445     * Prevents anyone from registering or logging in as FuzzyBot
446     * @inheritDoc
447     */
448    public function onUserGetReservedNames( &$reservedUsernames ): void {
449        $reservedUsernames[] = FuzzyBot::getName();
450        $reservedUsernames[] = TranslateUserManager::getName();
451    }
452
453    /** Used for setting an AbuseFilter variable. */
454    public static function onAbuseFilterAlterVariables(
455        VariableHolder &$vars, Title $title, User $user
456    ): void {
457        $handle = new MessageHandle( $title );
458
459        // Only set this variable if we are in a proper namespace to avoid
460        // unnecessary overhead in non-translation pages
461        if ( $handle->isMessageNamespace() ) {
462            $vars->setLazyLoadVar(
463                'translate_source_text',
464                'translate-get-source',
465                [ 'handle' => $handle ]
466            );
467            $vars->setLazyLoadVar(
468                'translate_target_language',
469                'translate-get-target-language',
470                [ 'handle' => $handle ]
471            );
472        }
473    }
474
475    /** Computes the translate_source_text and translate_target_language AbuseFilter variables */
476    public static function onAbuseFilterComputeVariable(
477        string $method,
478        VariableHolder $vars,
479        array $parameters,
480        ?string &$result
481    ): bool {
482        if ( $method !== 'translate-get-source' && $method !== 'translate-get-target-language' ) {
483            return true;
484        }
485
486        $handle = $parameters['handle'];
487        $value = '';
488        if ( $handle->isValid() ) {
489            if ( $method === 'translate-get-source' ) {
490                $group = $handle->getGroup();
491                $value = $group->getMessage( $handle->getKey(), $group->getSourceLanguage() );
492            } else {
493                $value = $handle->getCode();
494            }
495        }
496
497        $result = $value;
498
499        return false;
500    }
501
502    /** Register AbuseFilter variables provided by Translate. */
503    public static function onAbuseFilterBuilder( array &$builderValues ): void {
504        // The following messages are generated here:
505        // * abusefilter-edit-builder-vars-translate-source-text
506        // * abusefilter-edit-builder-vars-translate-target-language
507        $builderValues['vars']['translate_source_text'] = 'translate-source-text';
508        $builderValues['vars']['translate_target_language'] = 'translate-target-language';
509    }
510
511    /**
512     * Hook: ParserFirstCallInit
513     * Registers \<languages> tag with the parser.
514     */
515    public static function setupParserHooks( Parser $parser ): void {
516        // For nice language list in-page
517        $parser->setHook( 'languages', [ Hooks::class, 'languages' ] );
518    }
519
520    /**
521     * Hook: PageContentLanguage
522     * Set the correct page content language for translation units.
523     * @param Title $title
524     * @param Language|StubUserLang|string &$pageLang
525     */
526    public static function onPageContentLanguage( Title $title, &$pageLang ): void {
527        $handle = new MessageHandle( $title );
528        if ( $handle->isMessageNamespace() ) {
529            $pageLang = $handle->getEffectiveLanguage();
530        }
531    }
532
533    /**
534     * Hook: LanguageGetTranslatedLanguageNames
535     * Hook: TranslateSupportedLanguages
536     */
537    public static function translateMessageDocumentationLanguage( array &$names, ?string $code ): void {
538        global $wgTranslateDocumentationLanguageCode;
539        if ( $wgTranslateDocumentationLanguageCode ) {
540            // Special case the autonyms
541            if (
542                $wgTranslateDocumentationLanguageCode === $code ||
543                $code === null
544            ) {
545                $code = 'en';
546            }
547
548            $names[$wgTranslateDocumentationLanguageCode] =
549                wfMessage( 'translate-documentation-language' )->inLanguage( $code )->plain();
550        }
551    }
552
553    /** Hook: SpecialSearchProfiles */
554    public static function searchProfile( array &$profiles ): void {
555        global $wgTranslateMessageNamespaces;
556        $insert = [];
557        $insert['translation'] = [
558            'message' => 'translate-searchprofile',
559            'tooltip' => 'translate-searchprofile-tooltip',
560            'namespaces' => $wgTranslateMessageNamespaces,
561        ];
562
563        // Insert translations before 'all'
564        $index = array_search( 'all', array_keys( $profiles ) );
565
566        // Or just at the end if all is not found
567        if ( $index === false ) {
568            wfWarn( '"all" not found in search profiles' );
569            $index = count( $profiles );
570        }
571
572        $profiles = array_merge(
573            array_slice( $profiles, 0, $index ),
574            $insert,
575            array_slice( $profiles, $index )
576        );
577    }
578
579    /** Hook: SpecialSearchProfileForm */
580    public static function searchProfileForm(
581        SpecialSearch $search,
582        string &$form,
583        string $profile,
584        string $term,
585        array $opts
586    ): bool {
587        if ( $profile !== 'translation' ) {
588            return true;
589        }
590
591        if ( Services::getInstance()->getTtmServerFactory()->getDefaultForQuerying() instanceof SearchableTtmServer ) {
592            $href = SpecialPage::getTitleFor( 'SearchTranslations' )
593                ->getFullUrl( [ 'query' => $term ] );
594            $form = Html::successBox(
595                $search->msg( 'translate-searchprofile-note', $href )->parse(),
596                'plainlinks'
597            );
598
599            return false;
600        }
601
602        if ( !$search->getSearchEngine()->supports( 'title-suffix-filter' ) ) {
603            return false;
604        }
605
606        $hidden = '';
607        foreach ( $opts as $key => $value ) {
608            $hidden .= Html::hidden( $key, $value );
609        }
610
611        $context = $search->getContext();
612        $code = $context->getLanguage()->getCode();
613        $selected = $context->getRequest()->getVal( 'languagefilter' );
614
615        $languages = Utilities::getLanguageNames( $code );
616        ksort( $languages );
617
618        $selector = new XmlSelect( 'languagefilter', 'languagefilter' );
619        $selector->setDefault( $selected );
620        $selector->addOption( wfMessage( 'translate-search-nofilter' )->text(), '-' );
621        foreach ( $languages as $code => $name ) {
622            $selector->addOption( "$code - $name", $code );
623        }
624
625        $selector = $selector->getHTML();
626
627        $label = Xml::label(
628            wfMessage( 'translate-search-languagefilter' )->text(),
629            'languagefilter'
630        ) . "\u{00A0}";
631        $params = [ 'id' => 'mw-searchoptions' ];
632
633        $form = Xml::fieldset( false, false, $params ) .
634            $hidden . $label . $selector .
635            Html::closeElement( 'fieldset' );
636
637        return false;
638    }
639
640    /** Hook: SpecialSearchSetupEngine */
641    public static function searchProfileSetupEngine(
642        SpecialSearch $search,
643        string $profile,
644        SearchEngine $engine
645    ): void {
646        if ( $profile !== 'translation' ) {
647            return;
648        }
649
650        $context = $search->getContext();
651        $selected = $context->getRequest()->getVal( 'languagefilter' );
652        if ( $selected !== '-' && $selected ) {
653            $engine->setFeatureData( 'title-suffix-filter', "/$selected" );
654            $search->setExtraParam( 'languagefilter', $selected );
655        }
656    }
657
658    /** Hook: ParserAfterTidy */
659    public static function preventCategorization( Parser $parser, string &$html ): void {
660        $pageReference = $parser->getPage();
661        if ( !$pageReference ) {
662            return;
663        }
664
665        $linkTarget = TitleValue::newFromPage( $pageReference );
666        $handle = new MessageHandle( $linkTarget );
667        if ( $handle->isMessageNamespace() && !$handle->isDoc() ) {
668            $parserOutput = $parser->getOutput();
669            $names = $parserOutput->getCategoryNames();
670            $parserCategories = [];
671            foreach ( $names as $name ) {
672                $parserCategories[$name] = $parserOutput->getCategorySortKey( $name );
673            }
674            $parserOutput->setExtensionData( 'translate-fake-categories', $parserCategories );
675            $parserOutput->setCategories( [] );
676        }
677    }
678
679    /** Hook: OutputPageParserOutput */
680    public static function showFakeCategories( OutputPage $outputPage, ParserOutput $parserOutput ): void {
681        $fakeCategories = $parserOutput->getExtensionData( 'translate-fake-categories' );
682        if ( $fakeCategories ) {
683            $outputPage->setCategoryLinks( $fakeCategories );
684        }
685    }
686
687    /**
688     * Hook: MakeGlobalVariablesScript
689     * Adds $wgTranslateDocumentationLanguageCode to ResourceLoader configuration
690     * when Special:Translate is shown.
691     */
692    public static function addConfig( array &$vars, OutputPage $out ): void {
693        global $wgTranslateDocumentationLanguageCode,
694            $wgTranslatePermissionUrl,
695            $wgTranslateUseSandbox;
696
697        $title = $out->getTitle();
698        if ( $title->isSpecial( 'Translate' ) ||
699            $title->isSpecial( 'TranslationStash' ) ||
700            $title->isSpecial( 'SearchTranslations' )
701        ) {
702            $user = $out->getUser();
703            $vars['TranslateRight'] = $user->isAllowed( 'translate' );
704            $vars['TranslateMessageReviewRight'] = $user->isAllowed( 'translate-messagereview' );
705            $vars['DeleteRight'] = $user->isAllowed( 'delete' );
706            $vars['TranslateManageRight'] = $user->isAllowed( 'translate-manage' );
707            $vars['wgTranslateDocumentationLanguageCode'] = $wgTranslateDocumentationLanguageCode;
708            $vars['wgTranslatePermissionUrl'] = $wgTranslatePermissionUrl;
709            $vars['wgTranslateUseSandbox'] = $wgTranslateUseSandbox;
710        }
711    }
712
713    /** Hook: AdminLinks */
714    public static function onAdminLinks( ALTree $tree ): void {
715        global $wgTranslateUseSandbox;
716
717        if ( $wgTranslateUseSandbox ) {
718            $sectionLabel = wfMessage( 'adminlinks_users' )->text();
719            $row = $tree->getSection( $sectionLabel )->getRow( 'main' );
720            $row->addItem( ALItem::newFromSpecialPage( 'TranslateSandbox' ) );
721        }
722    }
723
724    /**
725     * Hook: MergeAccountFromTo
726     * For UserMerge extension.
727     */
728    public static function onMergeAccountFromTo( User $oldUser, User $newUser ): void {
729        $dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
730
731        // Update the non-duplicate rows, we'll just delete
732        // the duplicate ones later
733        foreach ( self::USER_MERGE_TABLES as $table => $field ) {
734            if ( $dbw->tableExists( $table, __METHOD__ ) ) {
735                $dbw->update(
736                    $table,
737                    [ $field => $newUser->getId() ],
738                    [ $field => $oldUser->getId() ],
739                    __METHOD__,
740                    [ 'IGNORE' ]
741                );
742            }
743        }
744    }
745
746    /**
747     * Hook: DeleteAccount
748     * For UserMerge extension.
749     */
750    public static function onDeleteAccount( User $oldUser ): void {
751        $dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
752
753        // Delete any remaining rows that didn't get merged
754        foreach ( self::USER_MERGE_TABLES as $table => $field ) {
755            if ( $dbw->tableExists( $table, __METHOD__ ) ) {
756                $dbw->delete(
757                    $table,
758                    [ $field => $oldUser->getId() ],
759                    __METHOD__
760                );
761            }
762        }
763    }
764
765    /** Hook: AbortEmailNotification */
766    public static function onAbortEmailNotificationReview(
767        User $editor,
768        Title $title,
769        RecentChange $rc
770    ): bool {
771        return $rc->getAttribute( 'rc_log_type' ) !== 'translationreview';
772    }
773
774    /**
775     * Hook: TitleIsAlwaysKnown
776     * Make Special:MyLanguage links red if the target page doesn't exist.
777     * A bit hacky because the core code is not so flexible.
778     * @param Title $target Title object that is being checked
779     * @param bool|null &$isKnown Whether MediaWiki currently thinks this page is known
780     * @return bool True or no return value to continue or false to abort
781     */
782    public static function onTitleIsAlwaysKnown( $target, &$isKnown ): bool {
783        if ( !$target->inNamespace( NS_SPECIAL ) ) {
784            return true;
785        }
786
787        [ $name, $subpage ] = MediaWikiServices::getInstance()
788            ->getSpecialPageFactory()->resolveAlias( $target->getDBkey() );
789        if ( $name !== 'MyLanguage' || (string)$subpage === '' ) {
790            return true;
791        }
792
793        $realTarget = Title::newFromText( $subpage );
794        if ( !$realTarget || !$realTarget->exists() ) {
795            $isKnown = false;
796
797            return false;
798        }
799
800        return true;
801    }
802
803    public function onParserFirstCallInit( $parser ) {
804        $parser->setFunctionHook( 'translation', [ $this, 'translateRenderParserFunction' ] );
805    }
806
807    public function translateRenderParserFunction( Parser $parser ): string {
808        $pageReference = $parser->getPage();
809        if ( !$pageReference ) {
810            return '';
811        }
812        $linkTarget = TitleValue::newFromPage( $pageReference );
813        $handle = new MessageHandle( $linkTarget );
814        $code = $handle->getCode();
815        if ( $this->languageNameUtils->isKnownLanguageTag( $code ) ) {
816            return '/' . $code;
817        }
818        return '';
819    }
820
821    /**
822     * Runs the configured validator to ensure that the message meets the required criteria.
823     * Hook: EditFilterMergedContent
824     * @return bool true if message is valid, false otherwise.
825     */
826    public static function validateMessage(
827        IContextSource $context,
828        Content $content,
829        Status $status,
830        string $summary,
831        User $user
832    ): bool {
833        if ( !$content instanceof TextContent ) {
834            // Not interested
835            return true;
836        }
837
838        $text = $content->getText();
839        $title = $context->getTitle();
840        $handle = new MessageHandle( $title );
841
842        if ( !$handle->isValid() ) {
843            return true;
844        }
845
846        // Don't bother validating if FuzzyBot or translation admin are saving.
847        if ( $user->isAllowed( 'translate-manage' ) || $user->equals( FuzzyBot::getUser() ) ) {
848            return true;
849        }
850
851        // Check the namespace, and perform validations for all messages excluding documentation.
852        if ( $handle->isMessageNamespace() && !$handle->isDoc() ) {
853            $group = $handle->getGroup();
854
855            if ( method_exists( $group, 'getMessageContent' ) ) {
856                // @phan-suppress-next-line PhanUndeclaredMethod
857                $definition = $group->getMessageContent( $handle );
858            } else {
859                $definition = $group->getMessage( $handle->getKey(), $group->getSourceLanguage() );
860            }
861
862            $message = new FatMessage( $handle->getKey(), $definition );
863            $message->setTranslation( $text );
864
865            $messageValidator = $group->getValidator();
866            if ( !$messageValidator ) {
867                return true;
868            }
869
870            $validationResponse = $messageValidator->validateMessage( $message, $handle->getCode() );
871            if ( $validationResponse->hasErrors() ) {
872                $status->fatal( new ApiRawMessage(
873                    $context->msg( 'translate-syntax-error' )->parse(),
874                    'translate-validation-failed',
875                    [
876                        'validation' => [
877                            'errors' => $validationResponse->getDescriptiveErrors( $context ),
878                            'warnings' => $validationResponse->getDescriptiveWarnings( $context )
879                        ]
880                    ]
881                ) );
882                return false;
883            }
884        }
885
886        return true;
887    }
888
889    /** @inheritDoc */
890    public function onRevisionRecordInserted( $revisionRecord ): void {
891        $parentId = $revisionRecord->getParentId();
892        if ( $parentId === 0 || $parentId === null ) {
893            // No parent, bail out.
894            return;
895        }
896
897        $prevRev = $this->revisionLookup->getRevisionById( $parentId );
898        if ( !$prevRev || !$revisionRecord->hasSameContent( $prevRev ) ) {
899            // Not a null revision, bail out.
900            return;
901        }
902
903        // List of tags that should be copied over when updating
904        // tp:tag and tp:mark handling is in Hooks::updateTranstagOnNullRevisions.
905        $tagsToCopy = [ RevTagStore::FUZZY_TAG, RevTagStore::TRANSVER_PROP ];
906
907        $db = $this->loadBalancer->getConnection( DB_PRIMARY );
908        $db->insertSelect(
909            'revtag',
910            'revtag',
911            [
912                'rt_type' => 'rt_type',
913                'rt_page' => 'rt_page',
914                'rt_revision' => $revisionRecord->getId(),
915                'rt_value' => 'rt_value',
916
917            ],
918            [
919                'rt_type' => $tagsToCopy,
920                'rt_revision' => $parentId,
921            ],
922            __METHOD__
923        );
924    }
925
926    /** @inheritDoc */
927    public function onListDefinedTags( &$tags ): void {
928        $tags[] = 'translate-translation-pages';
929    }
930
931    /** @inheritDoc */
932    public function onChangeTagsListActive( &$tags ): void {
933        if ( $this->config->get( 'EnablePageTranslation' ) ) {
934            $tags[] = 'translate-translation-pages';
935        }
936    }
937}