Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.40% covered (warning)
76.40%
68 / 89
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LexiconHandler
76.40% covered (warning)
76.40%
68 / 89
57.14% covered (warning)
57.14%
4 / 7
43.62
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
 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.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
8.09
 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 MWException;
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 MWException 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 MWException(
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 MWException(
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.9
153     * @param string $message
154     * @param LexiconEntryItem|null $localItem
155     * @param LexiconEntryItem|null $speechoidItem
156     * @return array
157     */
158    private function outOfSyncItemFactory(
159        string $message,
160        ?LexiconEntryItem $localItem,
161        ?LexiconEntryItem $speechoidItem
162    ): array {
163        return [
164            'message' => $message,
165            'localItem' => $localItem !== null ? $localItem->getProperties() : null,
166            'speechoidItem' => $speechoidItem !== null ? $speechoidItem->getProperties() : null
167        ];
168    }
169
170    /**
171     * @since 0.1.8
172     * @param string $language
173     * @param string $key
174     * @param LexiconEntryItem $item
175     */
176    public function createEntryItem(
177        string $language,
178        string $key,
179        LexiconEntryItem $item
180    ): void {
181        $this->updateEntryItem( $language, $key, $item );
182    }
183
184    /**
185     * This is in fact a put-action, it will call underlying create methods if required.
186     *
187     * @since 0.1.8
188     * @param string $language
189     * @param string $key
190     * @param LexiconEntryItem $item Will be updated on success.
191     * @throws InvalidArgumentException If $item->properties is null.
192     * @throws MWException If unable to push to any storage.
193     *  If successfully pushed to Speechoid but unable to push to local storage.
194     */
195    public function updateEntryItem(
196        string $language,
197        string $key,
198        LexiconEntryItem $item
199    ): void {
200        if ( $item->getProperties() === null ) {
201            // @todo Better sanity check, ensure that required values (IPA, etc) are set.
202            throw new InvalidArgumentException( '$item->properties must not be null.' );
203        }
204        $itemSpeechoidIdentity = $item->getSpeechoidIdentity();
205        $wasPreferred = false;
206        if ( $itemSpeechoidIdentity === null ) {
207            $this->speechoidStorage->createEntryItem( $language, $key, $item );
208            $this->localStorage->createEntryItem( $language, $key, $item );
209        } else {
210            // If item has not been created in local storage,
211            // then we should fetch the current revision from Speechoid
212            // and create that in local storage
213            // before we then update the local storage with the new data.
214            if ( !$this->localStorage->entryItemExists( $language, $key, $item ) ) {
215                $currentSpeechoidEntry = $this->speechoidStorage->getEntry( $language, $key );
216                if ( $currentSpeechoidEntry === null ) {
217                    throw new MWException( 'Expected current Speechoid entry to exist.' );
218                }
219                $currentSpeechoidEntryItem = $currentSpeechoidEntry->findItemBySpeechoidIdentity(
220                    $itemSpeechoidIdentity
221                );
222                if ( $currentSpeechoidEntryItem === null ) {
223                    throw new MWException( 'Expected current Speechoid entry item to exists.' );
224                }
225                $wasPreferred = $currentSpeechoidEntryItem->getPreferred();
226                $this->localStorage->createEntryItem( $language, $key, $currentSpeechoidEntryItem );
227            } else {
228                $currentLocalEntry = $this->localStorage->
229                    getEntry( $language, $key );
230                $currentLocalEntryItem = $currentLocalEntry->
231                    findItemBySpeechoidIdentity( $itemSpeechoidIdentity );
232                $wasPreferred = $currentLocalEntryItem->getPreferred();
233            }
234            $this->speechoidStorage->updateEntryItem( $language, $key, $item );
235            $this->localStorage->updateEntryItem( $language, $key, $item );
236        }
237        if ( $item->getPreferred() && !$wasPreferred ) {
238            $this->removePreferred( $language, $key, $item );
239        }
240    }
241
242    /**
243     * Remove "preferred" from all item in the an entry except one
244     *
245     * This is used to mirror the behaviour of Speechoid, which does
246     * this internally when preferred is set to true.
247     *
248     * @since 0.1.10
249     * @param string $language
250     * @param string $key
251     * @param LexiconEntryItem $excludedItem This item will not be
252     *  touched. Used to keep the preferred on the item that was just
253     *  set to true.
254     */
255    private function removePreferred( $language, $key, $excludedItem ): void {
256        $entry = $this->localStorage->getEntry( $language, $key );
257        foreach ( $entry->getItems() as $item ) {
258            if ( $item->getSpeechoidIdentity() !== $excludedItem->getSpeechoidIdentity() ) {
259                $item->removePreferred();
260                $this->localStorage->updateEntryItem( $language, $key, $item );
261            }
262        }
263    }
264
265    /**
266     * @since 0.1.8
267     * @param string $language
268     * @param string $key
269     * @param LexiconEntryItem $item
270     * @throws MWException If successfully deleted in Speechoid but unable to delete in local storage.
271     */
272    public function deleteEntryItem(
273        string $language,
274        string $key,
275        LexiconEntryItem $item
276    ): void {
277        $this->speechoidStorage->deleteEntryItem( $language, $key, $item );
278        $this->localStorage->deleteEntryItem( $language, $key, $item );
279    }
280}