Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 204 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
TranslationStore | |
0.00% |
0 / 204 |
|
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 / 19 |
|
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 / 23 |
|
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 | $isPublishedCondition = $dbr->makeList( |
163 | [ |
164 | 'translation_status' => self::TRANSLATION_STATUS_PUBLISHED, |
165 | 'translation_target_url IS NOT NULL', |
166 | ], |
167 | LIST_OR |
168 | ); |
169 | |
170 | $row = $dbr->newSelectQueryBuilder() |
171 | ->select( ISQLPlatform::ALL_ROWS ) |
172 | ->from( self::TRANSLATION_TABLE_NAME ) |
173 | ->where( [ |
174 | 'translation_target_language' => $targetLanguage, |
175 | 'translation_target_title' => $publishedTitle, |
176 | $isPublishedCondition |
177 | ] ) |
178 | ->caller( __METHOD__ ) |
179 | ->fetchRow(); |
180 | |
181 | return $row ? Translation::newFromRow( $row ) : null; |
182 | } |
183 | |
184 | /** |
185 | * Given a source title, a source language and a target language, |
186 | * find the oldest matching translation. |
187 | * |
188 | * @param string $sourceTitle |
189 | * @param string $sourceLanguage |
190 | * @param string $targetLanguage |
191 | * @return Translation|null |
192 | */ |
193 | public function findTranslationByTitle( |
194 | string $sourceTitle, |
195 | string $sourceLanguage, |
196 | string $targetLanguage |
197 | ): ?Translation { |
198 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
199 | |
200 | $row = $dbr->newSelectQueryBuilder() |
201 | ->select( ISQLPlatform::ALL_ROWS ) |
202 | ->from( self::TRANSLATION_TABLE_NAME ) |
203 | ->where( [ |
204 | 'translation_source_language' => $sourceLanguage, |
205 | 'translation_target_language' => $targetLanguage, |
206 | 'translation_source_title' => $sourceTitle |
207 | ] ) |
208 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC ) |
209 | ->limit( 1 ) |
210 | ->caller( __METHOD__ ) |
211 | ->fetchRow(); |
212 | |
213 | return $row ? Translation::newFromRow( $row ) : null; |
214 | } |
215 | |
216 | /** |
217 | * Given an array of source titles, a source language and a target language, |
218 | * find all matching translations. |
219 | * |
220 | * @param string[] $titles |
221 | * @param string $sourceLanguage |
222 | * @param string $targetLanguage |
223 | * @return Translation[] |
224 | */ |
225 | public function findTranslationsByTitles( array $titles, string $sourceLanguage, string $targetLanguage ): array { |
226 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
227 | |
228 | $resultSet = $dbr->newSelectQueryBuilder() |
229 | ->select( ISQLPlatform::ALL_ROWS ) |
230 | ->from( self::TRANSLATION_TABLE_NAME ) |
231 | ->where( [ |
232 | 'translation_source_language' => $sourceLanguage, |
233 | 'translation_target_language' => $targetLanguage, |
234 | 'translation_source_title' => $titles |
235 | ] ) |
236 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_ASC ) |
237 | ->caller( __METHOD__ ) |
238 | ->fetchResultSet(); |
239 | |
240 | $result = []; |
241 | foreach ( $resultSet as $row ) { |
242 | $result[] = Translation::newFromRow( $row ); |
243 | } |
244 | |
245 | return $result; |
246 | } |
247 | |
248 | /** |
249 | * Given a source title, a source language and a target language, find all conflicting translations. |
250 | * Conflicting translations are translations in progress ("draft") for same language pair and source |
251 | * page in last 24 hours. |
252 | * |
253 | * Here we assume that the caller already checked that no draft for the user already exists. |
254 | * |
255 | * @param string $title |
256 | * @param string $sourceLang |
257 | * @param string $targetLang |
258 | * @return Translation[] |
259 | * @throws \Exception |
260 | */ |
261 | public function findConflictingDraftTranslations( string $title, string $sourceLang, string $targetLang ): array { |
262 | $translations = $this->findTranslationsByTitles( [ $title ], $sourceLang, $targetLang ); |
263 | |
264 | $conflicts = array_filter( $translations, static function ( Translation $translation ) { |
265 | $isDraft = $translation->getData()['status'] === self::TRANSLATION_STATUS_DRAFT; |
266 | |
267 | // filter out non-draft translations |
268 | if ( !$isDraft ) { |
269 | return false; |
270 | } |
271 | |
272 | $lastUpdateTime = new DateTime( $translation->getData()['lastUpdateTimestamp'] ); |
273 | |
274 | // Only keep translations that have been updated in the last 24 hours |
275 | return (bool)$lastUpdateTime->diff( new DateTime( '-24 hours' ) )->invert; |
276 | } ); |
277 | |
278 | return array_values( $conflicts ); |
279 | } |
280 | |
281 | /** |
282 | * @param int $userId |
283 | * @param int $limit How many results to return |
284 | * @param string|null $offset Offset condition (timestamp) |
285 | * @param string|null $type |
286 | * @param string|null $from |
287 | * @param string|null $to |
288 | * @return Translation[] |
289 | */ |
290 | public function getAllTranslationsByUserId( |
291 | int $userId, |
292 | int $limit, |
293 | ?string $offset = null, |
294 | ?string $type = null, |
295 | ?string $from = null, |
296 | ?string $to = null |
297 | ): array { |
298 | // Note: there is no index on translation_last_updated_timestamp |
299 | $dbr = $this->lb->getConnection( DB_REPLICA ); |
300 | |
301 | $whereConditions = [ 'translation_started_by' => $userId ]; |
302 | |
303 | if ( $type !== null ) { |
304 | $whereConditions['translation_status'] = $type; |
305 | } |
306 | if ( $from !== null ) { |
307 | $whereConditions['translation_source_language'] = $from; |
308 | } |
309 | if ( $to !== null ) { |
310 | $whereConditions['translation_target_language'] = $to; |
311 | } |
312 | if ( $offset !== null ) { |
313 | $ts = $dbr->addQuotes( $dbr->timestamp( $offset ) ); |
314 | $whereConditions[] = "translation_last_updated_timestamp < $ts"; |
315 | } |
316 | |
317 | $resultSet = $dbr->newSelectQueryBuilder() |
318 | ->select( ISQLPlatform::ALL_ROWS ) |
319 | ->from( self::TRANSLATION_TABLE_NAME ) |
320 | ->where( $whereConditions ) |
321 | ->orderBy( 'translation_last_updated_timestamp', SelectQueryBuilder::SORT_DESC ) |
322 | ->limit( $limit ) |
323 | ->caller( __METHOD__ ) |
324 | ->fetchResultSet(); |
325 | |
326 | $result = []; |
327 | foreach ( $resultSet as $row ) { |
328 | $result[] = Translation::newFromRow( $row ); |
329 | } |
330 | |
331 | return $result; |
332 | } |
333 | |
334 | public function insertTranslation( Translation $translation, UserIdentity $user ): void { |
335 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
336 | |
337 | $row = [ |
338 | 'translation_source_title' => $translation->translation['sourceTitle'], |
339 | 'translation_target_title' => $translation->translation['targetTitle'], |
340 | 'translation_source_language' => $translation->translation['sourceLanguage'], |
341 | 'translation_target_language' => $translation->translation['targetLanguage'], |
342 | 'translation_source_revision_id' => $translation->translation['sourceRevisionId'], |
343 | 'translation_source_url' => $translation->translation['sourceURL'], |
344 | 'translation_status' => $translation->translation['status'], |
345 | 'translation_progress' => $translation->translation['progress'], |
346 | 'translation_last_updated_timestamp' => $dbw->timestamp(), |
347 | 'translation_last_update_by' => $this->userService->getGlobalUserId( $user ), |
348 | 'translation_start_timestamp' => $dbw->timestamp(), |
349 | 'translation_started_by' => $this->userService->getGlobalUserId( $user ), |
350 | 'translation_cx_version' => $translation->translation['cxVersion'], |
351 | ]; |
352 | |
353 | if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) { |
354 | $row['translation_target_url'] = $translation->translation['targetURL']; |
355 | $row['translation_target_revision_id'] = $translation->translation['targetRevisionId']; |
356 | } |
357 | |
358 | $dbw->newInsertQueryBuilder() |
359 | ->insertInto( self::TRANSLATION_TABLE_NAME ) |
360 | ->row( $row ) |
361 | ->caller( __METHOD__ ) |
362 | ->execute(); |
363 | |
364 | $translation->translation['id'] = (int)$dbw->insertId(); |
365 | $translation->setIsNew( true ); |
366 | } |
367 | |
368 | public function updateTranslation( Translation $translation, array $options = [] ): void { |
369 | $dbw = $this->lb->getConnection( DB_PRIMARY ); |
370 | |
371 | $set = [ |
372 | 'translation_target_title' => $translation->translation['targetTitle'], |
373 | 'translation_source_revision_id' => $translation->translation['sourceRevisionId'], |
374 | 'translation_source_url' => $translation->translation['sourceURL'], |
375 | 'translation_status' => $translation->translation['status'], |
376 | 'translation_last_updated_timestamp' => $dbw->timestamp(), |
377 | 'translation_progress' => $translation->translation['progress'], |
378 | 'translation_cx_version' => $translation->translation['cxVersion'], |
379 | ]; |
380 | |
381 | if ( $translation->translation['status'] === self::TRANSLATION_STATUS_PUBLISHED ) { |
382 | $set['translation_target_url'] = $translation->translation['targetURL']; |
383 | $set['translation_target_revision_id'] = $translation->translation['targetRevisionId']; |
384 | } |
385 | |
386 | $isFreshTranslation = $options['freshTranslation'] ?? false; |
387 | if ( $isFreshTranslation ) { |
388 | $set['translation_start_timestamp'] = $dbw->timestamp(); |
389 | } |
390 | |
391 | $dbw->newUpdateQueryBuilder() |
392 | ->update( self::TRANSLATION_TABLE_NAME ) |
393 | ->set( $set ) |
394 | ->where( [ 'translation_id' => $translation->getTranslationId() ] ) |
395 | ->caller( __METHOD__ ) |
396 | ->execute(); |
397 | |
398 | $translation->setIsNew( false ); |
399 | } |
400 | |
401 | /** |
402 | * A convenient abstraction of create and update methods. Checks if |
403 | * translation exists and chooses either of create or update actions. |
404 | * |
405 | * @param Translation $translation |
406 | * @param UserIdentity $user |
407 | */ |
408 | public function saveTranslation( Translation $translation, UserIdentity $user ): void { |
409 | $existingTranslation = $this->findTranslationByUser( |
410 | $user, |
411 | $translation->getSourceTitle(), |
412 | $translation->getSourceLanguage(), |
413 | $translation->getTargetLanguage() |
414 | ); |
415 | |
416 | if ( $existingTranslation === null ) { |
417 | $this->insertTranslation( $translation, $user ); |
418 | } else { |
419 | $options = []; |
420 | if ( $existingTranslation->translation['status'] === self::TRANSLATION_STATUS_DELETED ) { |
421 | // Existing translation is deleted, so this is a fresh start of same |
422 | // language pair and source title. |
423 | $options['freshTranslation'] = true; |
424 | } |
425 | $translation->translation['id'] = $existingTranslation->getTranslationId(); |
426 | $this->updateTranslation( $translation, $options ); |
427 | } |
428 | } |
429 | } |