Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 259
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiSectionTranslationPublish
0.00% covered (danger)
0.00%
0 / 259
0.00% covered (danger)
0.00%
0 / 13
1056
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
 getPublishSummary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 submitEditAction
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 publish
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 1
110
 saveWikitext
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 storeTags
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 isSandbox
0.00% covered (danger)
0.00%
0 / 3
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 / 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 * 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\Exception\HtmlToWikitextConversionException;
17use ContentTranslation\Exception\SectionWikitextRetrievalException;
18use ContentTranslation\LogNames;
19use ContentTranslation\SectionAction;
20use ContentTranslation\Service\SandboxTitleMaker;
21use ContentTranslation\Service\SectionContentEvaluator;
22use ContentTranslation\Service\SectionPositionCalculator;
23use ContentTranslation\Service\TranslationTargetUrlCreator;
24use ContentTranslation\Service\UserPermissionChecker;
25use ContentTranslation\Store\SectionTranslationStore;
26use ContentTranslation\Store\TranslationStore;
27use ContentTranslation\Translation;
28use Exception;
29use MediaWiki\Api\ApiBase;
30use MediaWiki\Api\ApiMain;
31use MediaWiki\Api\ApiUsageException;
32use MediaWiki\ChangeTags\ChangeTagsStore;
33use MediaWiki\Deferred\DeferredUpdates;
34use MediaWiki\HookContainer\HookContainer;
35use MediaWiki\Languages\LanguageNameUtils;
36use MediaWiki\Logger\LoggerFactory;
37use MediaWiki\Request\DerivativeRequest;
38use MediaWiki\Title\Title;
39use MediaWiki\Title\TitleFactory;
40use MediaWiki\User\User;
41use Psr\Log\LoggerInterface;
42use Wikimedia\ParamValidator\ParamValidator;
43
44class ApiSectionTranslationPublish extends ApiBase {
45    private LoggerInterface $logger;
46
47    public function __construct(
48        ApiMain $main,
49        string $action,
50        private readonly TitleFactory $titleFactory,
51        private readonly HookContainer $hookContainer,
52        private readonly LanguageNameUtils $languageNameUtils,
53        private readonly SectionContentEvaluator $sectionContentEvaluator,
54        private readonly SectionPositionCalculator $sectionPositionCalculator,
55        private readonly SandboxTitleMaker $sandboxTitleMaker,
56        private readonly SectionTranslationStore $sectionTranslationStore,
57        private readonly TranslationStore $translationStore,
58        private readonly TranslationTargetUrlCreator $targetUrlCreator,
59        private readonly UserPermissionChecker $userPermissionChecker,
60        private readonly ChangeTagsStore $changeTagsStore
61    ) {
62        parent::__construct( $main, $action );
63        $this->logger = LoggerFactory::getInstance( LogNames::MAIN );
64    }
65
66    /**
67     * @param string $sourceLanguage
68     * @param string $sourceRevId
69     * @param string $sourceTitle
70     * @param SectionAction $sectionAction
71     * @param string $sourceSectionTitle
72     * @return string
73     */
74    private function getPublishSummary(
75        string $sourceLanguage,
76        string $sourceRevId,
77        string $sourceTitle,
78        SectionAction $sectionAction,
79        string $sourceSectionTitle
80    ): string {
81        $sourceLink = "[[:{$sourceLanguage}:Special:Redirect/revision/{$sourceRevId}|{$sourceTitle}]]";
82        // if the published section is a lead section, the summary should be slightly different
83        if ( $sectionAction->isNewLeadSection() ) {
84            $message = $this->msg( 'cx-sx-publish-lead-section-summary', $sourceLink );
85        } else {
86            $message = $this->msg( 'cx-sx-publish-summary', $sourceSectionTitle, $sourceLink );
87        }
88
89        return $message->inContentLanguage()->text();
90    }
91
92    /**
93     * Attempt to save a given page's wikitext to MediaWiki's storage layer via its API
94     *
95     * @param Title $title The title of the page to write
96     * @param string $wikitext The wikitext to write
97     * @param int|string $sectionNumber
98     * @return mixed The result of the save attempt
99     * @throws ApiUsageException
100     */
101    protected function submitEditAction( Title $title, string $wikitext, $sectionNumber ) {
102        $params = $this->extractRequestParams();
103        [ 'sourcelanguage' => $from, 'sourcerevid' => $sourceRevId, 'sourcetitle' => $sourceTitle ] = $params;
104        $publishTarget = $params['publishtarget'];
105        $sectionAction = SectionAction::fromData( $sectionNumber, $publishTarget );
106        $summary = $this->getPublishSummary(
107            $from,
108            $sourceRevId,
109            $sourceTitle,
110            $sectionAction,
111            $params['sourcesectiontitle']
112        );
113
114        $apiParams = [
115            'action' => 'edit',
116            'title' => $title->getPrefixedDBkey(),
117            'summary' => $summary,
118            'sectiontitle' => $params['targetsectiontitle'],
119            'section' => $sectionNumber,
120            'captchaid' => $params['captchaid'],
121            'captchaword' => $params['captchaword'],
122        ];
123
124        if ( $sectionAction->isNewNumberedSection() ) {
125            $apiParams['prependtext'] = $wikitext;
126        } else {
127            $apiParams['text'] = $wikitext;
128        }
129
130        // Pass any unrecognized query parameters to the internal action=edit API request.
131        $allowedParams = array_diff_key(
132            $this->getRequest()->getValues(),
133            $this->getAllowedParams(),
134            $this->getMain()->getAllowedParams()
135        );
136        $api = new ApiMain(
137            new DerivativeRequest(
138                $this->getRequest(),
139                $apiParams + $allowedParams,
140                /* was posted? */ true
141            ),
142            /* enable write? */ true
143        );
144        $api->execute();
145        return $api->getResult()->getResultData();
146    }
147
148    /**
149     * @throws ApiUsageException
150     */
151    public function execute() {
152        $params = $this->extractRequestParams();
153
154        $block = $this->getUser()->getBlock();
155        if ( $block && $block->isSitewide() ) {
156            $this->dieBlocked( $block );
157        }
158
159        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['sourcelanguage'] ) ) {
160            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
161        }
162
163        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['targetlanguage'] ) ) {
164            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
165        }
166
167        if ( trim( $params['html'] ) === '' ) {
168            $this->dieWithError( [ 'apierror-paramempty', 'html' ], 'invalidhtml' );
169        }
170
171        // Check user group publishing requirements
172        if (
173            !$this->userPermissionChecker->checkUserCanPublish(
174                $this->getUser(),
175                $params['title'],
176                $this->isSandbox()
177            )
178        ) {
179            $this->dieWithError( 'apierror-cx-publish-usergroup-required', 'usergroup-required' );
180        }
181
182        $this->publish();
183    }
184
185    public function publish() {
186        $params = $this->extractRequestParams();
187
188        $targetTitleRaw = $params['title'];
189        $isSandbox = $this->isSandbox();
190        $user = $this->getUser();
191        if ( $isSandbox ) {
192            $targetTitle = $this->sandboxTitleMaker->makeSandboxTitle( $user, $targetTitleRaw );
193        } else {
194            $targetTitle = $this->titleFactory->newFromText( $targetTitleRaw );
195        }
196
197        if ( !$targetTitle ) {
198            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $targetTitleRaw ) ] );
199        }
200
201        $hookRunner = new ContentTranslationHookRunner( $this->hookContainer );
202        $targetLanguage = $params['targetlanguage'];
203        '@phan-var Title $targetTitle';
204        $hookRunner->onSectionTranslationBeforePublish( $targetTitle, $targetLanguage, $user );
205
206        $existingTargetSectionTitle = $params['existingsectiontitle'];
207        $sourceSectionTitle = $params['sourcesectiontitle'];
208        $sourceLanguage = $params['sourcelanguage'];
209        $sourceTitle = $params['sourcetitle'];
210        $sectionNumber = $this->sectionPositionCalculator->calculateSectionPosition(
211            $targetTitle,
212            $targetLanguage,
213            $isSandbox,
214            $sourceLanguage,
215            $sourceTitle,
216            $sourceSectionTitle,
217            $existingTargetSectionTitle,
218        );
219
220        $targetSectionTitle = $params['targetsectiontitle'];
221        try {
222            $editResult = $this->saveWikitext(
223                $params['html'],
224                $targetTitle,
225                $sectionNumber,
226                $targetSectionTitle,
227                $params['publishtarget'],
228                $existingTargetSectionTitle
229            );
230        } catch ( ApiUsageException $e ) {
231            throw $e;
232        } catch ( Exception $e ) {
233            $this->logger->error(
234                'Error when publishing section {targetTitle}, {exception}',
235                [
236                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
237                    'exception' => $e->getMessage(),
238                ]
239            );
240
241            throw $e;
242        }
243
244        $editStatus = $editResult['result'];
245
246        if ( $editStatus === 'Success' ) {
247            [ 'sourcelanguage' => $sourceLanguage, 'sourcetitle' => $sourceTitle ] = $params;
248            $result = [
249                'result' => 'success',
250                'edit' => $editResult,
251                'targettitle' => $targetTitle->getPrefixedDBkey(),
252                'targeturl' => $this->targetUrlCreator->createUrlForSXRedirection(
253                    $targetTitle->getPrefixedDBkey(),
254                    $targetLanguage,
255                    $sourceLanguage,
256                    $sourceTitle,
257                    $targetSectionTitle
258                )
259            ];
260            // newrevid can be unset when publishing already present sections with the exact same
261            // contents as the current revision
262            if ( isset( $editResult['newrevid'] ) ) {
263                // Add the tags post-send, after RC row insertion
264                $newRevId = intval( $editResult['newrevid'] );
265                $result['newrevid'] = $newRevId;
266                $this->storeTags( $newRevId );
267
268                $translation = $this->translationStore->findTranslationByUser(
269                    $user,
270                    $sourceTitle,
271                    $sourceLanguage,
272                    $targetLanguage,
273                );
274
275                if ( $translation === null ) {
276                    $this->dieWithError( 'apierror-cxpublishsection-translationnotfound', 'translationnotfound' );
277                }
278
279                // if translation exists update the "translation_target_revision_id" field for this row
280                '@phan-var Translation $translation';
281                $this->updateTranslation(
282                    $translation,
283                    $user,
284                    $newRevId,
285                    $targetTitle->getPrefixedDBkey(),
286                    $targetLanguage
287                );
288
289                $publishedStatusIndex = SectionTranslationStore::getStatusIndexByStatus(
290                    SectionTranslationStore::TRANSLATION_STATUS_PUBLISHED
291                );
292                $this->sectionTranslationStore->updateTranslationStatusById(
293                    $params['sectiontranslationid'],
294                    $publishedStatusIndex
295                );
296            }
297
298            if ( isset( $editResult['pageid'] ) ) {
299                $result['pageid'] = intval( $editResult['pageid'] );
300            }
301        } else {
302            $result = [
303                'result' => 'error',
304                'edit' => $editResult
305            ];
306
307            // Don't bother logging captcha related errors
308            // ApiContentTranslationPublish::saveWikiText returns the API response directly, whereas
309            // ApiSectionTranslationPublish::saveWikiText returns 'edit' property from API response.
310            if ( !isset( $editResult['captcha'] ) ) {
311                $this->logger->error(
312                    'Error when publishing section {targetTitle}',
313                    [
314                        'targetTitle' => $targetTitle->getPrefixedDBkey(),
315                        'editResult' => json_encode( $editResult, JSON_PRETTY_PRINT ),
316                    ]
317                );
318            }
319        }
320
321        $this->getResult()->addValue( null, $this->getModuleName(), $result );
322    }
323
324    /**
325     * Given the HTML of the published translation and the target page title (as a Title instance),
326     * this method uses the ParsoidClient service to convert the HTML to wikitext and calls
327     * the "submitEditAction" method to save the wikitext to MediaWiki's storage layer via its API.
328     *
329     * @param string $html
330     * @param Title $targetTitle
331     * @param int|string $sectionNumber
332     * @param string $targetSectionTitle
333     * @param string $publishTarget
334     * @param string|null $existingSectionTitle
335     * @return mixed
336     * @throws ApiUsageException
337     */
338    private function saveWikitext(
339        string $html,
340        Title $targetTitle,
341        $sectionNumber,
342        string $targetSectionTitle,
343        string $publishTarget,
344        ?string $existingSectionTitle = null
345    ) {
346        $sectionAction = SectionAction::fromData( $sectionNumber, $publishTarget );
347        try {
348            $wikitext = $this->sectionContentEvaluator->calculateSectionContent(
349                $html,
350                $targetTitle,
351                $sectionAction,
352                $targetSectionTitle,
353                $existingSectionTitle
354            );
355        } catch ( HtmlToWikitextConversionException $e ) {
356            $this->logger->error(
357                'Error when converting section HTML to Wikitext using ParsoidClient for {targetTitle}, {errorMessage}',
358                [
359                    'errorMessage' => $e->getMessage(),
360                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
361                ]
362            );
363            $this->dieWithError(
364                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
365            );
366        } catch ( SectionWikitextRetrievalException $e ) {
367            $this->logger->error(
368                'Error while retrieving wikitext of {targetTitle}:{sectionTitle}. {errorMessage}.',
369                [
370                    'targetTitle' => $targetTitle->getPrefixedDBkey(),
371                    'sectionTitle' => $existingSectionTitle,
372                    'errorMessage' => $e->getMessage(),
373                ]
374            );
375            $this->dieWithError(
376                [ 'apierror-cx-wikitext-retrieval-exception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
377            );
378        }
379        $editResult = $this->submitEditAction( $targetTitle, $wikitext, $sectionNumber );
380        return $editResult['edit'];
381    }
382
383    /**
384     * Given a new target revision id, this method adds a deferred update to be executed at the end
385     * of the current request. This update adds the "contenttranslation" and "sectiontranslation" tags
386     * to the given revision.
387     *
388     * @param int $newRevId
389     * @return void
390     */
391    private function storeTags( int $newRevId ) {
392        $tags = [
393            'contenttranslation',
394            'sectiontranslation'
395        ];
396        DeferredUpdates::addCallableUpdate( function () use ( $newRevId, $tags ) {
397            $this->changeTagsStore->addTags( $tags, null, $newRevId, null );
398        } );
399    }
400
401    private function isSandbox(): bool {
402        $params = $this->extractRequestParams();
403        // section number is not used for sandbox publishing
404        $sectionAction = SectionAction::fromData( '', $params['publishtarget'] );
405
406        return $sectionAction->isSandboxAction();
407    }
408
409    /**
410     * Given an existing Translation model and a new target revision id, this method updates the target
411     * revision id for this model and saves the corresponding row inside "cx_translations" table.
412     *
413     * @param Translation $translation
414     * @param User $user
415     * @param int $newRevId
416     * @param string $targetTitle
417     * @param string $targetLanguage
418     * @return void
419     */
420    private function updateTranslation(
421        Translation $translation,
422        User $user,
423        int $newRevId,
424        string $targetTitle,
425        string $targetLanguage
426    ): void {
427        $translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
428        $translation->translation['targetURL'] = $this->targetUrlCreator->createTargetUrl(
429            $targetTitle,
430            $targetLanguage
431        );
432        $translation->translation['targetRevisionId'] = $newRevId;
433        $this->translationStore->saveTranslation( $translation, $user );
434    }
435
436    /** @inheritDoc */
437    public function getAllowedParams() {
438        return [
439            'title' => [
440                ParamValidator::PARAM_REQUIRED => true,
441            ],
442            'html' => [
443                ParamValidator::PARAM_TYPE => 'string',
444                ParamValidator::PARAM_REQUIRED => true,
445            ],
446            'sourcelanguage' => [
447                ParamValidator::PARAM_REQUIRED => true,
448            ],
449            'targetlanguage' => [
450                ParamValidator::PARAM_REQUIRED => true,
451            ],
452            'sourcetitle' => [
453                ParamValidator::PARAM_REQUIRED => true,
454            ],
455            'sourcerevid' => [
456                ParamValidator::PARAM_REQUIRED => true,
457            ],
458            'sourcesectiontitle' => [
459                ParamValidator::PARAM_REQUIRED => true,
460            ],
461            'targetsectiontitle' => [
462                ParamValidator::PARAM_REQUIRED => true,
463            ],
464            'sectiontranslationid' => [
465                ParamValidator::PARAM_TYPE => 'integer',
466                ParamValidator::PARAM_REQUIRED => true,
467            ],
468            'publishtarget' => [
469                ParamValidator::PARAM_TYPE => [ 'NEW_SECTION', 'EXPAND', 'REPLACE', 'SANDBOX' ],
470                ParamValidator::PARAM_REQUIRED => false,
471            ],
472            'existingsectiontitle' => [
473                ParamValidator::PARAM_TYPE => 'string',
474                ParamValidator::PARAM_REQUIRED => false,
475            ],
476            'captchaid' => null,
477            'captchaword' => null,
478        ];
479    }
480
481    /** @inheritDoc */
482    public function needsToken() {
483        return 'csrf';
484    }
485
486    /** @inheritDoc */
487    public function isWriteMode() {
488        return true;
489    }
490
491    /** @inheritDoc */
492    public function isInternal() {
493        return true;
494    }
495}