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' )->text();
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 $content = $this->revLookup
347 ->getRevisionByTitle( $title )
348 ->getContent( SlotRecord::MAIN );
349 $wiki = ( $content instanceof TextContent ) ? $content->getText() : '';
350
351 if ( $wiki === '' ) {
352 $noticeHtml .= Html::warningBox(
353 $this->msg( 'translate-manage-empty-content' )->text()
354 );
355 }
356
357 $oldContent = ContentHandler::makeContent( (string)$wiki, $title );
358 $newContent = ContentHandler::makeContent( '', $title );
359 $this->diff->setContent( $oldContent, $newContent );
360 $text = $this->diff->getDiff( $titleLink, '', $noticeHtml );
361 } elseif ( $type === 'addition' ) {
362 $menu = '';
363 $sourceLanguage = $group->getSourceLanguage();
364 if ( $sourceLanguage === $language ) {
365 if ( $this->hasRight ) {
366 $menu = Html::rawElement(
367 'button',
368 [
369 'class' => 'smg-rename-actions',
370 'type' => 'button',
371 'data-group-id' => $group->getId(),
372 'data-lang' => $language,
373 'data-msgkey' => $key,
374 'data-msgtitle' => $title->getFullText()
375 ],
376 ''
377 );
378 }
379 } elseif ( !self::isMessageDefinitionPresent( $group, $changes, $key ) ) {
380 $noticeHtml .= Html::warningBox(
381 $this->msg( 'translate-manage-source-message-not-found' )->text(),
382 'mw-translate-smg-notice-important'
383 );
384
385 // Automatically ignore messages that don't have a definitions
386 $menu = Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
387 $limit--;
388 }
389
390 if ( $params['content'] === '' ) {
391 $noticeHtml .= Html::warningBox(
392 $this->msg( 'translate-manage-empty-content' )->text()
393 );
394 }
395
396 $oldContent = ContentHandler::makeContent( '', $title );
397 $newContent = ContentHandler::makeContent( (string)$params['content'], $title );
398 $this->diff->setContent( $oldContent, $newContent );
399 $text = $this->diff->getDiff( '', $titleLink . $menu, $noticeHtml );
400 } elseif ( $type === 'change' ) {
401 $wiki = Utilities::getContentForTitle( $title, true );
402
403 $actions = '';
404 $sourceLanguage = $group->getSourceLanguage();
405
406 // Option to fuzzy is only available for source languages, and should be used
407 // if content has changed.
408 $shouldFuzzy = $sourceLanguage === $language && $wiki !== $params['content'];
409
410 if ( $sourceLanguage === $language ) {
411 $label = $this->msg( 'translate-manage-action-fuzzy' )->text();
412 $actions .= Xml::radioLabel( $label, "msg/$id", "fuzzy", "f/$id", $shouldFuzzy );
413 }
414
415 if (
416 $sourceLanguage !== $language &&
417 $isReusedKey &&
418 !self::isMessageDefinitionPresent( $group, $changes, $key )
419 ) {
420 $noticeHtml .= Html::warningBox(
421 $this->msg( 'translate-manage-source-message-not-found' )->text(),
422 'mw-translate-smg-notice-important'
423 );
424
425 // Automatically ignore messages that don't have a definitions
426 $actions .= Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
427 $limit--;
428 } else {
429 $label = $this->msg( 'translate-manage-action-import' )->text();
430 $actions .= Xml::radioLabel( $label, "msg/$id", "import", "imp/$id", !$shouldFuzzy );
431
432 $label = $this->msg( 'translate-manage-action-ignore' )->text();
433 $actions .= Xml::radioLabel( $label, "msg/$id", "ignore", "i/$id" );
434 $limit--;
435 }
436
437 $oldContent = ContentHandler::makeContent( (string)$wiki, $title );
438 $newContent = ContentHandler::makeContent( (string)$params['content'], $title );
439
440 $this->diff->setContent( $oldContent, $newContent );
441 $text .= $this->diff->getDiff( $titleLink, $actions, $noticeHtml );
442 }
443
444 $hidden = Html::hidden( $id, 1 );
445 $limit--;
446 $text .= $hidden;
447 $classes = "mw-translate-smg-change smg-change-$type";
448
449 if ( $limit < 0 ) {
450 // Don't add if one of the fields might get dropped of at submission
451 return '';
452 }
453
454 return Html::rawElement( 'div', [ 'class' => $classes ], $text );
455 }
456
457 protected function processSubmit(): void {
458 $req = $this->getRequest();
459 $out = $this->getOutput();
460 $errorGroups = [];
461
462 $modificationJobs = $renameJobData = [];
463 $lastModifiedTime = intval( $req->getVal( 'changesetModifiedTime' ) );
464
465 if ( !MessageChangeStorage::isModifiedSince( $this->cdb, $lastModifiedTime ) ) {
466 $out->addWikiMsg( 'translate-smg-changeset-modified' );
467 return;
468 }
469
470 $reader = \Cdb\Reader::open( $this->cdb );
471 $groups = $this->getGroupsFromCdb( $reader );
472 $groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
473 $postponed = [];
474
475 foreach ( $groups as $groupId => $group ) {
476 try {
477 if ( !$group instanceof FileBasedMessageGroup ) {
478 throw new RuntimeException( "Expected $groupId to be FileBasedMessageGroup, got "
479 . get_class( $group )
480 . " instead."
481 );
482 }
483 $changes = Utilities::deserialize( $reader->get( $groupId ) );
484 if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $groupId ) ) {
485 $postponed[$groupId] = $changes;
486 continue;
487 }
488
489 $sourceChanges = MessageSourceChange::loadModifications( $changes );
490 $groupModificationJobs = [];
491 $groupRenameJobData = [];
492 $languages = $sourceChanges->getLanguages();
493 foreach ( $languages as $language ) {
494 // Handle changes, additions, deletions
495 $this->handleModificationsSubmit(
496 $group,
497 $sourceChanges,
498 $req,
499 $language,
500 $postponed,
501 $groupModificationJobs
502 );
503
504 // Handle renames, this might also add modification jobs based on user selection.
505 $this->handleRenameSubmit(
506 $group,
507 $sourceChanges,
508 $req,
509 $language,
510 $postponed,
511 $groupRenameJobData,
512 $groupModificationJobs
513 );
514
515 if ( !isset( $postponed[$groupId][$language] ) ) {
516 $group->getMessageGroupCache( $language )->create();
517 }
518 }
519
520 if ( $groupSyncCacheEnabled && !isset( $postponed[ $groupId ] ) ) {
521 $this->synchronizationCache->markGroupAsReviewed( $groupId );
522 }
523
524 $modificationJobs[$groupId] = $groupModificationJobs;
525 $renameJobData[$groupId] = $groupRenameJobData;
526 } catch ( Exception $e ) {
527 error_log(
528 "ManageGroupsSpecialPage: Error in processSubmit. Group: $groupId\n" .
529 "Exception: $e"
530 );
531
532 $errorGroups[] = $group->getLabel();
533 }
534 }
535
536 $renameJobs = $this->createRenameJobs( $renameJobData );
537 $this->startSync( $modificationJobs, $renameJobs );
538
539 $reader->close();
540 rename( $this->cdb, $this->cdb . '-' . wfTimestamp() );
541
542 if ( $errorGroups ) {
543 $errorMsg = $this->getProcessingErrorMessage( $errorGroups, count( $groups ) );
544 $out->addHTML(
545 Html::warningBox(
546 $errorMsg,
547 'mw-translate-smg-submitted'
548 )
549 );
550 }
551
552 if ( count( $postponed ) ) {
553 $postponedSourceChanges = [];
554 foreach ( $postponed as $groupId => $changes ) {
555 $postponedSourceChanges[$groupId] = MessageSourceChange::loadModifications( $changes );
556 }
557 MessageChangeStorage::writeChanges( $postponedSourceChanges, $this->cdb );
558
559 $this->showChanges( $this->getLimit() );
560 } elseif ( $errorGroups === [] ) {
561 $out->addWikiMsg( 'translate-smg-submitted' );
562 }
563 }
564
565 protected static function changeId(
566 string $groupId,
567 string $language,
568 string $type,
569 string $key
570 ): string {
571 return 'smg/' . substr( sha1( "$groupId/$language/$type/$key" ), 0, 7 );
572 }
573
578 public static function tabify( Skin $skin, array &$tabs ): void {
579 $title = $skin->getTitle();
580 if ( !$title->isSpecialPage() ) {
581 return;
582 }
583 $specialPageFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
584 [ $alias, ] = $specialPageFactory->resolveAlias( $title->getText() );
585
586 $pagesInGroup = [
587 'ManageMessageGroups' => 'namespaces',
588 'AggregateGroups' => 'namespaces',
589 'SupportedLanguages' => 'views',
590 'TranslationStats' => 'views',
591 ];
592 if ( !isset( $pagesInGroup[$alias] ) ) {
593 return;
594 }
595
596 $tabs['namespaces'] = [];
597 foreach ( $pagesInGroup as $spName => $section ) {
598 $spClass = $specialPageFactory->getPage( $spName );
599
600 if ( $spClass === null || $spClass instanceof DisabledSpecialPage ) {
601 continue; // Page explicitly disabled
602 }
603 $spTitle = $spClass->getPageTitle();
604
605 $tabs[$section][strtolower( $spName )] = [
606 'text' => $spClass->getDescription(),
607 'href' => $spTitle->getLocalURL(),
608 'class' => $alias === $spName ? 'selected' : '',
609 ];
610 }
611 }
612
617 private static function isMessageDefinitionPresent(
618 MessageGroup $group,
619 MessageSourceChange $changes,
620 string $msgKey
621 ): bool {
622 $sourceLanguage = $group->getSourceLanguage();
623 if ( $changes->findMessage( $sourceLanguage, $msgKey, [ MessageSourceChange::ADDITION ] ) ) {
624 return true;
625 }
626
627 $namespace = $group->getNamespace();
628 $sourceHandle = new MessageHandle( Title::makeTitle( $namespace, $msgKey ) );
629 return $sourceHandle->isValid();
630 }
631
632 private function showRenames(
633 MessageGroup $group,
634 MessageSourceChange $sourceChanges,
635 OutputPage $out,
636 string $language,
637 int &$limit
638 ): void {
639 $changes = $sourceChanges->getRenames( $language );
640 foreach ( $changes as $key => $params ) {
641 // Since we're removing items from the array within the loop add
642 // a check here to ensure that the current key is still set.
643 if ( !isset( $changes[ $key ] ) ) {
644 continue;
645 }
646
647 if ( $group->getSourceLanguage() !== $language &&
648 $sourceChanges->isEqual( $language, $key ) ) {
649 // This is a translation rename, that does not have any changes.
650 // We can group this along with the source rename.
651 continue;
652 }
653
654 // Determine added key, and corresponding removed key.
655 $firstMsg = $params;
656 $secondKey = $sourceChanges->getMatchedKey( $language, $key );
657 $secondMsg = $sourceChanges->getMatchedMessage( $language, $key );
658
659 if (
660 $sourceChanges->isPreviousState(
661 $language,
662 $key,
663 [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
664 )
665 ) {
666 $addedMsg = $firstMsg;
667 $deletedMsg = $secondMsg;
668 } else {
669 $addedMsg = $secondMsg;
670 $deletedMsg = $firstMsg;
671 }
672
673 $change = $this->formatRename(
674 $group,
675 $addedMsg,
676 $deletedMsg,
677 $language,
678 $sourceChanges->isEqual( $language, $key ),
679 $limit
680 );
681 $out->addHTML( $change );
682
683 // no need to process the second key again.
684 unset( $changes[$secondKey] );
685
686 if ( $limit <= 0 ) {
687 // We need to restrict the changes per page per form submission
688 // limitations as well as performance.
689 $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
690 break;
691 }
692 }
693 }
694
695 private function formatRename(
696 MessageGroup $group,
697 array $addedMsg,
698 array $deletedMsg,
699 string $language,
700 bool $isEqual,
701 int &$limit
702 ): string {
703 $addedKey = $addedMsg['key'];
704 $deletedKey = $deletedMsg['key'];
705 $actions = '';
706
707 $addedTitle = Title::makeTitleSafe( $group->getNamespace(), "$addedKey/$language" );
708 $deletedTitle = Title::makeTitleSafe( $group->getNamespace(), "$deletedKey/$language" );
709 $id = self::changeId( $group->getId(), $language, MessageSourceChange::RENAME, $addedKey );
710
711 $addedTitleLink = $this->getLinkRenderer()->makeLink( $addedTitle );
712 $deletedTitleLink = $this->getLinkRenderer()->makeLink( $deletedTitle );
713
714 $renameSelected = true;
715 if ( $group->getSourceLanguage() === $language ) {
716 if ( !$isEqual ) {
717 $renameSelected = false;
718 $label = $this->msg( 'translate-manage-action-rename-fuzzy' )->text();
719 $actions .= Xml::radioLabel( $label, "msg/$id", "renamefuzzy", "rf/$id", true );
720 }
721
722 $label = $this->msg( 'translate-manage-action-rename' )->text();
723 $actions .= Xml::radioLabel( $label, "msg/$id", "rename", "imp/$id", $renameSelected );
724 } else {
725 $label = $this->msg( 'translate-manage-action-import' )->text();
726 $actions .= Xml::radioLabel( $label, "msg/$id", "import", "imp/$id", true );
727 }
728
729 if ( $group->getSourceLanguage() !== $language ) {
730 // Allow user to ignore changes to non-source languages.
731 $label = $this->msg( 'translate-manage-action-ignore-change' )->text();
732 $actions .= Xml::radioLabel( $label, "msg/$id", "ignore", "i/$id" );
733 }
734 $limit--;
735
736 $addedContent = ContentHandler::makeContent( (string)$addedMsg['content'], $addedTitle );
737 $deletedContent = ContentHandler::makeContent( (string)$deletedMsg['content'], $deletedTitle );
738 $this->diff->setContent( $deletedContent, $addedContent );
739
740 $menu = '';
741 if ( $group->getSourceLanguage() === $language && $this->hasRight ) {
742 // Only show rename and add as new option for source language.
743 $menu = Html::rawElement(
744 'button',
745 [
746 'class' => 'smg-rename-actions',
747 'type' => 'button',
748 'data-group-id' => $group->getId(),
749 'data-msgkey' => $addedKey,
750 'data-msgtitle' => $addedTitle->getFullText()
751 ], ''
752 );
753 }
754
755 $actions = Html::rawElement( 'div', [ 'class' => 'smg-change-import-options' ], $actions );
756
757 $text = $this->diff->getDiff(
758 $deletedTitleLink,
759 $addedTitleLink . $menu . $actions,
760 $isEqual ? htmlspecialchars( $addedMsg['content'] ) : ''
761 );
762
763 $hidden = Html::hidden( $id, 1 );
764 $limit--;
765 $text .= $hidden;
766
767 return Html::rawElement(
768 'div',
769 [ 'class' => 'mw-translate-smg-change smg-change-rename' ],
770 $text
771 );
772 }
773
774 private function getRenameJobParams(
775 array $currentMsg,
776 MessageSourceChange $sourceChanges,
777 string $languageCode,
778 int $groupNamespace,
779 string $selectedVal,
780 bool $isSourceLang = true
781 ): ?array {
782 if ( $selectedVal === 'ignore' ) {
783 return null;
784 }
785
786 $params = [];
787 $replacementContent = '';
788 $currentMsgKey = $currentMsg['key'];
789 $matchedMsg = $sourceChanges->getMatchedMessage( $languageCode, $currentMsgKey );
790 $matchedMsgKey = $matchedMsg['key'];
791
792 if (
793 $sourceChanges->isPreviousState(
794 $languageCode,
795 $currentMsgKey,
796 [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
797 )
798 ) {
799 $params['target'] = $matchedMsgKey;
800 $params['replacement'] = $currentMsgKey;
801 $replacementContent = $currentMsg['content'];
802 } else {
803 $params['target'] = $currentMsgKey;
804 $params['replacement'] = $matchedMsgKey;
805 $replacementContent = $matchedMsg['content'];
806 }
807
808 $params['fuzzy'] = $selectedVal === 'renamefuzzy';
809
810 $params['content'] = $replacementContent;
811
812 if ( $isSourceLang ) {
813 $params['targetTitle'] = Title::newFromText(
814 Utilities::title( $params['target'], $languageCode, $groupNamespace ),
815 $groupNamespace
816 );
817 $params['others'] = [];
818 }
819
820 return $params;
821 }
822
823 private function handleRenameSubmit(
824 MessageGroup $group,
825 MessageSourceChange $sourceChanges,
826 WebRequest $req,
827 string $language,
828 array &$postponed,
829 array &$jobData,
830 array &$modificationJobs
831 ): void {
832 $groupId = $group->getId();
833 $renames = $sourceChanges->getRenames( $language );
834 $isSourceLang = $group->getSourceLanguage() === $language;
835 $groupNamespace = $group->getNamespace();
836
837 foreach ( $renames as $key => $params ) {
838 // Since we're removing items from the array within the loop add
839 // a check here to ensure that the current key is still set.
840 if ( !isset( $renames[$key] ) ) {
841 continue;
842 }
843
844 $id = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $key );
845
846 [ $renameMissing, $isCurrentKeyPresent ] = $this->isRenameMissing(
847 $req,
848 $sourceChanges,
849 $id,
850 $key,
851 $language,
852 $groupId,
853 $isSourceLang
854 );
855
856 if ( $renameMissing ) {
857 // we probably hit the limit with number of post parameters since neither
858 // addition nor deletion key is present.
859 $postponed[$groupId][$language][MessageSourceChange::RENAME][$key] = $params;
860 continue;
861 }
862
863 if ( !$isCurrentKeyPresent ) {
864 // still don't process this key, and wait for the matched rename
865 continue;
866 }
867
868 $selectedVal = $req->getVal( "msg/$id" );
869 $jobParams = $this->getRenameJobParams(
870 $params,
871 $sourceChanges,
872 $language,
873 $groupNamespace,
874 $selectedVal,
875 $isSourceLang
876 );
877
878 if ( $jobParams === null ) {
879 continue;
880 }
881
882 $targetStr = $jobParams[ 'target' ];
883 if ( $isSourceLang ) {
884 $jobData[ $targetStr ] = $jobParams;
885 } elseif ( isset( $jobData[ $targetStr ] ) ) {
886 // We are grouping the source rename, and content changes in other languages
887 // for the message together into a single job in order to avoid race conditions
888 // since jobs are not guaranteed to be run in order.
889 $jobData[ $targetStr ][ 'others' ][ $language ] = $jobParams[ 'content' ];
890 } else {
891 // the source was probably ignored, we should add this as a modification instead,
892 // since the source is not going to be renamed.
893 $title = Title::newFromText(
894 Utilities::title( $targetStr, $language, $groupNamespace ),
895 $groupNamespace
896 );
897 $modificationJobs[] = MessageUpdateJob::newJob( $title, $jobParams['content'] );
898 }
899
900 // remove the matched key in order to avoid double processing.
901 $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
902 unset( $renames[$matchedKey] );
903 }
904 }
905
906 private function handleModificationsSubmit(
907 MessageGroup $group,
908 MessageSourceChange $sourceChanges,
909 WebRequest $req,
910 string $language,
911 array &$postponed,
912 array &$messageUpdateJob
913 ): void {
914 $groupId = $group->getId();
915 $subchanges = $sourceChanges->getModificationsForLanguage( $language );
916
917 // Ignore renames
918 unset( $subchanges[ MessageSourceChange::RENAME ] );
919
920 // Handle additions, deletions, and changes.
921 foreach ( $subchanges as $type => $messages ) {
922 foreach ( $messages as $index => $params ) {
923 $key = $params['key'];
924 $id = self::changeId( $groupId, $language, $type, $key );
925 $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
926
927 if ( !$this->isTitlePresent( $title, $type ) ) {
928 continue;
929 }
930
931 if ( !$req->getCheck( $id ) ) {
932 // We probably hit the limit with number of post parameters.
933 $postponed[$groupId][$language][$type][$index] = $params;
934 continue;
935 }
936
937 $selectedVal = $req->getVal( "msg/$id" );
938 if ( $type === MessageSourceChange::DELETION || $selectedVal === 'ignore' ) {
939 continue;
940 }
941
942 $fuzzy = $selectedVal === 'fuzzy';
943 $messageUpdateJob[] = MessageUpdateJob::newJob( $title, $params['content'], $fuzzy );
944 }
945 }
946 }
947
949 private function createRenameJobs( array $jobParams ): array {
950 $jobs = [];
951 foreach ( $jobParams as $groupId => $groupJobParams ) {
952 $jobs[$groupId] = $jobs[$groupId] ?? [];
953 foreach ( $groupJobParams as $params ) {
954 $jobs[$groupId][] = MessageUpdateJob::newRenameJob(
955 $params['targetTitle'],
956 $params['target'],
957 $params['replacement'],
958 $params['fuzzy'],
959 $params['content'],
960 $params['others']
961 );
962 }
963 }
964
965 return $jobs;
966 }
967
969 private function isTitlePresent( Title $title, string $type ): bool {
970 // phpcs:ignore SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn
971 if (
972 ( $type === MessageSourceChange::DELETION || $type === MessageSourceChange::CHANGE ) &&
973 !$title->exists()
974 ) {
975 // This means that this change was probably introduced due to a rename
976 // which removed the key. No need to process.
977 return false;
978 }
979 return true;
980 }
981
993 private function isRenameMissing(
994 WebRequest $req,
995 MessageSourceChange $sourceChanges,
996 string $id,
997 string $key,
998 string $language,
999 string $groupId,
1000 bool $isSourceLang
1001 ): array {
1002 if ( $req->getCheck( $id ) ) {
1003 return [ false, true ];
1004 }
1005
1006 $isCurrentKeyPresent = false;
1007
1008 // Checked the matched key is also missing to confirm if its truly missing
1009 $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
1010 $matchedId = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $matchedKey );
1011 if ( $req->getCheck( $matchedId ) ) {
1012 return [ false, $isCurrentKeyPresent ];
1013 }
1014
1015 // For non source language, if strings are equal, they are not shown on the UI
1016 // and hence not submitted.
1017 return [
1018 $isSourceLang || !$sourceChanges->isEqual( $language, $matchedKey ),
1019 $isCurrentKeyPresent
1020 ];
1021 }
1022
1023 private function getProcessingErrorMessage( array $errorGroups, int $totalGroupCount ): string {
1024 // Number of error groups, are less than the total groups processed.
1025 if ( count( $errorGroups ) < $totalGroupCount ) {
1026 $errorMsg = $this->msg( 'translate-smg-submitted-with-failure' )
1027 ->numParams( count( $errorGroups ) )
1028 ->params(
1029 $this->getLanguage()->commaList( $errorGroups ),
1030 $this->msg( 'translate-smg-submitted-others-processing' )
1031 )->text();
1032 } else {
1033 $errorMsg = trim(
1034 $this->msg( 'translate-smg-submitted-with-failure' )
1035 ->numParams( count( $errorGroups ) )
1036 ->params( $this->getLanguage()->commaList( $errorGroups ), '' )
1037 ->text()
1038 );
1039 }
1040
1041 return $errorMsg;
1042 }
1043
1045 private function getGroupsFromCdb( \Cdb\Reader $reader ): array {
1046 $groups = [];
1047 $groupIds = Utilities::deserialize( $reader->get( '#keys' ) );
1048 foreach ( $groupIds as $id ) {
1049 $groups[$id] = MessageGroups::getGroup( $id );
1050 }
1051 return array_filter( $groups );
1052 }
1053
1059 private function startSync( array $modificationJobs, array $renameJobs ): void {
1060 // We are adding an empty array for groups that have no jobs. This is mainly done to
1061 // avoid adding unnecessary checks. Remove those using array_filter
1062 $modificationGroupIds = array_keys( array_filter( $modificationJobs ) );
1063 $renameGroupIds = array_keys( array_filter( $renameJobs ) );
1064 $uniqueGroupIds = array_unique( array_merge( $modificationGroupIds, $renameGroupIds ) );
1065 $jobQueueInstance = $this->jobQueueGroup;
1066
1067 foreach ( $uniqueGroupIds as $groupId ) {
1068 $messages = [];
1069 $messageKeys = [];
1070 $groupJobs = [];
1071
1072 $groupRenameJobs = $renameJobs[$groupId] ?? [];
1074 foreach ( $groupRenameJobs as $job ) {
1075 $groupJobs[] = $job;
1076 $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1077 $messages[] = $messageUpdateParam;
1078
1079 // Build the handle to add the message key in interim cache
1080 $replacement = $messageUpdateParam->getReplacementValue();
1081 $targetTitle = Title::makeTitle( $job->getTitle()->getNamespace(), $replacement );
1082 $messageKeys[] = ( new MessageHandle( $targetTitle ) )->getKey();
1083 }
1084
1085 $groupModificationJobs = $modificationJobs[$groupId] ?? [];
1087 foreach ( $groupModificationJobs as $job ) {
1088 $groupJobs[] = $job;
1089 $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1090 $messages[] = $messageUpdateParam;
1091
1092 $messageKeys[] = ( new MessageHandle( $job->getTitle() ) )->getKey();
1093 }
1094
1095 // Store all message keys in the interim cache - we're particularly interested in new
1096 // and renamed messages, but it's cleaner to just store everything.
1097 $group = MessageGroups::getGroup( $groupId );
1098 $this->messageIndex->storeInterim( $group, $messageKeys );
1099
1100 if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
1101 $this->synchronizationCache->addMessages( $groupId, ...$messages );
1102 $this->synchronizationCache->markGroupForSync( $groupId );
1103
1104 LoggerFactory::getInstance( 'Translate.GroupSynchronization' )->info(
1105 '[' . __CLASS__ . '] Synchronization started for {groupId} by {user}',
1106 [
1107 'groupId' => $groupId,
1108 'user' => $this->getUser()->getName()
1109 ]
1110 );
1111 }
1112
1113 // There is posibility for a race condition here: the translate_cache table / group sync
1114 // cache is not yet populated with the messages to be processed, but the jobs start
1115 // running and try to remove the message from the cache. This results in a "Key not found"
1116 // error. Avoid this condition by using a DeferredUpdate.
1117 DeferredUpdates::addCallableUpdate(
1118 static function () use ( $jobQueueInstance, $groupJobs ) {
1119 $jobQueueInstance->push( $groupJobs );
1120 }
1121 );
1122
1123 }
1124 }
1125}
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
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 process...
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:30
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.