Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 141
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiContentTranslationSave
0.00% covered (danger)
0.00%
0 / 141
0.00% covered (danger)
0.00%
0 / 9
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
182
 saveTranslation
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 isValidCategoriesJSON
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 saveCategories
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 41
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\CategoriesStorageManager;
10use ContentTranslation\Exception\InvalidSectionDataException;
11use ContentTranslation\LoadBalancer;
12use ContentTranslation\Manager\TranslationCorporaManager;
13use ContentTranslation\SiteMapper;
14use ContentTranslation\Store\TranslationStore;
15use ContentTranslation\Translation;
16use ContentTranslation\Translator;
17use ContentTranslation\Validator\TranslationUnitValidator;
18use Deflate;
19use MediaWiki\Api\ApiBase;
20use MediaWiki\Api\ApiMain;
21use MediaWiki\Json\FormatJson;
22use MediaWiki\Languages\LanguageNameUtils;
23use Wikimedia\ParamValidator\ParamValidator;
24use Wikimedia\ParamValidator\TypeDef\IntegerDef;
25use Wikimedia\ParamValidator\TypeDef\StringDef;
26
27class ApiContentTranslationSave extends ApiBase {
28    private TranslationCorporaManager $corporaManager;
29    private LoadBalancer $lb;
30    private TranslationUnitValidator $translationUnitValidator;
31    private LanguageNameUtils $languageNameUtils;
32    private TranslationStore $translationStore;
33
34    /**
35     * 64KB
36     */
37    private const SQL_BLOB_MAX_SIZE = 65535;
38
39    public function __construct(
40        ApiMain $mainModule,
41        string $action,
42        TranslationCorporaManager $corporaManager,
43        LoadBalancer $loadBalancer,
44        TranslationUnitValidator $translationUnitValidator,
45        LanguageNameUtils $languageNameUtils,
46        TranslationStore $translationStore
47    ) {
48        parent::__construct( $mainModule, $action );
49        $this->corporaManager = $corporaManager;
50        $this->lb = $loadBalancer;
51        $this->translationUnitValidator = $translationUnitValidator;
52        $this->languageNameUtils = $languageNameUtils;
53        $this->translationStore = $translationStore;
54    }
55
56    public function execute() {
57        $params = $this->extractRequestParams();
58
59        if ( $this->lb->getConnection( DB_PRIMARY )->isReadOnly() ) {
60            $this->dieReadOnly();
61        }
62
63        $user = $this->getUser();
64        $block = $user->getBlock();
65        if ( $block && $block->isSitewide() ) {
66            $this->dieBlocked( $block );
67        }
68
69        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['from'] ) ) {
70            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
71        }
72
73        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['to'] ) ) {
74            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
75        }
76
77        $progress = FormatJson::decode( $params['progress'], true );
78        if ( !is_array( $progress ) ) {
79            $this->dieWithError( 'apierror-cx-invalidprogress', 'invalidprogress' );
80        }
81
82        if ( $user->pingLimiter( 'cxsave' ) ) {
83            $this->dieWithError( 'apierror-ratelimited' );
84        }
85
86        $sourceCategories = $params['sourcecategories'];
87        $targetCategories = $params['targetcategories'];
88        if ( !$this->isValidCategoriesJSON( $sourceCategories ) ) {
89            $this->dieWithError( 'apierror-cx-invalidsourcecategories', 'invalidsourcecategories' );
90        }
91        if ( !$this->isValidCategoriesJSON( $targetCategories ) ) {
92            $this->dieWithError( 'apierror-cx-invalidtargetcategories', 'invalidtargetcategories' );
93        }
94
95        $translator = new Translator( $user );
96        $translation = $this->saveTranslation( $params, $translator );
97        $translationId = $translation->getTranslationId();
98
99        $content = Deflate::inflate( $params['content'] );
100        if ( !$content->isGood() ) {
101            $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
102        }
103
104        if ( trim( $content->getValue() ) === '' ) {
105            $this->dieWithError( [ 'apierror-paramempty', 'content' ], 'invalidcontent' );
106        }
107
108        try {
109            $translationUnits = $this->corporaManager->saveTranslationUnits( $translation, $content->getValue() );
110        } catch ( InvalidSectionDataException $exception ) {
111            $this->dieWithError( 'apierror-cx-invalidsectiondata', 'invalidcontent' );
112        }
113
114        $validationResults = $this->translationUnitValidator->validateTranslationUnitsForTitleUser(
115            $translationUnits,
116            $translation->getData()['targetTitle'],
117            $this->getUser()
118        );
119
120        $this->saveCategories( $sourceCategories, $targetCategories, $translation );
121
122        $result = [
123            'result' => 'success',
124            'validations' => $validationResults,
125            'translationid' => $translationId
126        ];
127        $this->getResult()->addValue( null, $this->getModuleName(), $result );
128    }
129
130    /**
131     * @param array $params
132     * @param Translator $translator
133     * @return Translation
134     */
135    protected function saveTranslation( array $params, Translator $translator ) {
136        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
137        $existingTranslation = $this->translationStore->findTranslationByUser(
138            $translator->getUser(),
139            $sourceTitle,
140            $sourceLanguage,
141            $targetLanguage
142        );
143
144        if ( $existingTranslation ) {
145            $data = $existingTranslation->getData();
146        } else {
147            $conflictingTranslations = $this->translationStore->findConflictingDraftTranslations(
148                $sourceTitle,
149                $sourceLanguage,
150                $targetLanguage
151            );
152
153            if ( $conflictingTranslations !== [] ) {
154                $this->dieWithError( 'apierror-cx-inuse', 'noaccess' );
155            }
156
157            // First time save, add relevant fields
158            $data = [
159                'sourceTitle' => $sourceTitle,
160                'sourceLanguage' => $sourceLanguage,
161                'targetLanguage' => $targetLanguage,
162                'sourceURL' => SiteMapper::getPageURL( $sourceLanguage, $sourceTitle ),
163            ];
164        }
165
166        // Update updateable fields
167        $data['targetTitle'] = $params['title'];
168        $data['sourceRevisionId'] = $params['sourcerevision'];
169        $data['status'] = TranslationStore::TRANSLATION_STATUS_DRAFT;
170        $data['progress'] = $params['progress'];
171        $data['cxVersion'] = $params['cxversion'] ?? $this->getConfig()->get( 'ContentTranslationVersion' );
172
173        // Save the translation
174        $translation = new Translation( $data );
175        $this->translationStore->saveTranslation( $translation, $translator->getUser() );
176
177        // Associate the translation with the translator
178        $translationId = $translation->getTranslationId();
179        $translator->addTranslation( $translationId );
180
181        return $translation;
182    }
183
184    /**
185     * Validate categories JSON param.
186     *
187     * @param string $categories JSON encoded array of categories
188     * @return bool
189     */
190    protected function isValidCategoriesJSON( $categories ) {
191        // Categories are optional, so empty categories param is valid.
192        if ( $categories === null || $categories === '' ) {
193            return true;
194        }
195
196        $parsedCategories = FormatJson::parse( $categories );
197        return $parsedCategories->isGood();
198    }
199
200    /**
201     * Save categories in cx_corpora table, if any are supplied.
202     *
203     * @param string $sourceCategories JSON encoded array of source categories
204     * @param string $targetCategories JSON encoded array of target categories
205     * @param Translation $translation Recently saved parent translation object
206     */
207    protected function saveCategories(
208        $sourceCategories, $targetCategories, Translation $translation
209    ) {
210        if ( !$sourceCategories && !$targetCategories ) {
211            return;
212        }
213
214        $translationId = $translation->getTranslationId();
215        $newTranslation = $translation->isNew();
216
217        CategoriesStorageManager::save(
218            $translationId,
219            $sourceCategories,
220            $targetCategories,
221            $newTranslation
222        );
223    }
224
225    public function getAllowedParams() {
226        return [
227            'from' => [
228                ParamValidator::PARAM_REQUIRED => true,
229            ],
230            'to' => [
231                ParamValidator::PARAM_REQUIRED => true,
232            ],
233            'sourcetitle' => [
234                ParamValidator::PARAM_REQUIRED => true,
235            ],
236            'title' => [
237                ParamValidator::PARAM_REQUIRED => true,
238            ],
239            'content' => [
240                ParamValidator::PARAM_REQUIRED => true,
241            ],
242            'sourcerevision' => [
243                ParamValidator::PARAM_TYPE => 'integer',
244                ParamValidator::PARAM_REQUIRED => true,
245            ],
246            'progress' => [
247                ParamValidator::PARAM_REQUIRED => true,
248            ],
249            'cxversion' => [
250                ParamValidator::PARAM_TYPE => 'integer',
251                // Making this required immediately would cause issues for ongoing translations
252                // during deployment. Maybe this doesn't ever need to be required.
253                ParamValidator::PARAM_REQUIRED => false,
254                ApiBase::PARAM_RANGE_ENFORCE => true,
255                IntegerDef::PARAM_MIN => 1,
256                IntegerDef::PARAM_MAX => 2,
257            ],
258            'sourcecategories' => [
259                ParamValidator::PARAM_TYPE => 'string',
260                // We don't always save categories when saving translation. Only save
261                // categories when user changes target categories by reordering,
262                // removing or adding. Source categories are saved only once per
263                // session and target categories are saved for every change.
264                ParamValidator::PARAM_REQUIRED => false,
265                // Source and target categories are saved in cx_corpora table, whose
266                // content column is MEDIUMBLOB, which has 16MB limit, but we limit
267                // the size of categories at BLOB limit, which is 64KB.
268                StringDef::PARAM_MAX_BYTES => self::SQL_BLOB_MAX_SIZE
269            ],
270            'targetcategories' => [
271                ParamValidator::PARAM_TYPE => 'string',
272                ParamValidator::PARAM_REQUIRED => false,
273                StringDef::PARAM_MAX_BYTES => self::SQL_BLOB_MAX_SIZE
274            ]
275        ];
276    }
277
278    public function needsToken() {
279        return 'csrf';
280    }
281
282    public function isWriteMode() {
283        return true;
284    }
285
286    public function isInternal() {
287        return true;
288    }
289}