Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
MessageWebImporter.php
1<?php
2declare( strict_types = 1 );
3
5
6use ContentHandler;
7use DifferenceEngine;
8use Html;
9use Language;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Revision\SlotRecord;
13use MessageGroup;
16use MWException;
17use OutputPage;
18use RequestContext;
19use Sanitizer;
20use Title;
22use User;
23use Xml;
24
36 protected $title;
38 protected $user;
40 protected $group;
41 protected $code;
42 protected $time;
44 protected $out;
48 protected $processingTime = 43;
49
55 public function __construct( Title $title = null, $group = null, $code = 'en' ) {
56 $this->setTitle( $title );
57 $this->setGroup( $group );
58 $this->setCode( $code );
59 }
60
62 public function getTitle(): Title {
63 return $this->title;
64 }
65
66 public function setTitle( Title $title ): void {
67 $this->title = $title;
68 }
69
70 public function getUser(): User {
71 return $this->user ?: RequestContext::getMain()->getUser();
72 }
73
74 public function setUser( User $user ): void {
75 $this->user = $user;
76 }
77
78 public function getGroup(): MessageGroup {
79 return $this->group;
80 }
81
83 public function setGroup( $group ): void {
84 if ( $group instanceof MessageGroup ) {
85 $this->group = $group;
86 } else {
87 $this->group = MessageGroups::getGroup( $group );
88 }
89 }
90
91 public function getCode(): string {
92 return $this->code;
93 }
94
95 public function setCode( string $code = 'en' ): void {
96 $this->code = $code;
97 }
98
99 protected function getAction(): string {
100 return $this->getTitle()->getLocalURL();
101 }
102
103 protected function doHeader(): string {
104 $formParams = [
105 'method' => 'post',
106 'action' => $this->getAction(),
107 'class' => 'mw-translate-manage'
108 ];
109
110 $csrfTokenSet = RequestContext::getMain()->getCsrfTokenSet();
111 return Xml::openElement( 'form', $formParams ) .
112 Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) .
113 Html::hidden( 'token', $csrfTokenSet->getToken() ) .
114 Html::hidden( 'process', 1 );
115 }
116
117 protected function doFooter(): string {
118 return '</form>';
119 }
120
121 protected function allowProcess(): bool {
122 $context = RequestContext::getMain();
123 $request = $context->getRequest();
124 $csrfTokenSet = $context->getCsrfTokenSet();
125
126 return $request->wasPosted()
127 && $request->getBool( 'process', false )
128 && $csrfTokenSet->matchTokenField( 'token' );
129 }
130
131 protected function getActions(): array {
132 if ( $this->code === 'en' ) {
133 return [ 'import', 'fuzzy', 'ignore' ];
134 }
135
136 return [ 'import', 'conflict', 'ignore' ];
137 }
138
139 protected function getDefaultAction( bool $fuzzy, ?string $action ): string {
140 if ( $action ) {
141 return $action;
142 }
143
144 return $fuzzy ? 'conflict' : 'import';
145 }
146
147 public function execute( array $messages ): bool {
148 $context = RequestContext::getMain();
149 $this->out = $context->getOutput();
150
151 // Set up diff engine
152 $diff = new DifferenceEngine();
153 $diff->showDiffStyle();
154 $diff->setReducedLineNumbers();
155
156 // Check whether we do processing
157 $process = $this->allowProcess();
158
159 // Initialise collection
160 $group = $this->getGroup();
161 $code = $this->getCode();
162 $collection = $group->initCollection( $code );
163 $collection->loadTranslations();
164
165 $this->out->addHTML( $this->doHeader() );
166
167 // Initialise variable to keep track whether all changes were imported
168 // or not. If we're allowed to process, initially assume they were.
169 $alldone = $process;
170
171 // Determine changes for each message.
172 $changed = [];
173
174 foreach ( $messages as $key => $value ) {
175 $fuzzy = false;
176 $old = null;
177
178 if ( isset( $collection[$key] ) ) {
179 // This returns null if no existing translation is found
180 $old = $collection[$key]->translation();
181 }
182
183 // No changes at all, ignore
184 if ( (string)$old === (string)$value ) {
185 continue;
186 }
187
188 if ( $old === null ) {
189 // We found a new translation for this message of the
190 // current group: import it.
191 if ( $process ) {
192 $action = 'import';
193 self::doAction(
194 $action,
195 $group,
196 $key,
197 $code,
198 $value,
199 '',
200 $this->getUser()
201 );
202 }
203 // Show the user that we imported the new translation
204 $para = '<code class="mw-tmi-new">' . htmlspecialchars( $key ) . '</code>';
205 $name = $context->msg( 'translate-manage-import-new' )->rawParams( $para )
206 ->escaped();
208 $changed[] = self::makeSectionElement( $name, 'new', $text );
209 } else {
210 $oldContent = ContentHandler::makeContent( $old, $diff->getTitle() );
211 $newContent = ContentHandler::makeContent( $value, $diff->getTitle() );
212 $diff->setContent( $oldContent, $newContent );
213 $text = $diff->getDiff( '', '' );
214
215 // This is a changed translation. Note it for the next steps.
216 $type = 'changed';
217
218 // Get the user instructions for the current message,
219 // submitted together with the form
220 $action = $context->getRequest()
221 ->getVal( self::escapeNameForPHP( "action-$type-$key" ) );
222
223 if ( $process ) {
224 if ( $changed === [] ) {
225 // Initialise the HTML list showing the changes performed
226 $changed[] = '<ul>';
227 }
228
229 if ( $action === null ) {
230 // We have been told to process the messages, but not
231 // what to do with this one. Tell the user.
232 $message = $context->msg(
233 'translate-manage-inconsistent',
234 wfEscapeWikiText( "action-$type-$key" )
235 )->parse();
236 $changed[] = "<li>$message</li></ul>";
237
238 // Also stop any further processing for the other messages.
239 $process = false;
240 } else {
241 // Check processing time
242 if ( !isset( $this->time ) ) {
243 $this->time = wfTimestamp();
244 }
245
246 // We have all the necessary information on this changed
247 // translation: actually process the message
248 $messageKeyAndParams = self::doAction(
249 $action,
250 $group,
251 $key,
252 $code,
253 $value,
254 '',
255 $this->getUser()
256 );
257
258 // Show what we just did, adding to the list of changes
259 $msgKey = array_shift( $messageKeyAndParams );
260 $params = $messageKeyAndParams;
261 $message = $context->msg( $msgKey, $params )->parse();
262 $changed[] = "<li>$message</li>";
263
264 // Stop processing further messages if too much time
265 // has been spent.
266 if ( $this->checkProcessTime() ) {
267 $process = false;
268 $message = $context->msg( 'translate-manage-toolong' )
269 ->numParams( $this->processingTime )->parse();
270 $changed[] = "<li>$message</li></ul>";
271 }
272
273 continue;
274 }
275 }
276
277 // We are not processing messages, or no longer, or this was an
278 // unactionable translation. We will eventually return false
279 $alldone = false;
280
281 // Prepare to ask the user what to do with this message
282 $actions = $this->getActions();
283 $defaction = $this->getDefaultAction( $fuzzy, $action );
284
285 $act = [];
286
287 // Give grep a chance to find the usages:
288 // translate-manage-action-import, translate-manage-action-conflict,
289 // translate-manage-action-ignore, translate-manage-action-fuzzy
290 foreach ( $actions as $action ) {
291 $label = $context->msg( "translate-manage-action-$action" )->text();
292 $name = self::escapeNameForPHP( "action-$type-$key" );
293 $id = Sanitizer::escapeIdForAttribute( "action-$key-$action" );
294 $act[] = Xml::radioLabel( $label, $name, $action, $id, $action === $defaction );
295 }
296
297 $param = '<code class="mw-tmi-diff">' . htmlspecialchars( $key ) . '</code>';
298 $name = $context->msg( 'translate-manage-import-diff' )
299 ->rawParams( $param, implode( ' ', $act ) )
300 ->escaped();
301
302 $changed[] = self::makeSectionElement( $name, $type, $text );
303 }
304 }
305
306 if ( !$process ) {
307 $collection->filter( 'hastranslation', false );
308 $keys = $collection->getMessageKeys();
309
310 $diff = array_diff( $keys, array_keys( $messages ) );
311
312 foreach ( $diff as $s ) {
313 $para = '<code class="mw-tmi-deleted">' . htmlspecialchars( $s ) . '</code>';
314 $name = $context->msg( 'translate-manage-import-deleted' )->rawParams( $para )->escaped();
315 $text = TranslateUtils::convertWhiteSpaceToHTML( $collection[$s]->translation() );
316 $changed[] = self::makeSectionElement( $name, 'deleted', $text );
317 }
318 }
319
320 if ( $process || ( $changed === [] && $code !== 'en' ) ) {
321 if ( $changed === [] ) {
322 $this->out->addWikiMsg( 'translate-manage-nochanges-other' );
323 }
324
325 if ( $changed === [] || strpos( end( $changed ), '<li>' ) !== 0 ) {
326 $changed[] = '<ul>';
327 }
328
329 $message = $context->msg( 'translate-manage-import-done' )->parse();
330 $changed[] = "<li>$message</li></ul>";
331 $this->out->addHTML( implode( "\n", $changed ) );
332 } else {
333 // END
334 if ( $changed !== [] ) {
335 if ( $code === 'en' ) {
336 $this->out->addWikiMsg( 'translate-manage-intro-en' );
337 } else {
339 $code,
340 $context->getLanguage()->getCode()
341 );
342 $this->out->addWikiMsg( 'translate-manage-intro-other', $lang );
343 }
344 $this->out->addHTML( Html::hidden( 'language', $code ) );
345 $this->out->addHTML( implode( "\n", $changed ) );
346 $this->out->addHTML( Xml::submitButton( $context->msg( 'translate-manage-submit' )->text() ) );
347 } else {
348 $this->out->addWikiMsg( 'translate-manage-nochanges' );
349 }
350 }
351
352 $this->out->addHTML( $this->doFooter() );
353
354 return $alldone;
355 }
356
372 public static function doAction(
373 string $action,
374 MessageGroup $group,
375 string $key,
376 string $code,
377 string $message,
378 string $comment = '',
379 User $user = null,
380 int $editFlags = 0
381 ): array {
382 global $wgTranslateDocumentationLanguageCode;
383
384 $title = self::makeTranslationTitle( $group, $key, $code );
385
386 if ( $action === 'import' || $action === 'conflict' ) {
387 if ( $action === 'import' ) {
388 $comment = wfMessage( 'translate-manage-import-summary' )->inContentLanguage()->plain();
389 } else {
390 $comment = wfMessage( 'translate-manage-conflict-summary' )->inContentLanguage()->plain();
391 $message = self::makeTextFuzzy( $message );
392 }
393
394 return self::doImport( $title, $message, $comment, $user, $editFlags );
395 } elseif ( $action === 'ignore' ) {
396 return [ 'translate-manage-import-ignore', $key ];
397 } elseif ( $action === 'fuzzy' && $code !== 'en' &&
398 $code !== $wgTranslateDocumentationLanguageCode
399 ) {
400 $message = self::makeTextFuzzy( $message );
401
402 return self::doImport( $title, $message, $comment, $user, $editFlags );
403 } elseif ( $action === 'fuzzy' && $code === 'en' ) {
404 return self::doFuzzy( $title, $message, $comment, $user, $editFlags );
405 } else {
406 throw new MWException( "Unhandled action $action" );
407 }
408 }
409
410 protected function checkProcessTime() {
411 return wfTimestamp() - $this->time >= $this->processingTime;
412 }
413
418 public static function doImport(
419 Title $title,
420 string $message,
421 string $summary,
422 ?User $user,
423 int $editFlags = 0
424 ): array {
425 $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
426 $content = ContentHandler::makeContent( $message, $title );
427 $status = $wikiPage->doUserEditContent(
428 $content,
429 $user,
430 $summary,
431 $editFlags
432 );
433 $success = $status->isOK();
434
435 if ( $success ) {
436 return [ 'translate-manage-import-ok',
437 wfEscapeWikiText( $title->getPrefixedText() )
438 ];
439 }
440
441 $text = "Failed to import new version of page {$title->getPrefixedText()}\n";
442 $text .= "{$status->getWikiText()}";
443 throw new MWException( $text );
444 }
445
447 public static function doFuzzy(
448 Title $title,
449 string $message,
450 string $comment,
451 ?User $user,
452 int $editFlags = 0
453 ): array {
454 $context = RequestContext::getMain();
455 $services = MediaWikiServices::getInstance();
456
457 if ( !$context->getUser()->isAllowed( 'translate-manage' ) ) {
458 return [ 'badaccess-group0' ];
459 }
460
461 // Edit with fuzzybot if there is no user.
462 if ( !$user ) {
463 $user = FuzzyBot::getUser();
464 }
465
466 // Work on all subpages of base title.
467 $handle = new MessageHandle( $title );
468 $titleText = $handle->getKey();
469
470 $revStore = $services->getRevisionStore();
471 $queryInfo = $revStore->getQueryInfo( [ 'page' ] );
472 $dbw = $services->getDBLoadBalancer()->getConnectionRef( DB_PRIMARY );
473 $rows = $dbw->select(
474 $queryInfo['tables'],
475 $queryInfo['fields'],
476 [
477 'page_namespace' => $title->getNamespace(),
478 'page_latest=rev_id',
479 'page_title' . $dbw->buildLike( "$titleText/", $dbw->anyString() ),
480 ],
481 __METHOD__,
482 [],
483 $queryInfo['joins']
484 );
485
486 $changed = [];
487 $slots = $revStore->getContentBlobsForBatch( $rows, [ SlotRecord::MAIN ] )->getValue();
488
489 foreach ( $rows as $row ) {
490 global $wgTranslateDocumentationLanguageCode;
491
492 $ttitle = Title::makeTitle( (int)$row->page_namespace, $row->page_title );
493
494 // No fuzzy for English original or documentation language code.
495 if ( $ttitle->getSubpageText() === 'en' ||
496 $ttitle->getSubpageText() === $wgTranslateDocumentationLanguageCode
497 ) {
498 // Use imported text, not database text.
499 $text = $message;
500 } elseif ( isset( $slots[$row->rev_id] ) ) {
501 $slot = $slots[$row->rev_id][SlotRecord::MAIN];
502 $text = self::makeTextFuzzy( $slot->blob_data );
503 } else {
504 $text = self::makeTextFuzzy(
505 TranslateUtils::getTextFromTextContent(
506 $revStore->newRevisionFromRow( $row )->getContent( SlotRecord::MAIN )
507 )
508 );
509 }
510
511 // Do actual import
512 $changed[] = self::doImport(
513 $ttitle,
514 $text,
515 $comment,
516 $user,
517 $editFlags
518 );
519 }
520
521 // Format return text
522 $text = '';
523 foreach ( $changed as $c ) {
524 $key = array_shift( $c );
525 $text .= '* ' . $context->msg( $key, $c )->plain() . "\n";
526 }
527
528 return [ 'translate-manage-import-fuzzy', "\n" . $text ];
529 }
530
540 public static function makeTranslationTitle( MessageGroup $group, string $key, string $code ): Title {
541 $ns = $group->getNamespace();
542
543 return Title::makeTitleSafe( $ns, "$key/$code" );
544 }
545
555 public static function makeSectionElement(
556 string $legend,
557 string $type,
558 string $content,
559 Language $lang = null
560 ): string {
561 $containerParams = [ 'class' => "mw-tpt-sp-section mw-tpt-sp-section-type-{$type}" ];
562 $legendParams = [ 'class' => 'mw-tpt-sp-legend' ];
563 $contentParams = [ 'class' => 'mw-tpt-sp-content' ];
564 if ( $lang ) {
565 $contentParams['dir'] = $lang->getDir();
566 $contentParams['lang'] = $lang->getCode();
567 }
568
569 $output = Html::rawElement( 'div', $containerParams,
570 Html::rawElement( 'div', $legendParams, $legend ) .
571 Html::rawElement( 'div', $contentParams, $content )
572 );
573
574 return $output;
575 }
576
583 public static function makeTextFuzzy( string $message ): string {
584 $message = str_replace( TRANSLATE_FUZZY, '', $message );
585
586 return TRANSLATE_FUZZY . $message;
587 }
588
594 public static function escapeNameForPHP( string $name ): string {
595 $replacements = [
596 '(' => '(OP)',
597 ' ' => '(SP)',
598 "\t" => '(TAB)',
599 '.' => '(DOT)',
600 "'" => '(SQ)',
601 "\"" => '(DQ)',
602 '%' => '(PC)',
603 '&' => '(AMP)',
604 ];
605
606 return strtr( $name, $replacements );
607 }
608}
return[ 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->getMainConfig(), $services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance( 'Translate.GroupSynchronization'), MessageIndex::singleton());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore(new RevTagStore(), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReview'=> static function(MediaWikiServices $services):MessageGroupReview { return new MessageGroupReview($services->getDBLoadBalancer(), $services->getHookContainer());}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=$services->getMainConfig() ->get( 'TranslateMessageIndex');if(is_string( $params)) { $params=(array) $params;} $class=array_shift( $params);return new $class( $params);}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getDBLoadBalancer(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'));}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), new RevTagStore(), $services->getDBLoadBalancer());}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnectionRef(DB_REPLICA);return new TranslationStashStorage( $db);}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getDBLoadBalancer());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}]
@phpcs-require-sorted-array
static makeTranslationTitle(MessageGroup $group, string $key, string $code)
Given a group, message key and language code, creates a title for the translation page.
static makeSectionElement(string $legend, string $type, string $content, Language $lang=null)
Make section elements.
static doImport(Title $title, string $message, string $summary, ?User $user, int $editFlags=0)
static escapeNameForPHP(string $name)
Escape name such that it validates as name and id parameter in html, and so that we can get it back w...
static doAction(string $action, MessageGroup $group, string $key, string $code, string $message, string $comment='', User $user=null, int $editFlags=0)
Perform an action on a given group/key/code.
static doFuzzy(Title $title, string $message, string $comment, ?User $user, int $editFlags=0)
static makeTextFuzzy(string $message)
Prepends translation with fuzzy tag and ensures there is only one of them.
FuzzyBot - the misunderstood workhorse.
Definition FuzzyBot.php:15
Factory class for accessing message groups individually by id or all of them as an list.
Class for pointing to messages, like Title class is for titles.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
static getLanguageName( $code, $language='en')
Returns a localised language name.
static convertWhiteSpaceToHTML( $message)
Escapes the message, and does some mangling to whitespace, so that it is preserved when outputted as-...
Interface for message groups.
Finds external changes for file based message groups.