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