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