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