Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.19% covered (danger)
30.19%
96 / 318
14.29% covered (danger)
14.29%
2 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditLexicon
30.19% covered (danger)
30.19%
96 / 318
14.29% covered (danger)
14.29%
2 / 14
1162.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
28.89% covered (danger)
28.89%
13 / 45
0.00% covered (danger)
0.00%
0 / 1
18.95
 formSteps
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
132
 syncSubmit
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 redirectToLoginPage
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 getSyncFields
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getLookupFields
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 getSelectFields
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 getAddFields
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 getEditFields
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 submit
83.87% covered (warning)
83.87%
52 / 62
0.00% covered (danger)
0.00%
0 / 1
15.94
 purgeOriginPageUtterances
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguageOptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 success
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Wikispeech\Specials;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use Exception;
12use HTMLForm;
13use InvalidArgumentException;
14use MediaWiki\Html\Html;
15use MediaWiki\Languages\LanguageNameUtils;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\Wikispeech\Lexicon\ConfiguredLexiconStorage;
18use MediaWiki\Wikispeech\Lexicon\LexiconEntry;
19use MediaWiki\Wikispeech\Lexicon\LexiconEntryItem;
20use MediaWiki\Wikispeech\Lexicon\LexiconStorage;
21use MediaWiki\Wikispeech\Lexicon\NullEditLexiconException;
22use MediaWiki\Wikispeech\SpeechoidConnector;
23use MediaWiki\Wikispeech\Utterance\UtteranceStore;
24use Psr\Log\LoggerInterface;
25use RuntimeException;
26use SpecialPage;
27
28/**
29 * Special page for editing the lexicon.
30 *
31 * @since 0.1.8
32 */
33
34class SpecialEditLexicon extends SpecialPage {
35
36    /** @var LanguageNameUtils */
37    private $languageNameUtils;
38
39    /** @var LexiconStorage */
40    private $lexiconStorage;
41
42    /** @var SpeechoidConnector */
43    private $speechoidConnector;
44
45    /** @var LexiconEntryItem */
46    private $modifiedItem;
47
48    /** @var LoggerInterface */
49    private $logger;
50
51    /** @var string */
52    private $postHtml;
53
54    /**
55     * @since 0.1.12 Removed ConfigFactory
56     * @since 0.1.8
57     * @param LanguageNameUtils $languageNameUtils
58     * @param LexiconStorage $lexiconStorage
59     * @param SpeechoidConnector $speechoidConnector
60     */
61    public function __construct(
62        $languageNameUtils,
63        $lexiconStorage,
64        $speechoidConnector
65    ) {
66        parent::__construct( 'EditLexicon', 'wikispeech-edit-lexicon' );
67        $this->languageNameUtils = $languageNameUtils;
68        $this->lexiconStorage = $lexiconStorage;
69        $this->speechoidConnector = $speechoidConnector;
70        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
71        $this->postHtml = '';
72    }
73
74    /**
75     * @since 0.1.8
76     * @param string|null $subpage
77     */
78    public function execute( $subpage ) {
79        $this->setHeaders();
80        if ( $this->redirectToLoginPage() ) {
81            return;
82        }
83
84        $this->checkPermissions();
85        $this->addHelpLink( 'Help:Extension:Wikispeech/Lexicon editor' );
86        try {
87            $this->speechoidConnector->requestLexicons();
88        } catch ( Exception $e ) {
89            $this->logger->error( 'Speechoid is down.' );
90            $this->getOutput()->showErrorPage(
91                'error',
92                'wikispeech-edit-lexicon-speechoid-down'
93            );
94            return;
95        }
96
97        $request = $this->getRequest();
98        $language = $request->getText( 'language' );
99        $word = $request->getText( 'word' );
100
101        try {
102            $this->lexiconStorage->getEntry( $language, $word );
103            $formData = $this->formSteps( $language, $word );
104        } catch ( RuntimeException $e ) {
105            $this->getOutput()->addHtml( Html::errorBox(
106                $this->msg( 'wikispeech-lexicon-sync-error' )->parse()
107            ) );
108
109            $formData = $this->getSyncFields( $language, $word );
110        }
111
112        $fields = $formData['fields'];
113        $formId = $formData['formId'];
114        $submitMessage = $formData['submitMessage'];
115        $submitCb = $formData['submitCb'];
116
117        $copyrightNote = $this->msg( 'wikispeech-lexicon-copyrightnote' )->parse();
118        $this->postHtml .= Html::rawElement( 'p', [], $copyrightNote );
119
120        $form = HTMLForm::factory(
121            'ooui',
122            $fields,
123            $this->getContext()
124        );
125
126        $form->setFormIdentifier( $formId );
127        $form->setSubmitCallback( [ $this, $submitCb ] );
128        $form->setSubmitTextMsg( $submitMessage );
129        $form->setPostHtml( $this->postHtml );
130
131        if ( $form->show() ) {
132            if ( $formId === 'editItem' ) {
133                $this->success( 'wikispeech-lexicon-edit-entry-success' );
134            } else {
135                $this->success( 'wikispeech-lexicon-add-entry-success' );
136            }
137        }
138
139        $this->getOutput()->addModules( [
140            'ext.wikispeech.specialEditLexicon'
141        ] );
142    }
143
144    /**
145     * @since 0.1.12
146     * @return array
147     */
148    private function formSteps( string $language, string $word ) {
149        $request = $this->getRequest();
150        $formId = '';
151        $submitMessage = 'wikispeech-lexicon-next';
152        $submitCb = 'submit';
153
154        if ( $request->getText( 'id' ) === '' ) {
155            $id = '';
156        } else {
157            $id = $request->getIntOrNull( 'id' );
158        }
159
160        try {
161            $entry = $this->lexiconStorage->getEntry( $language, $word );
162        } catch ( RuntimeException $e ) {
163            $entry = null;
164        }
165
166        if ( !$language || !$word ) {
167            $formId = 'lookup';
168            $fields = $this->getLookupFields();
169        } elseif ( $entry === null ) {
170            $formId = 'newEntry';
171            $fields = $this->getAddFields( $language, $word );
172            $submitMessage = 'wikispeech-lexicon-save';
173        } elseif ( !in_array( 'id', $request->getValueNames() ) ) {
174            $formId = 'selectItem';
175            $fields = $this->getSelectFields( $language, $word, $entry );
176        } elseif ( $id ) {
177            $formId = 'editItem';
178            $fields = $this->getEditFields( $language, $word, $id );
179            $submitMessage = 'wikispeech-lexicon-save';
180        } elseif ( $id === '' ) {
181            $formId = 'newItem';
182            $fields = $this->getAddFields( $language, $word );
183            $submitMessage = 'wikispeech-lexicon-save';
184        } else {
185            // We have a set of parameters that we can't do anything with. Show the first page.
186            $formId = 'lookup';
187            $fields = $this->getLookupFields();
188        }
189
190        // Set default values from the parameters.
191        foreach ( $fields as $field ) {
192            $name = $field['name'];
193            $value = $request->getVal( $name );
194            if ( $value !== null ) {
195                // There's no extra conversion logic so default values
196                // are set to strings and handled down the
197                // line. E.g. boolean values are true for "false" or
198                // "no".
199                $fields[$name]['default'] = $value;
200            }
201        }
202
203        return [
204            'fields' => $fields,
205            'formId' => $formId,
206            'submitMessage' => $submitMessage,
207            'submitCb' => $submitCb
208        ];
209    }
210
211    /**
212     * Overwrite the local entry with speechoid entry
213     *
214     * @since 0.1.12
215     */
216    public function syncSubmit() {
217        if ( !$this->lexiconStorage instanceof ConfiguredLexiconStorage ) {
218            return;
219        }
220        $request = $this->getRequest();
221        $language = $request->getText( 'language' );
222        $word = $request->getText( 'word' );
223
224        try {
225
226            $localEntry = $this->lexiconStorage->getLocalEntry( $language, $word );
227
228            if ( $localEntry === null ) {
229                throw new RuntimeException( "Local entry is missing." );
230            }
231
232            foreach ( $localEntry->getItems() as $localEntryItem ) {
233                $speechoidId = $localEntryItem->getSpeechoidIdentity();
234                if ( !$speechoidId ) {
235                    throw new InvalidArgumentException( "Cannot sync item without Speechoid identity" );
236                }
237                $this->lexiconStorage->syncEntryItem( $language, $word, $speechoidId );
238            }
239
240            $this->getOutput()->redirect(
241                $this->getPageTitle()->getFullURL( [
242                    'language' => $language,
243                    'word' => $word
244                ] )
245            );
246
247        } catch ( InvalidArgumentException $e ) {
248            $this->getOutput()->addHtml( Html::errorBox(
249                $this->msg( 'wikispeech-lexicon-sync-error-unidentified' )
250            ) );
251        }
252    }
253
254    /**
255     * Redirect the user to login page when appropriate
256     *
257     * If the the user is not logged in and config variable
258     * `WikispeechEditLexiconAutoLogin` is true this adds a redirect
259     * to the output. Returns to this special page after login and
260     * keeps any URL parameters that were originally given.
261     *
262     * @since 0.1.11
263     * @return bool True if a redirect was added, else false.
264     */
265    private function redirectToLoginPage(): bool {
266        if ( $this->getUser()->isNamed() ) {
267            // User already logged in.
268            return false;
269        }
270
271        if ( !$this->getConfig()->get( 'WikispeechEditLexiconAutoLogin' ) ) {
272            return false;
273        }
274
275        $lexiconUrl = $this->getPageTitle()->getPrefixedText();
276        $lexiconRequest = $this->getRequest();
277
278        $loginParemeters = [
279            'returnto' => $lexiconUrl
280        ];
281        if ( $lexiconRequest->getValues() ) {
282            // Add any parameters to this page to include after
283            // logging in.
284            $lexiconParametersString = http_build_query( $lexiconRequest->getValues() );
285            $loginParemeters['returntoquery'] = $lexiconParametersString;
286        }
287        $title = SpecialPage::getTitleFor( 'Userlogin' );
288        $loginUrl = $title->getFullURL( $loginParemeters );
289        $this->getOutput()->redirect( $loginUrl );
290
291        return true;
292    }
293
294    /**
295     * Create a field descriptor for sync form data fields
296     *
297     * @param string $language
298     * @param string $word
299     * @since 0.1.12
300     * @return array
301     */
302    private function getSyncFields( $language, $word ): array {
303        $formData = [
304            'fields' => [
305                'language' => [
306                    'type' => 'hidden',
307                    'name' => 'language',
308                    'default' => $language
309                ],
310                'word' => [
311                    'type' => 'hidden',
312                    'name' => 'word',
313                    'default' => $word
314                ]
315            ],
316            'formId' => 'syncForm',
317            'submitMessage' => $this->msg( 'wikispeech-lexicon-sync-error-button' ),
318            'submitCb' => "syncSubmit"
319        ];
320        return $formData;
321    }
322
323    /**
324     * Create a field descriptor for looking up a word
325     *
326     * Has one field for language and one for word.
327     *
328     * @since 0.1.10
329     * @return array
330     */
331    private function getLookupFields(): array {
332        $fields = [
333            'language' => [
334                'name' => 'language',
335                'type' => 'select',
336                'label' => $this->msg( 'wikispeech-language' )->text(),
337                'options' => $this->getLanguageOptions(),
338                'id' => 'ext-wikispeech-language'
339            ],
340            'word' => [
341                'name' => 'word',
342                'type' => 'text',
343                'label' => $this->msg( 'wikispeech-word' )->text(),
344                'required' => true
345            ],
346            'page' => [
347                'name' => 'page',
348                'type' => 'hidden'
349            ]
350        ];
351        return $fields;
352    }
353
354    /**
355     * Create a field descriptor for selecting an item
356     *
357     * Has a field for selecting the id of the item to edit or "new"
358     * for creating a new item. Also shows fields for language and
359     * word from previous page, but readonly.
360     *
361     * @since 0.1.10
362     * @param string $language
363     * @param string $word
364     * @param LexiconEntry|null $entry
365     * @return array
366     */
367    private function getSelectFields(
368        string $language,
369        string $word,
370        ?LexiconEntry $entry = null
371    ): array {
372        $fields = $this->getLookupFields();
373        $fields['language']['readonly'] = true;
374        $fields['language']['type'] = 'text';
375        $fields['word']['readonly'] = true;
376        $fields['word']['required'] = false;
377
378        $newLabel = $this->msg( 'wikispeech-lexicon-new' )->text();
379        $itemOptions = [ $newLabel => '' ];
380        if ( $entry ) {
381            foreach ( $entry->getItems() as $item ) {
382                $properties = $item->getProperties();
383                if ( !isset( $properties->id ) ) {
384                    $this->logger->warning(
385                        __METHOD__ . ': Skipping item with no id.'
386                    );
387                    continue;
388                }
389                $id = $properties->id;
390                // Add item id as option for selection.
391                $itemOptions[$id] = $id;
392                // Add item to info text.
393                $this->postHtml .= Html::element( 'pre', [], $item );
394            }
395        }
396
397        $fields['id'] = [
398            'name' => 'id',
399            'type' => 'select',
400            'label' => $this->msg( 'wikispeech-item-id' )->text(),
401            'options' => $itemOptions,
402            'default' => ''
403        ];
404        return $fields;
405    }
406
407    /**
408     * Create a field descriptor for adding an entry or item
409     *
410     * Has fields for transcription and preferred. Item id is held by
411     * a hidden field. Also shows fields for language and word from
412     * previous page, but readonly.
413     *
414     * @since 0.1.10
415     * @param string $language
416     * @param string $word
417     * @return array
418     */
419    private function getAddFields( string $language, string $word ): array {
420        $fields = $this->getSelectFields( $language, $word );
421        $fields['id']['type'] = 'hidden';
422        $fields += [
423            'transcription' => [
424                'name' => 'transcription',
425                'type' => 'textwithbutton',
426                'label' => $this->msg( 'wikispeech-transcription' )->text(),
427                'required' => true,
428                'id' => 'ext-wikispeech-transcription',
429                'buttontype' => 'button',
430                'buttondefault' => $this->msg( 'wikispeech-preview' )->text(),
431                'buttonid' => 'ext-wikispeech-preview-button'
432            ],
433            'preferred' => [
434                'name' => 'preferred',
435                'type' => 'check',
436                'label' => $this->msg( 'wikispeech-preferred' )->text()
437            ]
438        ];
439        return $fields;
440    }
441
442    /**
443     * Create a field descriptor for editing an item
444     *
445     * Has fields for transcription and preferred with default values
446     * from the lexicon. Item id is held by a hidden field. Also shows
447     * fields for language and word from previous page, but readonly.
448     *
449     * @since 0.1.10
450     * @param string $language
451     * @param string $word
452     * @param int $id
453     * @return array
454     */
455    private function getEditFields( string $language, string $word, int $id ): array {
456        $fields = $this->getAddFields( $language, $word );
457        $entry = $this->lexiconStorage->getEntry( $language, $word );
458        $item = $entry->findItemBySpeechoidIdentity( $id );
459        if ( $item === null ) {
460            $this->getOutput()->addHTML( Html::errorBox(
461                $this->getOutput()->msg( 'wikispeech-edit-lexicon-no-item-found' )->params( $id )->parse()
462            ) );
463            return $this->getSelectFields( $language, $word, $entry );
464        }
465        $transcriptionStatus = $this->speechoidConnector->toIpa(
466            $item->getTranscription(),
467            $language
468        );
469        if ( $transcriptionStatus->isOk() ) {
470            $transcription = $transcriptionStatus->getValue();
471        } else {
472            $transcription = '';
473            $this->getOutput()->addHTML( Html::errorBox(
474                $this->getOutput()->msg( 'wikispeech-edit-lexicon-transcription-unretrievable' )->params( $id )->parse()
475            ) );
476        }
477
478        $fields['transcription']['default'] = $transcription;
479        $fields['preferred']['default'] = $item->getPreferred();
480        return $fields;
481    }
482
483    /**
484     * Handle submit request
485     *
486     * If there is no entry for the given word a new one is created
487     * with a new item. If the request contains an id that item is
488     * updated or, if id is empty, a new item is created. If there
489     * isn't enough information to do any of the above this returns
490     * false which sends the user to the appropriate page via
491     * `execute()`.
492     *
493     * @since 0.1.9
494     * @param array $data
495     * @return bool
496     */
497    public function submit( array $data ): bool {
498        if (
499            !array_key_exists( 'language', $data ) ||
500            !array_key_exists( 'word', $data ) ||
501            !array_key_exists( 'id', $data ) ||
502            !array_key_exists( 'transcription', $data ) ||
503            $data['transcription'] === null ||
504            !array_key_exists( 'preferred', $data )
505        ) {
506            // We don't have all the information we need to make an
507            // edit yet.
508            return false;
509        }
510
511        $language = $data['language'];
512        $transcription = $data['transcription'];
513        $sampaStatus = $this->speechoidConnector->fromIpa(
514            $transcription,
515            $language
516        );
517        if ( !$sampaStatus->isOk() ) {
518            // TODO: Show error message (T308562).
519            return false;
520        }
521
522        $sampa = $sampaStatus->getValue();
523        $word = $data['word'];
524        $id = $data['id'];
525        $preferred = $data['preferred'];
526        if ( $id === '' ) {
527            // Empty id, create new item.
528            $item = new LexiconEntryItem();
529            $properties = [
530                'strn' => $word,
531                'transcriptions' => [ (object)[ 'strn' => $sampa ] ],
532                // Status is required by Speechoid.
533                'status' => (object)[
534                    'name' => 'ok'
535                ]
536            ];
537            if ( $preferred ) {
538                $properties['preferred'] = true;
539            }
540            $item->setProperties( (object)$properties );
541            $this->lexiconStorage->createEntryItem(
542                $language,
543                $word,
544                $item
545            );
546        } else {
547            // Id already exists, update item.
548            $entry = $this->lexiconStorage->getEntry( $language, $word );
549            $item = $entry->findItemBySpeechoidIdentity( intval( $id ) );
550            if ( $item === null ) {
551                throw new RuntimeException( "No item with id '$id' found." );
552            }
553
554            $properties = $item->getProperties();
555            $properties->transcriptions[0]->strn = $sampa;
556            if ( $preferred ) {
557                $properties->preferred = true;
558            } else {
559                unset( $properties->preferred );
560            }
561
562            $item->setProperties( $properties );
563            try {
564                $this->lexiconStorage->updateEntryItem(
565                    $language,
566                    $word,
567                    $item
568                );
569            } catch ( NullEditLexiconException $e ) {
570                $this->getOutput()->addHtml(
571                    Html::warningBox(
572                        $this->msg( 'wikispeech-lexicon-null-edit' )->parse()
573                   )
574                );
575                return false;
576            }
577
578        }
579        // Item is updated by createEntryItem(), so we just need to
580        // store it.
581        $this->modifiedItem = $item;
582
583        if ( array_key_exists( 'page', $data ) && $data['page'] ) {
584            // @todo Introduce $consumerUrl to request parameters and
585            // @todo pass it down here. Currently we're passing null,
586            // @todo meaning it only support flushing local wiki
587            // @todo utterances.
588            $this->purgeOriginPageUtterances( $data['page'], null );
589        }
590
591        return true;
592    }
593
594    /**
595     * Immediately removes any utterance from the origin page.
596     * @since 0.1.8
597     * @param int $pageId
598     * @param string|null $consumerUrl
599     */
600    private function purgeOriginPageUtterances( int $pageId, ?string $consumerUrl ) {
601        $utteranceStore = new UtteranceStore();
602        $utteranceStore->flushUtterancesByPage( $consumerUrl, $pageId );
603    }
604
605    /**
606     * Make options to be used by in a select field
607     *
608     * Each language that is specified in the config variable
609     * "WikispeechVoices" is included in the options. The labels are
610     * of the format "code - autonym".
611     *
612     * @since 0.1.8
613     * @return array Keys are labels and values are language codes.
614     */
615    private function getLanguageOptions(): array {
616        $voices = $this->getConfig()->get( 'WikispeechVoices' );
617        $languages = array_keys( $voices );
618        sort( $languages );
619        $options = [];
620        foreach ( $languages as $code ) {
621            $name = $this->languageNameUtils->getLanguageName( $code );
622            $label = "$code - $name";
623            $options[$label] = $code;
624        }
625        ksort( $options );
626        return $options;
627    }
628
629    /**
630     * Show success page containing the properties of the added/edited item
631     *
632     * @since 0.1.9
633     * @param string $message Success message.
634     */
635    private function success( $message ) {
636        $this->getOutput()->addHtml(
637            Html::successBox(
638                $this->msg( $message )->parse()
639            )
640        );
641        $this->getOutput()->addHtml(
642            Html::element( 'pre', [], $this->modifiedItem )
643        );
644    }
645}