Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiSectionTranslationPublish
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 14
812
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getParsoidClient
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPublishSummary
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 submitEditAction
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 publish
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
42
 saveWikitext
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 prependSectionTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeTags
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 updateTranslation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 37
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 * Saving a section to an existing wikipage, created using Section translation feature of ContentTranslation.
4 * The following special things happen when the page is created:
5 * - HTML from the translation editor's contenteditable is converted to wiki syntax using Parsoid.
6 * - A change tag is added.
7 * - The edit summary shows a link to the revision from which the translation was made.
8 *
9 * @copyright See AUTHORS.txt
10 * @license GPL-2.0-or-later
11 */
12
13namespace ContentTranslation\ActionApi;
14
15use ApiBase;
16use ApiMain;
17use ContentTranslation\ContentTranslationHookRunner;
18use ContentTranslation\ParsoidClient;
19use ContentTranslation\ParsoidClientFactory;
20use ContentTranslation\Service\SandboxTitleMaker;
21use ContentTranslation\Service\SectionPositionCalculator;
22use ContentTranslation\Service\TranslationTargetUrlCreator;
23use ContentTranslation\Store\SectionTranslationStore;
24use ContentTranslation\Store\TranslationStore;
25use ContentTranslation\Translation;
26use Exception;
27use MediaWiki\HookContainer\HookContainer;
28use MediaWiki\Languages\LanguageNameUtils;
29use MediaWiki\Request\DerivativeRequest;
30use MediaWiki\Title\Title;
31use MediaWiki\Title\TitleFactory;
32use User;
33use Wikimedia\ParamValidator\ParamValidator;
34
35class ApiSectionTranslationPublish extends ApiBase {
36
37    private TitleFactory $titleFactory;
38    private HookContainer $hookContainer;
39    private LanguageNameUtils $languageNameUtils;
40    protected ParsoidClientFactory $parsoidClientFactory;
41    private SectionPositionCalculator $sectionPositionCalculator;
42    private SandboxTitleMaker $sandboxTitleMaker;
43    private SectionTranslationStore $sectionTranslationStore;
44    private TranslationStore $translationStore;
45    private TranslationTargetUrlCreator $targetUrlCreator;
46
47    /**
48     * @param ApiMain $main
49     * @param string $action
50     * @param TitleFactory $titleFactory
51     * @param HookContainer $hookContainer
52     * @param LanguageNameUtils $languageNameUtils
53     * @param ParsoidClientFactory $parsoidClientFactory
54     * @param SectionPositionCalculator $sectionPositionCalculator
55     * @param SandboxTitleMaker $sandboxTitleMaker
56     * @param SectionTranslationStore $sectionTranslationStore
57     * @param TranslationStore $translationStore
58     * @param TranslationTargetUrlCreator $targetUrlCreator
59     */
60    public function __construct(
61        ApiMain $main,
62        $action,
63        TitleFactory $titleFactory,
64        HookContainer $hookContainer,
65        LanguageNameUtils $languageNameUtils,
66        ParsoidClientFactory $parsoidClientFactory,
67        SectionPositionCalculator $sectionPositionCalculator,
68        SandboxTitleMaker $sandboxTitleMaker,
69        SectionTranslationStore $sectionTranslationStore,
70        TranslationStore $translationStore,
71        TranslationTargetUrlCreator $targetUrlCreator
72    ) {
73        parent::__construct( $main, $action );
74        $this->titleFactory = $titleFactory;
75        $this->hookContainer = $hookContainer;
76        $this->languageNameUtils = $languageNameUtils;
77        $this->parsoidClientFactory = $parsoidClientFactory;
78        $this->sectionPositionCalculator = $sectionPositionCalculator;
79        $this->sandboxTitleMaker = $sandboxTitleMaker;
80        $this->sectionTranslationStore = $sectionTranslationStore;
81        $this->translationStore = $translationStore;
82        $this->targetUrlCreator = $targetUrlCreator;
83    }
84
85    protected function getParsoidClient(): ParsoidClient {
86        return $this->parsoidClientFactory->createParsoidClient();
87    }
88
89    /**
90     * @param string $sourceLanguage
91     * @param string $sourceRevId
92     * @param string $sourceTitle
93     * @param bool $isLeadSection
94     * @param string $sourceSectionTitle
95     * @return string
96     */
97    private function getPublishSummary(
98        string $sourceLanguage,
99        string $sourceRevId,
100        string $sourceTitle,
101        bool $isLeadSection,
102        string $sourceSectionTitle
103    ): string {
104        $sourceLink = "[[:{$sourceLanguage}:Special:Redirect/revision/{$sourceRevId}|{$sourceTitle}]]";
105        // if the published section is a lead section, the summary should be slightly different
106        if ( $isLeadSection ) {
107            return $this->msg(
108                'cx-sx-publish-lead-section-summary',
109                $sourceLink
110            )->inContentLanguage()->text();
111        } else {
112            return $this->msg(
113                'cx-sx-publish-summary',
114                $sourceSectionTitle,
115                $sourceLink
116            )->inContentLanguage()->text();
117        }
118    }
119
120    /**
121     * Attempt to save a given page's wikitext to MediaWiki's storage layer via its API
122     *
123     * @param Title $title The title of the page to write
124     * @param string $wikitext The wikitext to write
125     * @param int|string $sectionNumber
126     * @return mixed The result of the save attempt
127     * @throws \ApiUsageException
128     */
129    protected function submitEditAction( Title $title, string $wikitext, $sectionNumber ) {
130        $params = $this->extractRequestParams();
131        [ 'sourcelanguage' => $from, 'sourcerevid' => $sourceRevId, 'sourcetitle' => $sourceTitle ] = $params;
132        $isLeadSection = $sectionNumber === 0;
133        $summary = $this->getPublishSummary(
134            $from,
135            $sourceRevId,
136            $sourceTitle,
137            $isLeadSection,
138            $params['sourcesectiontitle']
139        );
140
141        $apiParams = [
142            'action' => 'edit',
143            'title' => $title->getPrefixedDBkey(),
144            'summary' => $summary,
145            'sectiontitle' => $params['targetsectiontitle'],
146            'section' => $sectionNumber,
147            'captchaid' => $params['captchaid'],
148            'captchaword' => $params['captchaword'],
149        ];
150
151        if ( (int)$sectionNumber > 0 ) {
152            $apiParams['prependtext'] = $wikitext;
153        } else {
154            $apiParams['text'] = $wikitext;
155        }
156
157        // Pass any unrecognized query parameters to the internal action=edit API request.
158        $allowedParams = array_diff_key(
159            $this->getRequest()->getValues(),
160            $this->getAllowedParams(),
161            $this->getMain()->getAllowedParams()
162        );
163        $api = new ApiMain(
164            new DerivativeRequest(
165                $this->getRequest(),
166                $apiParams + $allowedParams,
167                /* was posted? */ true
168            ),
169            /* enable write? */ true
170        );
171        $api->execute();
172        return $api->getResult()->getResultData();
173    }
174
175    /**
176     * @throws \ApiUsageException
177     */
178    public function execute() {
179        $params = $this->extractRequestParams();
180
181        $block = $this->getUser()->getBlock();
182        if ( $block && $block->isSitewide() ) {
183            $this->dieBlocked( $block );
184        }
185
186        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['sourcelanguage'] ) ) {
187            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
188        }
189
190        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['targetlanguage'] ) ) {
191            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
192        }
193
194        if ( trim( $params['html'] ) === '' ) {
195            $this->dieWithError( [ 'apierror-paramempty', 'html' ], 'invalidhtml' );
196        }
197
198        $this->publish();
199    }
200
201    public function publish() {
202        $params = $this->extractRequestParams();
203
204        $targetTitleRaw = $params['title'];
205        $isSandbox = $params['issandbox'];
206        $user = $this->getUser();
207        if ( $isSandbox ) {
208            $targetTitle = $this->sandboxTitleMaker->makeSandboxTitle( $user, $targetTitleRaw );
209        } else {
210            $targetTitle = $this->titleFactory->newFromText( $targetTitleRaw );
211        }
212
213        if ( !$targetTitle ) {
214            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $targetTitleRaw ) ] );
215        }
216
217        $hookRunner = new ContentTranslationHookRunner( $this->hookContainer );
218        $targetLanguage = $params['targetlanguage'];
219        '@phan-var Title $targetTitle';
220        $hookRunner->onSectionTranslationBeforePublish( $targetTitle, $targetLanguage, $user );
221
222        $sectionNumber = $this->sectionPositionCalculator->calculateSectionPosition(
223            $targetTitle,
224            $targetLanguage,
225            $isSandbox
226        );
227
228        $targetSectionTitle = $params['targetsectiontitle'];
229        $editResult = $this->saveWikitext( $params['html'], $targetTitle, $sectionNumber, $targetSectionTitle );
230        $editStatus = $editResult['result'];
231
232        if ( $editStatus === 'Success' ) {
233            [ 'sourcelanguage' => $sourceLanguage, 'sourcetitle' => $sourceTitle ] = $params;
234            $result = [
235                'result' => 'success',
236                'edit' => $editResult,
237                'targettitle' => $targetTitle->getPrefixedDBkey(),
238                'targeturl' => $this->targetUrlCreator->createUrlForSXRedirection(
239                    $targetTitle->getPrefixedDBkey(),
240                    $targetLanguage,
241                    $sourceLanguage,
242                    $sourceTitle,
243                    $targetSectionTitle
244                )
245            ];
246            // newrevid can be unset when publishing already present sections with the exact same
247            // contents as the current revision
248            if ( isset( $editResult['newrevid'] ) ) {
249                // Add the tags post-send, after RC row insertion
250                $newRevId = intval( $editResult['newrevid'] );
251                $this->storeTags( $newRevId );
252
253                $translation = $this->translationStore->findTranslationByUser(
254                    $user,
255                    $sourceTitle,
256                    $sourceLanguage,
257                    $targetLanguage,
258                );
259
260                if ( $translation === null ) {
261                    $this->dieWithError( 'apierror-cxpublishsection-translationnotfound', 'translationnotfound' );
262                }
263
264                // if translation exists update the "translation_target_revision_id" field for this row
265                '@phan-var Translation $translation';
266                $this->updateTranslation(
267                    $translation,
268                    $user,
269                    $newRevId,
270                    $targetTitle->getPrefixedDBkey(),
271                    $targetLanguage
272                );
273
274                $publishedStatusIndex = SectionTranslationStore::getStatusIndexByStatus(
275                    SectionTranslationStore::TRANSLATION_STATUS_PUBLISHED
276                );
277                $this->sectionTranslationStore->updateTranslationStatusById(
278                    $params['sectiontranslationid'],
279                    $publishedStatusIndex
280                );
281            }
282        } else {
283            $result = [
284                'result' => 'error',
285                'edit' => $editResult
286            ];
287        }
288
289        $this->getResult()->addValue( null, $this->getModuleName(), $result );
290    }
291
292    /**
293     * Given the HTML of the published translation and the target page title (as a Title instance),
294     * this method uses the RestbaseClient service to convert the HTML to wikitext and calls
295     * the "submitEditAction" method to save the wikitext to MediaWiki's storage layer via its API.
296     *
297     * @param string $html
298     * @param Title $targetTitle
299     * @param int|string $sectionNumber
300     * @param string $targetSectionTitle
301     * @return mixed
302     * @throws \ApiUsageException
303     */
304    private function saveWikitext( string $html, Title $targetTitle, $sectionNumber, string $targetSectionTitle ) {
305        // When the section number is a positive integer, it means that the section needs to be positioned
306        // before the first appendix section. In those cases, we need to prepend the target section title
307        // to the HTML that is being published
308        if ( (int)$sectionNumber > 0 ) {
309            $html = $this->prependSectionTitle( $html, $targetSectionTitle );
310        }
311        $wikitext = null;
312        try {
313            $wikitext = $this->getParsoidClient()->convertHtmlToWikitext(
314                $targetTitle,
315                $html
316            )['body'];
317        } catch ( Exception $e ) {
318            $this->dieWithError(
319                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
320            );
321        }
322        $editResult = $this->submitEditAction( $targetTitle, $wikitext, $sectionNumber );
323        return $editResult['edit'];
324    }
325
326    /**
327     * This method prepends the target section title to the HTML that is being published.
328     * Used for sections that need to be prepended to the first appendix section of the
329     * target article.
330     * @param string $html
331     * @param string $sectionTitle
332     * @return string
333     */
334    private function prependSectionTitle( string $html, string $sectionTitle ): string {
335        // add empty line to the end of HTML string, so that the first appendix section title goes into the next line
336        return "<h2>$sectionTitle</h2>\n$html\n";
337    }
338
339    /**
340     * Given a new target revision id, this method adds a deferred update to be executed at the end
341     * of the current request. This update adds the "contenttranslation" and "sectiontranslation" tags
342     * to the given revision.
343     *
344     * @param int $newRevId
345     * @return void
346     */
347    private function storeTags( int $newRevId ) {
348        $tags = [
349            'contenttranslation',
350            'sectiontranslation'
351        ];
352        \DeferredUpdates::addCallableUpdate( static function () use ( $newRevId, $tags ) {
353            \ChangeTags::addTags( $tags, null, $newRevId, null );
354        } );
355    }
356
357    /**
358     * Given an existing Translation model and a new target revision id, this method updates the target
359     * revision id for this model and saves the corresponding row inside "cx_translations" table.
360     *
361     * @param Translation $translation
362     * @param User $user
363     * @param int $newRevId
364     * @param string $targetTitle
365     * @param string $targetLanguage
366     * @return void
367     */
368    private function updateTranslation(
369        Translation $translation,
370        User $user,
371        int $newRevId,
372        string $targetTitle,
373        string $targetLanguage
374    ): void {
375        $translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
376        $translation->translation['targetURL'] = $this->targetUrlCreator->createTargetUrl(
377            $targetTitle,
378            $targetLanguage
379        );
380        $translation->translation['targetRevisionId'] = $newRevId;
381        $this->translationStore->saveTranslation( $translation, $user );
382    }
383
384    public function getAllowedParams() {
385        return [
386            'title' => [
387                ParamValidator::PARAM_REQUIRED => true,
388            ],
389            'html' => [
390                ParamValidator::PARAM_TYPE => 'string',
391                ParamValidator::PARAM_REQUIRED => true,
392            ],
393            'sourcelanguage' => [
394                ParamValidator::PARAM_REQUIRED => true,
395            ],
396            'targetlanguage' => [
397                ParamValidator::PARAM_REQUIRED => true,
398            ],
399            'sourcetitle' => [
400                ParamValidator::PARAM_REQUIRED => true,
401            ],
402            'sourcerevid' => [
403                ParamValidator::PARAM_REQUIRED => true,
404            ],
405            'sourcesectiontitle' => [
406                ParamValidator::PARAM_REQUIRED => true,
407            ],
408            'targetsectiontitle' => [
409                ParamValidator::PARAM_REQUIRED => true,
410            ],
411            'sectiontranslationid' => [
412                ParamValidator::PARAM_TYPE => 'integer',
413                ParamValidator::PARAM_REQUIRED => true,
414            ],
415            'issandbox' => [
416                ParamValidator::PARAM_TYPE => 'boolean',
417                ParamValidator::PARAM_REQUIRED => false,
418            ],
419            'captchaid' => null,
420            'captchaword' => null,
421        ];
422    }
423
424    public function needsToken() {
425        return 'csrf';
426    }
427
428    public function isWriteMode() {
429        return true;
430    }
431
432    public function isInternal() {
433        return true;
434    }
435}