Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.40% |
68 / 89 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
LexiconHandler | |
76.40% |
68 / 89 |
|
57.14% |
4 / 7 |
43.62 | |
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 | |||
outOfSyncItemFactory | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
createEntryItem | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
updateEntryItem | |
88.89% |
24 / 27 |
|
0.00% |
0 / 1 |
8.09 | |||
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 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 | */ |
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 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 | } |