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 CommentStoreComment;
12use Content;
13use ExternalStoreException;
14use FormatJson;
15use InvalidArgumentException;
16use JsonContent;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\SlotRecord;
20use MWException;
21use Title;
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
111        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
112        if ( !$wikiPage->exists() ) {
113            return null;
114        }
115        /** @var JsonContent $content */
116        $content = $wikiPage->getContent( RevisionRecord::FOR_PUBLIC );
117        return self::deserializeEntryContent( $content, $language, $key );
118    }
119
120    /**
121     * This is a public static method because we might want to access this from a hook
122     * that validate manual changes to lexicon wiki pages. In fact, this is how it first
123     * was implemented, but we reverted. Kept the method like this though.
124     * As a reminder if nothing else.
125     *
126     * @since 0.1.9
127     * @param Content $content
128     * @param string $language
129     * @param string $key
130     * @return LexiconEntry
131     * @throws ExternalStoreException If revision content is not of type JSON.
132     *   If revision content failed to be deserialized as JSON.
133     */
134    public static function deserializeEntryContent(
135        Content $content,
136        string $language,
137        string $key
138    ) {
139        if ( !( $content instanceof JsonContent ) ) {
140            throw new ExternalStoreException( 'Revision content is not of type JsonContent' );
141        }
142        $entry = new LexiconEntry();
143        $entry->setLanguage( $language );
144        $entry->setKey( $key );
145        // $content->getData() does not force associative array.
146        $status = FormatJson::parse( $content->getText(), FormatJson::FORCE_ASSOC );
147        if ( !$status->isOK() ) {
148            throw new ExternalStoreException( 'Failed to decode revision content as JSON' );
149        }
150        $deserialized = $status->getValue();
151        foreach ( $deserialized as $itemProperties ) {
152            $entryItem = new LexiconEntryItem();
153            $entryItem->setProperties( $itemProperties );
154            // @todo assert language and key match parameters
155            $entry->addItem( $entryItem );
156        }
157        return $entry;
158    }
159
160    /**
161     * @since 0.1.9
162     * @param string $language
163     * @param string $key
164     * @param LexiconEntryItem $item
165     * @throws InvalidArgumentException If the entry already contains an item with the same id.
166     */
167    public function createEntryItem(
168        string $language,
169        string $key,
170        LexiconEntryItem $item
171    ): void {
172        $entry = $this->getEntry( $language, $key );
173        if ( $entry === null ) {
174            $entry = new LexiconEntry();
175            $entry->setLanguage( $language );
176            $entry->setKey( $key );
177        } else {
178            if ( $entry->findItemByItem( $item ) !== null ) {
179                throw new InvalidArgumentException(
180                    'Lexicon entry already contains an item with the same Speechoid identity.'
181                );
182            }
183        }
184        $entry->addItem( $item );
185        $this->saveLexiconEntryRevision(
186            $language,
187            $key,
188            $entry,
189            'Via LexiconWikiStorage::createEntryItem'
190        );
191    }
192
193    /**
194     * @since 0.1.9
195     * @param string $language
196     * @param string $key
197     * @param LexiconEntryItem $item
198     * @throws InvalidArgumentException If the entry with language and key does not exist.
199     *  If entry already contains an item with the same id.
200     */
201    public function updateEntryItem(
202        string $language,
203        string $key,
204        LexiconEntryItem $item
205    ): void {
206        $entry = $this->getEntry( $language, $key );
207        if ( $entry === null ) {
208            throw new InvalidArgumentException(
209                'Lexicon entry does not exist for that language and key.'
210            );
211        }
212        $itemIndex = $entry->findItemIndexByItem( $item );
213        if ( $itemIndex === null ) {
214            throw new InvalidArgumentException(
215                'Lexicon entry does not contain an item with the same Speechoid identity.'
216            );
217        }
218        $entry->replaceItemAt( $itemIndex, $item );
219        $this->saveLexiconEntryRevision(
220            $language,
221            $key,
222            $entry,
223            'Via LexiconWikiStorage::updateEntryItem'
224        );
225    }
226
227    /**
228     * @since 0.1.9
229     * @param string $language
230     * @param string $key
231     * @param LexiconEntryItem $item
232     * @throws InvalidArgumentException If the entry with language and key does not exist.
233     *  If entry already contains an item with the same id.
234     */
235    public function deleteEntryItem(
236        string $language,
237        string $key,
238        LexiconEntryItem $item
239    ): void {
240        $entry = $this->getEntry( $language, $key );
241        if ( $entry === null ) {
242            throw new InvalidArgumentException(
243                'Lexicon entry does not exist for that language and key.'
244            );
245        }
246        $itemIndex = $entry->findItemIndexByItem( $item );
247        if ( $itemIndex === null ) {
248            throw new InvalidArgumentException(
249                'Lexicon entry does not contain an item with the same Speechoid identity.'
250            );
251        }
252        $entry->deleteItemAt( $itemIndex );
253        $this->saveLexiconEntryRevision(
254            $language,
255            $key,
256            $entry,
257            'Via LexiconWikiStorage::deleteEntryItem'
258        );
259    }
260
261    /**
262     * @since 0.1.9
263     * @param string $language
264     * @param string $key
265     * @param LexiconEntry $entry
266     * @param string $revisionComment
267     * @throws MWException If failed to encode entry to JSON.
268     */
269    private function saveLexiconEntryRevision(
270        string $language,
271        string $key,
272        LexiconEntry $entry,
273        string $revisionComment
274    ) {
275        $array = [];
276        foreach ( $entry->getItems() as $entryItem ) {
277            $array[] = $entryItem->getProperties();
278        }
279        $json = FormatJson::encode( $array );
280        if ( $json === false ) {
281            throw new MWException( 'Failed to encode entry to JSON.' );
282        }
283        $content = new JsonContent( $json );
284        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
285        $pageUpdater = $wikiPage->newPageUpdater( $this->user );
286        $pageUpdater->setContent( SlotRecord::MAIN, $content );
287        $pageUpdater->saveRevision(
288            CommentStoreComment::newUnsavedComment( $revisionComment )
289        );
290    }
291}