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