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