Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
ManageGroupsSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
5
6use ContentHandler;
7use DeferredUpdates;
8use DifferenceEngine;
9use DisabledSpecialPage;
10use Exception;
12use Html;
13use JobQueueGroup;
14use Language;
15use MediaWiki\Cache\LinkBatchFactory;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Revision\RevisionLookup;
22use MediaWiki\Revision\SlotRecord;
24use MessageGroup;
26use MessageIndex;
28use NamespaceInfo;
29use OOUI\ButtonInputWidget;
30use OutputPage;
31use PermissionsError;
32use RuntimeException;
33use Skin;
34use SpecialPage;
35use TextContent;
36use Title;
37use UserBlockedError;
38use WebRequest;
39use Xml;
40
52class ManageGroupsSpecialPage extends SpecialPage {
53 private const GROUP_SYNC_INFO_WRAPPER_CLASS = 'smg-group-sync-cache-info';
54 private const RIGHT = 'translate-manage';
56 protected $diff;
58 protected $cdb;
60 protected $hasRight = false;
62 private $contLang;
64 private $nsInfo;
66 private $revLookup;
68 private $synchronizationCache;
70 private $displayGroupSyncInfo;
72 private $jobQueueGroup;
74 private $messageIndex;
76 private $linkBatchFactory;
77
78 public function __construct(
79 Language $contLang,
80 NamespaceInfo $nsInfo,
81 RevisionLookup $revLookup,
82 GroupSynchronizationCache $synchronizationCache,
83 JobQueueGroup $jobQueueGroup,
84 MessageIndex $messageIndex,
85 LinkBatchFactory $linkBatchFactory
86 ) {
87 // Anyone is allowed to see, but actions are restricted
88 parent::__construct( 'ManageMessageGroups' );
89 $this->contLang = $contLang;
90 $this->nsInfo = $nsInfo;
91 $this->revLookup = $revLookup;
92 $this->synchronizationCache = $synchronizationCache;
93 $this->displayGroupSyncInfo = new DisplayGroupSynchronizationInfo( $this, $this->getLinkRenderer() );
94 $this->jobQueueGroup = $jobQueueGroup;
95 $this->messageIndex = $messageIndex;
96 $this->linkBatchFactory = $linkBatchFactory;
97 }
98
99 public function doesWrites() {
100 return true;
101 }
102
103 protected function getGroupName() {
104 return 'translation';
105 }
106
107 public function getDescription() {
108 return $this->msg( 'managemessagegroups' );
109 }
110
111 public function execute( $par ) {
112 $this->setHeaders();
113
114 $out = $this->getOutput();
115 $out->addModuleStyles( 'ext.translate.specialpages.styles' );
116 $out->addModules( 'ext.translate.special.managegroups' );
117 $out->addHelpLink( 'Help:Extension:Translate/Group_management' );
118
119 $name = $par ?: MessageChangeStorage::DEFAULT_NAME;
120
121 $this->cdb = MessageChangeStorage::getCdbPath( $name );
122 if ( !MessageChangeStorage::isValidCdbName( $name ) || !file_exists( $this->cdb ) ) {
123 if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
124 $out->addHTML(
125 $this->displayGroupSyncInfo->getGroupsInSyncHtml(
126 $this->synchronizationCache->getGroupsInSync(),
127 self::GROUP_SYNC_INFO_WRAPPER_CLASS
128 )
129 );
130
131 $out->addHTML(
132 $this->displayGroupSyncInfo->getHtmlForGroupsWithError(
133 $this->synchronizationCache,
134 self::GROUP_SYNC_INFO_WRAPPER_CLASS,
135 $this->getLanguage()
136 )
137 );
138 }
139
140 // @todo Tell them when changes was last checked/process
141 // or how to initiate recheck.
142 $out->addWikiMsg( 'translate-smg-nochanges' );
143
144 return;
145 }
146
147 $user = $this->getUser();
148 $this->hasRight = $user->isAllowed( self::RIGHT );
149
150 $req = $this->getRequest();
151 if ( !$req->wasPosted() ) {
152 $this->showChanges( $this->getLimit() );
153
154 return;
155 }
156
157 $block = $user->getBlock();
158 if ( $block && $block->isSitewide() ) {
159 throw new UserBlockedError(
160 $block,
161 $user,
162 $this->getLanguage(),
163 $req->getIP()
164 );
165 }
166
167 $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
168 if ( !$this->hasRight || !$csrfTokenSet->matchTokenField( 'token' ) ) {
169 throw new PermissionsError( self::RIGHT );
170 }
171
172 $this->processSubmit();
173 }
174
176 protected function getLimit(): int {
177 $limits = [
178 1000, // Default max
179 ini_get( 'max_input_vars' ),
180 ini_get( 'suhosin.post.max_vars' ),
181 ini_get( 'suhosin.request.max_vars' )
182 ];
183 // Ignore things not set
184 $limits = array_filter( $limits );
185 return (int)min( $limits );
186 }
187
188 protected function getLegend(): string {
189 $text = $this->diff->addHeader(
190 '',
191 $this->msg( 'translate-smg-left' )->escaped(),
192 $this->msg( 'translate-smg-right' )->escaped()
193 );
194
195 return Html::rawElement( 'div', [ 'class' => 'mw-translate-smg-header' ], $text );
196 }
197
198 protected function showChanges( int $limit ): void {
199 $diff = new DifferenceEngine( $this->getContext() );
200 $diff->showDiffStyle();
201 $diff->setReducedLineNumbers();
202 $this->diff = $diff;
203
204 $out = $this->getOutput();
205 $out->addHTML(
206 Html::openElement( 'form', [ 'method' => 'post' ] ) .
207 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText(), [
208 'id' => 'smgPageTitle'
209 ] ) .
210 Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
211 Html::hidden( 'changesetModifiedTime',
212 MessageChangeStorage::getLastModifiedTime( $this->cdb ) ) .
213 $this->getLegend()
214 );
215
216 // The above count as three
217 $limit -= 3;
218
219 $groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
220 if ( $groupSyncCacheEnabled ) {
221 $out->addHTML(
222 $this->displayGroupSyncInfo->getGroupsInSyncHtml(
223 $this->synchronizationCache->getGroupsInSync(),
224 self::GROUP_SYNC_INFO_WRAPPER_CLASS
225 )
226 );
227
228 $out->addHTML(
229 $this->displayGroupSyncInfo->getHtmlForGroupsWithError(
230 $this->synchronizationCache,
231 self::GROUP_SYNC_INFO_WRAPPER_CLASS,
232 $this->getLanguage()
233 )
234 );
235 }
236
237 $reader = \Cdb\Reader::open( $this->cdb );
238 $groups = $this->getGroupsFromCdb( $reader );
239 foreach ( $groups as $id => $group ) {
240 $sourceChanges = MessageSourceChange::loadModifications(
241 Utilities::deserialize( $reader->get( $id ) )
242 );
243 $out->addHTML( Html::element( 'h2', [], $group->getLabel() ) );
244
245 if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $id ) ) {
246 $out->addHTML(
247 Html::warningBox( $this->msg( 'translate-smg-group-sync-error-warn' )->escaped(), 'center' )
248 );
249 }
250
251 // Reduce page existance queries to one per group
252 $lb = $this->linkBatchFactory->newLinkBatch();
253 $ns = $group->getNamespace();
254 $isCap = $this->nsInfo->isCapitalized( $ns );
255 $languages = $sourceChanges->getLanguages();
256
257 foreach ( $languages as $language ) {
258 $languageChanges = $sourceChanges->getModificationsForLanguage( $language );
259 foreach ( $languageChanges as $type => $changes ) {
260 foreach ( $changes as $params ) {
261 // Constructing title objects is way slower
262 $key = $params['key'];
263 if ( $isCap ) {
264 $key = $this->contLang->ucfirst( $key );
265 }
266 $lb->add( $ns, "$key/$language" );
267 }
268 }
269 }
270 $lb->execute();
271
272 foreach ( $languages as $language ) {
273 // Handle and generate UI for additions, deletions, change
274 $changes = [];
275 $changes[ MessageSourceChange::ADDITION ] = $sourceChanges->getAdditions( $language );
276 $changes[ MessageSourceChange::DELETION ] = $sourceChanges->getDeletions( $language );
277 $changes[ MessageSourceChange::CHANGE ] = $sourceChanges->getChanges( $language );
278
279 foreach ( $changes as $type => $messages ) {
280 foreach ( $messages as $params ) {
281 $change = $this->formatChange( $group, $sourceChanges, $language, $type, $params, $limit );
282 $out->addHTML( $change );
283
284 if ( $limit <= 0 ) {
285 // We need to restrict the changes per page per form submission
286 // limitations as well as performance.
287 $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
288 break 4;
289 }
290 }
291 }
292
293 // Handle and generate UI for renames
294 $this->showRenames( $group, $sourceChanges, $out, $language, $limit );
295 }
296 }
297
298 $out->enableOOUI();
299 $button = new ButtonInputWidget( [
300 'type' => 'submit',
301 'label' => $this->msg( 'translate-smg-submit' )->plain(),
302 'disabled' => !$this->hasRight ? 'disabled' : null,
303 'classes' => [ 'mw-translate-smg-submit' ],
304 'title' => !$this->hasRight ? $this->msg( 'translate-smg-notallowed' )->plain() : null,
305 'flags' => [ 'primary', 'progressive' ],
306 ] );
307 $out->addHTML( $button );
308 $out->addHTML( Html::closeElement( 'form' ) );
309 }
310
311 protected function formatChange(
312 MessageGroup $group,
313 MessageSourceChange $changes,
314 string $language,
315 string $type,
316 array $params,
317 int &$limit
318 ): string {
319 $key = $params['key'];
320 $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
321 $id = self::changeId( $group->getId(), $language, $type, $key );
322 $noticeHtml = '';
323 $isReusedKey = false;
324
325 if ( $title && $type === 'addition' && $title->exists() ) {
326 // The message has for some reason dropped out from cache
327 // or perhaps it is being reused. In any case treat it
328 // as a change for display, so the admin can see if
329 // action is needed and let the message be processed.
330 // Otherwise it will end up in the postponed category
331 // forever and will prevent rebuilding the cache, which
332 // leads to many other annoying problems.
333 $type = 'change';
334 $noticeHtml .= Html::warningBox( $this->msg( 'translate-manage-key-reused' )->text() );
335 $isReusedKey = true;
336 } elseif ( $title && ( $type === 'deletion' || $type === 'change' ) && !$title->exists() ) {
337 // This happens if a message key has been renamed
338 // The change can be ignored.
339 return '';
340 }
341
342 $text = '';
343 $titleLink = $this->getLinkRenderer()->makeLink( $title );
344
345 if ( $type === 'deletion' ) {
346 $revTitle = $this->revLookup->getRevisionByTitle( $title );
347 if ( !$revTitle ) {
348 wfWarn( "[ManageGroupSpecialPage] No revision associated with {$title->getPrefixedText()}" );
349 }
350 $content = $revTitle ? $revTitle->getContent( SlotRecord::MAIN ) : null;
351 $wiki = ( $content instanceof TextContent ) ? $content->getText() : '';
352
353 if ( $wiki === '' ) {
354 $noticeHtml .= Html::warningBox(
355 $this->msg( 'translate-manage-empty-content' )->text()
356 );
357 }
358
359 $oldContent = ContentHandler::makeContent( (string)$wiki, $title );
360 $newContent = ContentHandler::makeContent( '', $title );
361 $this->diff->setContent( $oldContent, $newContent );
362 $text = $this->diff->getDiff( $titleLink, '', $noticeHtml );
363 } elseif ( $type === 'addition' ) {
364 $menu = '';
365 $sourceLanguage = $group->getSourceLanguage();
366 if ( $sourceLanguage === $language ) {
367 if ( $this->hasRight ) {
368 $menu = Html::rawElement(
369 'button',
370 [
371 'class' => 'smg-rename-actions',
372 'type' => 'button',
373 'data-group-id' => $group->getId(),
374 'data-lang' => $language,
375 'data-msgkey' => $key,
376 'data-msgtitle' => $title->getFullText()
377 ],
378 ''
379 );
380 }
381 } elseif ( !self::isMessageDefinitionPresent( $group, $changes, $key ) ) {
382 $noticeHtml .= Html::warningBox(
383 $this->msg( 'translate-manage-source-message-not-found' )->text(),
384 'mw-translate-smg-notice-important'
385 );
386
387 // Automatically ignore messages that don't have a definitions
388 $menu = Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
389 $limit--;
390 }
391
392 if ( $params['content'] === '' ) {
393 $noticeHtml .= Html::warningBox(
394 $this->msg( 'translate-manage-empty-content' )->text()
395 );
396 }
397
398 $oldContent = ContentHandler::makeContent( '', $title );
399 $newContent = ContentHandler::makeContent( (string)$params['content'], $title );
400 $this->diff->setContent( $oldContent, $newContent );
401 $text = $this->diff->getDiff( '', $titleLink . $menu, $noticeHtml );
402 } elseif ( $type === 'change' ) {
403 $wiki = Utilities::getContentForTitle( $title, true );
404
405 $actions = '';
406 $sourceLanguage = $group->getSourceLanguage();
407
408 // Option to fuzzy is only available for source languages, and should be used
409 // if content has changed.
410 $shouldFuzzy = $sourceLanguage === $language && $wiki !== $params['content'];
411
412 if ( $sourceLanguage === $language ) {
413 $label = $this->msg( 'translate-manage-action-fuzzy' )->text();
414 $actions .= Xml::radioLabel( $label, "msg/$id", "fuzzy", "f/$id", $shouldFuzzy );
415 }
416
417 if (
418 $sourceLanguage !== $language &&
419 $isReusedKey &&
420 !self::isMessageDefinitionPresent( $group, $changes, $key )
421 ) {
422 $noticeHtml .= Html::warningBox(
423 $this->msg( 'translate-manage-source-message-not-found' )->text(),
424 'mw-translate-smg-notice-important'
425 );
426
427 // Automatically ignore messages that don't have a definitions
428 $actions .= Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
429 $limit--;
430 } else {
431 $label = $this->msg( 'translate-manage-action-import' )->text();
432 $actions .= Xml::radioLabel( $label, "msg/$id", "import", "imp/$id", !$shouldFuzzy );
433
434 $label = $this->msg( 'translate-manage-action-ignore' )->text();
435 $actions .= Xml::radioLabel( $label, "msg/$id", "ignore", "i/$id" );
436 $limit--;
437 }
438
439 $oldContent = ContentHandler::makeContent( (string)$wiki, $title );
440 $newContent = ContentHandler::makeContent( (string)$params['content'], $title );
441
442 $this->diff->setContent( $oldContent, $newContent );
443 $text .= $this->diff->getDiff( $titleLink, $actions, $noticeHtml );
444 }
445
446 $hidden = Html::hidden( $id, 1 );
447 $limit--;
448 $text .= $hidden;
449 $classes = "mw-translate-smg-change smg-change-$type";
450
451 if ( $limit < 0 ) {
452 // Don't add if one of the fields might get dropped of at submission
453 return '';
454 }
455
456 return Html::rawElement( 'div', [ 'class' => $classes ], $text );
457 }
458
459 protected function processSubmit(): void {
460 $req = $this->getRequest();
461 $out = $this->getOutput();
462 $errorGroups = [];
463
464 $modificationJobs = $renameJobData = [];
465 $lastModifiedTime = intval( $req->getVal( 'changesetModifiedTime' ) );
466
467 if ( !MessageChangeStorage::isModifiedSince( $this->cdb, $lastModifiedTime ) ) {
468 $out->addWikiMsg( 'translate-smg-changeset-modified' );
469 return;
470 }
471
472 $reader = \Cdb\Reader::open( $this->cdb );
473 $groups = $this->getGroupsFromCdb( $reader );
474 $groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
475 $postponed = [];
476
477 foreach ( $groups as $groupId => $group ) {
478 try {
479 if ( !$group instanceof FileBasedMessageGroup ) {
480 throw new RuntimeException( "Expected $groupId to be FileBasedMessageGroup, got "
481 . get_class( $group )
482 . " instead."
483 );
484 }
485 $changes = Utilities::deserialize( $reader->get( $groupId ) );
486 if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $groupId ) ) {
487 $postponed[$groupId] = $changes;
488 continue;
489 }
490
491 $sourceChanges = MessageSourceChange::loadModifications( $changes );
492 $groupModificationJobs = [];
493 $groupRenameJobData = [];
494 $languages = $sourceChanges->getLanguages();
495 foreach ( $languages as $language ) {
496 // Handle changes, additions, deletions
497 $this->handleModificationsSubmit(
498 $group,
499 $sourceChanges,
500 $req,
501 $language,
502 $postponed,
503 $groupModificationJobs
504 );
505
506 // Handle renames, this might also add modification jobs based on user selection.
507 $this->handleRenameSubmit(
508 $group,
509 $sourceChanges,
510 $req,
511 $language,
512 $postponed,
513 $groupRenameJobData,
514 $groupModificationJobs
515 );
516
517 if ( !isset( $postponed[$groupId][$language] ) ) {
518 $group->getMessageGroupCache( $language )->create();
519 }
520 }
521
522 if ( $groupSyncCacheEnabled && !isset( $postponed[ $groupId ] ) ) {
523 $this->synchronizationCache->markGroupAsReviewed( $groupId );
524 }
525
526 $modificationJobs[$groupId] = $groupModificationJobs;
527 $renameJobData[$groupId] = $groupRenameJobData;
528 } catch ( Exception $e ) {
529 error_log(
530 "ManageGroupsSpecialPage: Error in processSubmit. Group: $groupId\n" .
531 "Exception: $e"
532 );
533
534 $errorGroups[] = $group->getLabel();
535 }
536 }
537
538 $renameJobs = $this->createRenameJobs( $renameJobData );
539 $this->startSync( $modificationJobs, $renameJobs );
540
541 $reader->close();
542 rename( $this->cdb, $this->cdb . '-' . wfTimestamp() );
543
544 if ( $errorGroups ) {
545 $errorMsg = $this->getProcessingErrorMessage( $errorGroups, count( $groups ) );
546 $out->addHTML(
547 Html::warningBox(
548 $errorMsg,
549 'mw-translate-smg-submitted'
550 )
551 );
552 }
553
554 if ( count( $postponed ) ) {
555 $postponedSourceChanges = [];
556 foreach ( $postponed as $groupId => $changes ) {
557 $postponedSourceChanges[$groupId] = MessageSourceChange::loadModifications( $changes );
558 }
559 MessageChangeStorage::writeChanges( $postponedSourceChanges, $this->cdb );
560
561 $this->showChanges( $this->getLimit() );
562 } elseif ( $errorGroups === [] ) {
563 $out->addWikiMsg( 'translate-smg-submitted' );
564 }
565 }
566
567 protected static function changeId(
568 string $groupId,
569 string $language,
570 string $type,
571 string $key
572 ): string {
573 return 'smg/' . substr( sha1( "$groupId/$language/$type/$key" ), 0, 7 );
574 }
575
580 public static function tabify( Skin $skin, array &$tabs ): void {
581 $title = $skin->getTitle();
582 if ( !$title->isSpecialPage() ) {
583 return;
584 }
585 $specialPageFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
586 [ $alias, ] = $specialPageFactory->resolveAlias( $title->getText() );
587
588 $pagesInGroup = [
589 'ManageMessageGroups' => 'namespaces',
590 'AggregateGroups' => 'namespaces',
591 'SupportedLanguages' => 'views',
592 'TranslationStats' => 'views',
593 ];
594 if ( !isset( $pagesInGroup[$alias] ) ) {
595 return;
596 }
597
598 $tabs['namespaces'] = [];
599 foreach ( $pagesInGroup as $spName => $section ) {
600 $spClass = $specialPageFactory->getPage( $spName );
601
602 if ( $spClass === null || $spClass instanceof DisabledSpecialPage ) {
603 continue; // Page explicitly disabled
604 }
605 $spTitle = $spClass->getPageTitle();
606
607 $tabs[$section][strtolower( $spName )] = [
608 'text' => $spClass->getDescription(),
609 'href' => $spTitle->getLocalURL(),
610 'class' => $alias === $spName ? 'selected' : '',
611 ];
612 }
613 }
614
619 private static function isMessageDefinitionPresent(
620 MessageGroup $group,
621 MessageSourceChange $changes,
622 string $msgKey
623 ): bool {
624 $sourceLanguage = $group->getSourceLanguage();
625 if ( $changes->findMessage( $sourceLanguage, $msgKey, [ MessageSourceChange::ADDITION ] ) ) {
626 return true;
627 }
628
629 $namespace = $group->getNamespace();
630 $sourceHandle = new MessageHandle( Title::makeTitle( $namespace, $msgKey ) );
631 return $sourceHandle->isValid();
632 }
633
634 private function showRenames(
635 MessageGroup $group,
636 MessageSourceChange $sourceChanges,
637 OutputPage $out,
638 string $language,
639 int &$limit
640 ): void {
641 $changes = $sourceChanges->getRenames( $language );
642 foreach ( $changes as $key => $params ) {
643 // Since we're removing items from the array within the loop add
644 // a check here to ensure that the current key is still set.
645 if ( !isset( $changes[ $key ] ) ) {
646 continue;
647 }
648
649 if ( $group->getSourceLanguage() !== $language &&
650 $sourceChanges->isEqual( $language, $key ) ) {
651 // This is a translation rename, that does not have any changes.
652 // We can group this along with the source rename.
653 continue;
654 }
655
656 // Determine added key, and corresponding removed key.
657 $firstMsg = $params;
658 $secondKey = $sourceChanges->getMatchedKey( $language, $key ) ?? '';
659 $secondMsg = $sourceChanges->getMatchedMessage( $language, $key );
660 if ( $secondMsg === null ) {
661 throw new RuntimeException( "Could not find matched message for $key" );
662 }
663
664 if (
665 $sourceChanges->isPreviousState(
666 $language,
667 $key,
668 [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
669 )
670 ) {
671 $addedMsg = $firstMsg;
672 $deletedMsg = $secondMsg;
673 } else {
674 $addedMsg = $secondMsg;
675 $deletedMsg = $firstMsg;
676 }
677
678 $change = $this->formatRename(
679 $group,
680 $addedMsg,
681 $deletedMsg,
682 $language,
683 $sourceChanges->isEqual( $language, $key ),
684 $limit
685 );
686 $out->addHTML( $change );
687
688 // no need to process the second key again.
689 unset( $changes[$secondKey] );
690
691 if ( $limit <= 0 ) {
692 // We need to restrict the changes per page per form submission
693 // limitations as well as performance.
694 $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
695 break;
696 }
697 }
698 }
699
700 private function formatRename(
701 MessageGroup $group,
702 array $addedMsg,
703 array $deletedMsg,
704 string $language,
705 bool $isEqual,
706 int &$limit
707 ): string {
708 $addedKey = $addedMsg['key'];
709 $deletedKey = $deletedMsg['key'];
710 $actions = '';
711
712 $addedTitle = Title::makeTitleSafe( $group->getNamespace(), "$addedKey/$language" );
713 $deletedTitle = Title::makeTitleSafe( $group->getNamespace(), "$deletedKey/$language" );
714 $id = self::changeId( $group->getId(), $language, MessageSourceChange::RENAME, $addedKey );
715
716 $addedTitleLink = $this->getLinkRenderer()->makeLink( $addedTitle );
717 $deletedTitleLink = $this->getLinkRenderer()->makeLink( $deletedTitle );
718
719 $renameSelected = true;
720 if ( $group->getSourceLanguage() === $language ) {
721 if ( !$isEqual ) {
722 $renameSelected = false;
723 $label = $this->msg( 'translate-manage-action-rename-fuzzy' )->text();
724 $actions .= Xml::radioLabel( $label, "msg/$id", "renamefuzzy", "rf/$id", true );
725 }
726
727 $label = $this->msg( 'translate-manage-action-rename' )->text();
728 $actions .= Xml::radioLabel( $label, "msg/$id", "rename", "imp/$id", $renameSelected );
729 } else {
730 $label = $this->msg( 'translate-manage-action-import' )->text();
731 $actions .= Xml::radioLabel( $label, "msg/$id", "import", "imp/$id", true );
732 }
733
734 if ( $group->getSourceLanguage() !== $language ) {
735 // Allow user to ignore changes to non-source languages.
736 $label = $this->msg( 'translate-manage-action-ignore-change' )->text();
737 $actions .= Xml::radioLabel( $label, "msg/$id", "ignore", "i/$id" );
738 }
739 $limit--;
740
741 $addedContent = ContentHandler::makeContent( (string)$addedMsg['content'], $addedTitle );
742 $deletedContent = ContentHandler::makeContent( (string)$deletedMsg['content'], $deletedTitle );
743 $this->diff->setContent( $deletedContent, $addedContent );
744
745 $menu = '';
746 if ( $group->getSourceLanguage() === $language && $this->hasRight ) {
747 // Only show rename and add as new option for source language.
748 $menu = Html::rawElement(
749 'button',
750 [
751 'class' => 'smg-rename-actions',
752 'type' => 'button',
753 'data-group-id' => $group->getId(),
754 'data-msgkey' => $addedKey,
755 'data-msgtitle' => $addedTitle->getFullText()
756 ], ''
757 );
758 }
759
760 $actions = Html::rawElement( 'div', [ 'class' => 'smg-change-import-options' ], $actions );
761
762 $text = $this->diff->getDiff(
763 $deletedTitleLink,
764 $addedTitleLink . $menu . $actions,
765 $isEqual ? htmlspecialchars( $addedMsg['content'] ) : ''
766 );
767
768 $hidden = Html::hidden( $id, 1 );
769 $limit--;
770 $text .= $hidden;
771
772 return Html::rawElement(
773 'div',
774 [ 'class' => 'mw-translate-smg-change smg-change-rename' ],
775 $text
776 );
777 }
778
779 private function getRenameJobParams(
780 array $currentMsg,
781 MessageSourceChange $sourceChanges,
782 string $languageCode,
783 int $groupNamespace,
784 string $selectedVal,
785 bool $isSourceLang = true
786 ): ?array {
787 if ( $selectedVal === 'ignore' ) {
788 return null;
789 }
790
791 $params = [];
792 $replacementContent = '';
793 $currentMsgKey = $currentMsg['key'];
794 $matchedMsg = $sourceChanges->getMatchedMessage( $languageCode, $currentMsgKey );
795 if ( $matchedMsg === null ) {
796 throw new RuntimeException( "Could not find matched message for $currentMsgKey." );
797 }
798 $matchedMsgKey = $matchedMsg['key'];
799
800 if (
801 $sourceChanges->isPreviousState(
802 $languageCode,
803 $currentMsgKey,
804 [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
805 )
806 ) {
807 $params['target'] = $matchedMsgKey;
808 $params['replacement'] = $currentMsgKey;
809 $replacementContent = $currentMsg['content'];
810 } else {
811 $params['target'] = $currentMsgKey;
812 $params['replacement'] = $matchedMsgKey;
813 $replacementContent = $matchedMsg['content'];
814 }
815
816 $params['fuzzy'] = $selectedVal === 'renamefuzzy';
817
818 $params['content'] = $replacementContent;
819
820 if ( $isSourceLang ) {
821 $params['targetTitle'] = Title::newFromText(
822 Utilities::title( $params['target'], $languageCode, $groupNamespace ),
823 $groupNamespace
824 );
825 $params['others'] = [];
826 }
827
828 return $params;
829 }
830
831 private function handleRenameSubmit(
832 MessageGroup $group,
833 MessageSourceChange $sourceChanges,
834 WebRequest $req,
835 string $language,
836 array &$postponed,
837 array &$jobData,
838 array &$modificationJobs
839 ): void {
840 $groupId = $group->getId();
841 $renames = $sourceChanges->getRenames( $language );
842 $isSourceLang = $group->getSourceLanguage() === $language;
843 $groupNamespace = $group->getNamespace();
844
845 foreach ( $renames as $key => $params ) {
846 // Since we're removing items from the array within the loop add
847 // a check here to ensure that the current key is still set.
848 if ( !isset( $renames[$key] ) ) {
849 continue;
850 }
851
852 $id = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $key );
853
854 [ $renameMissing, $isCurrentKeyPresent ] = $this->isRenameMissing(
855 $req,
856 $sourceChanges,
857 $id,
858 $key,
859 $language,
860 $groupId,
861 $isSourceLang
862 );
863
864 if ( $renameMissing ) {
865 // we probably hit the limit with number of post parameters since neither
866 // addition nor deletion key is present.
867 $postponed[$groupId][$language][MessageSourceChange::RENAME][$key] = $params;
868 continue;
869 }
870
871 if ( !$isCurrentKeyPresent ) {
872 // still don't process this key, and wait for the matched rename
873 continue;
874 }
875
876 $selectedVal = $req->getVal( "msg/$id" );
877 $jobParams = $this->getRenameJobParams(
878 $params,
879 $sourceChanges,
880 $language,
881 $groupNamespace,
882 $selectedVal,
883 $isSourceLang
884 );
885
886 if ( $jobParams === null ) {
887 continue;
888 }
889
890 $targetStr = $jobParams[ 'target' ];
891 if ( $isSourceLang ) {
892 $jobData[ $targetStr ] = $jobParams;
893 } elseif ( isset( $jobData[ $targetStr ] ) ) {
894 // We are grouping the source rename, and content changes in other languages
895 // for the message together into a single job in order to avoid race conditions
896 // since jobs are not guaranteed to be run in order.
897 $jobData[ $targetStr ][ 'others' ][ $language ] = $jobParams[ 'content' ];
898 } else {
899 // the source was probably ignored, we should add this as a modification instead,
900 // since the source is not going to be renamed.
901 $title = Title::newFromText(
902 Utilities::title( $targetStr, $language, $groupNamespace ),
903 $groupNamespace
904 );
905 $modificationJobs[] = MessageUpdateJob::newJob( $title, $jobParams['content'] );
906 }
907
908 // remove the matched key in order to avoid double processing.
909 $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
910 unset( $renames[$matchedKey] );
911 }
912 }
913
914 private function handleModificationsSubmit(
915 MessageGroup $group,
916 MessageSourceChange $sourceChanges,
917 WebRequest $req,
918 string $language,
919 array &$postponed,
920 array &$messageUpdateJob
921 ): void {
922 $groupId = $group->getId();
923 $subchanges = $sourceChanges->getModificationsForLanguage( $language );
924
925 // Ignore renames
926 unset( $subchanges[ MessageSourceChange::RENAME ] );
927
928 // Handle additions, deletions, and changes.
929 foreach ( $subchanges as $type => $messages ) {
930 foreach ( $messages as $index => $params ) {
931 $key = $params['key'];
932 $id = self::changeId( $groupId, $language, $type, $key );
933 $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
934
935 if ( !$this->isTitlePresent( $title, $type ) ) {
936 continue;
937 }
938
939 if ( !$req->getCheck( $id ) ) {
940 // We probably hit the limit with number of post parameters.
941 $postponed[$groupId][$language][$type][$index] = $params;
942 continue;
943 }
944
945 $selectedVal = $req->getVal( "msg/$id" );
946 if ( $type === MessageSourceChange::DELETION || $selectedVal === 'ignore' ) {
947 continue;
948 }
949
950 $fuzzy = $selectedVal === 'fuzzy';
951 $messageUpdateJob[] = MessageUpdateJob::newJob( $title, $params['content'], $fuzzy );
952 }
953 }
954 }
955
957 private function createRenameJobs( array $jobParams ): array {
958 $jobs = [];
959 foreach ( $jobParams as $groupId => $groupJobParams ) {
960 $jobs[$groupId] = $jobs[$groupId] ?? [];
961 foreach ( $groupJobParams as $params ) {
962 $jobs[$groupId][] = MessageUpdateJob::newRenameJob(
963 $params['targetTitle'],
964 $params['target'],
965 $params['replacement'],
966 $params['fuzzy'],
967 $params['content'],
968 $params['others']
969 );
970 }
971 }
972
973 return $jobs;
974 }
975
977 private function isTitlePresent( Title $title, string $type ): bool {
978 // phpcs:ignore SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn
979 if (
980 ( $type === MessageSourceChange::DELETION || $type === MessageSourceChange::CHANGE ) &&
981 !$title->exists()
982 ) {
983 // This means that this change was probably introduced due to a rename
984 // which removed the key. No need to process.
985 return false;
986 }
987 return true;
988 }
989
1001 private function isRenameMissing(
1002 WebRequest $req,
1003 MessageSourceChange $sourceChanges,
1004 string $id,
1005 string $key,
1006 string $language,
1007 string $groupId,
1008 bool $isSourceLang
1009 ): array {
1010 if ( $req->getCheck( $id ) ) {
1011 return [ false, true ];
1012 }
1013
1014 $isCurrentKeyPresent = false;
1015
1016 // Checked the matched key is also missing to confirm if its truly missing
1017 $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
1018 $matchedId = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $matchedKey );
1019 if ( $req->getCheck( $matchedId ) ) {
1020 return [ false, $isCurrentKeyPresent ];
1021 }
1022
1023 // For non source language, if strings are equal, they are not shown on the UI
1024 // and hence not submitted.
1025 return [
1026 $isSourceLang || !$sourceChanges->isEqual( $language, $matchedKey ),
1027 $isCurrentKeyPresent
1028 ];
1029 }
1030
1031 private function getProcessingErrorMessage( array $errorGroups, int $totalGroupCount ): string {
1032 // Number of error groups, are less than the total groups processed.
1033 if ( count( $errorGroups ) < $totalGroupCount ) {
1034 $errorMsg = $this->msg( 'translate-smg-submitted-with-failure' )
1035 ->numParams( count( $errorGroups ) )
1036 ->params(
1037 $this->getLanguage()->commaList( $errorGroups ),
1038 $this->msg( 'translate-smg-submitted-others-processing' )
1039 )->text();
1040 } else {
1041 $errorMsg = trim(
1042 $this->msg( 'translate-smg-submitted-with-failure' )
1043 ->numParams( count( $errorGroups ) )
1044 ->params( $this->getLanguage()->commaList( $errorGroups ), '' )
1045 ->text()
1046 );
1047 }
1048
1049 return $errorMsg;
1050 }
1051
1053 private function getGroupsFromCdb( \Cdb\Reader $reader ): array {
1054 $groups = [];
1055 $groupIds = Utilities::deserialize( $reader->get( '#keys' ) );
1056 foreach ( $groupIds as $id ) {
1057 $groups[$id] = MessageGroups::getGroup( $id );
1058 }
1059 return array_filter( $groups );
1060 }
1061
1067 private function startSync( array $modificationJobs, array $renameJobs ): void {
1068 // We are adding an empty array for groups that have no jobs. This is mainly done to
1069 // avoid adding unnecessary checks. Remove those using array_filter
1070 $modificationGroupIds = array_keys( array_filter( $modificationJobs ) );
1071 $renameGroupIds = array_keys( array_filter( $renameJobs ) );
1072 $uniqueGroupIds = array_unique( array_merge( $modificationGroupIds, $renameGroupIds ) );
1073 $jobQueueInstance = $this->jobQueueGroup;
1074
1075 foreach ( $uniqueGroupIds as $groupId ) {
1076 $messages = [];
1077 $messageKeys = [];
1078 $groupJobs = [];
1079
1080 $groupRenameJobs = $renameJobs[$groupId] ?? [];
1082 foreach ( $groupRenameJobs as $job ) {
1083 $groupJobs[] = $job;
1084 $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1085 $messages[] = $messageUpdateParam;
1086
1087 // Build the handle to add the message key in interim cache
1088 $replacement = $messageUpdateParam->getReplacementValue();
1089 $targetTitle = Title::makeTitle( $job->getTitle()->getNamespace(), $replacement );
1090 $messageKeys[] = ( new MessageHandle( $targetTitle ) )->getKey();
1091 }
1092
1093 $groupModificationJobs = $modificationJobs[$groupId] ?? [];
1095 foreach ( $groupModificationJobs as $job ) {
1096 $groupJobs[] = $job;
1097 $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1098 $messages[] = $messageUpdateParam;
1099
1100 $messageKeys[] = ( new MessageHandle( $job->getTitle() ) )->getKey();
1101 }
1102
1103 // Store all message keys in the interim cache - we're particularly interested in new
1104 // and renamed messages, but it's cleaner to just store everything.
1105 $group = MessageGroups::getGroup( $groupId );
1106 $this->messageIndex->storeInterim( $group, $messageKeys );
1107
1108 if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
1109 $this->synchronizationCache->addMessages( $groupId, ...$messages );
1110 $this->synchronizationCache->markGroupForSync( $groupId );
1111
1112 LoggerFactory::getInstance( 'Translate.GroupSynchronization' )->info(
1113 '[' . __CLASS__ . '] Synchronization started for {groupId} by {user}',
1114 [
1115 'groupId' => $groupId,
1116 'user' => $this->getUser()->getName()
1117 ]
1118 );
1119 }
1120
1121 // There is posibility for a race condition here: the translate_cache table / group sync
1122 // cache is not yet populated with the messages to be processed, but the jobs start
1123 // running and try to remove the message from the cache. This results in a "Key not found"
1124 // error. Avoid this condition by using a DeferredUpdate.
1125 DeferredUpdates::addCallableUpdate(
1126 static function () use ( $jobQueueInstance, $groupJobs ) {
1127 $jobQueueInstance->push( $groupJobs );
1128 }
1129 );
1130
1131 }
1132 }
1133}
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:FileFormatFactory'=> static function(MediaWikiServices $services):FileFormatFactory { return new FileFormatFactory( $services->getObjectFactory());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:HookRunner'=> static function(MediaWikiServices $services):HookRunner { return new HookRunner( $services->getHookContainer());}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore($services->get( 'Translate:RevTagStore'), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReviewStore'=> static function(MediaWikiServices $services):MessageGroupReviewStore { return new MessageGroupReviewStore($services->getDBLoadBalancer(), $services->get( 'Translate:HookRunner'));}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->get( 'Translate:MessageGroupReviewStore'), $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:RevTagStore'=> static function(MediaWikiServices $services):RevTagStore { return new RevTagStore($services->getDBLoadBalancerFactory());}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleExporter'=> static function(MediaWikiServices $services):TranslatableBundleExporter { return new TranslatableBundleExporter($services->get( 'Translate:SubpageListBuilder'), $services->getWikiExporterFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleImporter'=> static function(MediaWikiServices $services):TranslatableBundleImporter { return new TranslatableBundleImporter($services->getWikiImporterFactory(), $services->get( 'Translate:TranslatablePageParser'), $services->getRevisionLookup());}, '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(), $services->get( 'Translate:RevTagStore'), $services->getDBLoadBalancer(), $services->get( 'Translate:TranslatableBundleStatusStore'), $services->get( 'Translate:TranslatablePageParser'),);}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnection(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(), $services->getDBLoadBalancer());}, '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
This class implements default behavior for file based message groups.
Factory class for accessing message groups individually by id or all of them as a list.
Class is used to track the changes made when importing messages from the remote sources using importE...
static tabify(Skin $skin, array &$tabs)
Adds the task-based tabs on Special:Translate and few other special pages.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:31
static writeChanges(array $changes, $file)
Writes change array as a serialized file.
Class for pointing to messages, like Title class is for titles.
Creates a database of keys in all groups, so that namespace and key can be used to get the groups the...
Job for updating translation pages when translation or message definition changes.
static newRenameJob(Title $target, string $targetStr, string $replacement, $fuzzy, string $content, array $otherLangContents=[])
Create a message update job containing a rename process.
static newJob(Title $target, string $content, $fuzzy=false)
Create a normal message update job without a rename process.
Interface for message groups.
getNamespace()
Returns the namespace where messages are placed.
getSourceLanguage()
Returns language code depicting the language of source text.
getId()
Returns the unique identifier for this group.
getLabel(IContextSource $context=null)
Returns the human readable label (as plain text).
Finds external changes for file based message groups.