Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
49.11% |
83 / 169 |
|
27.27% |
3 / 11 |
CRAP | |
0.00% |
0 / 1 |
Phonos | |
49.11% |
83 / 169 |
|
27.27% |
3 / 11 |
311.85 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
onParserFirstCallInit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
renderPhonos | |
82.35% |
42 / 51 |
|
0.00% |
0 / 1 |
16.24 | |||
getFileLink | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
addAttributionLink | |
25.00% |
2 / 8 |
|
0.00% |
0 / 1 |
3.69 | |||
handleNewFile | |
60.00% |
15 / 25 |
|
0.00% |
0 / 1 |
6.60 | |||
handleExistingFile | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
handleWikibaseEntity | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
getFileUrl | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
56 | |||
pushJob | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
recordError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | namespace MediaWiki\Extension\Phonos; |
3 | |
4 | use ExtensionRegistry; |
5 | use File; |
6 | use JobQueueGroup; |
7 | use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
8 | use MediaWiki\Config\Config; |
9 | use MediaWiki\Extension\Phonos\Engine\AudioParams; |
10 | use MediaWiki\Extension\Phonos\Engine\Engine; |
11 | use MediaWiki\Extension\Phonos\Exception\PhonosException; |
12 | use MediaWiki\Extension\Phonos\Job\PhonosIPAFilePersistJob; |
13 | use MediaWiki\Extension\Phonos\Wikibase\WikibaseEntityAndLexemeFetcher; |
14 | use MediaWiki\Hook\ParserFirstCallInitHook; |
15 | use MediaWiki\Html\Html; |
16 | use MediaWiki\Linker\Linker; |
17 | use MediaWiki\Linker\LinkRenderer; |
18 | use MediaWiki\Logger\LoggerFactory; |
19 | use MediaWiki\Output\OutputPage; |
20 | use MediaWiki\Page\PageReferenceValue; |
21 | use MediaWiki\TimedMediaHandler\TimedMediaHandler; |
22 | use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode; |
23 | use MediaWiki\Title\Title; |
24 | use OOUI\HtmlSnippet; |
25 | use Parser; |
26 | use Psr\Log\LoggerInterface; |
27 | use RepoGroup; |
28 | |
29 | /** |
30 | * Phonos extension |
31 | * |
32 | * @file |
33 | * @ingroup Extensions |
34 | * @license GPL-2.0-or-later |
35 | */ |
36 | class 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 | } |