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