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