Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.18% covered (danger)
14.18%
40 / 282
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageWebImporter
14.18% covered (danger)
14.18%
40 / 282
0.00% covered (danger)
0.00%
0 / 22
3077.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setGroup
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doHeader
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 doFooter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 allowProcess
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getActions
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 / 137
0.00% covered (danger)
0.00%
0 / 1
650
 doAction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 checkProcessTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doImport
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 doFuzzy
86.96% covered (warning)
86.96%
40 / 46
0.00% covered (danger)
0.00%
0 / 1
8.14
 makeTranslationTitle
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeSectionElement
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 escapeNameForPHP
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Synchronization;
5
6use DifferenceEngine;
7use InvalidArgumentException;
8use MediaWiki\CommentStore\CommentStoreComment;
9use MediaWiki\Content\ContentHandler;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
12use MediaWiki\Extension\Translate\MessageLoading\MessageCollection;
13use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
14use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
15use MediaWiki\Extension\Translate\Utilities\Utilities;
16use MediaWiki\Html\Html;
17use MediaWiki\Language\Language;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Revision\MutableRevisionRecord;
20use MediaWiki\Revision\SlotRecord;
21use MediaWiki\Title\Title;
22use MediaWiki\User\User;
23use MediaWiki\Xml\Xml;
24use MessageGroup;
25use MessageLocalizer;
26use RecentChange;
27use RuntimeException;
28
29/**
30 * Class which encapsulates message importing. It scans for changes (new, changed, deleted),
31 * displays them in pretty way with diffs and finally executes the actions the user choices.
32 *
33 * @author Niklas Laxström
34 * @author Siebrand Mazeland
35 * @copyright Copyright © 2009-2013, Niklas Laxström, Siebrand Mazeland
36 * @license GPL-2.0-or-later
37 */
38class MessageWebImporter {
39    private Title $title;
40    private User $user;
41    private MessageGroup $group;
42    private string $code;
43    /** @var int|null */
44    private $time;
45    private MessageLocalizer $messageLocalizer;
46    /** Maximum processing time in seconds. */
47    private const MAX_PROCESSING_TIME = 43;
48
49    /**
50     * @param Title $title
51     * @param User $user
52     * @param MessageLocalizer $messageLocalizer
53     * @param MessageGroup|string|null $group
54     * @param string $code
55     */
56    public function __construct(
57        Title $title,
58        User $user,
59        MessageLocalizer $messageLocalizer,
60        $group = null,
61        string $code = 'en'
62    ) {
63        $this->setTitle( $title );
64        $this->setUser( $user );
65        $this->setGroup( $group );
66        $this->setCode( $code );
67        $this->messageLocalizer = $messageLocalizer;
68    }
69
70    /** Wrapper for consistency with SpecialPage */
71    public function getTitle(): Title {
72        return $this->title;
73    }
74
75    public function setTitle( Title $title ): void {
76        $this->title = $title;
77    }
78
79    public function getUser(): User {
80        return $this->user;
81    }
82
83    public function setUser( User $user ): void {
84        $this->user = $user;
85    }
86
87    public function getGroup(): MessageGroup {
88        return $this->group;
89    }
90
91    /** @param MessageGroup|string $group MessageGroup object or group ID */
92    public function setGroup( $group ): void {
93        if ( $group instanceof MessageGroup ) {
94            $this->group = $group;
95        } else {
96            $this->group = MessageGroups::getGroup( $group );
97        }
98    }
99
100    public function getCode(): string {
101        return $this->code;
102    }
103
104    public function setCode( string $code = 'en' ): void {
105        $this->code = $code;
106    }
107
108    protected function getAction(): string {
109        return $this->getTitle()->getLocalURL();
110    }
111
112    protected function doHeader(): string {
113        $formParams = [
114            'method' => 'post',
115            'action' => $this->getAction(),
116            'class' => 'mw-translate-manage'
117        ];
118
119        $csrfTokenSet = RequestContext::getMain()->getCsrfTokenSet();
120        return Xml::openElement( 'form', $formParams ) .
121            Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) .
122            Html::hidden( 'token', $csrfTokenSet->getToken() ) .
123            Html::hidden( 'process', 1 );
124    }
125
126    protected function doFooter(): string {
127        return '</form>';
128    }
129
130    protected function allowProcess(): bool {
131        $context = RequestContext::getMain();
132        $request = $context->getRequest();
133        $csrfTokenSet = $context->getCsrfTokenSet();
134
135        return $request->wasPosted()
136            && $request->getBool( 'process' )
137            && $csrfTokenSet->matchTokenField( 'token' );
138    }
139
140    protected function getActions(): array {
141        return [
142            'import',
143            $this->code === 'en' ? 'fuzzy' : 'conflict',
144            'ignore',
145        ];
146    }
147
148    public function execute( array $messages ): bool {
149        $context = RequestContext::getMain();
150        $output = $context->getOutput();
151
152        // Set up diff engine
153        $diff = new DifferenceEngine();
154        $diff->showDiffStyle();
155        $diff->setReducedLineNumbers();
156
157        // Check whether we do processing
158        $process = $this->allowProcess();
159
160        // Initialise collection
161        $group = $this->getGroup();
162        $code = $this->getCode();
163        $collection = $group->initCollection( $code );
164        $collection->loadTranslations();
165
166        $output->addHTML( $this->doHeader() );
167
168        // Initialise variable to keep track whether all changes were imported
169        // or not. If we're allowed to process, initially assume they were.
170        $allDone = $process;
171
172        // Determine changes for each message.
173        $changed = [];
174
175        foreach ( $messages as $key => $value ) {
176            $old = null;
177            $isExistingMessageFuzzy = false;
178
179            if ( isset( $collection[$key] ) ) {
180                // This returns null if no existing translation is found
181                $old = $collection[$key]->translation();
182                $isExistingMessageFuzzy = $collection[$key]->hasTag( 'fuzzy' );
183            }
184
185            if ( $old === null ) {
186                // We found a new translation for this message of the
187                // current group: import it.
188                if ( $process ) {
189                    $action = 'import';
190                    $this->doAction(
191                        $action,
192                        $group,
193                        $key,
194                        $value
195                    );
196                }
197                // Show the user that we imported the new translation
198                $para = '<code class="mw-tmi-new">' . htmlspecialchars( $key ) . '</code>';
199                $name = $context->msg( 'translate-manage-import-new' )->rawParams( $para )
200                    ->escaped();
201                $text = Utilities::convertWhiteSpaceToHTML( $value );
202                $changed[] = self::makeSectionElement( $name, 'new', $text );
203            } else {
204                // No changes at all, ignore
205                if ( $old === (string)$value ) {
206                    continue;
207                }
208
209                // Check if the message is already fuzzy in the system, and then determine if there are changes
210                $oldTextForDiff = $old;
211                if ( $isExistingMessageFuzzy ) {
212                    if ( MessageHandle::makeFuzzyString( $old ) === (string)$value ) {
213                        continue;
214                    }
215
216                    // Normalize the display of FUZZY message diffs so that if an old message has
217                    // a fuzzy tag, then that is added to the text used in the diff.
218                    $oldTextForDiff = MessageHandle::makeFuzzyString( $old );
219                }
220
221                // MutableRevisionRecord expects a page that can exist, so use a dummy non-special page.
222                $dummyMainPage = Title::makeTitle( NS_MAIN, 'Some title just for diff' );
223                $oldContent = ContentHandler::makeContent( $oldTextForDiff, $dummyMainPage );
224                $oldRevision = new MutableRevisionRecord( $dummyMainPage );
225                $oldRevision->setContent( SlotRecord::MAIN, $oldContent );
226
227                $newContent = ContentHandler::makeContent( $value, $dummyMainPage );
228                $newRevision = new MutableRevisionRecord( $dummyMainPage );
229                $newRevision->setContent( SlotRecord::MAIN, $newContent );
230
231                $diff->setRevisions( $oldRevision, $newRevision );
232                $text = $diff->getDiff( '', '' );
233
234                // This is a changed translation. Note it for the next steps.
235                $type = 'changed';
236
237                // Get the user instructions for the current message,
238                // submitted together with the form
239                $action = $context->getRequest()
240                    ->getVal( self::escapeNameForPHP( "action-$type-$key" ) );
241
242                if ( $process ) {
243                    if ( $changed === [] ) {
244                        // Initialise the HTML list showing the changes performed
245                        $changed[] = '<ul>';
246                    }
247
248                    if ( $action === null ) {
249                        // We have been told to process the messages, but not
250                        // what to do with this one. Tell the user.
251                        $message = $context->msg(
252                            'translate-manage-inconsistent',
253                            wfEscapeWikiText( "action-$type-$key" )
254                        )->parse();
255                        $changed[] = "<li>$message</li></ul>";
256
257                        // Also stop any further processing for the other messages.
258                        $process = false;
259                    } else {
260                        // Check processing time
261                        if ( $this->time === null ) {
262                            $this->time = (int)wfTimestamp();
263                        }
264
265                        // We have all the necessary information on this changed
266                        // translation: actually process the message
267                        $messageKeyAndParams = $this->doAction(
268                            $action,
269                            $group,
270                            $key,
271                            $value
272                        );
273
274                        // Show what we just did, adding to the list of changes
275                        $msgKey = array_shift( $messageKeyAndParams );
276                        $params = $messageKeyAndParams;
277                        $message = $context->msg( $msgKey, $params )->parse();
278                        $changed[] = "<li>$message</li>";
279
280                        // Stop processing further messages if too much time
281                        // has been spent.
282                        if ( $this->checkProcessTime() ) {
283                            $process = false;
284                            $message = $context->msg( 'translate-manage-toolong' )
285                                ->numParams( self::MAX_PROCESSING_TIME )->parse();
286                            $changed[] = "<li>$message</li></ul>";
287                        }
288
289                        continue;
290                    }
291                }
292
293                // We are not processing messages, or no longer, or this was an
294                // un-actionable translation. We will eventually return false
295                $allDone = false;
296
297                // Prepare to ask the user what to do with this message
298                $actions = $this->getActions();
299                $defaultAction = $action ?: 'import';
300
301                $act = [];
302
303                // Give grep a chance to find the usages:
304                // translate-manage-action-import, translate-manage-action-conflict,
305                // translate-manage-action-ignore, translate-manage-action-fuzzy
306                foreach ( $actions as $action ) {
307                    $label = $context->msg( "translate-manage-action-$action" )->escaped();
308                    $act[] = Html::rawElement(
309                        'label',
310                        [],
311                        Html::radio(
312                            self::escapeNameForPHP( "action-$type-$key" ),
313                            $action === $defaultAction,
314                            [ 'value' => $action ]
315                        ) .
316                        "\u{00A0}" .
317                        $label
318                    );
319                }
320
321                $param = '<code class="mw-tmi-diff">' . htmlspecialchars( $key ) . '</code>';
322                $name = $context->msg( 'translate-manage-import-diff' )
323                    ->rawParams( $param, implode( ' ', $act ) )
324                    ->escaped();
325
326                $changed[] = self::makeSectionElement( $name, $type, $text );
327            }
328        }
329
330        if ( !$process ) {
331            $collection->filter( MessageCollection::FILTER_HAS_TRANSLATION, MessageCollection::INCLUDE_MATCHING );
332            $keys = $collection->getMessageKeys();
333
334            $diff = array_diff( $keys, array_keys( $messages ) );
335
336            foreach ( $diff as $s ) {
337                $para = '<code class="mw-tmi-deleted">' . htmlspecialchars( $s ) . '</code>';
338                $name = $context->msg( 'translate-manage-import-deleted' )->rawParams( $para )->escaped();
339                $text = Utilities::convertWhiteSpaceToHTML( $collection[$s]->translation() );
340                $changed[] = self::makeSectionElement( $name, 'deleted', $text );
341            }
342        }
343
344        if ( $process || ( $changed === [] && $code !== 'en' ) ) {
345            if ( $changed === [] ) {
346                $output->addWikiMsg( 'translate-manage-nochanges-other' );
347            }
348
349            if ( $changed === [] || !str_starts_with( end( $changed ), '<li>' ) ) {
350                $changed[] = '<ul>';
351            }
352
353            $changed[] = '</ul>';
354
355            $languageName = Utilities::getLanguageName( $code, $context->getLanguage()->getCode() );
356            $message = $context
357                ->msg( 'translate-manage-import-done', $group->getId(), $group->getLabel(), $languageName )
358                ->parse();
359            $changed[] = Html::successBox( $message );
360            $output->addHTML( implode( "\n", $changed ) );
361        } else {
362            // END
363            if ( $changed !== [] ) {
364                if ( $code === 'en' ) {
365                    $output->addWikiMsg( 'translate-manage-intro-en' );
366                } else {
367                    $lang = Utilities::getLanguageName(
368                        $code,
369                        $context->getLanguage()->getCode()
370                    );
371                    $output->addWikiMsg( 'translate-manage-intro-other', $lang );
372                }
373                $output->addHTML( Html::hidden( 'language', $code ) );
374                $output->addHTML( implode( "\n", $changed ) );
375                $output->addHTML( Html::submitButton( $context->msg( 'translate-manage-submit' )->text() ) );
376            } else {
377                $output->addWikiMsg( 'translate-manage-nochanges' );
378            }
379        }
380
381        $output->addHTML( $this->doFooter() );
382
383        return $allDone;
384    }
385
386    /**
387     * Perform an action on a given group/key/code
388     *
389     * @param string $action Options: 'import', 'conflict' or 'ignore'
390     * @param MessageGroup $group
391     * @param string $key Message key
392     * @param string $message Contents for the $key/code combination
393     * @return array Action result
394     */
395    private function doAction(
396        string $action,
397        MessageGroup $group,
398        string $key,
399        string $message
400    ): array {
401        global $wgTranslateDocumentationLanguageCode;
402
403        $comment = '';
404        $code = $this->getCode();
405        $title = $this->makeTranslationTitle( $group, $key, $code );
406
407        if ( $action === 'import' || $action === 'conflict' ) {
408            if ( $action === 'import' ) {
409                $comment = wfMessage( 'translate-manage-import-summary' )->inContentLanguage()->plain();
410            } else {
411                $comment = wfMessage( 'translate-manage-conflict-summary' )->inContentLanguage()->plain();
412                $message = MessageHandle::makeFuzzyString( $message );
413            }
414
415            return self::doImport( $title, $message, $comment, $this->getUser(), $this->messageLocalizer );
416        } elseif ( $action === 'ignore' ) {
417            return [ 'translate-manage-import-ignore', $key ];
418        } elseif ( $action === 'fuzzy' && $code !== 'en' &&
419            $code !== $wgTranslateDocumentationLanguageCode
420        ) {
421            $message = MessageHandle::makeFuzzyString( $message );
422
423            return self::doImport( $title, $message, $comment, $this->getUser(), $this->messageLocalizer );
424        } elseif ( $action === 'fuzzy' && $code === 'en' ) {
425            return self::doFuzzy( $title, $message, $comment, $this->getUser(), $this->messageLocalizer );
426        } else {
427            throw new InvalidArgumentException( "Unhandled action $action" );
428        }
429    }
430
431    protected function checkProcessTime() {
432        return (int)wfTimestamp() - $this->time >= self::MAX_PROCESSING_TIME;
433    }
434
435    /** @return string[] */
436    private static function doImport(
437        Title $title,
438        string $message,
439        string $summary,
440        User $user,
441        MessageLocalizer $messageLocalizer
442    ): array {
443        $mwServices = MediaWikiServices::getInstance();
444        $wikiPage = $mwServices->getWikiPageFactory()->newFromTitle( $title );
445        $content = ContentHandler::makeContent( $message, $title );
446
447        $updater = $wikiPage->newPageUpdater( $user )->setContent( SlotRecord::MAIN, $content );
448        if ( $user->authorizeWrite( 'autopatrol', $title ) ) {
449            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
450        }
451        $updater->saveRevision( CommentStoreComment::newUnsavedComment( $summary ) );
452        $status = $updater->getStatus();
453        $success = $status->isOK();
454
455        if ( $success ) {
456            return [ 'translate-manage-import-ok',
457                wfEscapeWikiText( $title->getPrefixedText() )
458            ];
459        }
460
461        $statusFormatter = $mwServices
462            ->getFormatterFactory()
463            ->getStatusFormatter( $messageLocalizer );
464        $text = "Failed to import new version of page {$title->getPrefixedText()}\n";
465        $text .= $statusFormatter->getWikiText( $status );
466        throw new RuntimeException( $text );
467    }
468
469    /** @return string[] */
470    public static function doFuzzy(
471        Title $title,
472        string $message,
473        string $comment,
474        ?User $user,
475        MessageLocalizer $messageLocalizer
476    ): array {
477        $context = RequestContext::getMain();
478        $services = MediaWikiServices::getInstance();
479
480        if ( !$context->getUser()->isAllowed( 'translate-manage' ) ) {
481            return [ 'badaccess-group0' ];
482        }
483
484        // Edit with fuzzybot if there is no user.
485        if ( !$user ) {
486            $user = FuzzyBot::getUser();
487        }
488
489        // Work on all subpages of base title.
490        $handle = new MessageHandle( $title );
491        $titleText = $handle->getKey();
492
493        $revStore = $services->getRevisionStore();
494        $dbw = $services->getDBLoadBalancer()->getConnection( DB_PRIMARY );
495        $rows = $revStore->newSelectQueryBuilder( $dbw )
496            ->joinPage()
497            ->where( [
498                'page_namespace' => $title->getNamespace(),
499                'page_latest=rev_id',
500                'page_title' . $dbw->buildLike( "$titleText/", $dbw->anyString() ),
501            ] )
502            ->caller( __METHOD__ )
503            ->fetchResultSet();
504
505        $changed = [];
506        $slots = $revStore->getContentBlobsForBatch( $rows, [ SlotRecord::MAIN ] )->getValue();
507
508        foreach ( $rows as $row ) {
509            global $wgTranslateDocumentationLanguageCode;
510
511            $translationTitle = Title::makeTitle( (int)$row->page_namespace, $row->page_title );
512
513            // No fuzzy for English original or documentation language code.
514            if ( $translationTitle->getSubpageText() === 'en' ||
515                $translationTitle->getSubpageText() === $wgTranslateDocumentationLanguageCode
516            ) {
517                // Use imported text, not database text.
518                $text = $message;
519            } elseif ( isset( $slots[$row->rev_id] ) ) {
520                $slot = $slots[$row->rev_id][SlotRecord::MAIN];
521                $text = MessageHandle::makeFuzzyString( $slot->blob_data );
522            } else {
523                $text = MessageHandle::makeFuzzyString(
524                    Utilities::getTextFromTextContent(
525                        $revStore->newRevisionFromRow( $row )->getContent( SlotRecord::MAIN )
526                    )
527                );
528            }
529
530            // Do actual import
531            $changed[] = self::doImport(
532                $translationTitle,
533                $text,
534                $comment,
535                $user,
536                $messageLocalizer
537            );
538        }
539
540        // Format return text
541        $text = '';
542        foreach ( $changed as $c ) {
543            $key = array_shift( $c );
544            $text .= '* ' . $context->msg( $key, $c )->plain() . "\n";
545        }
546
547        return [ 'translate-manage-import-fuzzy', "\n" . $text ];
548    }
549
550    /**
551     * Given a group, message key and language code, creates a title for the
552     * translation page.
553     *
554     * @param MessageGroup $group
555     * @param string $key Message key
556     * @param string $code Language code
557     * @return Title
558     */
559    private function makeTranslationTitle( MessageGroup $group, string $key, string $code ): Title {
560        $ns = $group->getNamespace();
561
562        return Title::makeTitleSafe( $ns, "$key/$code" );
563    }
564
565    /**
566     * Make section elements.
567     *
568     * @param string $legend Legend as raw html.
569     * @param string $type Contents of type class.
570     * @param string $content Contents as raw html.
571     * @param Language|null $lang The language in which the text is written.
572     * @return string Section element as html.
573     */
574    public static function makeSectionElement(
575        string $legend,
576        string $type,
577        string $content,
578        ?Language $lang = null
579    ): string {
580        $containerParams = [ 'class' => "mw-tpt-sp-section mw-tpt-sp-section-type-{$type}" ];
581        $legendParams = [ 'class' => 'mw-tpt-sp-legend' ];
582        $contentParams = [ 'class' => 'mw-tpt-sp-content' ];
583        if ( $lang ) {
584            $contentParams['dir'] = $lang->getDir();
585            $contentParams['lang'] = $lang->getCode();
586        }
587
588        return Html::rawElement( 'div', $containerParams,
589            Html::rawElement( 'div', $legendParams, $legend ) .
590                Html::rawElement( 'div', $contentParams, $content )
591        );
592    }
593
594    /**
595     * Escape name such that it validates as name and id parameter in html, and
596     * so that we can get it back with WebRequest::getVal(). Especially dot and
597     * spaces are difficult for the latter.
598     */
599    private static function escapeNameForPHP( string $name ): string {
600        $replacements = [
601            '(' => '(OP)',
602            ' ' => '(SP)',
603            "\t" => '(TAB)',
604            '.' => '(DOT)',
605            "'" => '(SQ)',
606            "\"" => '(DQ)',
607            '%' => '(PC)',
608            '&' => '(AMP)',
609        ];
610
611        return strtr( $name, $replacements );
612    }
613}