Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.39% covered (warning)
73.39%
80 / 109
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LexiconHandler
73.39% covered (warning)
73.39%
80 / 109
55.56% covered (warning)
55.56%
5 / 9
70.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getEntry
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
21.73
 getLocalEntry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 syncEntryItem
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
4.79
 outOfSyncItemFactory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createEntryItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateEntryItem
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
13.28
 removePreferred
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 deleteEntryItem
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Wikispeech\Lexicon;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use FormatJson;
12use InvalidArgumentException;
13use RuntimeException;
14
15/**
16 * Keeps track of lexicon entries in Speechoid and local storage.
17 *
18 * The local storage could contain less items per entry than the Speechoid storage.
19 * The local storage must not contain items that is not available in the Speechoid storage.
20 * Items in the local storage must equal those in the Speechoid storage.
21 *
22 * All requests are first sent to Speechoid, then to the local. If Speechoid fails,
23 * nothing will be invoked on the local. However, since all actions are atomic, it
24 * is possible we mess things up if one of the systems shut down in the middle of
25 * everything. We might for instance access Speechoid to retrieve the current version
26 * of an item prior to updating it locally or in Speechoid. Such an event could put
27 * us out of sync for this item.
28 *
29 * The only way to solve this problem is to introduce a transaction that spans
30 * both storages, so that we can roll back changes in both of them, or making sure
31 * that data isn't committed if an operation is killed halfway through.
32 * That would be quite an undertaking and we doubt this will ever be implemented.
33 *
34 * @note What if Speechoid is successful but the local fails?
35 * If it's due to locking of local Wiki page, then user will get an error and will
36 * have to retry.
37 *
38 * @since 0.1.8
39 */
40class LexiconHandler implements LexiconStorage {
41
42    /** @var LexiconLocalStorage */
43    private $localStorage;
44
45    /** @var LexiconSpeechoidStorage */
46    private $speechoidStorage;
47
48    /**
49     * @since 0.1.8
50     * @param LexiconSpeechoidStorage $speechoidStorage
51     * @param LexiconStorage $localStorage
52     */
53    public function __construct(
54        LexiconSpeechoidStorage $speechoidStorage,
55        LexiconStorage $localStorage
56    ) {
57        $this->speechoidStorage = $speechoidStorage;
58        $this->localStorage = $localStorage;
59    }
60
61    /**
62     * Retrieves entries from both local and Speechoid lexicon
63     * and ensures data integrity before returning the Speechoid entry.
64     *
65     * Entry items existing in local and not in Speechoid is an error.
66     * Entry items sharing identity that otherwise does not equal each other is an error.
67     *
68     * @since 0.1.8
69     * @param string $language
70     * @param string $key
71     * @return LexiconEntry|null Entry retrieved from Speechoid
72     * @throws RuntimeException On various merge errors.
73     */
74    public function getEntry(
75        string $language,
76        string $key
77    ): ?LexiconEntry {
78        $speechoidEntry = $this->speechoidStorage->getEntry( $language, $key );
79        $localEntry = $this->localStorage->getEntry( $language, $key );
80        if ( $localEntry === null && $speechoidEntry !== null ) {
81            return $speechoidEntry;
82        } elseif ( $localEntry !== null && $speechoidEntry === null ) {
83            throw new RuntimeException(
84                'Storages out of sync. Local storage contains items unknown to Speechoid.'
85            );
86        } elseif ( $localEntry === null && $speechoidEntry === null ) {
87            return null;
88        }
89
90        // Ensure that all items in local entry also exists in Speechoid entry.
91
92        /** @var LexiconEntryItem[] $itemsOutOfSync */
93        $itemsOutOfSync = [];
94
95        foreach ( $localEntry->getItems() as $localEntryItem ) {
96            $localEntryItemSpeechoidIdentity = $localEntryItem->getSpeechoidIdentity();
97            if ( $localEntryItemSpeechoidIdentity === null ) {
98                $itemsOutOfSync[] = $this->outOfSyncItemFactory(
99                    'Local missing identity',
100                    $localEntryItem,
101                    null
102                );
103                continue;
104            }
105
106            $matchingSpeechoidEntryItem = null;
107            foreach ( $speechoidEntry->getItems() as $speechoidEntryItem ) {
108                if ( $speechoidEntryItem->getSpeechoidIdentity() === $localEntryItemSpeechoidIdentity ) {
109                    $matchingSpeechoidEntryItem = $speechoidEntryItem;
110                    break;
111                }
112            }
113
114            // @note Validation is split up to show we want future handling that differs depending
115            // on what error we have. It might in fact be Speechoid that is out of sync.
116
117            if ( $matchingSpeechoidEntryItem === null ) {
118                // Only exists in local lexicon.
119                $itemsOutOfSync[] = $this->outOfSyncItemFactory(
120                    'Identity only exists locally',
121                    $localEntryItem,
122                    null
123                );
124                continue;
125            }
126
127            // Use != instead of !== since the latter also compares order, which is not relevant.
128            if ( $localEntryItem->getProperties() != $matchingSpeechoidEntryItem->getProperties() ) {
129                // Exists in both, but the item properties are not equal.
130                $itemsOutOfSync[] = $this->outOfSyncItemFactory(
131                    'Same identities but not equal',
132                    $localEntryItem,
133                    $matchingSpeechoidEntryItem
134                );
135                continue;
136            }
137        }
138
139        $failedLocalEntryItemsCount = count( $itemsOutOfSync );
140        if ( $failedLocalEntryItemsCount > 0 ) {
141            throw new RuntimeException(
142                'Storages out of sync. ' . $failedLocalEntryItemsCount .
143                ' entry items from local and Speechoid lexicon failed to merge.: ' .
144                FormatJson::encode( $itemsOutOfSync )
145            );
146        }
147
148        return $speechoidEntry;
149    }
150
151    /**
152     * @since 0.1.12
153     * @param string $language
154     * @param string $key
155     * @return LexiconEntry|null
156     */
157    public function getLocalEntry( string $language, string $key ): ?LexiconEntry {
158        return $this->localStorage->getEntry( $language, $key );
159    }
160
161    /**
162     * @since 0.1.12
163     * @param string $language
164     * @param string $key
165     * @param int $speechoidId
166     */
167    public function syncEntryItem( string $language, string $key, int $speechoidId ): void {
168        $speechoidEntry = $this->speechoidStorage->getEntry( $language, $key );
169
170        if ( $speechoidEntry === null ) {
171            throw new RuntimeException( "Speechoid entry not found for language '$language' and key '$key'" .
172            "If this entry previously existed in the wiki lexicon but is missing in Speechoid, " .
173            "run the maintenance script 'populateSpeechoidLexiconFromWiki.php' to restore it."
174            );
175        }
176
177        $matchingSpeechoidItem = $speechoidEntry->findItemBySpeechoidIdentity( $speechoidId );
178
179        if ( $matchingSpeechoidItem === null ) {
180            throw new RuntimeException( "Speechoid ID not found for '$language' and key '$key'" .
181            "You may need to run 'populateSpeechoidLexiconFromWiki.php' to re-create missing Speechoid entries."
182            );
183        }
184
185        $this->localStorage->updateEntryItem( $language, $key, $matchingSpeechoidItem );
186    }
187
188    /**
189     * @since 0.1.9
190     * @param string $message
191     * @param LexiconEntryItem|null $localItem
192     * @param LexiconEntryItem|null $speechoidItem
193     * @return array
194     */
195    private function outOfSyncItemFactory(
196        string $message,
197        ?LexiconEntryItem $localItem,
198        ?LexiconEntryItem $speechoidItem
199    ): array {
200        return [
201            'message' => $message,
202            'localItem' => $localItem !== null ? $localItem->getProperties() : null,
203            'speechoidItem' => $speechoidItem !== null ? $speechoidItem->getProperties() : null
204        ];
205    }
206
207    /**
208     * @since 0.1.8
209     * @param string $language
210     * @param string $key
211     * @param LexiconEntryItem $item
212     */
213    public function createEntryItem(
214        string $language,
215        string $key,
216        LexiconEntryItem $item
217    ): void {
218        $this->updateEntryItem( $language, $key, $item );
219    }
220
221    /**
222     * This is in fact a put-action, it will call underlying create methods if required.
223     *
224     * @since 0.1.8
225     * @param string $language
226     * @param string $key
227     * @param LexiconEntryItem $item Will be updated on success.
228     * @throws InvalidArgumentException If $item->properties is null.
229     * @throws NullEditLexiconException
230     * @throws RuntimeException If unable to push to any storage.
231     *  If successfully pushed to Speechoid but unable to push to local storage.
232     */
233    public function updateEntryItem(
234        string $language,
235        string $key,
236        LexiconEntryItem $item
237    ): void {
238        if ( $item->getProperties() === null ) {
239            // @todo Better sanity check, ensure that required values (IPA, etc) are set.
240            throw new InvalidArgumentException( '$item->properties must not be null.' );
241        }
242        $itemSpeechoidIdentity = $item->getSpeechoidIdentity();
243        $wasPreferred = false;
244
245        // will check if the entry already exists in speechoid storage and has the same properties
246        // if the item exists with the same ID, then compare to decide if it should be updated or not
247        if ( $itemSpeechoidIdentity !== null ) {
248            $currentEntry = $this->speechoidStorage->getEntry( $language, $key );
249            if ( $currentEntry !== null ) {
250                $currentItem = $currentEntry->findItemBySpeechoidIdentity( $itemSpeechoidIdentity );
251                if ( $currentItem !== null && $currentItem->getProperties() == $item->getProperties() ) {
252                    throw new NullEditLexiconException();
253                }
254                $wasPreferred = $currentItem ? $currentItem->getPreferred() : false;
255            }
256        }
257
258        if ( $itemSpeechoidIdentity === null ) {
259            $this->speechoidStorage->createEntryItem( $language, $key, $item );
260            $this->localStorage->createEntryItem( $language, $key, $item );
261        } else {
262            // If item has not been created in local storage,
263            // then we should fetch the current revision from Speechoid
264            // and create that in local storage
265            // before we then update the local storage with the new data.
266            if ( !$this->localStorage->entryItemExists( $language, $key, $item ) ) {
267                $currentSpeechoidEntry = $this->speechoidStorage->getEntry( $language, $key );
268                if ( $currentSpeechoidEntry === null ) {
269                    throw new RuntimeException( 'Expected current Speechoid entry to exist.' );
270                }
271                $currentSpeechoidEntryItem = $currentSpeechoidEntry->findItemBySpeechoidIdentity(
272                    $itemSpeechoidIdentity
273                );
274                if ( $currentSpeechoidEntryItem === null ) {
275                    throw new RuntimeException( 'Expected current Speechoid entry item to exists.' );
276                }
277                $wasPreferred = $currentSpeechoidEntryItem->getPreferred();
278                $this->localStorage->createEntryItem( $language, $key, $currentSpeechoidEntryItem );
279            } else {
280                $currentLocalEntry = $this->localStorage
281                    ->getEntry( $language, $key );
282                $currentLocalEntryItem = $currentLocalEntry->
283                    findItemBySpeechoidIdentity( $itemSpeechoidIdentity );
284                $wasPreferred = $currentLocalEntryItem->getPreferred();
285            }
286            $this->speechoidStorage->updateEntryItem( $language, $key, $item );
287            $this->localStorage->updateEntryItem( $language, $key, $item );
288        }
289        if ( $item->getPreferred() && !$wasPreferred ) {
290            $this->removePreferred( $language, $key, $item );
291        }
292    }
293
294    /**
295     * Remove "preferred" from all item in the an entry except one
296     *
297     * This is used to mirror the behaviour of Speechoid, which does
298     * this internally when preferred is set to true.
299     *
300     * @since 0.1.10
301     * @param string $language
302     * @param string $key
303     * @param LexiconEntryItem $excludedItem This item will not be
304     *  touched. Used to keep the preferred on the item that was just
305     *  set to true.
306     */
307    private function removePreferred( $language, $key, $excludedItem ): void {
308        $entry = $this->localStorage->getEntry( $language, $key );
309        foreach ( $entry->getItems() as $item ) {
310            if ( $item->getSpeechoidIdentity() !== $excludedItem->getSpeechoidIdentity() ) {
311                $item->removePreferred();
312                $this->localStorage->updateEntryItem( $language, $key, $item );
313            }
314        }
315    }
316
317    /**
318     * @since 0.1.8
319     * @param string $language
320     * @param string $key
321     * @param LexiconEntryItem $item
322     * @throws RuntimeException If successfully deleted in Speechoid but unable to delete in local storage.
323     */
324    public function deleteEntryItem(
325        string $language,
326        string $key,
327        LexiconEntryItem $item
328    ): void {
329        $this->speechoidStorage->deleteEntryItem( $language, $key, $item );
330        $this->localStorage->deleteEntryItem( $language, $key, $item );
331    }
332}