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