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