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