Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.39% covered (warning)
67.39%
62 / 92
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LexiconWikiStorage
67.39% covered (warning)
67.39%
62 / 92
40.00% covered (danger)
40.00%
4 / 10
46.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lexiconEntryTitleFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lexiconEntryWikiPageFactory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 entryItemExists
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getEntry
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 deserializeEntryContent
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 createEntryItem
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
3.06
 updateEntryItem
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 deleteEntryItem
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
3.40
 saveLexiconEntryRevision
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
1<?php
2
3namespace MediaWiki\Wikispeech\Lexicon;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use Content;
12use ExternalStoreException;
13use FormatJson;
14use InvalidArgumentException;
15use JsonContent;
16use MediaWiki\CommentStore\CommentStoreComment;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\SlotRecord;
20use Mediawiki\Title\Title;
21use MWException;
22use User;
23use WikiPage;
24
25/**
26 * Keeps track of pronunciation lexicon entries as JSON content in the main slot
27 * of a wiki page named [[Pronunciation_lexicon:language/key]].
28 *
29 * @todo
30 * It is possible for users to edit the lexicon entries using the normal wiki interface.
31 * As for now, there is no way for the extension to populate such changes to Speechoid.
32 * In the future, we might consider adding a hook that determine how the change was done
33 * in order to either stop the edit or to pass it down to Speechoid.
34 *
35 * @since 0.1.9
36 */
37class LexiconWikiStorage implements LexiconLocalStorage {
38
39    /** @var User */
40    private $user;
41
42    /**
43     * @since 0.1.9
44     * @param User $user
45     */
46    public function __construct( User $user ) {
47        $this->user = $user;
48    }
49
50    /**
51     * @since 0.1.9
52     * @param string $language
53     * @param string $key
54     * @return Title
55     */
56    private function lexiconEntryTitleFactory(
57        string $language,
58        string $key
59    ): Title {
60        // @todo Switch to TitleFactory when upgrading to MW 1.36+
61        return Title::makeTitle( NS_PRONUNCIATION_LEXICON, $language )->getSubpage( $key );
62    }
63
64    /**
65     * @since 0.1.9
66     * @param string $language
67     * @param string $key
68     * @return WikiPage
69     */
70    private function lexiconEntryWikiPageFactory(
71        string $language,
72        string $key
73    ): WikiPage {
74        return MediaWikiServices::getInstance()->getWikiPageFactory()
75            ->newFromTitle( $this->lexiconEntryTitleFactory( $language, $key ) );
76    }
77
78    /**
79     * @since 0.1.9
80     * @param string $language
81     * @param string $key
82     * @param LexiconEntryItem $item
83     * @return bool
84     */
85    public function entryItemExists(
86        string $language,
87        string $key,
88        LexiconEntryItem $item
89    ): bool {
90        $entry = $this->getEntry( $language, $key );
91        if ( $entry === null ) {
92            return false;
93        }
94        return $entry->findItemByItem( $item ) !== null;
95    }
96
97    /**
98     * @since 0.1.9
99     * @param string $language
100     * @param string $key
101     * @return LexiconEntry|null
102     */
103    public function getEntry(
104        string $language,
105        string $key
106    ): ?LexiconEntry {
107        if ( !$language || !$key ) {
108            return null;
109        }
110        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
111        if ( !$wikiPage->exists() ) {
112            return null;
113        }
114        /** @var JsonContent $content */
115        $content = $wikiPage->getContent( RevisionRecord::FOR_PUBLIC );
116        return self::deserializeEntryContent( $content, $language, $key );
117    }
118
119    /**
120     * This is a public static method because we might want to access this from a hook
121     * that validate manual changes to lexicon wiki pages. In fact, this is how it first
122     * was implemented, but we reverted. Kept the method like this though.
123     * As a reminder if nothing else.
124     *
125     * @since 0.1.9
126     * @param Content $content
127     * @param string $language
128     * @param string $key
129     * @return LexiconEntry
130     * @throws ExternalStoreException If revision content is not of type JSON.
131     *   If revision content failed to be deserialized as JSON.
132     */
133    public static function deserializeEntryContent(
134        Content $content,
135        string $language,
136        string $key
137    ) {
138        if ( !( $content instanceof JsonContent ) ) {
139            throw new ExternalStoreException( 'Revision content is not of type JsonContent' );
140        }
141        $entry = new LexiconEntry();
142        $entry->setLanguage( $language );
143        $entry->setKey( $key );
144        $status = $content->getData();
145        if ( !$status->isGood() ) {
146            throw new ExternalStoreException( 'Failed to decode revision content as JSON' );
147        }
148        $deserialized = $status->getValue();
149        foreach ( $deserialized as $itemProperties ) {
150            $entryItem = new LexiconEntryItem();
151            $entryItem->setProperties( $itemProperties );
152            // @todo assert language and key match parameters
153            $entry->addItem( $entryItem );
154        }
155        return $entry;
156    }
157
158    /**
159     * @since 0.1.9
160     * @param string $language
161     * @param string $key
162     * @param LexiconEntryItem $item
163     * @throws InvalidArgumentException If the entry already contains an item with the same id.
164     */
165    public function createEntryItem(
166        string $language,
167        string $key,
168        LexiconEntryItem $item
169    ): void {
170        $entry = $this->getEntry( $language, $key );
171        if ( $entry === null ) {
172            $entry = new LexiconEntry();
173            $entry->setLanguage( $language );
174            $entry->setKey( $key );
175        } else {
176            if ( $entry->findItemByItem( $item ) !== null ) {
177                throw new InvalidArgumentException(
178                    'Lexicon entry already contains an item with the same Speechoid identity.'
179                );
180            }
181        }
182        $entry->addItem( $item );
183        $this->saveLexiconEntryRevision(
184            $language,
185            $key,
186            $entry,
187            'Via LexiconWikiStorage::createEntryItem'
188        );
189    }
190
191    /**
192     * @since 0.1.9
193     * @param string $language
194     * @param string $key
195     * @param LexiconEntryItem $item
196     * @throws InvalidArgumentException If the entry with language and key does not exist.
197     *  If entry already contains an item with the same id.
198     */
199    public function updateEntryItem(
200        string $language,
201        string $key,
202        LexiconEntryItem $item
203    ): void {
204        $entry = $this->getEntry( $language, $key );
205        if ( $entry === null ) {
206            throw new InvalidArgumentException(
207                'Lexicon entry does not exist for that language and key.'
208            );
209        }
210        $itemIndex = $entry->findItemIndexByItem( $item );
211        if ( $itemIndex === null ) {
212            throw new InvalidArgumentException(
213                'Lexicon entry does not contain an item with the same Speechoid identity.'
214            );
215        }
216        $entry->replaceItemAt( $itemIndex, $item );
217        $this->saveLexiconEntryRevision(
218            $language,
219            $key,
220            $entry,
221            'Via LexiconWikiStorage::updateEntryItem'
222        );
223    }
224
225    /**
226     * @since 0.1.9
227     * @param string $language
228     * @param string $key
229     * @param LexiconEntryItem $item
230     * @throws InvalidArgumentException If the entry with language and key does not exist.
231     *  If entry already contains an item with the same id.
232     */
233    public function deleteEntryItem(
234        string $language,
235        string $key,
236        LexiconEntryItem $item
237    ): void {
238        $entry = $this->getEntry( $language, $key );
239        if ( $entry === null ) {
240            throw new InvalidArgumentException(
241                'Lexicon entry does not exist for that language and key.'
242            );
243        }
244        $itemIndex = $entry->findItemIndexByItem( $item );
245        if ( $itemIndex === null ) {
246            throw new InvalidArgumentException(
247                'Lexicon entry does not contain an item with the same Speechoid identity.'
248            );
249        }
250        $entry->deleteItemAt( $itemIndex );
251        $this->saveLexiconEntryRevision(
252            $language,
253            $key,
254            $entry,
255            'Via LexiconWikiStorage::deleteEntryItem'
256        );
257    }
258
259    /**
260     * @since 0.1.9
261     * @param string $language
262     * @param string $key
263     * @param LexiconEntry $entry
264     * @param string $revisionComment
265     * @throws MWException If failed to encode entry to JSON.
266     */
267    private function saveLexiconEntryRevision(
268        string $language,
269        string $key,
270        LexiconEntry $entry,
271        string $revisionComment
272    ) {
273        $array = [];
274        foreach ( $entry->getItems() as $entryItem ) {
275            $array[] = $entryItem->getProperties();
276        }
277        $json = FormatJson::encode( $array );
278        if ( $json === false ) {
279            throw new MWException( 'Failed to encode entry to JSON.' );
280        }
281        $content = new JsonContent( $json );
282        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
283        $pageUpdater = $wikiPage->newPageUpdater( $this->user );
284        $pageUpdater->setContent( SlotRecord::MAIN, $content );
285        $pageUpdater->saveRevision(
286            CommentStoreComment::newUnsavedComment( $revisionComment )
287        );
288    }
289}