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;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Revision\SlotRecord;
15use MessageGroup;
17use MWException;
18use OutputPage;
19use RequestContext;
20use Sanitizer;
21use 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();
207 $text = Utilities::convertWhiteSpaceToHTML( $value );
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 = Utilities::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 {
338 $lang = Utilities::getLanguageName(
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 Utilities::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'), $services->get( 'Translate:MessageIndex'));}, '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:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->getMainConfig() ->get( 'TranslateWorkflowStates') !==false);}, '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:MessagePrefixStats'=> static function(MediaWikiServices $services):MessagePrefixStats { return new MessagePrefixStats( $services->getTitleParser());}, '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:TranslatableBundleStatusStore'=> static function(MediaWikiServices $services):TranslatableBundleStatusStore { return new TranslatableBundleStatusStore($services->getDBLoadBalancer() ->getConnection(DB_PRIMARY), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), $services->getDBLoadBalancer() ->getMaintenanceConnectionRef(DB_PRIMARY));}, '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(), $services->get( 'Translate:TranslatableBundleStatusStore'));}, '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
Factory class for accessing message groups individually by id or all of them as a list.
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
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
Class for pointing to messages, like Title class is for titles.
Interface for message groups.
Finds external changes for file based message groups.