Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiContentTranslationPublish
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 13
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
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
 saveWikitext
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 getTags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getCategories
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 removeApiCategoryNamespacePrefix
0.00% covered (danger)
0.00%
0 / 5
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 / 51
0.00% covered (danger)
0.00%
0 / 1
72
 notifyTranslator
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 30
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 wiki page created using 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. CX2 uses an additional tag.
7 * - The edit summary shows a link to the revision from which the translation was made.
8 * - Optionally, a template is added if the article appears to have a lot of machine translation.
9 * - Categories are hidden in <nowiki> if the page is published to the user namespace.
10 * - Information about the translated page is saved to the central ContentTranslation database.
11 * - When relevant, values of MediaWiki CAPTCHA can be sent.
12 * - When relevant, Echo notifications about publishing milestones will be sent.
13 * This borrows heavily from ApiVisualEditorEdit.
14 *
15 * @copyright See AUTHORS.txt
16 * @license GPL-2.0-or-later
17 */
18
19namespace ContentTranslation\ActionApi;
20
21use ApiBase;
22use ApiMain;
23use ChangeTags;
24use ContentTranslation\Notification;
25use ContentTranslation\ParsoidClient;
26use ContentTranslation\ParsoidClientFactory;
27use ContentTranslation\Service\TranslationTargetUrlCreator;
28use ContentTranslation\SiteMapper;
29use ContentTranslation\Store\TranslationStore;
30use ContentTranslation\Translation;
31use ContentTranslation\Translator;
32use DeferredUpdates;
33use Deflate;
34use Exception;
35use ExtensionRegistry;
36use IBufferingStatsdDataFactory;
37use MediaWiki\Languages\LanguageFactory;
38use MediaWiki\Languages\LanguageNameUtils;
39use MediaWiki\Request\DerivativeRequest;
40use MediaWiki\Title\Title;
41use Wikimedia\ParamValidator\ParamValidator;
42use Wikimedia\ParamValidator\TypeDef\IntegerDef;
43
44class ApiContentTranslationPublish extends ApiBase {
45
46    protected ParsoidClientFactory $parsoidClientFactory;
47    protected ?Translation $translation;
48    private LanguageFactory $languageFactory;
49    private IBufferingStatsdDataFactory $statsdDataFactory;
50    private LanguageNameUtils $languageNameUtils;
51    private TranslationStore $translationStore;
52    private TranslationTargetUrlCreator $targetUrlCreator;
53
54    public function __construct(
55        ApiMain $main,
56        $name,
57        ParsoidClientFactory $parsoidClientFactory,
58        LanguageFactory $languageFactory,
59        IBufferingStatsdDataFactory $statsdDataFactory,
60        LanguageNameUtils $languageNameUtils,
61        TranslationStore $translationStore,
62        TranslationTargetUrlCreator $targetUrlCreator
63    ) {
64        parent::__construct( $main, $name );
65        $this->parsoidClientFactory = $parsoidClientFactory;
66        $this->languageFactory = $languageFactory;
67        $this->statsdDataFactory = $statsdDataFactory;
68        $this->languageNameUtils = $languageNameUtils;
69        $this->translationStore = $translationStore;
70        $this->targetUrlCreator = $targetUrlCreator;
71    }
72
73    protected function getParsoidClient(): ParsoidClient {
74        return $this->parsoidClientFactory->createParsoidClient();
75    }
76
77    protected function saveWikitext( $title, $wikitext, $params ) {
78        $categories = $this->getCategories( $params );
79        if ( count( $categories ) ) {
80            $categoryText = "\n[[" . implode( "]]\n[[", $categories ) . ']]';
81            // If publishing to User namespace, wrap categories in <nowiki>
82            // to avoid blocks by abuse filter. See T88007.
83            if ( $title->inNamespace( NS_USER ) ) {
84                $categoryText = "\n<nowiki>$categoryText</nowiki>";
85            }
86            $wikitext .= $categoryText;
87        }
88
89        $sourceLink = '[[:' . Sitemapper::getDomainCode( $params['from'] )
90            . ':Special:Redirect/revision/'
91            . $this->translation->translation['sourceRevisionId']
92            . '|' . $params['sourcetitle'] . ']]';
93
94        $summary = $this->msg(
95            'cx-publish-summary',
96            $sourceLink
97        )->inContentLanguage()->text();
98
99        $apiParams = [
100            'action' => 'edit',
101            'title' => $title->getPrefixedDBkey(),
102            'text' => $wikitext,
103            'summary' => $summary,
104        ];
105
106        $request = $this->getRequest();
107
108        $api = new ApiMain(
109            new DerivativeRequest(
110                $request,
111                $apiParams + $request->getValues(),
112                true // was posted
113            ),
114            true // enable write
115        );
116
117        $api->execute();
118
119        return $api->getResult()->getResultData();
120    }
121
122    protected function getTags( array $params ) {
123        $tags = $params['publishtags'];
124        $tags[] = 'contenttranslation';
125        if ( $params['cxversion'] === 2 ) {
126            $tags[] = 'contenttranslation-v2'; // Tag for CX2: contenttranslation-v2
127        }
128        // Remove any tags that are not registered.
129        return array_intersect( $tags, ChangeTags::listSoftwareActivatedTags() );
130    }
131
132    protected function getCategories( array $params ) {
133        $trackingCategoryMsg = 'cx-unreviewed-translation-category';
134        $categories = [];
135
136        if ( $params['categories'] ) {
137            $categories = explode( '|', $params['categories'] );
138        }
139
140        $trackingCategoryKey = array_search( $trackingCategoryMsg, $categories );
141        if ( $trackingCategoryKey !== false ) {
142            $cat = $this->msg( $trackingCategoryMsg )->inContentLanguage()->plain();
143            $containerCategory = Title::makeTitleSafe( NS_CATEGORY, $cat );
144            if ( $cat !== '-' && $containerCategory ) {
145                // Title without namespace prefix
146                $categories[$trackingCategoryKey] = $containerCategory->getText();
147                // Record using Graphite that the published translation is marked for review
148                $this->statsdDataFactory->increment( 'cx.publish.highmt.' . $params['to'] );
149            } else {
150                wfDebug( __METHOD__ . ": [[MediaWiki:$trackingCategoryMsg]] is not a valid title!\n" );
151                unset( $categories[$trackingCategoryKey] );
152            }
153        }
154
155        // Validate and normalize all categories.
156        foreach ( $categories as $index => $category ) {
157            $category = $this->removeApiCategoryNamespacePrefix( $category, $params['to'] );
158            // Also remove the namespace in English, if any. May be from T264490
159            $category = $this->removeApiCategoryNamespacePrefix( $category, 'en' );
160            $title = Title::makeTitleSafe( NS_CATEGORY, $category );
161            if ( $title !== null ) {
162                $categories[$index] = $title->getPrefixedText();
163            } else {
164                unset( $categories[$index] );
165            }
166        }
167
168        // Guard against duplicates, if any.
169        $categories = array_unique( $categories );
170
171        return $categories;
172    }
173
174    /**
175     * Removes category namespace prefix for a given category received
176     * from API, if existing, otherwise returns category as is
177     * @param string $category
178     * @param string $targetLanguage
179     * @return string
180     */
181    private function removeApiCategoryNamespacePrefix( $category, $targetLanguage ) {
182        $targetLanguage = $this->languageFactory->getLanguage( $targetLanguage );
183        $targetLanguageCategoryPrefix = $targetLanguage->getNsText( NS_CATEGORY ) . ":";
184        if ( substr( $category, 0, strlen( $targetLanguageCategoryPrefix ) ) === $targetLanguageCategoryPrefix ) {
185            return substr( $category, strlen( $targetLanguageCategoryPrefix ) );
186        }
187        return $category;
188    }
189
190    public function execute() {
191        $params = $this->extractRequestParams();
192
193        $block = $this->getUser()->getBlock();
194        if ( $block && $block->isSitewide() ) {
195            $this->dieBlocked( $block );
196        }
197
198        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['from'] ) ) {
199            $this->dieWithError( 'apierror-cx-invalidsourcelanguage', 'invalidsourcelanguage' );
200        }
201
202        if ( !$this->languageNameUtils->isKnownLanguageTag( $params['to'] ) ) {
203            $this->dieWithError( 'apierror-cx-invalidtargetlanguage', 'invalidtargetlanguage' );
204        }
205
206        if ( trim( $params['html'] ) === '' ) {
207            $this->dieWithError( [ 'apierror-paramempty', 'html' ], 'invalidhtml' );
208        }
209
210        $this->publish();
211    }
212
213    public function publish() {
214        $params = $this->extractRequestParams();
215        $user = $this->getUser();
216
217        $targetTitle = Title::newFromText( $params['title'] );
218        if ( !$targetTitle ) {
219            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
220        }
221
222        [ 'sourcetitle' => $sourceTitle, 'from' => $sourceLanguage, 'to' => $targetLanguage ] = $params;
223        $this->translation = $this->translationStore->findTranslationByUser(
224            $user,
225            $sourceTitle,
226            $sourceLanguage,
227            $targetLanguage
228        );
229
230        if ( $this->translation === null ) {
231            $this->dieWithError( 'apierror-cx-translationnotfound', 'translationnotfound' );
232        }
233
234        $html = Deflate::inflate( $params['html'] );
235        if ( !$html->isGood() ) {
236            $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
237        }
238        try {
239            $wikitext = $this->getParsoidClient()->convertHtmlToWikitext(
240                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
241                $targetTitle,
242                $html->getValue()
243            )['body'];
244        } catch ( Exception $e ) {
245            $this->dieWithError(
246                [ 'apierror-cx-docserverexception', wfEscapeWikiText( $e->getMessage() ) ], 'docserver'
247            );
248        }
249
250        $saveresult = $this->saveWikitext( $targetTitle, $wikitext, $params );
251        $editStatus = $saveresult['edit']['result'];
252
253        if ( $editStatus === 'Success' ) {
254            if ( isset( $saveresult['edit']['newrevid'] ) ) {
255                $tags = $this->getTags( $params );
256                // Add the tags post-send, after RC row insertion
257                $revId = intval( $saveresult['edit']['newrevid'] );
258                DeferredUpdates::addCallableUpdate( static function () use ( $revId, $tags ) {
259                    ChangeTags::addTags( $tags, null, $revId, null );
260                } );
261            }
262
263            $targetURL = $this->targetUrlCreator->createTargetUrl( $targetTitle->getPrefixedDBkey(), $params['to'] );
264            $result = [
265                'result' => 'success',
266                'targeturl' => $targetURL
267            ];
268
269            $this->translation->translation['status'] = TranslationStore::TRANSLATION_STATUS_PUBLISHED;
270            $this->translation->translation['targetURL'] = $targetURL;
271
272            if ( isset( $saveresult['edit']['newrevid'] ) ) {
273                $result['newrevid'] = intval( $saveresult['edit']['newrevid'] );
274                $this->translation->translation['targetRevisionId'] = $result['newrevid'];
275            }
276
277            // Save the translation history.
278            $this->translationStore->saveTranslation( $this->translation, $user );
279
280            // Notify user about milestones
281            $this->notifyTranslator();
282        } else {
283            $result = [
284                'result' => 'error',
285                'edit' => $saveresult['edit']
286            ];
287        }
288
289        $this->getResult()->addValue( null, $this->getModuleName(), $result );
290    }
291
292    /**
293     * Notify user about milestones.
294     */
295    public function notifyTranslator() {
296        $params = $this->extractRequestParams();
297
298        // Check if Echo is available. If not, skip.
299        if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
300            return;
301        }
302
303        $user = $this->getUser();
304        $translator = new Translator( $user );
305        $translationCount = $translator->getTranslationsCount();
306
307        switch ( $translationCount ) {
308            case 1:
309                Notification::firstTranslation( $user );
310                break;
311            case 2:
312                Notification::suggestionsAvailable( $user, $params['sourcetitle'] );
313                break;
314            case 10:
315                Notification::tenthTranslation( $user );
316                break;
317            case 100:
318                Notification::hundredthTranslation( $user );
319                break;
320        }
321    }
322
323    public function getAllowedParams() {
324        return [
325            'title' => [
326                ParamValidator::PARAM_REQUIRED => true,
327            ],
328            'html' => [
329                ParamValidator::PARAM_REQUIRED => true,
330            ],
331            'from' => [
332                ParamValidator::PARAM_REQUIRED => true,
333            ],
334            'to' => [
335                ParamValidator::PARAM_REQUIRED => true,
336            ],
337            'sourcetitle' => [
338                ParamValidator::PARAM_REQUIRED => true,
339            ],
340            'categories' => null,
341            'publishtags' => [
342                ParamValidator::PARAM_ISMULTI => true,
343            ],
344            /** @todo These should be renamed to something all-lowercase and lacking a "wp" prefix */
345            'wpCaptchaId' => null,
346            'wpCaptchaWord' => null,
347            'cxversion' => [
348                ParamValidator::PARAM_TYPE => 'integer',
349                ParamValidator::PARAM_REQUIRED => true,
350                ApiBase::PARAM_RANGE_ENFORCE => true,
351                IntegerDef::PARAM_MIN => 1,
352                IntegerDef::PARAM_MAX => 2,
353            ],
354        ];
355    }
356
357    public function needsToken() {
358        return 'csrf';
359    }
360
361    public function isWriteMode() {
362        return true;
363    }
364
365    public function isInternal() {
366        return true;
367    }
368}