Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 196 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
TranslationStore | |
0.00% |
0 / 196 |
|
0.00% |
0 / 14 |
1056 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
unlinkTranslationFromTranslator | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
deleteTranslation | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
findTranslationByUser | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
findRecentTranslationByUser | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
findByUserAndId | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
findByPublishedTitle | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
findTranslationByTitle | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
findTranslationsByTitles | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
findConflictingDraftTranslations | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
getAllTranslationsByUserId | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
42 | |||
insertTranslation | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
6 | |||
updateTranslation | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
saveTranslation | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace ContentTranslation\Store; |
4 | |
5 | use ContentTranslation\LoadBalancer; |
6 | use ContentTranslation\Service\UserService; |
7 | use ContentTranslation\Translation; |
8 | use DateTime; |
9 | use MediaWiki\User\UserIdentity; |
10 | use Wikimedia\Rdbms\Platform\ISQLPlatform; |
11 | use Wikimedia\Rdbms\SelectQueryBuilder; |
12 | |
13 | class TranslationStore { |
14 | public const TRANSLATION_TABLE_NAME = 'cx_translations'; |
15 | public const TRANSLATOR_TABLE_NAME = 'cx_translators'; |
16 | |
17 | public const TRANSLATION_STATUS_DRAFT = 'draft'; |
18 | public const TRANSLATION_STATUS_PUBLISHED = 'published'; |
19 | public const TRANSLATION_STATUS_DELETED = 'deleted'; |
20 | |
21 | private LoadBalancer $lb; |
22 | private UserService $userService; |
23 | |
24 | public function __construct( LoadBalancer $lb, UserService $userService ) { |
25 | $this->lb = $lb; |
26 | $this->userService = $userService; |
27 | } |
28 | |
29 | public function unlinkTranslationFromTranslator( int $translationId ) { |
30 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
31 | |
32 | $dbw->newDeleteQueryBuilder() |
33 | ->deleteFrom( self::TRANSLATOR_TABLE_NAME ) |
34 | ->where( [ 'translator_translation_id' => $translationId ] ) |
35 | ->caller( __METHOD__ ) |
36 | ->execute(); |
37 | } |
38 | |
39 | public function deleteTranslation( int $translationId ) { |
40 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
41 | |
42 | $dbw->newUpdateQueryBuilder() |
43 | ->update( self::TRANSLATION_TABLE_NAME ) |
44 | ->set( [ 'translation_status' => self::TRANSLATION_STATUS_DELETED ] ) |
45 | ->where( [ 'translation_id' => $translationId ] ) |
46 | ->caller( __METHOD__ ) |
47 | ->execute(); |
48 | } |
49 | |
50 | /** |
51 | * This method finds a translation inside "cx_translations" table, that corresponds to the |
52 | * given source/target languages, source title and the translator of the published |
53 | * translation, and returns it. If no such translation exists, the method returns null. |
54 | * |
55 | * There can only ever be one translation, returned by this method. |
56 | * |
57 | * @param UserIdentity $user |
58 | * @param string $sourceTitle |
59 | * @param string $sourceLanguage |
60 | * @param string $targetLanguage |
61 | * @param string|null $status possible status values: "published"|"draft"|"deleted" |
62 | * @return Translation|null |
63 | */ |
64 | public function findTranslationByUser( |
65 | UserIdentity $user, |
66 | string $sourceTitle, |
67 | string $sourceLanguage, |
68 | string $targetLanguage, |
69 | ?string $status = null |
70 | ): ?Translation { |
71 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
72 | $globalUserId = $this->userService->getGlobalUserId( $user ); |
73 | |
74 | $conditions = [ |
75 | 'translation_source_language' => $sourceLanguage, |
76 | 'translation_target_language' => $targetLanguage, |
77 | 'translation_source_title' => $sourceTitle, |
78 | 'translation_started_by' => $globalUserId, |
79 | 'translation_last_update_by' => $globalUserId, |
80 | ]; |
81 | |
82 | if ( $status ) { |
83 | $conditions['translation_status'] = $status; |
84 | } |
85 | $row = $dbr->newSelectQueryBuilder() |
86 | ->select( ISQLPlatform::ALL_ROWS ) |
87 | ->from( self::TRANSLATION_TABLE_NAME ) |
88 | ->where( $conditions ) |
89 | ->caller( __METHOD__ ) |
90 | ->fetchRow(); |
91 | |
92 | return $row ? Translation::newFromRow( $row ) : null; |
93 | } |
94 | |
95 | /** |
96 | * Given a user id, this method returns the last published translation for that translator, |
97 | * which have been started within the last 10 minutes. If no published translation within |
98 | * the last 10 minutes, null is returned. |
99 | */ |
100 | public function findRecentTranslationByUser( int $userId ): ?Translation { |
101 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
102 | |
103 | $conditions = [ |
104 | 'translation_started_by' => $userId, |
105 | // Only fetch translations within 10 last minutes |
106 | // Translations older than 10 minutes, are not considered recent here |
107 | $dbr->expr( 'translation_start_timestamp', '>=', $dbr->timestamp( time() - ( 10 * 60 ) ) ), |
108 | // target URL is always not null for articles that have been published at some point |
109 | $dbr->expr( 'translation_target_url', '!=', null ), |
110 | ]; |
111 | |
112 | $row = $dbr->newSelectQueryBuilder() |
113 | ->select( ISQLPlatform::ALL_ROWS ) |
114 | ->from( self::TRANSLATION_TABLE_NAME ) |
115 | ->where( $conditions ) |
116 | ->orderBy( 'translation_start_timestamp', SelectQueryBuilder::SORT_DESC ) |
117 | ->limit( 1 ) |
118 | ->caller( __METHOD__ ) |
119 | ->fetchRow(); |
120 | |
121 | return $row ? Translation::newFromRow( $row ) : null; |
122 | } |
123 | |
124 | /** |
125 | * This method finds a translation inside "cx_translations" table, that corresponds to the |
126 | * given id and the translator (user) of the translation, and returns it. If no such translation |
127 | * exists, the method returns null. |
128 | * |
129 | * @param UserIdentity $user |
130 | * @param int $id |
131 | * @return Translation|null |
132 | * @throws \Exception |
133 | */ |
134 | public function findByUserAndId( UserIdentity $user, int $id ): ?Translation { |
135 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
136 | $globalUserId = $this->userService->getGlobalUserId( $user ); |
137 | |
138 | $row = $dbr->newSelectQueryBuilder() |
139 | ->select( ISQLPlatform::ALL_ROWS ) |
140 | ->from( self::TRANSLATION_TABLE_NAME ) |
141 | ->where( [ |
142 | 'translation_id' => $id, |
143 | 'translation_started_by' => $globalUserId, |
144 | 'translation_last_update_by' => $globalUserId, |
145 | ] ) |
146 | ->caller( __METHOD__ ) |
147 | ->fetchRow(); |
148 | |
149 | return $row ? Translation::newFromRow( $row ) : null; |
150 | } |
151 | |
152 | /** |
153 | * Find a published translation for a given target title and language |
154 | * |
155 | * @param string $publishedTitle |
156 | * @param string $targetLanguage |
157 | * @return Translation|null |
158 | */ |
159 | public function findByPublishedTitle( string $publishedTitle, string $targetLanguage ): ?Translation { |
160 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
161 | |
162 | $row = $dbr->newSelectQueryBuilder() |
163 | ->select( ISQLPlatform::ALL_ROWS ) |
164 | ->from( self::TRANSLATION_TABLE_NAME ) |
165 | ->where( [ |
166 | 'translation_target_language' => $targetLanguage, |
167 | 'translation_target_title' => $publishedTitle, |
168 | Translation::getPublishedCondition( $dbr ), |
169 | ] ) |
170 | ->caller( __METHOD__ ) |
171 | ->fetchRow(); |
172 | |
173 | return $row ? Translation::newFromRow( $row ) : null; |
174 | } |
175 | |
176 | /** |
177 | * Given a source title, a source language and a target language, |
178 | * find the oldest matching translation. |
179 | * |
180 | * @param string $sourceTitle |
181 | * @param string $sourceLanguage |
182 | * @param string $targetLanguage |
183 | * @return Translation|null |
184 | */ |
185 | public function findTranslationByTitle( |
186 | string $sourceTitle, |
187 | string $sourceLanguage, |
188 | string $targetLanguage |
189 | ): ?Translation { |
190 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
191 | |
192 | $row = $dbr->newSelectQueryBuilder() |
193 | ->select( ISQLPlatform::ALL_ROWS ) |
194 | ->from( self::TRANSLATION_TABLE_NAME ) |
195 | ->where( [ |
196 | 'translation_source_language' => $sourceLanguage, |
197 | 'translation_target_language' => $targetLanguage, |
198 | 'translation_source_title' => $sourceTitle |
199 | ] ) |
200 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC ) |
201 | ->limit( 1 ) |
202 | ->caller( __METHOD__ ) |
203 | ->fetchRow(); |
204 | |
205 | return $row ? Translation::newFromRow( $row ) : null; |
206 | } |
207 | |
208 | /** |
209 | * Given an array of source titles, a source language and a target language, |
210 | * find all matching translations. |
211 | * |
212 | * @param string[] $titles |
213 | * @param string $sourceLanguage |
214 | * @param string $targetLanguage |
215 | * @return Translation[] |
216 | */ |
217 | public function findTranslationsByTitles( array $titles, string $sourceLanguage, string $targetLanguage ): array { |
218 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
219 | |
220 | $resultSet = $dbr->newSelectQueryBuilder() |
221 | ->select( ISQLPlatform::ALL_ROWS ) |
222 | ->from( self::TRANSLATION_TABLE_NAME ) |
223 | ->where( [ |
224 | 'translation_source_language' => $sourceLanguage, |
225 | 'translation_target_language' => $targetLanguage, |
226 | 'translation_source_title' => $titles |
227 | ] ) |
228 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC ) |
229 | ->caller( __METHOD__ ) |
230 | ->fetchResultSet(); |
231 | |
232 | $result = []; |
233 | foreach ( $resultSet as $row ) { |
234 | $result[] = Translation::newFromRow( $row ); |
235 | } |
236 | |
237 | return $result; |
238 | } |
239 | |
240 | /** |
241 | * Given a source title, a source language and a target language, find all conflicting translations. |
242 | * Conflicting translations are translations in progress ("draft") for same language pair and source |
243 | * page in last 24 hours. |
244 | * |
245 | * Here we assume that the caller already checked that no draft for the user already exists. |
246 | * |
247 | * @param string $title |
248 | * @param string $sourceLang |
249 | * @param string $targetLang |
250 | * @return Translation[] |
251 | * @throws \Exception |
252 | */ |
253 | public function findConflictingDraftTranslations( string $title, string $sourceLang, string $targetLang ): array { |
254 | $translations = $this->findTranslationsByTitles( [ $title ], $sourceLang, $targetLang ); |
255 | |
256 | $conflicts = array_filter( $translations, static function ( Translation $translation ) { |
257 | $isDraft = $translation->getData()['status'] === self::TRANSLATION_STATUS_DRAFT; |
258 | |
259 | // filter out non-draft translations |
260 | if ( !$isDraft ) { |
261 | return false; |
262 | } |
263 | |
264 | $lastUpdateTime = new DateTime( $translation->getData()['lastUpdateTimestamp'] ); |
265 | |
266 | // Only keep translations that have been updated in the last 24 hours |
267 | return (bool)$lastUpdateTime->diff( new DateTime( '-24 hours' ) )->invert; |
268 | } ); |
269 | |
270 | return array_values( $conflicts ); |
271 | } |
272 | |
273 | /** |
274 | * @param int $userId |
275 | * @param int $limit How many results to return |
276 | * @param string|null $offset Offset condition (timestamp) |
277 | * @param string|null $type |
278 | * @param string|null $from |
279 | * @param string|null $to |
280 | * @return Translation[] |
281 | */ |
282 | public function getAllTranslationsByUserId( |
283 | int $userId, |
284 | int $limit, |
285 | ?string $offset = null, |
286 | ?string $type = null, |
287 | ?string $from = null, |
288 | ?string $to = null |
289 | ): array { |
290 | // Note: there is no index on translation_last_updated_timestamp |
291 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
292 | |
293 | $whereConditions = [ 'translation_started_by' => $userId ]; |
294 | |
295 | if ( $type !== null ) { |
296 | $whereConditions['translation_status'] = $type; |
297 | } |
298 | if ( $from !== null ) { |
299 | $whereConditions['translation_source_language'] = $from; |
300 | } |
301 | if ( $to !== null ) { |
302 | $whereConditions['translation_target_language'] = $to; |
303 | } |
304 | if ( $offset !== null ) { |
305 | $whereConditions[] = $dbr->expr( 'translation_last_updated_timestamp', '<', $dbr->timestamp( $offset ) ); |
306 | } |
307 | |
308 | $resultSet = $dbr->newSelectQueryBuilder() |
309 | ->select( ISQLPlatform::ALL_ROWS ) |
310 | ->from( self::TRANSLATION_TABLE_NAME ) |
311 | ->where( $whereConditions ) |
312 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC ) |
313 | ->limit( $limit ) |
314 | ->caller( __METHOD__ ) |
315 | ->fetchResultSet(); |
316 | |
317 | $result = []; |
318 | foreach ( $resultSet as $row ) { |
319 | $result[] = Translation::newFromRow( $row ); |
320 | } |
321 | |
322 | return $result; |
323 | } |
324 | |
325 | public function insertTranslation( Translation $translation, UserIdentity $user ): void { |
326 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
327 | |
328 | $row = [ |
329 | 'translation_source_title' => $translation->translation['sourceTitle'], |
330 | 'translation_target_title' => $translation->translation['targetTitle'], |
331 | 'translation_source_language' => $translation->translation['sourceLanguage'], |
332 | 'translation_target_language' => $translation->translation['targetLanguage'], |
333 | 'translation_source_revision_id' => $translation->translation['sourceRevisionId'], |
334 | 'translation_source_url' => $translation->translation['sourceURL'], |
335 | 'translation_status' => $translation->translation['status'], |
336 | 'translation_progress' => $translation->translation['progress'], |
337 | 'translation_last_updated_timestamp' => $dbw->timestamp(), |
338 | 'translation_last_update_by' => $this->userService->getGlobalUserId( $user ), |
339 | 'translation_start_timestamp' => $dbw->timestamp(), |
340 | 'translation_started_by' => $this->userService->getGlobalUserId( $user ), |
341 | 'translation_cx_version' => $translation->translation['cxVersion'], |
342 | ]; |
343 | |
344 | if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) { |
345 | $row['translation_target_url'] = $translation->translation['targetURL']; |
346 | $row['translation_target_revision_id'] = $translation->translation['targetRevisionId']; |
347 | } |
348 | |
349 | $dbw->newInsertQueryBuilder() |
350 | ->insertInto( self::TRANSLATION_TABLE_NAME ) |
351 | ->row( $row ) |
352 | ->caller( __METHOD__ ) |
353 | ->execute(); |
354 | |
355 | $translation->translation['id'] = (int)$dbw->insertId(); |
356 | $translation->setIsNew( true ); |
357 | } |
358 | |
359 | public function updateTranslation( Translation $translation, array $options = [] ): void { |
360 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
361 | |
362 | $set = [ |
363 | 'translation_target_title' => $translation->translation['targetTitle'], |
364 | 'translation_source_revision_id' => $translation->translation['sourceRevisionId'], |
365 | 'translation_source_url' => $translation->translation['sourceURL'], |
366 | 'translation_status' => $translation->translation['status'], |
367 | 'translation_last_updated_timestamp' => $dbw->timestamp(), |
368 | 'translation_progress' => $translation->translation['progress'], |
369 | 'translation_cx_version' => $translation->translation['cxVersion'], |
370 | ]; |
371 | |
372 | if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) { |
373 | $set['translation_target_url'] = $translation->translation['targetURL']; |
374 | $set['translation_target_revision_id'] = $translation->translation['targetRevisionId']; |
375 | } |
376 | |
377 | $isFreshTranslation = $options['freshTranslation'] ?? false; |
378 | if ( $isFreshTranslation ) { |
379 | $set['translation_start_timestamp'] = $dbw->timestamp(); |
380 | } |
381 | |
382 | $dbw->newUpdateQueryBuilder() |
383 | ->update( self::TRANSLATION_TABLE_NAME ) |
384 | ->set( $set ) |
385 | ->where( [ 'translation_id' => $translation->getTranslationId() ] ) |
386 | ->caller( __METHOD__ ) |
387 | ->execute(); |
388 | |
389 | $translation->setIsNew( false ); |
390 | } |
391 | |
392 | /** |
393 | * A convenient abstraction of create and update methods. Checks if |
394 | * translation exists and chooses either of create or update actions. |
395 | * |
396 | * @param Translation $translation |
397 | * @param UserIdentity $user |
398 | */ |
399 | public function saveTranslation( Translation $translation, UserIdentity $user ): void { |
400 | $existingTranslation = $this->findTranslationByUser( |
401 | $user, |
402 | $translation->getSourceTitle(), |
403 | $translation->getSourceLanguage(), |
404 | $translation->getTargetLanguage() |
405 | ); |
406 | |
407 | if ( $existingTranslation === null ) { |
408 | $this->insertTranslation( $translation, $user ); |
409 | } else { |
410 | $options = []; |
411 | if ( $existingTranslation->translation['status'] === self::TRANSLATION_STATUS_DELETED ) { |
412 | // Existing translation is deleted, so this is a fresh start of same |
413 | // language pair and source title. |
414 | $options['freshTranslation'] = true; |
415 | } |
416 | $translation->translation['id'] = $existingTranslation->getTranslationId(); |
417 | $this->updateTranslation( $translation, $options ); |
418 | } |
419 | } |
420 | } |