Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiSectionTranslationSave
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 10
552
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 validateRequest
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 execute
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 createNewTranslationFromPayload
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 saveTranslation
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 saveSectionTranslation
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @copyright See AUTHORS.txt
4 * @license GPL-2.0-or-later
5 */
6
7namespace ContentTranslation\ActionApi;
8
9use ContentTranslation\Entity\SectionTranslation;
10use ContentTranslation\Exception\InvalidSectionDataException;
11use ContentTranslation\LoadBalancer;
12use ContentTranslation\Manager\TranslationCorporaManager;
13use ContentTranslation\Service\SandboxTitleMaker;
14use ContentTranslation\SiteMapper;
15use ContentTranslation\Store\SectionTranslationStore;
16use ContentTranslation\Store\TranslationStore;
17use ContentTranslation\Translation;
18use ContentTranslation\Translator;
19use MediaWiki\Api\ApiBase;
20use MediaWiki\Api\ApiMain;
21use MediaWiki\Api\ApiUsageException;
22use MediaWiki\Languages\LanguageNameUtils;
23use MediaWiki\Title\TitleFactory;
24use MediaWiki\User\User;
25use Wikimedia\ParamValidator\ParamValidator;
26
27class ApiSectionTranslationSave extends ApiBase {
28    private TranslationCorporaManager $corporaManager;
29    private LoadBalancer $lb;
30    private SectionTranslationStore $sectionTranslationStore;
31    private SandboxTitleMaker $sandboxTitleMaker;
32    private TitleFactory $titleFactory;
33    private LanguageNameUtils $languageNameUtils;
34    private TranslationStore $translationStore;
35
36    public function __construct(
37        ApiMain $mainModule,
38        string $action,
39        TranslationCorporaManager $corporaManager,
40        LoadBalancer $loadBalancer,
41        SectionTranslationStore $sectionTranslationStore,
42        SandboxTitleMaker $sandboxTitleMaker,
43        TitleFactory $titleFactory,
44        LanguageNameUtils $languageNameUtils,
45        TranslationStore $translationStore
46    ) {
47        parent::__construct( $mainModule, $action );
48        $this->corporaManager = $corporaManager;
49        $this->lb = $loadBalancer;
50        $this->sectionTranslationStore = $sectionTranslationStore;
51        $this->sandboxTitleMaker = $sandboxTitleMaker;
52        $this->titleFactory = $titleFactory;
53        $this->languageNameUtils = $languageNameUtils;
54        $this->translationStore = $translationStore;
55    }
56
57    private function validateRequest() {
58        if ( $this->lb->getConnection( DB_PRIMARY )->isReadOnly() ) {
59            $this->dieReadOnly();
60        }
61
62        $user = $this->getUser();
63
64        if ( !$user->isNamed() ) {
65            $this->dieWithError( 'apierror-sxsave-anon-user' );
66        }
67
68        $block = $user->getBlock();
69        if ( $block && $block->isSitewide() ) {
70            $this->dieBlocked( $block );
71        }
72
73        if ( $user->pingLimiter( 'sxsave' ) ) {
74            $this->dieWithError( 'apierror-ratelimited' );
75        }
76
77        $params = $this->extractRequestParams();
78        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['sourcelanguage'] ) ) {
79            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
80        }
81
82        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['targetlanguage'] ) ) {
83            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
84        }
85
86        if ( trim( $params['content'] ) === '' ) {
87            $this->dieWithError( [ 'apierror-paramempty', 'content' ], 'invalidcontent' );
88        }
89    }
90
91    /**
92     * @throws ApiUsageException
93     */
94    public function execute() {
95        $this->validateRequest();
96        $params = $this->extractRequestParams();
97        $user = $this->getUser();
98        $targetTitleRaw = $params['targettitle'];
99        $isSandbox = $params['issandbox'];
100        if ( $isSandbox ) {
101            $targetTitle = $this->sandboxTitleMaker->makeSandboxTitle( $user, $targetTitleRaw );
102        } else {
103            $targetTitle = $this->titleFactory->newFromText( $targetTitleRaw );
104        }
105
106        if ( !$targetTitle ) {
107            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $targetTitleRaw ) ] );
108        }
109
110        $translation = $this->saveTranslation(
111            $user,
112            $params['sourcelanguage'],
113            $params['targetlanguage'],
114            $params['sourcetitle'],
115            $targetTitle->getPrefixedText(),
116            $params['sourcerevision']
117        );
118        $translationId = $translation->getTranslationId();
119
120        try {
121            $this->corporaManager->saveTranslationUnits( $translation, $params['content'] );
122        } catch ( InvalidSectionDataException $exception ) {
123            $this->dieWithError( 'apierror-cx-invalidsectiondata', 'invalidcontent' );
124        }
125        $sectionTranslationId = $this->saveSectionTranslation(
126            $translationId,
127            $params['sectionid'],
128            $params['sourcesectiontitle'],
129            $params['targetsectiontitle'],
130            $params['progress']
131        );
132        $result = [
133            'result' => 'success',
134            'sectiontranslationid' => $sectionTranslationId,
135            'translationid' => $translationId
136        ];
137        $this->getResult()->addValue( null, $this->getModuleName(), $result );
138    }
139
140    /**
141     * This method creates a new Translation model for the saved translation and returns it
142     *
143     * @param string $sourceLanguage
144     * @param string $sourceTitle
145     * @param string $targetLanguage
146     * @param string $targetTitle
147     * @param string $sourceRevision
148     * @return Translation
149     */
150    private function createNewTranslationFromPayload(
151        string $sourceLanguage,
152        string $sourceTitle,
153        string $targetLanguage,
154        string $targetTitle,
155        string $sourceRevision
156    ): Translation {
157        $translationData = [
158            'sourceTitle' => $sourceTitle,
159            'targetTitle' => $targetTitle,
160            'sourceLanguage' => $sourceLanguage,
161            'targetLanguage' => $targetLanguage,
162            'sourceRevisionId' => $sourceRevision,
163            'sourceURL' => SiteMapper::getPageURL( $sourceLanguage, $sourceTitle ),
164            'status' => TranslationStore::TRANSLATION_STATUS_DRAFT,
165            'progress' => json_encode( [ "any" => null, "mt" => null, "human" => null ] ),
166            'cxVersion' => 3,
167        ];
168
169        return new Translation( $translationData );
170    }
171
172    protected function saveTranslation(
173        User $user,
174        string $sourceLanguage,
175        string $targetLanguage,
176        string $sourceTitle,
177        string $targetTitle,
178        string $sourceRevision
179    ): Translation {
180        $translation = $this->translationStore->findTranslationByUser(
181            $user,
182            $sourceTitle,
183            $sourceLanguage,
184            $targetLanguage
185        );
186
187        if ( !$translation ) {
188            $translation = $this->createNewTranslationFromPayload(
189                $sourceLanguage,
190                $sourceTitle,
191                $targetLanguage,
192                $targetTitle,
193                $sourceRevision
194            );
195        } else {
196            $translation->translation['sourceRevisionId'] = $sourceRevision;
197            // target title can be changed any time during translation
198            $translation->translation['targetTitle'] = $targetTitle;
199        }
200        $this->translationStore->saveTranslation( $translation, $user );
201
202        // Associate the translation with the translator
203        $translator = new Translator( $user );
204        $translationId = $translation->getTranslationId();
205        $translator->addTranslation( $translationId );
206
207        return $translation;
208    }
209
210    /**
211     * Given a translation id (corresponding to a row inside "cx_translations" table), this
212     * method creates a new SectionTranslation model and stores it inside "cx_section_translations"
213     * table.
214     *
215     * Lead sections are also stored inside the table. For such sections we set empty strings as
216     * values for "cxsx_source_section_title" and "cxsx_target_section_title" values, as empty
217     * strings are considered valid values for non-nullable fields in MySQL.
218     *
219     * @param int $translationId
220     * @param string $sectionId
221     * @param string $sourceSectionTitle
222     * @param string $targetSectionTitle
223     * @return int the id (cxsx_id) of the saved section translation
224     */
225    private function saveSectionTranslation(
226        int $translationId,
227        string $sectionId,
228        string $sourceSectionTitle,
229        string $targetSectionTitle,
230        string $progress
231    ): int {
232        $sectionTranslation = $this->sectionTranslationStore->findTranslation( $translationId, $sectionId );
233        $draftStatusIndex = SectionTranslationStore::getStatusIndexByStatus(
234            SectionTranslationStore::TRANSLATION_STATUS_DRAFT
235        );
236
237        if ( !$sectionTranslation ) {
238            $sectionTranslation = new SectionTranslation(
239                null,
240                $translationId,
241                $sectionId,
242                $sourceSectionTitle,
243                $targetSectionTitle,
244                $draftStatusIndex,
245                $progress
246            );
247            $this->sectionTranslationStore->insertTranslation( $sectionTranslation );
248        } else {
249            // update updatable fields
250            $sectionTranslation->setTargetSectionTitle( $targetSectionTitle );
251            $sectionTranslation->setTranslationStatus( $draftStatusIndex );
252            $sectionTranslation->setProgress( $progress );
253            $this->sectionTranslationStore->updateTranslation( $sectionTranslation );
254        }
255
256        // the id of the section translation is always set, since the entity has been stored in the database
257        // @phan-suppress-next-line PhanTypeMismatchReturnNullable
258        return $sectionTranslation->getId();
259    }
260
261    public function getAllowedParams() {
262        return [
263            'sourcelanguage' => [
264                ParamValidator::PARAM_REQUIRED => true,
265            ],
266            'targetlanguage' => [
267                ParamValidator::PARAM_REQUIRED => true,
268            ],
269            'sourcetitle' => [
270                ParamValidator::PARAM_REQUIRED => true,
271            ],
272            'targettitle' => [
273                ParamValidator::PARAM_REQUIRED => true,
274            ],
275            'content' => [
276                ParamValidator::PARAM_REQUIRED => true,
277            ],
278            'sourcerevision' => [
279                ParamValidator::PARAM_TYPE => 'integer',
280                ParamValidator::PARAM_REQUIRED => true,
281            ],
282            'sourcesectiontitle' => [
283                ParamValidator::PARAM_TYPE => 'string',
284                ParamValidator::PARAM_REQUIRED => true,
285            ],
286            'targetsectiontitle' => [
287                ParamValidator::PARAM_TYPE => 'string',
288                ParamValidator::PARAM_REQUIRED => true,
289            ],
290            'sectionid' => [
291                ParamValidator::PARAM_TYPE => 'string',
292                ParamValidator::PARAM_REQUIRED => true
293            ],
294            'issandbox' => [
295                ParamValidator::PARAM_TYPE => 'boolean',
296                ParamValidator::PARAM_REQUIRED => false,
297            ],
298            'progress' => [
299                ParamValidator::PARAM_REQUIRED => true,
300            ],
301        ];
302    }
303
304    public function needsToken() {
305        return 'csrf';
306    }
307
308    public function isWriteMode() {
309        return true;
310    }
311
312    public function isInternal() {
313        return true;
314    }
315}