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