Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.92% covered (warning)
76.92%
80 / 104
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LexiconHandler
76.92% covered (warning)
76.92%
80 / 104
55.56% covered (warning)
55.56%
5 / 9
59.66
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
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 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        }
173
174        $matchingSpeechoidItem = $speechoidEntry->findItemBySpeechoidIdentity( $speechoidId );
175
176        if ( $matchingSpeechoidItem === null ) {
177            throw new RuntimeException( "Speechoid ID not found for '$language' and key '$key'" );
178        }
179
180        $this->localStorage->updateEntryItem( $language, $key, $matchingSpeechoidItem );
181    }
182
183    /**
184     * @since 0.1.9
185     * @param string $message
186     * @param LexiconEntryItem|null $localItem
187     * @param LexiconEntryItem|null $speechoidItem
188     * @return array
189     */
190    private function outOfSyncItemFactory(
191        string $message,
192        ?LexiconEntryItem $localItem,
193        ?LexiconEntryItem $speechoidItem
194    ): array {
195        return [
196            'message' => $message,
197            'localItem' => $localItem !== null ? $localItem->getProperties() : null,
198            'speechoidItem' => $speechoidItem !== null ? $speechoidItem->getProperties() : null
199        ];
200    }
201
202    /**
203     * @since 0.1.8
204     * @param string $language
205     * @param string $key
206     * @param LexiconEntryItem $item
207     */
208    public function createEntryItem(
209        string $language,
210        string $key,
211        LexiconEntryItem $item
212    ): void {
213        $this->updateEntryItem( $language, $key, $item );
214    }
215
216    /**
217     * This is in fact a put-action, it will call underlying create methods if required.
218     *
219     * @since 0.1.8
220     * @param string $language
221     * @param string $key
222     * @param LexiconEntryItem $item Will be updated on success.
223     * @throws InvalidArgumentException If $item->properties is null.
224     * @throws RuntimeException If unable to push to any storage.
225     *  If successfully pushed to Speechoid but unable to push to local storage.
226     */
227    public function updateEntryItem(
228        string $language,
229        string $key,
230        LexiconEntryItem $item
231    ): void {
232        if ( $item->getProperties() === null ) {
233            // @todo Better sanity check, ensure that required values (IPA, etc) are set.
234            throw new InvalidArgumentException( '$item->properties must not be null.' );
235        }
236        $itemSpeechoidIdentity = $item->getSpeechoidIdentity();
237        $wasPreferred = false;
238
239        // will check if the entry already exists in speechoid storage and has the same properties
240        // if the item exists with the same ID, then compare to decide if it should be updated or not
241        if ( $itemSpeechoidIdentity !== null ) {
242            $currentEntry = $this->speechoidStorage->getEntry( $language, $key );
243            if ( $currentEntry !== null ) {
244                $currentItem = $currentEntry->findItemBySpeechoidIdentity( $itemSpeechoidIdentity );
245                if ( $currentItem !== null && $currentItem->getProperties() == $item->getProperties() ) {
246                    throw new NullEditLexiconException();
247                }
248                $wasPreferred = $currentItem ? $currentItem->getPreferred() : false;
249            }
250        }
251
252        if ( $itemSpeechoidIdentity === null ) {
253            $this->speechoidStorage->createEntryItem( $language, $key, $item );
254            $this->localStorage->createEntryItem( $language, $key, $item );
255        } else {
256            // If item has not been created in local storage,
257            // then we should fetch the current revision from Speechoid
258            // and create that in local storage
259            // before we then update the local storage with the new data.
260            if ( !$this->localStorage->entryItemExists( $language, $key, $item ) ) {
261                $currentSpeechoidEntry = $this->speechoidStorage->getEntry( $language, $key );
262                if ( $currentSpeechoidEntry === null ) {
263                    throw new RuntimeException( 'Expected current Speechoid entry to exist.' );
264                }
265                $currentSpeechoidEntryItem = $currentSpeechoidEntry->findItemBySpeechoidIdentity(
266                    $itemSpeechoidIdentity
267                );
268                if ( $currentSpeechoidEntryItem === null ) {
269                    throw new RuntimeException( 'Expected current Speechoid entry item to exists.' );
270                }
271                $wasPreferred = $currentSpeechoidEntryItem->getPreferred();
272                $this->localStorage->createEntryItem( $language, $key, $currentSpeechoidEntryItem );
273            } else {
274                $currentLocalEntry = $this->localStorage
275                    ->getEntry( $language, $key );
276                $currentLocalEntryItem = $currentLocalEntry->
277                    findItemBySpeechoidIdentity( $itemSpeechoidIdentity );
278                $wasPreferred = $currentLocalEntryItem->getPreferred();
279            }
280            $this->speechoidStorage->updateEntryItem( $language, $key, $item );
281            $this->localStorage->updateEntryItem( $language, $key, $item );
282        }
283        if ( $item->getPreferred() && !$wasPreferred ) {
284            $this->removePreferred( $language, $key, $item );
285        }
286    }
287
288    /**
289     * Remove "preferred" from all item in the an entry except one
290     *
291     * This is used to mirror the behaviour of Speechoid, which does
292     * this internally when preferred is set to true.
293     *
294     * @since 0.1.10
295     * @param string $language
296     * @param string $key
297     * @param LexiconEntryItem $excludedItem This item will not be
298     *  touched. Used to keep the preferred on the item that was just
299     *  set to true.
300     */
301    private function removePreferred( $language, $key, $excludedItem ): void {
302        $entry = $this->localStorage->getEntry( $language, $key );
303        foreach ( $entry->getItems() as $item ) {
304            if ( $item->getSpeechoidIdentity() !== $excludedItem->getSpeechoidIdentity() ) {
305                $item->removePreferred();
306                $this->localStorage->updateEntryItem( $language, $key, $item );
307            }
308        }
309    }
310
311    /**
312     * @since 0.1.8
313     * @param string $language
314     * @param string $key
315     * @param LexiconEntryItem $item
316     * @throws RuntimeException If successfully deleted in Speechoid but unable to delete in local storage.
317     */
318    public function deleteEntryItem(
319        string $language,
320        string $key,
321        LexiconEntryItem $item
322    ): void {
323        $this->speechoidStorage->deleteEntryItem( $language, $key, $item );
324        $this->localStorage->deleteEntryItem( $language, $key, $item );
325    }
326}