Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.92% |
80 / 104 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
LexiconHandler | |
76.92% |
80 / 104 |
|
55.56% |
5 / 9 |
59.66 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getEntry | |
65.96% |
31 / 47 |
|
0.00% |
0 / 1 |
21.73 | |||
getLocalEntry | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
syncEntryItem | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
outOfSyncItemFactory | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
createEntryItem | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
updateEntryItem | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
13.28 | |||
removePreferred | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
deleteEntryItem | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Wikispeech\Lexicon; |
4 | |
5 | /** |
6 | * @file |
7 | * @ingroup Extensions |
8 | * @license GPL-2.0-or-later |
9 | */ |
10 | |
11 | use FormatJson; |
12 | use InvalidArgumentException; |
13 | use 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 | */ |
40 | class 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 | } |