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