Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.11% covered (danger)
49.11%
83 / 169
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Phonos
49.11% covered (danger)
49.11%
83 / 169
27.27% covered (danger)
27.27%
3 / 11
311.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 onParserFirstCallInit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderPhonos
82.35% covered (warning)
82.35%
42 / 51
0.00% covered (danger)
0.00%
0 / 1
16.24
 getFileLink
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 addAttributionLink
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
3.69
 handleNewFile
60.00% covered (warning)
60.00%
15 / 25
0.00% covered (danger)
0.00%
0 / 1
6.60
 handleExistingFile
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 handleWikibaseEntity
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getFileUrl
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 pushJob
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 recordError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2namespace MediaWiki\Extension\Phonos;
3
4use ExtensionRegistry;
5use File;
6use JobQueueGroup;
7use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
8use MediaWiki\Config\Config;
9use MediaWiki\Extension\Phonos\Engine\AudioParams;
10use MediaWiki\Extension\Phonos\Engine\Engine;
11use MediaWiki\Extension\Phonos\Exception\PhonosException;
12use MediaWiki\Extension\Phonos\Job\PhonosIPAFilePersistJob;
13use MediaWiki\Extension\Phonos\Wikibase\WikibaseEntityAndLexemeFetcher;
14use MediaWiki\Hook\ParserFirstCallInitHook;
15use MediaWiki\Html\Html;
16use MediaWiki\Linker\Linker;
17use MediaWiki\Linker\LinkRenderer;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\Output\OutputPage;
20use MediaWiki\Page\PageReferenceValue;
21use MediaWiki\TimedMediaHandler\TimedMediaHandler;
22use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode;
23use MediaWiki\Title\Title;
24use OOUI\HtmlSnippet;
25use Parser;
26use Psr\Log\LoggerInterface;
27use RepoGroup;
28
29/**
30 * Phonos extension
31 *
32 * @file
33 * @ingroup Extensions
34 * @license GPL-2.0-or-later
35 */
36class Phonos implements ParserFirstCallInitHook {
37
38    /** @var RepoGroup */
39    protected $repoGroup;
40
41    /** @var LinkRenderer */
42    protected $linkRenderer;
43
44    /** @var Engine */
45    protected $engine;
46
47    /** @var WikibaseEntityAndLexemeFetcher */
48    protected $wikibaseEntityAndLexemeFetcher;
49
50    /** @var StatsdDataFactoryInterface */
51    private $statsdDataFactory;
52
53    /** @var JobQueueGroup */
54    private $jobQueueGroup;
55
56    /** @var bool */
57    private $renderingEnabled;
58
59    /** @var LoggerInterface */
60    protected $logger;
61
62    /** @var bool */
63    private $inlineAudioPlayerMode;
64
65    /** @var array */
66    private $wikibaseProperties;
67
68    /**
69     * @param RepoGroup $repoGroup
70     * @param Engine $engine
71     * @param WikibaseEntityAndLexemeFetcher $wikibaseEntityAndLexemeFetcher
72     * @param StatsdDataFactoryInterface $statsdDataFactory
73     * @param JobQueueGroup $jobQueueGroup
74     * @param LinkRenderer $linkRenderer
75     * @param Config $config
76     */
77    public function __construct(
78        RepoGroup $repoGroup,
79        Engine $engine,
80        WikibaseEntityAndLexemeFetcher $wikibaseEntityAndLexemeFetcher,
81        StatsdDataFactoryInterface $statsdDataFactory,
82        JobQueueGroup $jobQueueGroup,
83        LinkRenderer $linkRenderer,
84        Config $config
85    ) {
86        $this->repoGroup = $repoGroup;
87        $this->engine = $engine;
88        $this->wikibaseEntityAndLexemeFetcher = $wikibaseEntityAndLexemeFetcher;
89        $this->statsdDataFactory = $statsdDataFactory;
90        $this->jobQueueGroup = $jobQueueGroup;
91        $this->linkRenderer = $linkRenderer;
92        $this->renderingEnabled = $config->get( 'PhonosIPARenderingEnabled' );
93        $this->logger = LoggerFactory::getInstance( 'Phonos' );
94        $this->inlineAudioPlayerMode = $config->get( 'PhonosInlineAudioPlayerMode' );
95        $this->wikibaseProperties = $config->get( 'PhonosWikibaseProperties' );
96    }
97
98    /**
99     * Bind the renderPhonos function to the phonos magic word
100     * @param Parser $parser
101     */
102    public function onParserFirstCallInit( $parser ) {
103        $parser->setHook( 'phonos', [ $this, 'renderPhonos' ] );
104    }
105
106    /**
107     * Convert phonos magic word to HTML
108     * <phonos ipa="/həˈləʊ/" text="hello" file="foo.ogg" language="en" wikibase="Q23501">Hello!</phonos>
109     *
110     * @param string|null $label
111     * @param array $args
112     * @param Parser $parser
113     * @return string
114     */
115    public function renderPhonos( ?string $label, array $args, Parser $parser ): string {
116        // Add the CSS and JS
117        $parser->getOutput()->addModuleStyles( [ 'ext.phonos.styles', 'ext.phonos.icons' ] );
118        $parser->getOutput()->addModules( [ 'ext.phonos.init' ] );
119        $parser->addTrackingCategory( 'phonos-tracking-category' );
120
121        // Get the named parameters and merge with defaults.
122        $defaultOptions = [
123            'lang' => $parser->getContentLanguage()->getCode(),
124            'text' => '',
125            'file' => '',
126            'label' => $label ?: '',
127            'ipa' => '',
128            'wikibase' => '',
129        ];
130        // Don't allow a label= attribute; see T340905#8983499
131        unset( $args['label'] );
132        $options = array_merge( $defaultOptions, $args );
133
134        $buttonLabel = $options['ipa'];
135        if ( $options['label'] ) {
136            $content = $parser->recursiveTagParseFully( trim( $options['label'] ) );
137            // Strip out the <p> tag that might have been added by the parser.
138            $buttonLabel = new HtmlSnippet( Parser::stripOuterParagraph( $content ) );
139        }
140        $buttonConfig = [
141            'label' => $buttonLabel,
142            'data' => [
143                'ipa' => $options['ipa'],
144                'text' => $options['text'],
145                'lang' => $options['lang'],
146                'wikibase' => $options['wikibase']
147            ],
148        ];
149
150        try {
151            // Require at least something to display generated from something other than just plain text (T322787).
152            if ( !$options['ipa'] && !$options['file'] && !$options['wikibase'] ) {
153                throw new PhonosException( 'phonos-param-error' );
154            }
155
156            // Check for maximum IPA length.
157            if ( strlen( $options['ipa'] ) > 300 ) {
158                throw new PhonosException( 'phonos-ipa-too-long' );
159            }
160
161            if ( $options['file'] ) {
162                $this->handleExistingFile( $options, $buttonConfig, $parser );
163            } elseif ( $options['wikibase'] ) {
164                $this->handleWikibaseEntity( $options, $buttonConfig, $parser );
165            }
166
167            // If there's not yet an audio file, and no error, fetch audio from the engine.
168            if ( !isset( $buttonConfig['href'] ) && !isset( $buttonConfig['data']['error'] )
169                && is_string( $options['ipa'] ) && $options['ipa']
170            ) {
171                $this->handleNewFile( $options, $buttonConfig, $parser );
172            }
173
174            // Add aria-label for screenreaders. This is also used as the tooltip.
175            $buttonConfig['aria-label'] = wfMessage( 'phonos-player-aria-description' )->parse();
176        } catch ( PhonosException $e ) {
177            $this->recordError( $e );
178            $buttonConfig['data']['error'] = $e->getMessageKeyAndArgs();
179            // Tell screenreaders that there's an error, but we can't add the actual error message because it's in the
180            // client-side popup which doesn't exist here.
181            $buttonConfig['aria-label'] = wfMessage( 'phonos-aria-error' )->parse();
182        }
183
184        // Errors also set outside from exceptions, add tracking for it, but it missing stats at the moment
185        if ( isset( $buttonConfig['data']['error'] ) ) {
186            $parser->addTrackingCategory( 'phonos-error-category' );
187        }
188
189        // FIXME: Use Codex button (T359605)
190        OutputPage::setupOOUI();
191        $button = new PhonosButton( $buttonConfig );
192        return Html::rawElement(
193            'span',
194            [ 'class' => 'ext-phonos skin-invert' ],
195            $button->toString() . $this->addAttributionLink( $buttonConfig )
196        );
197    }
198
199    /**
200     * Get a link to the file, or to upload if the file doesn't exist.
201     *
202     * @param array $buttonConfig
203     * @param string $linkText Text to display in the link
204     * @return string HTML
205     */
206    private function getFileLink( array $buttonConfig, string $linkText ): string {
207        $file = $this->repoGroup->findFile( $buttonConfig['data']['file'] );
208        $pageReference = PageReferenceValue::localReference( NS_FILE, $buttonConfig['data']['file'] );
209
210        if ( $file ) {
211            // File exists, link to PageReference
212            $linkContent = $this->linkRenderer->makeLink(
213                $pageReference,
214                $linkText
215            );
216        } else {
217            // File does not exist, link to upload (UploadMissingFileUrl)
218            $linkContent = Linker::makeBrokenImageLinkObj(
219                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable argument will never be null
220                Title::castFromPageReference( $pageReference ),
221                $linkText
222            );
223        }
224
225        // Returns HTML
226        return $linkContent;
227    }
228
229    /**
230     * Return an attribution link if required.
231     *
232     * @param array $buttonConfig
233     * @return string
234     */
235    private function addAttributionLink( array $buttonConfig ): string {
236        if ( !isset( $buttonConfig['data']['file'] ) ) {
237            return '';
238        }
239        $linkContent = $this->getFileLink( $buttonConfig, wfMessage( 'phonos-attribution-icon' )->plain() );
240
241        return Html::rawElement(
242            'sup',
243            [ 'class' => 'ext-phonos-attribution noexcerpt navigation-not-searchable' ],
244            $linkContent
245        );
246    }
247
248    /**
249     * Fetch audio from the engine and persist a new file.
250     *
251     * @param array $options
252     * @param array &$buttonConfig
253     * @param Parser $parser
254     */
255    private function handleNewFile( array $options, array &$buttonConfig, Parser $parser ): void {
256        if ( $this->inlineAudioPlayerMode ) {
257            throw new PhonosException(
258                'phonos-inline-audio-player-mode',
259                [
260                    $this->wikibaseProperties[ 'wikibasePronunciationAudioProp' ]
261                ]
262            );
263        }
264        $options['lang'] = $this->engine->checkLanguageSupport( $options['lang'] );
265        $audioParams = new AudioParams( $options['ipa'], $options['text'], $options['lang'] );
266        $isPersisted = $this->engine->isPersisted( $audioParams );
267        if ( $isPersisted ) {
268            $this->engine->updateFileExpiry( $audioParams );
269        } else {
270            if ( !$this->renderingEnabled ) {
271                throw new PhonosException( 'phonos-rendering-disabled' );
272            }
273            // Generate audio file in a job, so that the parser doesn't have to wait for it, similar to
274            // image thumbnails (T325464#9400171). Using jobs also allows controlling the execution rate
275            // to avoid hitting backend rate limits (T318086).
276            $this->pushJob( $options['ipa'], $options['text'], $options['lang'] );
277        }
278        // Pass the URL to the clientside even if audio file is not ready
279        $buttonConfig['href'] = $this->engine->getFileUrl( $audioParams );
280        // Store the filename as a page prop so that we can track orphaned files (T326163).
281        // We append to the existing page prop, if it exists, since we can have multiple files per page.
282        // The database transaction shouldn't happen until the request finishes.
283        $propFiles = json_decode(
284            $parser->getOutput()->getPageProperty( 'phonos-files' ) ?? '[]'
285        );
286        $propFiles[] = basename( $this->engine->getFileName( $audioParams ), '.mp3' );
287        $parser->getOutput()->setPageProperty( 'phonos-files', json_encode( array_unique( $propFiles ) ) );
288
289        $previousError = $this->engine->getError( $audioParams );
290        if ( $previousError ) {
291            // If the job failed the last time, assume it's going to fail again, and display its error
292            // message. There should be a way to do this without parsing the page again…
293            $key = array_shift( $previousError );
294            throw new PhonosException( $key, $previousError );
295        }
296    }
297
298    /**
299     * Fetch the upload URL of an existing File.
300     *
301     * @param array $options
302     * @param array &$buttonConfig
303     * @param Parser $parser
304     * @throws PhonosException
305     */
306    private function handleExistingFile( array $options, array &$buttonConfig, Parser $parser ): void {
307        $buttonConfig['data']['file'] = $options['file'];
308        $file = $this->repoGroup->findFile( $options['file'] );
309        $title = Title::makeTitleSafe( NS_FILE, $options['file'] );
310        if ( !$title ) {
311            // title is malformed
312            throw new PhonosException( 'phonos-invalid-title', [ $options['file'] ] );
313        }
314        if ( !$file ) {
315            throw new PhonosException( 'phonos-file-not-found', [
316                Linker::getUploadUrl( $title ),
317                $title->getText()
318            ] );
319        }
320        $buttonConfig['data']['file'] = $file->getTitle()->getText();
321        $parser->getOutput()->addImage( $file->getTitle()->getDBkey() );
322        if ( $file->getMediaType() !== MEDIATYPE_AUDIO ) {
323            throw new PhonosException( 'phonos-file-not-audio', [
324                $title->getPrefixedText(),
325                $title->getText()
326            ] );
327        }
328        $buttonConfig['href'] = $this->getFileUrl( $file );
329    }
330
331    /**
332     * Fetch IPA and/or audio from Wikibase entity/lexeme.
333     *
334     * @param array &$options
335     * @param array &$buttonConfig
336     * @param Parser $parser
337     * @throws PhonosException
338     */
339    private function handleWikibaseEntity( array &$options, array &$buttonConfig, Parser $parser ): void {
340        // If a wikibase attribute has been provided, fetch from Wikibase.
341        $wikibaseEntity = $this->wikibaseEntityAndLexemeFetcher->fetch(
342            $options['wikibase'],
343            $options['text'],
344            $options['lang']
345        );
346
347        // Set file URL if available.
348        $audioFile = $wikibaseEntity->getAudioFile();
349        if ( $audioFile ) {
350            $buttonConfig['data']['file'] = $audioFile->getTitle()->getText();
351            $buttonConfig['href'] = $this->getFileUrl( $audioFile );
352            $parser->getOutput()->addImage( $audioFile->getTitle()->getDBkey() );
353        }
354
355        // Set the IPA option and button config, if available.
356        if ( !$options['ipa'] ) {
357            if ( $wikibaseEntity->getIPATranscription() ) {
358                $options['ipa'] = $wikibaseEntity->getIPATranscription();
359                $buttonConfig['data']['ipa'] = $options['ipa'];
360                if ( !$buttonConfig['label'] ) {
361                    $buttonConfig['label'] = $options['ipa'];
362                }
363            } elseif ( !isset( $buttonConfig['href'] ) ) {
364                // If a Wikibase item is provided, but it doesn't have IPA (in the correct language).
365                throw new PhonosException( 'phonos-wikibase-no-ipa' );
366            }
367        }
368    }
369
370    /**
371     * Get the public URL for the given File, using TimedMediaHandler to
372     * find a transcoded MP3 source if the given File isn't already an MP3.
373     *
374     * If TimeMediaHandler can't find an MP3 source, the original non-MP3
375     * file URL will be returned instead.
376     *
377     * @param File $file
378     * @return string
379     */
380    public function getFileUrl( File $file ): string {
381        $isAlreadyMP3 = $file->getMimeType() === 'audio/mpeg';
382        $isHandledByTMH = ExtensionRegistry::getInstance()->isLoaded( 'TimedMediaHandler' ) &&
383            $file->getHandler() && $file->getHandler() instanceof TimedMediaHandler;
384
385        if ( !$isAlreadyMP3 && $isHandledByTMH ) {
386            $mp3Source = array_filter( WebVideoTranscode::getSources( $file ), static function ( $source ) {
387                return isset( $source['transcodekey'] ) && $source[ 'transcodekey' ] === 'mp3';
388            } );
389            $mp3Source = reset( $mp3Source );
390            if ( isset( $mp3Source['src'] ) ) {
391                return $mp3Source[ 'src' ];
392            }
393        }
394
395        return $file->getUrl();
396    }
397
398    /**
399     * Push a job into the job queue
400     *
401     * @param string $ipa
402     * @param string $text
403     * @param string $lang
404     *
405     * @return void
406     */
407    private function pushJob( string $ipa, string $text, string $lang ): void {
408        $jobParams = [
409            'ipa' => $ipa,
410            'text' => $text,
411            'lang' => $lang,
412        ];
413
414        $this->logger->info(
415            __METHOD__ . ' Job being created',
416            [
417                'params' => $jobParams
418            ]
419        );
420
421        $job = new PhonosIPAFilePersistJob( $jobParams );
422        $this->jobQueueGroup->push( $job );
423    }
424
425    /**
426     * Record exceptions that we capture and their types into statsd.
427     *
428     * @param PhonosException $e
429     * @return void
430     */
431    private function recordError( PhonosException $e ): void {
432        $key = $e->getStatsdKey();
433        $this->statsdDataFactory->increment( "extension.Phonos.error.$key" );
434    }
435}