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