Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 191
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryContentTranslation
0.00% covered (danger)
0.00%
0 / 191
0.00% covered (danger)
0.00%
0 / 10
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 executeGenerator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
182
 serveUnifiedDashboardTranslations
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 serveDesktopEditorDraft
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 serveTranslationCorporaUnits
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
2
 addUnitsAndCategoriesToTranslation
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Api module for querying Content translations.
4 *
5 * @copyright See AUTHORS.txt
6 * @license GPL-2.0-or-later
7 */
8
9namespace ContentTranslation\ActionApi;
10
11use ApiBase;
12use ApiPageSet;
13use ApiQuery;
14use ApiQueryGeneratorBase;
15use ContentTranslation\DTO\CXDraftTranslationDTO;
16use ContentTranslation\Manager\TranslationCorporaManager;
17use ContentTranslation\Service\UserService;
18use ContentTranslation\Store\SectionTranslationStore;
19use ContentTranslation\Store\TranslationStore;
20use ContentTranslation\Translation;
21use ContentTranslation\Translator;
22use Wikimedia\ParamValidator\ParamValidator;
23use Wikimedia\ParamValidator\TypeDef\IntegerDef;
24
25/**
26 * Api module for querying ContentTranslation.
27 */
28class ApiQueryContentTranslation extends ApiQueryGeneratorBase {
29
30    private SectionTranslationStore $sectionTranslationStore;
31    private TranslationCorporaManager $corporaManager;
32    private UserService $userService;
33    private TranslationStore $translationStore;
34
35    /**
36     * @param ApiQuery $query
37     * @param string $moduleName
38     * @param SectionTranslationStore $sectionTranslationStore
39     * @param TranslationCorporaManager $corporaManager
40     * @param UserService $userService
41     * @param TranslationStore $translationStore
42     */
43    public function __construct(
44        $query,
45        $moduleName,
46        SectionTranslationStore $sectionTranslationStore,
47        TranslationCorporaManager $corporaManager,
48        UserService $userService,
49        TranslationStore $translationStore
50    ) {
51        parent::__construct( $query, $moduleName );
52
53        $this->sectionTranslationStore = $sectionTranslationStore;
54        $this->corporaManager = $corporaManager;
55        $this->userService = $userService;
56        $this->translationStore = $translationStore;
57    }
58
59    public function execute() {
60        $this->run();
61    }
62
63    public function executeGenerator( $resultPageSet ) {
64        $this->run( $resultPageSet );
65    }
66
67    /**
68     * @param ApiPageSet|null $resultPageSet
69     */
70    private function run( $resultPageSet = null ) {
71        $params = $this->extractRequestParams();
72        $result = $this->getResult();
73        $user = $this->getUser();
74        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
75
76        // Case A: Find a translation for given work from anonymous context
77        if ( !$user->isRegistered() ) {
78            if ( $params['translationid'] ) {
79                $this->dieWithError( 'apierror-cx-mustbeloggedin-viewtranslations', 'notloggedin' );
80            }
81            if ( $sourceTitle && $sourceLanguage && $targetLanguage ) {
82                $translation = $this->translationStore->findTranslationByTitle(
83                    $sourceTitle,
84                    $sourceLanguage,
85                    $targetLanguage
86                );
87
88                if ( $translation === null ) {
89                    $this->dieWithError( 'apierror-cx-translationnotfound', 'translationnotfound' );
90                }
91
92                $result->addValue(
93                    [ 'query', 'contenttranslation' ],
94                    'translation',
95                    $translation->translation
96                );
97            }
98
99            return;
100        }
101
102        if ( $params['usecase'] === 'unified-dashboard' ) {
103            $this->serveUnifiedDashboardTranslations( $params );
104
105            return;
106        } elseif ( $params['usecase'] === 'desktop-editor-draft' ) {
107            $this->serveDesktopEditorDraft( $params );
108
109            return;
110        } elseif ( $params['usecase'] === 'translation-corpora-units' ) {
111            $this->serveTranslationCorporaUnits( $params['translationid'] );
112
113            return;
114        }
115
116        $translator = new Translator( $user );
117
118        // Case D: Find list of translations. Either section translations or article translations
119        $offset = null;
120        $translatorUserId = $this->userService->getGlobalUserId( $user );
121        $translations = $this->translationStore->getAllTranslationsByUserId(
122            $translatorUserId,
123            $params['limit'],
124            $params['offset'],
125            $params['type'],
126            $sourceLanguage,
127            $targetLanguage
128        );
129
130        $count = count( $translations );
131        if ( $count === $params['limit'] ) {
132            $offset = $translations[$count - 1]->translation['lastUpdateTimestamp'];
133        }
134
135        // We will have extra "continue" in case the last batch is exactly the size of the limit
136        if ( $offset ) {
137            $this->setContinueEnumParameter( 'offset', $offset );
138        }
139
140        $result->addValue( [ 'query', 'contenttranslation' ], 'translations', $translations );
141
142        // Simple optimization
143        if ( $params['offset'] === null ) {
144            $result->addValue(
145                [ 'query', 'contenttranslation' ],
146                'languages',
147                $translator->getLanguages( $params['type'] )
148            );
149        }
150    }
151
152    private function serveUnifiedDashboardTranslations( array $params ): void {
153        $status = $params['type'];
154
155        if ( !$status || !in_array( $status, SectionTranslationStore::TRANSLATION_STATUSES ) ) {
156            $this->dieWithError( 'apierror-cx-invalid-type-viewtranslations', 'invalidtype' );
157        }
158
159        $sectionTranslations = [];
160        $user = $this->getUser();
161        $translatorUserId = $this->userService->getGlobalUserId( $user );
162
163        if ( $status === SectionTranslationStore::TRANSLATION_STATUS_PUBLISHED ) {
164            $sectionTranslations = $this->sectionTranslationStore->findPublishedSectionTranslationsByUser(
165                $translatorUserId,
166                $params['from'],
167                $params['to'],
168                $params['limit'],
169                $params['offset']
170            );
171        } elseif ( $status === SectionTranslationStore::TRANSLATION_STATUS_DRAFT ) {
172            $sectionTranslations = $this->sectionTranslationStore->findDraftSectionTranslationsByUser(
173                $translatorUserId,
174                $params['from'],
175                $params['to'],
176                $params['limit'],
177                $params['offset']
178            );
179        }
180
181        $translations = array_map( static function ( $sectionTranslation ) {
182            return $sectionTranslation->toArray();
183        }, $sectionTranslations );
184
185        // We will have extra "continue" in case the last batch is exactly the size of the limit
186        $count = count( $sectionTranslations );
187
188        if ( $count === $params['limit'] ) {
189            $offset = $sectionTranslations[$count - 1]->getLastUpdatedTimestamp();
190            // We will have extra "continue" in case the last batch is exactly the size of the limit
191            if ( $offset ) {
192                $this->setContinueEnumParameter( 'offset', $offset );
193            }
194        }
195
196        $result = $this->getResult();
197        $result->addValue( [ 'query', 'contenttranslation' ], 'translations', $translations );
198
199        // Simple optimization
200        if ( $params['offset'] === null ) {
201            $translator = new Translator( $user );
202            $translatorLanguages = $translator->getLanguages( $params['type'] );
203            $result->addValue( [ 'query', 'contenttranslation' ], 'languages', $translatorLanguages );
204        }
205    }
206
207    private function serveDesktopEditorDraft( array $params ): void {
208        $result = $this->getResult();
209        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
210
211        $translation = $this->translationStore->findTranslationByUser(
212            $this->getUser(),
213            $sourceTitle,
214            $sourceLanguage,
215            $targetLanguage,
216            TranslationStore::TRANSLATION_STATUS_DRAFT
217        );
218
219        if ( $translation instanceof Translation ) {
220            if ( $params['sourcesectiontitle'] ) {
221                $sectionTranslation = $this->sectionTranslationStore->findTranslationBySectionTitle(
222                    $translation->getTranslationId(),
223                    $params['sourcesectiontitle']
224                );
225
226                $sectionTranslationId = $sectionTranslation ? $sectionTranslation->getId() : null;
227                $targetSectionTitle = $sectionTranslation ? $sectionTranslation->getTargetSectionTitle() : null;
228                $translation->translation['sectionTranslationId'] = $sectionTranslationId;
229                $translation->translation['targetSectionTitle'] = $targetSectionTitle;
230            }
231
232            $this->addUnitsAndCategoriesToTranslation( $translation );
233            $draftDTO = CXDraftTranslationDTO::createFromTranslation( $translation );
234
235            $result->addValue( [ 'query', 'contenttranslation' ], 'translation', $draftDTO->toArray() );
236        } else {
237            // Check for other drafts. If one exists, return that to the UI which will then
238            // know to display an error to the user because we disallow two users to start
239            // drafts on the same translation work.
240            $conflictingTranslations = $this->translationStore->findConflictingDraftTranslations(
241                $sourceTitle,
242                $sourceLanguage,
243                $targetLanguage
244            );
245
246            if ( !$conflictingTranslations ) {
247                return;
248            }
249
250            // if at least one conflicting translation is found, let the UI know
251            $result->addValue( [ 'query', 'contenttranslation' ], 'hasConflicts', true );
252            // Take only the last conflicting translation due to UI limitations
253            $translation = array_pop( $conflictingTranslations );
254            // $globalUserId is always expected to be integer or null, since it has been populated
255            // by the "translation_started_by" column of "cx_translations" table
256            $globalUserId = $translation->getData()['lastUpdatedTranslator'];
257            // $user can be null if the local user does not exist. Currently, this should never happen
258            // in our case because we redirect translators to the target wiki, and they cannot
259            // do translations without logging in.
260            // $user can also be null, if the current user has no permission to see the username.
261            // For whatever reason, fallback gracefully by letting 'translatorName' and 'translatorGender'
262            // to be null.
263            [ 'name' => $name, 'gender' => $gender ] = $this->userService->getUsernameAndGender( $globalUserId );
264            // Add name and gender information to the returned result. The UI can use this
265            // to display the conflict message.
266            $result->addValue( [ 'query', 'contenttranslation' ], 'translatorName', $name );
267            $result->addValue( [ 'query', 'contenttranslation' ], 'translatorGender', $gender );
268        }
269    }
270
271    private function serveTranslationCorporaUnits( $translationId ) {
272        $translation = $this->translationStore->findByUserAndId( $this->getUser(), $translationId );
273        if ( $translation !== null ) {
274            $this->addUnitsAndCategoriesToTranslation( $translation );
275            $result = $this->getResult();
276            $result->addValue( [ 'query', 'contenttranslation' ], 'translation', $translation->translation );
277        } else {
278            $this->dieWithError( 'apierror-cx-missingdraft', 'missingdraft' );
279        }
280    }
281
282    public function getAllowedParams() {
283        $allowedParams = [
284            'translationid' => [
285                ParamValidator::PARAM_TYPE => 'string',
286            ],
287            'from' => [
288                ParamValidator::PARAM_TYPE => 'string',
289            ],
290            'to' => [
291                ParamValidator::PARAM_TYPE => 'string',
292            ],
293            'sourcetitle' => [
294                ParamValidator::PARAM_TYPE => 'string',
295            ],
296            'sourcesectiontitle' => [
297                ParamValidator::PARAM_TYPE => 'string',
298            ],
299            'limit' => [
300                ParamValidator::PARAM_DEFAULT => 100,
301                ParamValidator::PARAM_TYPE => 'limit',
302                IntegerDef::PARAM_MIN => 1,
303                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
304                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
305            ],
306            'offset' => [
307                ParamValidator::PARAM_DEFAULT => null,
308                ParamValidator::PARAM_TYPE => 'string',
309            ],
310            'type' => [
311                ParamValidator::PARAM_DEFAULT => null,
312                ParamValidator::PARAM_TYPE => [ 'draft', 'published' ],
313            ],
314            'usecase' => [
315                ParamValidator::PARAM_DEFAULT => null,
316                ParamValidator::PARAM_TYPE => [
317                    'unified-dashboard',
318                    'desktop-editor-draft',
319                    'translation-corpora-units'
320                ],
321            ]
322        ];
323        return $allowedParams;
324    }
325
326    private function addUnitsAndCategoriesToTranslation( Translation $translation ): void {
327        // Translation units and target categories. Only target categories are fetched
328        // when translation draft is restored. Source categories are saved into cx_corpora table for
329        // pairing with target categories, but not retrieved when translation draft is restored.
330        // Associative array with 'translationUnits' and 'categories' data
331        $unitsAndCategories = $this->corporaManager->getUnitsAndCategoriesByTranslationId(
332            (int)$translation->getTranslationId()
333        );
334        $translation->translation['translationUnits'] = $unitsAndCategories['translationUnits'];
335        $translation->translation['targetCategories'] = $unitsAndCategories['categories'];
336    }
337
338    protected function getExamplesMessages() {
339        return [
340            'action=query&list=contenttranslation' =>
341                'apihelp-query+contenttranslation-example-1',
342            'action=query&list=contenttranslation&translationid=94' =>
343                'apihelp-query+contenttranslation-example-2',
344            'action=query&list=contenttranslation&from=en&to=es&sourcetitle=Hibiscus' =>
345                'apihelp-query+contenttranslation-example-3',
346        ];
347    }
348}