Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 185
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 / 185
0.00% covered (danger)
0.00%
0 / 10
992
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 / 47
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 / 41
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
136    private function serveUnifiedDashboardTranslations( array $params ): void {
137        $status = $params['type'];
138
139        $sectionTranslations = [];
140        $user = $this->getUser();
141        $translatorUserId = $this->userService->getGlobalUserId( $user );
142
143        if ( $status === SectionTranslationStore::TRANSLATION_STATUS_PUBLISHED ) {
144            $sectionTranslations = $this->sectionTranslationStore->findPublishedSectionTranslationsByUser(
145                $translatorUserId,
146                $params['from'],
147                $params['to'],
148                $params['limit'],
149                $params['offset']
150            );
151        } elseif ( $status === SectionTranslationStore::TRANSLATION_STATUS_DRAFT ) {
152            $sectionTranslations = $this->sectionTranslationStore->findDraftSectionTranslationsByUser(
153                $translatorUserId,
154                $params['from'],
155                $params['to'],
156                $params['limit'],
157                $params['offset']
158            );
159        }
160
161        $translations = array_map( static function ( $sectionTranslation ) {
162            return $sectionTranslation->toArray();
163        }, $sectionTranslations );
164
165        // We will have extra "continue" in case the last batch is exactly the size of the limit
166        $count = count( $sectionTranslations );
167
168        if ( $count === $params['limit'] ) {
169            $offset = $sectionTranslations[$count - 1]->getLastUpdatedTimestamp();
170            // We will have extra "continue" in case the last batch is exactly the size of the limit
171            if ( $offset ) {
172                $this->setContinueEnumParameter( 'offset', $offset );
173            }
174        }
175
176        $result = $this->getResult();
177        $result->addValue( [ 'query', 'contenttranslation' ], 'translations', $translations );
178    }
179
180    private function serveDesktopEditorDraft( array $params ): void {
181        $result = $this->getResult();
182        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
183
184        $translation = $this->translationStore->findTranslationByUser(
185            $this->getUser(),
186            $sourceTitle,
187            $sourceLanguage,
188            $targetLanguage
189        );
190
191        if ( $translation instanceof Translation ) {
192            if ( $params['sourcesectiontitle'] ) {
193                $sectionTranslation = $this->sectionTranslationStore->findTranslationBySectionTitle(
194                    $translation->getTranslationId(),
195                    $params['sourcesectiontitle']
196                );
197
198                $draftStatusIndex = SectionTranslationStore::getStatusIndexByStatus(
199                    SectionTranslationStore::TRANSLATION_STATUS_DRAFT
200                );
201                if ( $sectionTranslation?->getTranslationStatus() !== $draftStatusIndex ) {
202                    return;
203                }
204
205                $sectionTranslationId = $sectionTranslation?->getId();
206                $targetSectionTitle = $sectionTranslation?->getTargetSectionTitle();
207                $translation->translation['sectionTranslationId'] = $sectionTranslationId;
208                $translation->translation['targetSectionTitle'] = $targetSectionTitle;
209            } elseif ( $translation->getStatus() !== TranslationStore::TRANSLATION_STATUS_DRAFT ) {
210                return;
211            }
212
213            $this->addUnitsAndCategoriesToTranslation( $translation );
214            $draftDTO = CXDraftTranslationDTO::createFromTranslation( $translation );
215
216            $result->addValue( [ 'query', 'contenttranslation' ], 'translation', $draftDTO->toArray() );
217        } else {
218            // Check for other drafts. If one exists, return that to the UI which will then
219            // know to display an error to the user because we disallow two users to start
220            // drafts on the same translation work.
221            $conflictingTranslations = $this->translationStore->findConflictingDraftTranslations(
222                $sourceTitle,
223                $sourceLanguage,
224                $targetLanguage
225            );
226
227            if ( !$conflictingTranslations ) {
228                return;
229            }
230
231            // if at least one conflicting translation is found, let the UI know
232            $result->addValue( [ 'query', 'contenttranslation' ], 'hasConflicts', true );
233            // Take only the last conflicting translation due to UI limitations
234            $translation = array_pop( $conflictingTranslations );
235            // $globalUserId is always expected to be integer or null, since it has been populated
236            // by the "translation_started_by" column of "cx_translations" table
237            $globalUserId = $translation->getData()['lastUpdatedTranslator'];
238            // $user can be null if the local user does not exist. Currently, this should never happen
239            // in our case because we redirect translators to the target wiki, and they cannot
240            // do translations without logging in.
241            // $user can also be null, if the current user has no permission to see the username.
242            // For whatever reason, fallback gracefully by letting 'translatorName' and 'translatorGender'
243            // to be null.
244            [ 'name' => $name, 'gender' => $gender ] = $this->userService->getUsernameAndGender( $globalUserId );
245            // Add name and gender information to the returned result. The UI can use this
246            // to display the conflict message.
247            $result->addValue( [ 'query', 'contenttranslation' ], 'translatorName', $name );
248            $result->addValue( [ 'query', 'contenttranslation' ], 'translatorGender', $gender );
249        }
250    }
251
252    /**
253     * @param int $translationId
254     */
255    private function serveTranslationCorporaUnits( $translationId ) {
256        $translation = $this->translationStore->findByUserAndId( $this->getUser(), $translationId );
257        if ( $translation !== null ) {
258            $this->addUnitsAndCategoriesToTranslation( $translation );
259            $result = $this->getResult();
260            $result->addValue( [ 'query', 'contenttranslation' ], 'translation', $translation->translation );
261        } else {
262            $this->dieWithError( 'apierror-cx-missingdraft', 'missingdraft' );
263        }
264    }
265
266    /** @inheritDoc */
267    public function getAllowedParams() {
268        $allowedParams = [
269            'translationid' => [
270                ParamValidator::PARAM_TYPE => 'string',
271            ],
272            'from' => [
273                ParamValidator::PARAM_TYPE => 'string',
274            ],
275            'to' => [
276                ParamValidator::PARAM_TYPE => 'string',
277            ],
278            'sourcetitle' => [
279                ParamValidator::PARAM_TYPE => 'string',
280            ],
281            'sourcesectiontitle' => [
282                ParamValidator::PARAM_TYPE => 'string',
283            ],
284            'limit' => [
285                ParamValidator::PARAM_DEFAULT => 100,
286                ParamValidator::PARAM_TYPE => 'limit',
287                IntegerDef::PARAM_MIN => 1,
288                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
289                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
290            ],
291            'offset' => [
292                ParamValidator::PARAM_DEFAULT => null,
293                ParamValidator::PARAM_TYPE => 'string',
294            ],
295            'type' => [
296                ParamValidator::PARAM_DEFAULT => 'draft',
297                ParamValidator::PARAM_TYPE => [ 'draft', 'published' ],
298            ],
299            'usecase' => [
300                ParamValidator::PARAM_DEFAULT => null,
301                ParamValidator::PARAM_TYPE => [
302                    'unified-dashboard',
303                    'desktop-editor-draft',
304                    'translation-corpora-units'
305                ],
306            ]
307        ];
308        return $allowedParams;
309    }
310
311    /** @inheritDoc */
312    private function addUnitsAndCategoriesToTranslation( Translation $translation ): void {
313        // Translation units and target categories. Only target categories are fetched
314        // when translation draft is restored. Source categories are saved into cx_corpora table for
315        // pairing with target categories, but not retrieved when translation draft is restored.
316        // Associative array with 'translationUnits' and 'categories' data
317        $unitsAndCategories = $this->corporaManager->getUnitsAndCategoriesByTranslationId(
318            (int)$translation->getTranslationId()
319        );
320        $translation->translation['translationUnits'] = $unitsAndCategories['translationUnits'];
321        $translation->translation['targetCategories'] = $unitsAndCategories['categories'];
322    }
323
324    /** @inheritDoc */
325    protected function getExamplesMessages() {
326        return [
327            'action=query&list=contenttranslation' =>
328                'apihelp-query+contenttranslation-example-1',
329            'action=query&list=contenttranslation&translationid=94' =>
330                'apihelp-query+contenttranslation-example-2',
331            'action=query&list=contenttranslation&from=en&to=es&sourcetitle=Hibiscus' =>
332                'apihelp-query+contenttranslation-example-3',
333        ];
334    }
335}