Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.62% covered (warning)
59.62%
62 / 104
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LexiconWikiStorage
59.62% covered (warning)
59.62%
62 / 104
33.33% covered (danger)
33.33%
4 / 12
79.64
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
 setUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
 replaceEntryItem
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
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 RuntimeException;
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     * This is required when running from maintenance scripts,
52     * where no user context is available by default.
53     *
54     * @since 0.1.13
55     * @param User $user The user to perform edits as.
56     */
57    public function setUser( User $user ): void {
58        $this->user = $user;
59    }
60
61    /**
62     * @since 0.1.9
63     * @param string $language
64     * @param string $key
65     * @return Title
66     */
67    private function lexiconEntryTitleFactory(
68        string $language,
69        string $key
70    ): Title {
71        // @todo Switch to TitleFactory when upgrading to MW 1.36+
72        return Title::makeTitle( NS_PRONUNCIATION_LEXICON, $language )->getSubpage( $key );
73    }
74
75    /**
76     * @since 0.1.9
77     * @param string $language
78     * @param string $key
79     * @return WikiPage
80     */
81    private function lexiconEntryWikiPageFactory(
82        string $language,
83        string $key
84    ): WikiPage {
85        return MediaWikiServices::getInstance()->getWikiPageFactory()
86            ->newFromTitle( $this->lexiconEntryTitleFactory( $language, $key ) );
87    }
88
89    /**
90     * @since 0.1.9
91     * @param string $language
92     * @param string $key
93     * @param LexiconEntryItem $item
94     * @return bool
95     */
96    public function entryItemExists(
97        string $language,
98        string $key,
99        LexiconEntryItem $item
100    ): bool {
101        $entry = $this->getEntry( $language, $key );
102        if ( $entry === null ) {
103            return false;
104        }
105        return $entry->findItemByItem( $item ) !== null;
106    }
107
108    /**
109     * @since 0.1.9
110     * @param string $language
111     * @param string $key
112     * @return LexiconEntry|null
113     */
114    public function getEntry(
115        string $language,
116        string $key
117    ): ?LexiconEntry {
118        if ( !$language || !$key ) {
119            return null;
120        }
121        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
122        if ( !$wikiPage->exists() ) {
123            return null;
124        }
125        /** @var JsonContent $content */
126        $content = $wikiPage->getContent( RevisionRecord::FOR_PUBLIC );
127        return self::deserializeEntryContent( $content, $language, $key );
128    }
129
130    /**
131     * This is a public static method because we might want to access this from a hook
132     * that validate manual changes to lexicon wiki pages. In fact, this is how it first
133     * was implemented, but we reverted. Kept the method like this though.
134     * As a reminder if nothing else.
135     *
136     * @since 0.1.9
137     * @param Content $content
138     * @param string $language
139     * @param string $key
140     * @return LexiconEntry
141     * @throws ExternalStoreException If revision content is not of type JSON.
142     *   If revision content failed to be deserialized as JSON.
143     */
144    public static function deserializeEntryContent(
145        Content $content,
146        string $language,
147        string $key
148    ) {
149        if ( !( $content instanceof JsonContent ) ) {
150            throw new ExternalStoreException( 'Revision content is not of type JsonContent' );
151        }
152        $entry = new LexiconEntry();
153        $entry->setLanguage( $language );
154        $entry->setKey( $key );
155        $status = $content->getData();
156        if ( !$status->isGood() ) {
157            throw new ExternalStoreException( 'Failed to decode revision content as JSON' );
158        }
159        $deserialized = $status->getValue();
160        foreach ( $deserialized as $itemProperties ) {
161            $entryItem = new LexiconEntryItem();
162            $entryItem->setProperties( $itemProperties );
163            // @todo assert language and key match parameters
164            $entry->addItem( $entryItem );
165        }
166        return $entry;
167    }
168
169    /**
170     * @since 0.1.9
171     * @param string $language
172     * @param string $key
173     * @param LexiconEntryItem $item
174     * @throws InvalidArgumentException If the entry already contains an item with the same id.
175     */
176    public function createEntryItem(
177        string $language,
178        string $key,
179        LexiconEntryItem $item
180    ): void {
181        $entry = $this->getEntry( $language, $key );
182        if ( $entry === null ) {
183            $entry = new LexiconEntry();
184            $entry->setLanguage( $language );
185            $entry->setKey( $key );
186        } else {
187            if ( $entry->findItemByItem( $item ) !== null ) {
188                throw new InvalidArgumentException(
189                    'Lexicon entry already contains an item with the same Speechoid identity.'
190                );
191            }
192        }
193        $entry->addItem( $item );
194        $this->saveLexiconEntryRevision(
195            $language,
196            $key,
197            $entry,
198            'Via LexiconWikiStorage::createEntryItem'
199        );
200    }
201
202    /**
203     * @since 0.1.9
204     * @param string $language
205     * @param string $key
206     * @param LexiconEntryItem $item
207     * @throws InvalidArgumentException If the entry with language and key does not exist.
208     *  If entry already contains an item with the same id.
209     */
210    public function updateEntryItem(
211        string $language,
212        string $key,
213        LexiconEntryItem $item
214    ): void {
215        $entry = $this->getEntry( $language, $key );
216        if ( $entry === null ) {
217            throw new InvalidArgumentException(
218                'Lexicon entry does not exist for that language and key.'
219            );
220        }
221        $itemIndex = $entry->findItemIndexByItem( $item );
222        if ( $itemIndex === null ) {
223            throw new InvalidArgumentException(
224                'Lexicon entry does not contain an item with the same Speechoid identity.'
225            );
226        }
227        $entry->replaceItemAt( $itemIndex, $item );
228        $this->saveLexiconEntryRevision(
229            $language,
230            $key,
231            $entry,
232            'Via LexiconWikiStorage::updateEntryItem'
233        );
234    }
235
236    /**
237     * @since 0.1.9
238     * @param string $language
239     * @param string $key
240     * @param LexiconEntryItem $item
241     * @throws InvalidArgumentException If the entry with language and key does not exist.
242     *  If entry already contains an item with the same id.
243     */
244    public function deleteEntryItem(
245        string $language,
246        string $key,
247        LexiconEntryItem $item
248    ): void {
249        $entry = $this->getEntry( $language, $key );
250        if ( $entry === null ) {
251            throw new InvalidArgumentException(
252                'Lexicon entry does not exist for that language and key.'
253            );
254        }
255        $itemIndex = $entry->findItemIndexByItem( $item );
256        if ( $itemIndex === null ) {
257            throw new InvalidArgumentException(
258                'Lexicon entry does not contain an item with the same Speechoid identity.'
259            );
260        }
261        $entry->deleteItemAt( $itemIndex );
262        $this->saveLexiconEntryRevision(
263            $language,
264            $key,
265            $entry,
266            'Via LexiconWikiStorage::deleteEntryItem'
267        );
268    }
269
270    /**
271     * @since 0.1.9
272     * @param string $language
273     * @param string $key
274     * @param LexiconEntry $entry
275     * @param string $revisionComment
276     * @throws RuntimeException If failed to encode entry to JSON.
277     */
278    public function saveLexiconEntryRevision(
279        string $language,
280        string $key,
281        LexiconEntry $entry,
282        string $revisionComment
283    ) {
284        $array = [];
285        foreach ( $entry->getItems() as $entryItem ) {
286            $array[] = $entryItem->getProperties();
287        }
288        $json = FormatJson::encode( $array );
289        if ( $json === false ) {
290            throw new RuntimeException( 'Failed to encode entry to JSON.' );
291        }
292        $content = new JsonContent( $json );
293        $wikiPage = $this->lexiconEntryWikiPageFactory( $language, $key );
294        $pageUpdater = $wikiPage->newPageUpdater( $this->user );
295        $pageUpdater->setContent( SlotRecord::MAIN, $content );
296        $pageUpdater->saveRevision(
297            CommentStoreComment::newUnsavedComment( $revisionComment )
298        );
299    }
300
301    /**
302     * Replaces or adds a lexicon item based on its Speechoid identity.
303     * If the entry does not exist, it will be created.
304     * Existing item with the same identity is removed before adding the new one.
305     *
306     * @since 0.1.13
307     * @param string $language
308     * @param string $key
309     * @param LexiconEntryItem $newItem
310     */
311    public function replaceEntryItem( string $language, string $key, LexiconEntryItem $newItem ): void {
312        $entry = $this->getEntry( $language, $key );
313        if ( $entry === null ) {
314            $entry = new LexiconEntry();
315            $entry->setLanguage( $language );
316            $entry->setKey( $key );
317        }
318
319        $identity = $newItem->getSpeechoidIdentity();
320        $items = $entry->getItems();
321
322        $items = array_filter( $items, static fn ( $item ) => $item->getSpeechoidIdentity() !== $identity );
323
324        $items[] = $newItem;
325        $entry->setItems( $items );
326
327        $this->saveLexiconEntryRevision( $language, $key, $entry, "Replaced item with Speechoid ID $identity" );
328    }
329
330}