Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.96% covered (danger)
10.96%
24 / 219
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditLexicon
10.96% covered (danger)
10.96%
24 / 219
20.00% covered (danger)
20.00%
2 / 10
1169.51
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
 execute
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
156
 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 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 submit
12.73% covered (danger)
12.73%
7 / 55
0.00% covered (danger)
0.00%
0 / 1
144.28
 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 Config;
12use ConfigFactory;
13use Html;
14use HTMLForm;
15use MediaWiki\Languages\LanguageNameUtils;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\Wikispeech\Lexicon\LexiconEntry;
18use MediaWiki\Wikispeech\Lexicon\LexiconEntryItem;
19use MediaWiki\Wikispeech\Lexicon\LexiconStorage;
20use MediaWiki\Wikispeech\SpeechoidConnector;
21use MediaWiki\Wikispeech\Utterance\UtteranceStore;
22use MWException;
23use Psr\Log\LoggerInterface;
24use SpecialPage;
25
26/**
27 * Special page for editing the lexicon.
28 *
29 * @since 0.1.8
30 */
31
32class SpecialEditLexicon extends SpecialPage {
33
34    /** @var Config */
35    private $config;
36
37    /** @var LanguageNameUtils */
38    private $languageNameUtils;
39
40    /** @var LexiconStorage */
41    private $lexiconStorage;
42
43    /** @var SpeechoidConnector */
44    private $speechoidConnector;
45
46    /** @var LexiconEntryItem */
47    private $modifiedItem;
48
49    /** @var LoggerInterface */
50    private $logger;
51
52    /** @var string */
53    private $postText;
54
55    /**
56     * @since 0.1.8
57     * @param ConfigFactory $configFactory
58     * @param LanguageNameUtils $languageNameUtils
59     * @param LexiconStorage $lexiconStorage
60     * @param SpeechoidConnector $speechoidConnector
61     */
62    public function __construct(
63        $configFactory,
64        $languageNameUtils,
65        $lexiconStorage,
66        $speechoidConnector
67    ) {
68        parent::__construct( 'EditLexicon', 'wikispeech-edit-lexicon' );
69        $this->config = $configFactory->makeConfig( 'wikispeech' );
70        $this->languageNameUtils = $languageNameUtils;
71        $this->lexiconStorage = $lexiconStorage;
72        $this->speechoidConnector = $speechoidConnector;
73        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
74        $this->postText = '';
75    }
76
77    /**
78     * @since 0.1.8
79     * @param string|null $subpage
80     */
81    public function execute( $subpage ) {
82        $this->setHeaders();
83        $this->checkPermissions();
84
85        $request = $this->getRequest();
86        $language = $request->getText( 'language' );
87        $word = $request->getText( 'word' );
88        if ( $request->getText( 'id' ) === '' ) {
89            $id = '';
90        } else {
91            $id = $request->getIntOrNull( 'id' );
92        }
93        $entry = $this->lexiconStorage->getEntry( $language, $word );
94        $copyrightNote = $this->msg( 'wikispeech-lexicon-copyrightnote' )->parse();
95        $this->postText = Html::rawElement( 'p', [], $copyrightNote );
96        $successMessage = '';
97
98        $formId = '';
99        $submitMessage = 'wikispeech-lexicon-next';
100        if ( !$language || !$word ) {
101            $formId = 'lookup';
102            $fields = $this->getLookupFields();
103        } elseif ( $entry === null ) {
104            $formId = 'newEntry';
105            $fields = $this->getAddFields( $language, $word );
106            $submitMessage = 'wikispeech-lexicon-save';
107            $successMessage = 'wikispeech-lexicon-add-entry-success';
108        } elseif ( !in_array( 'id', $request->getValueNames() ) ) {
109            $formId = 'selectItem';
110            $fields = $this->getSelectFields( $language, $word, $entry );
111        } elseif ( $id ) {
112            $formId = 'editItem';
113            $fields = $this->getEditFields( $language, $word, $id );
114            $submitMessage = 'wikispeech-lexicon-save';
115            $successMessage = 'wikispeech-lexicon-edit-entry-success';
116        } elseif ( $id === '' ) {
117            $formId = 'newItem';
118            $fields = $this->getAddFields( $language, $word );
119            $submitMessage = 'wikispeech-lexicon-save';
120            $successMessage = 'wikispeech-lexicon-add-entry-success';
121        } else {
122            // We have a set of parameters that we can't do anything
123            // with. Show the first page.
124            $formId = 'lookup';
125            $fields = $this->getLookupFields();
126        }
127
128        // Set default values from the parameters.
129        foreach ( $fields as $field ) {
130            $name = $field['name'];
131            $value = $request->getVal( $name );
132            if ( $value !== null ) {
133                // There's no extra conversion logic so default values
134                // are set to strings and handled down the
135                // line. E.g. boolean values are true for "false" or
136                // "no".
137                $fields[$name]['default'] = $value;
138            }
139        }
140        $form = HTMLForm::factory(
141            'ooui',
142            $fields,
143            $this->getContext()
144        );
145        $form->setFormIdentifier( $formId );
146        $form->setSubmitCallback( [ $this, 'submit' ] );
147        $form->setSubmitTextMsg( $submitMessage );
148        $form->setPostText( $this->postText );
149        if ( $form->show() && $successMessage ) {
150            $this->success( $successMessage );
151        }
152
153        $this->getOutput()->addModules( [
154            'ext.wikispeech.specialEditLexicon'
155        ] );
156    }
157
158    /**
159     * Create a field descriptor for looking up a word
160     *
161     * Has one field for language and one for word.
162     *
163     * @since 0.1.10
164     * @return array
165     */
166    private function getLookupFields(): array {
167        $fields = [
168            'language' => [
169                'name' => 'language',
170                'type' => 'select',
171                'label' => $this->msg( 'wikispeech-language' )->text(),
172                'options' => $this->getLanguageOptions(),
173                'id' => 'ext-wikispeech-language'
174            ],
175            'word' => [
176                'name' => 'word',
177                'type' => 'text',
178                'label' => $this->msg( 'wikispeech-word' )->text(),
179                'required' => true
180            ],
181            'page' => [
182                'name' => 'page',
183                'type' => 'hidden'
184            ]
185        ];
186        return $fields;
187    }
188
189    /**
190     * Create a field descriptor for selecting an item
191     *
192     * Has a field for selecting the id of the item to edit or "new"
193     * for creating a new item. Also shows fields for language and
194     * word from previous page, but readonly.
195     *
196     * @since 0.1.10
197     * @param string $language
198     * @param string $word
199     * @param LexiconEntry|null $entry
200     * @return array
201     */
202    private function getSelectFields(
203        string $language,
204        string $word,
205        ?LexiconEntry $entry = null
206    ): array {
207        $fields = $this->getLookupFields();
208        $fields['language']['readonly'] = true;
209        $fields['language']['type'] = 'text';
210        $fields['word']['readonly'] = true;
211        $fields['word']['required'] = false;
212
213        $newLabel = $this->msg( 'wikispeech-lexicon-new' )->text();
214        $itemOptions = [ $newLabel => '' ];
215        if ( $entry ) {
216            foreach ( $entry->getItems() as $item ) {
217                $properties = $item->getProperties();
218                if ( !isset( $properties['id'] ) ) {
219                    $this->logger->warning(
220                        __METHOD__ . ': Skipping item with no id.'
221                    );
222                    continue;
223                }
224                $id = $properties['id'];
225                // Add item id as option for selection.
226                $itemOptions[$id] = $id;
227                // Add item to info text.
228                $this->postText .= Html::element( 'pre', [], $item );
229            }
230        }
231
232        $fields['id'] = [
233            'name' => 'id',
234            'type' => 'select',
235            'label' => $this->msg( 'wikispeech-item-id' )->text(),
236            'options' => $itemOptions,
237            'default' => ''
238        ];
239        return $fields;
240    }
241
242    /**
243     * Create a field descriptor for adding an entry or item
244     *
245     * Has fields for transcription and preferred. Item id is held by
246     * a hidden field. Also shows fields for language and word from
247     * previous page, but readonly.
248     *
249     * @since 0.1.10
250     * @param string $language
251     * @param string $word
252     * @return array
253     */
254    private function getAddFields( string $language, string $word ): array {
255        $fields = $this->getSelectFields( $language, $word );
256        $fields['id']['type'] = 'hidden';
257        $fields += [
258            'transcription' => [
259                'name' => 'transcription',
260                'type' => 'textwithbutton',
261                'label' => $this->msg( 'wikispeech-transcription' )->text(),
262                'required' => true,
263                'id' => 'ext-wikispeech-transcription',
264                'buttontype' => 'button',
265                'buttondefault' => $this->msg( 'wikispeech-preview' )->text(),
266                'buttonid' => 'ext-wikispeech-preview-button'
267            ],
268            'preferred' => [
269                'name' => 'preferred',
270                'type' => 'check',
271                'label' => $this->msg( 'wikispeech-preferred' )->text()
272            ]
273        ];
274        return $fields;
275    }
276
277    /**
278     * Create a field descriptor for editing an item
279     *
280     * Has fields for transcription and preferred with default values
281     * from the lexicon. Item id is held by a hidden field. Also shows
282     * fields for language and word from previous page, but readonly.
283     *
284     * @since 0.1.10
285     * @param string $language
286     * @param string $word
287     * @param int $id
288     * @return array
289     */
290    private function getEditFields( string $language, string $word, int $id ): array {
291        $fields = $this->getAddFields( $language, $word );
292        $entry = $this->lexiconStorage->getEntry( $language, $word );
293        $item = $entry->findItemBySpeechoidIdentity( $id );
294        if ( $item === null ) {
295            throw new MWException( "No item with id '$id' found." );
296            // TODO: Show error message (T308562).
297        }
298        $transcriptionStatus = $this->speechoidConnector->toIpa(
299            $item->getTranscription(),
300            $language
301        );
302        if ( $transcriptionStatus->isOk() ) {
303            $transcription = $transcriptionStatus->getValue();
304        } else {
305            $transcription = '';
306            // TODO: Show error message (T308562).
307        }
308
309        $fields['transcription']['default'] = $transcription;
310        $fields['preferred']['default'] = $item->getPreferred();
311        return $fields;
312    }
313
314    /**
315     * Handle submit request
316     *
317     * If there is no entry for the given word a new one is created
318     * with a new item. If the request contains an id that item is
319     * updated or, if id is empty, a new item is created. If there
320     * isn't enough information to do any of the above this returns
321     * false which sends the user to the appropriate page via
322     * `execute()`.
323     *
324     * @since 0.1.9
325     * @param array $data
326     * @return bool
327     */
328    public function submit( array $data ): bool {
329        if (
330            !array_key_exists( 'language', $data ) ||
331            !array_key_exists( 'word', $data ) ||
332            !array_key_exists( 'id', $data ) ||
333            !array_key_exists( 'transcription', $data ) ||
334            $data['transcription'] === null ||
335            !array_key_exists( 'preferred', $data )
336        ) {
337            // We don't have all the information we need to make an
338            // edit yet.
339            return false;
340        }
341
342        $language = $data['language'];
343        $transcription = $data['transcription'];
344        $sampaStatus = $this->speechoidConnector->fromIpa(
345            $transcription,
346            $language
347        );
348        if ( !$sampaStatus->isOk() ) {
349            // TODO: Show error message (T308562).
350            return false;
351        }
352
353        $sampa = $sampaStatus->getValue();
354        $word = $data['word'];
355        $id = $data['id'];
356        $preferred = $data['preferred'];
357        if ( $id === '' ) {
358            // Empty id, create new item.
359            $item = new LexiconEntryItem();
360            $properties = [
361                'strn' => $word,
362                'transcriptions' => [ [ 'strn' => $sampa ] ],
363                // Status is required by Speechoid.
364                'status' => [
365                    'name' => 'ok'
366                ]
367            ];
368            if ( $preferred ) {
369                $properties['preferred'] = true;
370            }
371            $item->setProperties( $properties );
372            $this->lexiconStorage->createEntryItem(
373                $language,
374                $word,
375                $item
376            );
377        } else {
378            // Id already exists, update item.
379            $entry = $this->lexiconStorage->getEntry( $language, $word );
380            $item = $entry->findItemBySpeechoidIdentity( intval( $id ) );
381            if ( $item === null ) {
382                throw new MWException( "No item with id '$id' found." );
383            }
384            $properties = $item->getProperties();
385            $properties['transcriptions'] = [ [ 'strn' => $sampa ] ];
386            if ( $preferred ) {
387                $properties['preferred'] = true;
388            } else {
389                unset( $properties['preferred'] );
390            }
391            $item->setProperties( $properties );
392            $this->lexiconStorage->updateEntryItem(
393                $language,
394                $word,
395                $item
396            );
397        }
398        // Item is updated by createEntryItem(), so we just need to
399        // store it.
400        $this->modifiedItem = $item;
401
402        if ( array_key_exists( 'page', $data ) && $data['page'] ) {
403            // @todo Introduce $consumerUrl to request parameters and
404            // @todo pass it down here. Currently we're passing null,
405            // @todo meaning it only support flushing local wiki
406            // @todo utterances.
407            $this->purgeOriginPageUtterances( $data['page'], null );
408        }
409
410        return true;
411    }
412
413    /**
414     * Immediately removes any utterance from the origin page.
415     * @since 0.1.8
416     * @param int $pageId
417     * @param string|null $consumerUrl
418     */
419    private function purgeOriginPageUtterances( int $pageId, ?string $consumerUrl ) {
420        $utteranceStore = new UtteranceStore();
421        $utteranceStore->flushUtterancesByPage( $consumerUrl, $pageId );
422    }
423
424    /**
425     * Make options to be used by in a select field
426     *
427     * Each language that is specified in the config variable
428     * "WikispeechVoices" is included in the options. The labels are
429     * of the format "code - autonym".
430     *
431     * @since 0.1.8
432     * @return array Keys are labels and values are language codes.
433     */
434    private function getLanguageOptions(): array {
435        $voices = $this->config->get( 'WikispeechVoices' );
436        $languages = array_keys( $voices );
437        sort( $languages );
438        $options = [];
439        foreach ( $languages as $code ) {
440            $name = $this->languageNameUtils->getLanguageName( $code );
441            $label = "$code - $name";
442            $options[$label] = $code;
443        }
444        ksort( $options );
445        return $options;
446    }
447
448    /**
449     * Show success page containing the properties of the added/edited item
450     *
451     * @since 0.1.9
452     * @param string $message Success message.
453     */
454    private function success( $message ) {
455        $this->getOutput()->addHtml(
456            Html::successBox(
457                $this->msg( $message )->text()
458            )
459        );
460        $this->getOutput()->addHtml(
461            Html::element( 'pre', [], $this->modifiedItem )
462        );
463    }
464}