Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.95% covered (danger)
44.95%
98 / 218
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiHooks
44.95% covered (danger)
44.95%
98 / 218
25.00% covered (danger)
25.00%
3 / 12
306.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 validateConfiguration
41.25% covered (danger)
41.25%
33 / 80
0.00% covered (danger)
0.00%
0 / 1
41.20
 onBeforePageDisplay
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 shouldWikispeechRun
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
10
 onApiBeforeMain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 addVoicePreferences
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 addSpeechRatePreferences
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 onApiCheckCanExecute
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onSkinTemplateNavigation__Universal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultUserOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Wikispeech\Hooks;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use Action;
12use ApiBase;
13use ApiMain;
14use ApiMessage;
15use Config;
16use ConfigFactory;
17use Exception;
18use IApiMessage;
19use MediaWiki\Api\Hook\ApiCheckCanExecuteHook;
20use MediaWiki\Hook\ApiBeforeMainHook;
21use MediaWiki\Hook\BeforePageDisplayHook;
22use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
23use MediaWiki\Http\HttpRequestFactory;
24use MediaWiki\Languages\LanguageFactory;
25use MediaWiki\Logger\LoggerFactory;
26use MediaWiki\Permissions\PermissionManager;
27use MediaWiki\Preferences\Hook\GetPreferencesHook;
28use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
29use MediaWiki\User\UserOptionsLookup;
30use MediaWiki\Wikispeech\SpeechoidConnector;
31use MediaWiki\Wikispeech\VoiceHandler;
32use Message;
33use OutputPage;
34use Psr\Log\LoggerInterface;
35use Skin;
36use SkinTemplate;
37use User;
38use WANObjectCache;
39
40/**
41 * @since 0.1.8
42 */
43class ApiHooks implements
44    ApiBeforeMainHook,
45    BeforePageDisplayHook,
46    ResourceLoaderGetConfigVarsHook,
47    GetPreferencesHook,
48    ApiCheckCanExecuteHook,
49    SkinTemplateNavigation__UniversalHook
50{
51    /** @var Config */
52    private $config;
53
54    /** @var UserOptionsLookup */
55    private $userOptionsLookup;
56
57    /** @var LoggerInterface */
58    private $logger;
59
60    /** @var WANObjectCache */
61    private $mainWANObjectCache;
62
63    /** @var LanguageFactory */
64    private $languageFactory;
65
66    /** @var PermissionManager */
67    private $permissionManager;
68
69    /** @var HttpRequestFactory */
70    private $requestFactory;
71
72    /**
73     * @since 0.1.8
74     * @param ConfigFactory $configFactory
75     * @param UserOptionsLookup $userOptionsLookup
76     * @param WANObjectCache $mainWANObjectCache
77     * @param LanguageFactory $languageFactory
78     * @param PermissionManager $permissionManager
79     * @param HttpRequestFactory $requestFactory
80     */
81    public function __construct(
82        ConfigFactory $configFactory,
83        UserOptionsLookup $userOptionsLookup,
84        WANObjectCache $mainWANObjectCache,
85        LanguageFactory $languageFactory,
86        PermissionManager $permissionManager,
87        HttpRequestFactory $requestFactory
88    ) {
89        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
90        $this->config = $configFactory->makeConfig( 'wikispeech' );
91        $this->userOptionsLookup = $userOptionsLookup;
92        $this->mainWANObjectCache = $mainWANObjectCache;
93        $this->languageFactory = $languageFactory;
94        $this->permissionManager = $permissionManager;
95        $this->requestFactory = $requestFactory;
96    }
97
98    /**
99     * Investigates whether or not configuration is valid.
100     *
101     * Writes all invalid configuration entries to the log.
102     *
103     * @since 0.1.8
104     * @return bool true if all configuration passes validation
105     */
106    private function validateConfiguration() {
107        $success = true;
108
109        $speechoidUrl = $this->config->get( 'WikispeechSpeechoidUrl' );
110        if ( !filter_var( $speechoidUrl, FILTER_VALIDATE_URL ) ) {
111            $this->logger
112                ->warning( __METHOD__ . ': Configuration value for ' .
113                    '\'WikispeechSpeechoidUrl\' is not a valid URL: {value}',
114                    [ 'value' => $speechoidUrl ]
115                );
116            $success = false;
117        }
118        $speechoidResponseTimeoutSeconds = $this->config
119            ->get( 'WikispeechSpeechoidResponseTimeoutSeconds' );
120        if ( $speechoidResponseTimeoutSeconds &&
121            !is_int( $speechoidResponseTimeoutSeconds ) ) {
122            $this->logger
123                ->warning( __METHOD__ . ': Configuration value ' .
124                    '\'WikispeechSpeechoidResponseTimeoutSeconds\' ' .
125                    'is not a falsy or integer value.'
126                );
127            $success = false;
128        }
129
130        $utteranceTimeToLiveDays = $this->config
131            ->get( 'WikispeechUtteranceTimeToLiveDays' );
132        if ( $utteranceTimeToLiveDays === null ) {
133            $this->logger
134                ->warning( __METHOD__ . ': Configuration value for ' .
135                    '\'WikispeechUtteranceTimeToLiveDays\' is missing.'
136                );
137            $success = false;
138        }
139        $utteranceTimeToLiveDays = intval( $utteranceTimeToLiveDays );
140        if ( $utteranceTimeToLiveDays < 0 ) {
141            $this->logger
142                ->warning( __METHOD__ . ': Configuration value for ' .
143                    '\'WikispeechUtteranceTimeToLiveDays\' must not be negative.'
144                );
145            $success = false;
146        }
147
148        $minimumMinutesBetweenFlushExpiredUtterancesJobs = $this->config
149            ->get( 'WikispeechMinimumMinutesBetweenFlushExpiredUtterancesJobs' );
150        if ( $minimumMinutesBetweenFlushExpiredUtterancesJobs === null ) {
151            $this->logger
152                ->warning( __METHOD__ . ': Configuration value for ' .
153                    '\'WikispeechMinimumMinutesBetweenFlushExpiredUtterancesJobs\' ' .
154                    'is missing.'
155                );
156            $success = false;
157        }
158        $minimumMinutesBetweenFlushExpiredUtterancesJobs = intval(
159            $minimumMinutesBetweenFlushExpiredUtterancesJobs
160        );
161        if ( $minimumMinutesBetweenFlushExpiredUtterancesJobs < 0 ) {
162            $this->logger
163                ->warning( __METHOD__ . ': Configuration value for ' .
164                    '\'WikispeechMinimumMinutesBetweenFlushExpiredUtterancesJobs\'' .
165                    ' must not be negative.'
166                );
167            $success = false;
168        }
169
170        $fileBackendName = $this->config->get( 'WikispeechUtteranceFileBackendName' );
171        if ( $fileBackendName === null ) {
172            $this->logger
173                ->warning( __METHOD__ . ':  Configuration value ' .
174                    '\'WikispeechUtteranceFileBackendName\' is missing.'
175                );
176            // This is not a failure.
177            // It will fall back on default, but admin should be aware.
178        } elseif ( !is_string( $fileBackendName ) ) {
179            $this->logger
180                ->warning( __METHOD__ . ': Configuration value ' .
181                    '\'WikispeechUtteranceFileBackendName\' is not a string value.'
182                );
183            $success = false;
184        }
185
186        $fileBackendContainerName = $this->config
187            ->get( 'WikispeechUtteranceFileBackendContainerName' );
188        if ( $fileBackendContainerName === null ) {
189            $this->logger
190                ->warning( __METHOD__ . ': Configuration value ' .
191                    '\'WikispeechUtteranceFileBackendContainerName\' is missing.'
192                );
193            $success = false;
194        } elseif ( !is_string( $fileBackendContainerName ) ) {
195            $this->logger
196                ->warning( __METHOD__ . ': Configuration value ' .
197                    '\'WikispeechUtteranceFileStore\' is not a string value.'
198                );
199            $success = false;
200        }
201
202        return $success;
203    }
204
205    /**
206     * Hook for BeforePageDisplay.
207     *
208     * Enables JavaScript.
209     *
210     * @since 0.1.8
211     * @param OutputPage $out The OutputPage object.
212     * @param Skin $skin Skin object that will be used to generate the page,
213     *  added in MediaWiki 1.13.
214     */
215    public function onBeforePageDisplay( $out, $skin ): void {
216        if ( !$this->shouldWikispeechRun( $out ) ) {
217            return;
218        }
219        $showPlayer = $this->userOptionsLookup->getOption(
220            $out->getUser(), 'wikispeechShowPlayer'
221        );
222        if ( $showPlayer ) {
223            $this->logger->info( __METHOD__ . ': Loading player.' );
224            $out->addModules( [ 'ext.wikispeech' ] );
225        } else {
226            $this->logger->info( __METHOD__ . ': Adding option to load player.' );
227            $out->addModules( [ 'ext.wikispeech.loader' ] );
228        }
229        $out->addJsConfigVars( [
230            'wgWikispeechKeyboardShortcuts' => $this->config->get( 'WikispeechKeyboardShortcuts' ),
231            'wgWikispeechContentSelector' => $this->config->get( 'WikispeechContentSelector' ),
232            'wgWikispeechSkipBackRewindsThreshold' =>
233                $this->config->get( 'WikispeechSkipBackRewindsThreshold' ),
234            'wgWikispeechHelpPage' => $this->config->get( 'WikispeechHelpPage' ),
235            'wgWikispeechFeedbackPage' => $this->config->get( 'WikispeechFeedbackPage' )
236        ] );
237    }
238
239    /**
240     * Checks if Wikispeech should run.
241     *
242     * Returns true if all of the following are true:
243     * * User has enabled Wikispeech in the settings
244     * * User is allowed to listen to pages
245     * * Wikispeech configuration is valid
246     * * Wikispeech is enabled for the page's namespace
247     * * Revision is current
248     * * Page's language is enabled for Wikispeech
249     * * The action is "view"
250     *
251     * @since 0.1.8
252     * @param OutputPage $out
253     * @return bool
254     */
255    private function shouldWikispeechRun( OutputPage $out ) {
256        $wikispeechEnabled = $this->userOptionsLookup
257            ->getOption( $out->getUser(), 'wikispeechEnable' );
258        if ( !$wikispeechEnabled ) {
259            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: disabled by user.' );
260            return false;
261        }
262
263        $userIsAllowed = $this->permissionManager
264            ->userHasRight( $out->getUser(), 'wikispeech-listen' );
265        if ( !$userIsAllowed ) {
266            $this->logger->info( __METHOD__ .
267                ': Not loading Wikispeech: user lacks right "wikispeech-listen".' );
268            return false;
269        }
270
271        if ( !$this->validateConfiguration() ) {
272            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: config invalid.' );
273            return false;
274        }
275
276        $namespace = $out->getTitle()->getNamespace();
277        $validNamespaces = $this->config->get( 'WikispeechNamespaces' );
278        if ( !in_array( $namespace, $validNamespaces ) ) {
279            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: unsupported namespace.' );
280            return false;
281        }
282
283        if ( !$out->isRevisionCurrent() ) {
284            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: non-current revision.' );
285            return false;
286        }
287
288        if ( $namespace == NS_MEDIA || $namespace < 0 ) {
289            // cannot get pageContentLanguage of e.g. a Special page or a
290            // virtual page. These should all use the interface language.
291            $pageContentLanguage = $out->getLanguage();
292        } else {
293            $pageContentLanguage = $out->getTitle()->getPageLanguage();
294        }
295        $validLanguages = array_keys( $this->config->get( 'WikispeechVoices' ) );
296        if ( !in_array( $pageContentLanguage->getCode(), $validLanguages ) ) {
297            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: unsupported language.' );
298            return false;
299        }
300
301        $actionName = Action::getActionName( $out );
302        if ( $actionName !== 'view' ) {
303            $this->logger->info( __METHOD__ . ': Not loading Wikispeech: unsupported action.' );
304            return false;
305        }
306
307        return true;
308    }
309
310    /**
311     * Calls configuration validation for logging purposes on API calls,
312     * but doesn't stop the use of the API due to invalid configuration.
313     * Generally a user would not call the API at this point as the module
314     * wouldn't actually have been added in onBeforePageDisplay.
315     *
316     * @since 0.1.8
317     * @param ApiMain &$main
318     * @return bool|void
319     */
320    public function onApiBeforeMain( &$main ) {
321        $this->validateConfiguration();
322    }
323
324    /**
325     * Conditionally register static configuration variables for the
326     * ext.wikispeech module only if that module is loaded.
327     *
328     * @since 0.1.8
329     * @param array &$vars The array of static configuration variables.
330     * @param string $skin
331     * @param Config $config
332     */
333    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
334        $vars['wgWikispeechSpeechoidUrl'] = $config->get( 'WikispeechSpeechoidUrl' );
335        $vars['wgWikispeechNamespaces'] = $config->get( 'WikispeechNamespaces' );
336    }
337
338    /**
339     * Add Wikispeech options to Special:Preferences.
340     *
341     * @since 0.1.8
342     * @param User $user current User object.
343     * @param array &$preferences Preferences array.
344     */
345    public function onGetPreferences( $user, &$preferences ) {
346        $speechoidConnector = new SpeechoidConnector(
347            $this->config,
348            $this->requestFactory
349        );
350        $voiceHandler = new VoiceHandler(
351            $this->logger,
352            $this->config,
353            $speechoidConnector,
354            $this->mainWANObjectCache
355        );
356        $preferences['wikispeechEnable'] = [
357            'type' => 'toggle',
358            'label-message' => 'prefs-wikispeech-enable',
359            'section' => 'wikispeech'
360        ];
361        $preferences['wikispeechShowPlayer'] = [
362            'type' => 'toggle',
363            'label-message' => 'prefs-wikispeech-show-player',
364            'section' => 'wikispeech'
365        ];
366        $this->addVoicePreferences( $preferences, $voiceHandler );
367        $this->addSpeechRatePreferences( $preferences );
368    }
369
370    /**
371     * Add preferences for selecting voices per language.
372     *
373     * @since 0.1.8
374     * @param array &$preferences Preferences array.
375     * @param VoiceHandler $voiceHandler
376     */
377    private function addVoicePreferences( &$preferences, $voiceHandler ) {
378        $wikispeechVoices = $this->config->get( 'WikispeechVoices' );
379        foreach ( $wikispeechVoices as $language => $voices ) {
380            $languageKey = 'wikispeechVoice' . ucfirst( $language );
381            $mwLanguage = $this->languageFactory->getLanguage( 'en' );
382            $languageName = $mwLanguage->getVariantname( $language );
383            $options = [];
384            try {
385                $defaultVoice = $voiceHandler->getDefaultVoice( $language );
386                $options["Default ($defaultVoice)"] = '';
387            } catch ( Exception $e ) {
388                $options["Default"] = '';
389            }
390            foreach ( $voices as $voice ) {
391                $options[$voice] = $voice;
392            }
393            $preferences[$languageKey] = [
394                'type' => 'select',
395                'label' => $languageName,
396                'section' => 'wikispeech/wikispeech-voice',
397                'options' => $options
398            ];
399        }
400    }
401
402    /**
403     * Add preferences for selecting speech rate.
404     *
405     * @since 0.1.8
406     * @param array &$preferences Preferences array.
407     */
408    private function addSpeechRatePreferences( &$preferences ) {
409        $options = [
410            '400%' => 4.0,
411            '200%' => 2.0,
412            '150%' => 1.5,
413            '100%' => 1.0,
414            '75%' => 0.75,
415            '50%' => 0.5
416        ];
417        $preferences['wikispeechSpeechRate'] = [
418            'type' => 'select',
419            'label-message' => 'prefs-wikispeech-speech-rate',
420            'section' => 'wikispeech/wikispeech-voice',
421            'options' => $options
422        ];
423    }
424
425    /**
426     * Check if the user is allowed to use a API module.
427     *
428     * @since 0.1.8
429     * @param ApiBase $module
430     * @param User $user
431     * @param IApiMessage|Message|string|array &$message
432     * @return bool
433     */
434    public function onApiCheckCanExecute( $module, $user, &$message ) {
435        if (
436            $module->getModuleName() == 'wikispeech-listen' &&
437            !$this->permissionManager->userHasRight( $user, 'wikispeech-listen' )
438        ) {
439            $message = ApiMessage::create(
440                'apierror-wikispeech-listen-notallowed'
441            );
442            return false;
443        }
444        return true;
445    }
446
447    /**
448     * Add tab for activating Wikispeech player.
449     *
450     * @since 0.1.8
451     * @param SkinTemplate $skinTemplate The skin template on which
452     *  the UI is built.
453     * @param array &$links Navigation links.
454     */
455    public function onSkinTemplateNavigation__Universal( $skinTemplate, &$links ): void { // phpcs:ignore MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName, Generic.Files.LineLength.TooLong
456        $out = $skinTemplate->getOutput();
457        if ( $this->shouldWikispeechRun( $out ) ) {
458            $links['actions']['listen'] = [
459                'class' => 'ext-wikispeech-listen',
460                'text' => $skinTemplate->msg( 'wikispeech-listen' )->text(),
461                'href' => 'javascript:void(0)'
462            ];
463        }
464    }
465
466    /**
467     * Get default user options when used as a producer
468     *
469     * Used when a consumer loads the gadget module.
470     *
471     * @since 0.1.9
472     * @return array
473     */
474    public static function getDefaultUserOptions() {
475        global $wgDefaultUserOptions;
476        $wikispeechOptions = array_filter(
477            $wgDefaultUserOptions,
478            static function ( $key ) {
479                // Only add options starting with "wikispeech".
480                return strpos( $key, 'wikispeech' ) === 0;
481            },
482            ARRAY_FILTER_USE_KEY
483        );
484        return $wikispeechOptions;
485    }
486}