Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
73.39% |
80 / 109 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
| LexiconHandler | |
73.39% |
80 / 109 |
|
55.56% |
5 / 9 |
70.13 | |
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 | |
41.67% |
5 / 12 |
|
0.00% |
0 / 1 |
4.79 | |||
| 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 | "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 | } |