src/Synchronization/ManageGroupsSpecialPage.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\Synchronization;
use Cdb\Reader;
use ContentHandler;
use DifferenceEngine;
use Exception;
use FileBasedMessageGroup;
use JobQueueGroup;
use Language;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscription;
use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
use MediaWiki\Extension\Translate\MessageLoading\MessageIndex;
use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\Html\Html;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\WebRequest;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\SpecialPage\DisabledSpecialPage;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MessageGroup;
use OOUI\ButtonInputWidget;
use PermissionsError;
use RuntimeException;
use Skin;
use TextContent;
use UserBlockedError;
/**
* Class for special page Special:ManageMessageGroups. On this special page
* file based message groups can be managed (FileBasedMessageGroup). This page
* allows updating of the file cache, import and fuzzy for source language
* messages, as well as import/update of messages in other languages.
*
* @author Niklas Laxström
* @author Siebrand Mazeland
* @ingroup SpecialPage TranslateSpecialPage
* @license GPL-2.0-or-later
*/
class ManageGroupsSpecialPage extends SpecialPage {
private const GROUP_SYNC_INFO_WRAPPER_CLASS = 'smg-group-sync-cache-info';
private const RIGHT = 'translate-manage';
protected DifferenceEngine $diff;
/** Name of the import. */
private string $name;
/** Path to the change cdb file, derived from the name. */
protected string $cdb;
/** Has the necessary right specified by the RIGHT constant */
protected bool $hasRight = false;
private Language $contLang;
private NamespaceInfo $nsInfo;
private RevisionLookup $revLookup;
private GroupSynchronizationCache $synchronizationCache;
private DisplayGroupSynchronizationInfo $displayGroupSyncInfo;
private JobQueueGroup $jobQueueGroup;
private MessageIndex $messageIndex;
private LinkBatchFactory $linkBatchFactory;
private MessageGroupSubscription $messageGroupSubscription;
public function __construct(
Language $contLang,
NamespaceInfo $nsInfo,
RevisionLookup $revLookup,
GroupSynchronizationCache $synchronizationCache,
JobQueueGroup $jobQueueGroup,
MessageIndex $messageIndex,
LinkBatchFactory $linkBatchFactory,
MessageGroupSubscription $messageGroupSubscription
) {
// Anyone is allowed to see, but actions are restricted
parent::__construct( 'ManageMessageGroups' );
$this->contLang = $contLang;
$this->nsInfo = $nsInfo;
$this->revLookup = $revLookup;
$this->synchronizationCache = $synchronizationCache;
$this->displayGroupSyncInfo = new DisplayGroupSynchronizationInfo( $this, $this->getLinkRenderer() );
$this->jobQueueGroup = $jobQueueGroup;
$this->messageIndex = $messageIndex;
$this->linkBatchFactory = $linkBatchFactory;
$this->messageGroupSubscription = $messageGroupSubscription;
}
public function doesWrites() {
return true;
}
protected function getGroupName() {
return 'translation';
}
public function getDescription() {
return $this->msg( 'managemessagegroups' );
}
public function execute( $par ) {
$this->setHeaders();
$out = $this->getOutput();
$out->addModuleStyles( 'ext.translate.specialpages.styles' );
$out->addModules( 'ext.translate.special.managegroups' );
$out->addHelpLink( 'Help:Extension:Translate/Group_management' );
$this->name = $par ?: MessageChangeStorage::DEFAULT_NAME;
$this->cdb = MessageChangeStorage::getCdbPath( $this->name );
if ( !MessageChangeStorage::isValidCdbName( $this->name ) || !file_exists( $this->cdb ) ) {
if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
$out->addHTML(
$this->displayGroupSyncInfo->getGroupsInSyncHtml(
$this->synchronizationCache->getGroupsInSync(),
self::GROUP_SYNC_INFO_WRAPPER_CLASS
)
);
$out->addHTML(
$this->displayGroupSyncInfo->getHtmlForGroupsWithError(
$this->synchronizationCache,
self::GROUP_SYNC_INFO_WRAPPER_CLASS,
$this->getLanguage()
)
);
}
// @todo Tell them when changes was last checked/process
// or how to initiate recheck.
$out->addWikiMsg( 'translate-smg-nochanges' );
return;
}
$user = $this->getUser();
$this->hasRight = $user->isAllowed( self::RIGHT );
$req = $this->getRequest();
if ( !$req->wasPosted() ) {
$this->showChanges( $this->getLimit() );
return;
}
$block = $user->getBlock();
if ( $block && $block->isSitewide() ) {
throw new UserBlockedError(
$block,
$user,
$this->getLanguage(),
$req->getIP()
);
}
$csrfTokenSet = $this->getContext()->getCsrfTokenSet();
if ( !$this->hasRight || !$csrfTokenSet->matchTokenField( 'token' ) ) {
throw new PermissionsError( self::RIGHT );
}
$this->processSubmit();
}
/** How many changes can be shown per page. */
protected function getLimit(): int {
$limits = [
1000, // Default max
ini_get( 'max_input_vars' ),
ini_get( 'suhosin.post.max_vars' ),
ini_get( 'suhosin.request.max_vars' )
];
// Ignore things not set
$limits = array_filter( $limits );
return (int)min( $limits );
}
protected function getLegend(): string {
$text = $this->diff->addHeader(
'',
$this->msg( 'translate-smg-left' )->escaped(),
$this->msg( 'translate-smg-right' )->escaped()
);
return Html::rawElement( 'div', [ 'class' => 'mw-translate-smg-header' ], $text );
}
protected function showChanges( int $limit ): void {
$diff = new DifferenceEngine( $this->getContext() );
$diff->showDiffStyle();
$diff->setReducedLineNumbers();
$this->diff = $diff;
$out = $this->getOutput();
$out->addHTML(
Html::openElement( 'form', [ 'method' => 'post', 'id' => 'smgForm', 'data-name' => $this->name ] ) .
Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
Html::hidden( 'changesetModifiedTime',
MessageChangeStorage::getLastModifiedTime( $this->cdb ) ) .
$this->getLegend()
);
// The above count as three
$limit -= 3;
$groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
if ( $groupSyncCacheEnabled ) {
$out->addHTML(
$this->displayGroupSyncInfo->getGroupsInSyncHtml(
$this->synchronizationCache->getGroupsInSync(),
self::GROUP_SYNC_INFO_WRAPPER_CLASS
)
);
$out->addHTML(
$this->displayGroupSyncInfo->getHtmlForGroupsWithError(
$this->synchronizationCache,
self::GROUP_SYNC_INFO_WRAPPER_CLASS,
$this->getLanguage()
)
);
}
$reader = Reader::open( $this->cdb );
$groups = $this->getGroupsFromCdb( $reader );
foreach ( $groups as $id => $group ) {
$sourceChanges = MessageSourceChange::loadModifications(
Utilities::deserialize( $reader->get( $id ) )
);
$out->addHTML( Html::element( 'h2', [], $group->getLabel() ) );
if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $id ) ) {
$out->addHTML(
Html::warningBox( $this->msg( 'translate-smg-group-sync-error-warn' )->escaped(), 'center' )
);
}
// Reduce page existence queries to one per group
$lb = $this->linkBatchFactory->newLinkBatch();
$ns = $group->getNamespace();
$isCap = $this->nsInfo->isCapitalized( $ns );
$languages = $sourceChanges->getLanguages();
foreach ( $languages as $language ) {
$languageChanges = $sourceChanges->getModificationsForLanguage( $language );
foreach ( $languageChanges as $changes ) {
foreach ( $changes as $params ) {
// Constructing title objects is way slower
$key = $params['key'];
if ( $isCap ) {
$key = $this->contLang->ucfirst( $key );
}
$lb->add( $ns, "$key/$language" );
}
}
}
$lb->execute();
foreach ( $languages as $language ) {
// Handle and generate UI for additions, deletions, change
$changes = [];
$changes[ MessageSourceChange::ADDITION ] = $sourceChanges->getAdditions( $language );
$changes[ MessageSourceChange::DELETION ] = $sourceChanges->getDeletions( $language );
$changes[ MessageSourceChange::CHANGE ] = $sourceChanges->getChanges( $language );
foreach ( $changes as $type => $messages ) {
foreach ( $messages as $params ) {
$change = $this->formatChange( $group, $sourceChanges, $language, $type, $params, $limit );
$out->addHTML( $change );
if ( $limit <= 0 ) {
// We need to restrict the changes per page per form submission
// limitations as well as performance.
$out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
break 4;
}
}
}
// Handle and generate UI for renames
$this->showRenames( $group, $sourceChanges, $out, $language, $limit );
}
}
$out->enableOOUI();
$button = new ButtonInputWidget( [
'type' => 'submit',
'label' => $this->msg( 'translate-smg-submit' )->plain(),
'disabled' => !$this->hasRight ? 'disabled' : null,
'classes' => [ 'mw-translate-smg-submit' ],
'title' => !$this->hasRight ? $this->msg( 'translate-smg-notallowed' )->plain() : null,
'flags' => [ 'primary', 'progressive' ],
] );
$out->addHTML( $button );
$out->addHTML( Html::closeElement( 'form' ) );
}
protected function formatChange(
MessageGroup $group,
MessageSourceChange $changes,
string $language,
string $type,
array $params,
int &$limit
): string {
$key = $params['key'];
$title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
$id = self::changeId( $group->getId(), $language, $type, $key );
$noticeHtml = '';
$isReusedKey = false;
if ( $title && $type === 'addition' && $title->exists() ) {
// The message has for some reason dropped out from cache
// or, perhaps it is being reused. In any case treat it
// as a change for display, so the admin can see if
// action is needed and let the message be processed.
// Otherwise, it will end up in the postponed category
// forever and will prevent rebuilding the cache, which
// leads to many other annoying problems.
$type = 'change';
$noticeHtml .= Html::warningBox( $this->msg( 'translate-manage-key-reused' )->parse() );
$isReusedKey = true;
} elseif ( $title && ( $type === 'deletion' || $type === 'change' ) && !$title->exists() ) {
// This happens if a message key has been renamed
// The change can be ignored.
return '';
}
$text = '';
$titleLink = $this->getLinkRenderer()->makeLink( $title );
if ( $type === 'deletion' ) {
$revTitle = $this->revLookup->getRevisionByTitle( $title );
if ( !$revTitle ) {
wfWarn( "[ManageGroupSpecialPage] No revision associated with {$title->getPrefixedText()}" );
}
$content = $revTitle ? $revTitle->getContent( SlotRecord::MAIN ) : null;
$wiki = ( $content instanceof TextContent ) ? $content->getText() : '';
if ( $wiki === '' ) {
$noticeHtml .= Html::warningBox(
$this->msg( 'translate-manage-empty-content' )->parse()
);
}
$oldContent = ContentHandler::makeContent( (string)$wiki, $title );
$newContent = ContentHandler::makeContent( '', $title );
$this->diff->setContent( $oldContent, $newContent );
$text = $this->diff->getDiff( $titleLink, '', $noticeHtml );
} elseif ( $type === 'addition' ) {
$menu = '';
$sourceLanguage = $group->getSourceLanguage();
if ( $sourceLanguage === $language ) {
if ( $this->hasRight ) {
$menu = Html::rawElement(
'button',
[
'class' => 'smg-rename-actions',
'type' => 'button',
'data-group-id' => $group->getId(),
'data-lang' => $language,
'data-msgkey' => $key,
'data-msgtitle' => $title->getFullText()
]
);
}
} elseif ( !self::isMessageDefinitionPresent( $group, $changes, $key ) ) {
$noticeHtml .= Html::warningBox(
$this->msg( 'translate-manage-source-message-not-found' )->parse(),
'mw-translate-smg-notice-important'
);
// Automatically ignore messages that don't have a definitions
$menu = Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
$limit--;
}
if ( $params['content'] === '' ) {
$noticeHtml .= Html::warningBox(
$this->msg( 'translate-manage-empty-content' )->parse()
);
}
$oldContent = ContentHandler::makeContent( '', $title );
$newContent = ContentHandler::makeContent( (string)$params['content'], $title );
$this->diff->setContent( $oldContent, $newContent );
$text = $this->diff->getDiff( '', $titleLink . $menu, $noticeHtml );
} elseif ( $type === 'change' ) {
$wiki = Utilities::getContentForTitle( $title, true );
$actions = '';
$sourceLanguage = $group->getSourceLanguage();
// Option to fuzzy is only available for source languages, and should be used
// if content has changed.
$shouldFuzzy = $sourceLanguage === $language && $wiki !== $params['content'];
if ( $sourceLanguage === $language ) {
$label = $this->msg( 'translate-manage-action-fuzzy' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "fuzzy", $shouldFuzzy );
}
if (
$sourceLanguage !== $language &&
$isReusedKey &&
!self::isMessageDefinitionPresent( $group, $changes, $key )
) {
$noticeHtml .= Html::warningBox(
$this->msg( 'translate-manage-source-message-not-found' )->parse(),
'mw-translate-smg-notice-important'
);
// Automatically ignore messages that don't have a definitions
$actions .= Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
$limit--;
} else {
$label = $this->msg( 'translate-manage-action-import' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "import", !$shouldFuzzy );
$label = $this->msg( 'translate-manage-action-ignore' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "ignore" );
$limit--;
}
$oldContent = ContentHandler::makeContent( (string)$wiki, $title );
$newContent = ContentHandler::makeContent( (string)$params['content'], $title );
$this->diff->setContent( $oldContent, $newContent );
$text .= $this->diff->getDiff( $titleLink, $actions, $noticeHtml );
}
$hidden = Html::hidden( $id, 1 );
$limit--;
$text .= $hidden;
$classes = "mw-translate-smg-change smg-change-$type";
if ( $limit < 0 ) {
// Don't add if one of the fields might get dropped of at submission
return '';
}
return Html::rawElement( 'div', [ 'class' => $classes ], $text );
}
protected function processSubmit(): void {
$req = $this->getRequest();
$out = $this->getOutput();
$errorGroups = [];
$modificationJobs = $renameJobData = [];
$lastModifiedTime = intval( $req->getVal( 'changesetModifiedTime' ) );
if ( !MessageChangeStorage::isModifiedSince( $this->cdb, $lastModifiedTime ) ) {
$out->addWikiMsg( 'translate-smg-changeset-modified' );
return;
}
$reader = Reader::open( $this->cdb );
$groups = $this->getGroupsFromCdb( $reader );
$groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
$postponed = [];
foreach ( $groups as $groupId => $group ) {
try {
if ( !$group instanceof FileBasedMessageGroup ) {
throw new RuntimeException( "Expected $groupId to be FileBasedMessageGroup, got "
. get_class( $group )
. " instead."
);
}
$changes = Utilities::deserialize( $reader->get( $groupId ) );
if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $groupId ) ) {
$postponed[$groupId] = $changes;
continue;
}
$sourceChanges = MessageSourceChange::loadModifications( $changes );
$groupModificationJobs = [];
$groupRenameJobData = [];
$languages = $sourceChanges->getLanguages();
foreach ( $languages as $language ) {
// Handle changes, additions, deletions
$this->handleModificationsSubmit(
$group,
$sourceChanges,
$req,
$language,
$postponed,
$groupModificationJobs
);
// Handle renames, this might also add modification jobs based on user selection.
$this->handleRenameSubmit(
$group,
$sourceChanges,
$req,
$language,
$postponed,
$groupRenameJobData,
$groupModificationJobs
);
if ( !isset( $postponed[$groupId][$language] ) ) {
$group->getMessageGroupCache( $language )->create();
}
}
if ( $groupSyncCacheEnabled && !isset( $postponed[ $groupId ] ) ) {
$this->synchronizationCache->markGroupAsReviewed( $groupId );
}
$modificationJobs[$groupId] = $groupModificationJobs;
$renameJobData[$groupId] = $groupRenameJobData;
} catch ( Exception $e ) {
error_log(
"ManageGroupsSpecialPage: Error in processSubmit. Group: $groupId\n" .
"Exception: $e"
);
$errorGroups[] = $group->getLabel();
}
}
$this->messageGroupSubscription->queueNotificationJob();
$renameJobs = $this->createRenameJobs( $renameJobData );
$this->startSync( $modificationJobs, $renameJobs );
$reader->close();
rename( $this->cdb, $this->cdb . '-' . wfTimestamp() );
if ( $errorGroups ) {
$errorMsg = $this->getProcessingErrorMessage( $errorGroups, count( $groups ) );
$out->addHTML(
Html::warningBox(
$errorMsg,
'mw-translate-smg-submitted'
)
);
}
if ( count( $postponed ) ) {
$postponedSourceChanges = [];
foreach ( $postponed as $groupId => $changes ) {
$postponedSourceChanges[$groupId] = MessageSourceChange::loadModifications( $changes );
}
MessageChangeStorage::writeChanges( $postponedSourceChanges, $this->cdb );
$this->showChanges( $this->getLimit() );
} elseif ( $errorGroups === [] ) {
$out->addWikiMsg( 'translate-smg-submitted' );
}
}
protected static function changeId(
string $groupId,
string $language,
string $type,
string $key
): string {
return 'smg/' . substr( sha1( "$groupId/$language/$type/$key" ), 0, 7 );
}
/**
* Adds the task-based tabs on Special:Translate and few other special pages.
* Hook: SkinTemplateNavigation::Universal
*/
public static function tabify( Skin $skin, array &$tabs ): void {
$title = $skin->getTitle();
if ( !$title->isSpecialPage() ) {
return;
}
$specialPageFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
[ $alias, ] = $specialPageFactory->resolveAlias( $title->getText() );
$pagesInGroup = [
'ManageMessageGroups' => 'namespaces',
'AggregateGroups' => 'namespaces',
'SupportedLanguages' => 'views',
'TranslationStats' => 'views',
];
if ( !isset( $pagesInGroup[$alias] ) ) {
return;
}
$tabs['namespaces'] = [];
foreach ( $pagesInGroup as $spName => $section ) {
$spClass = $specialPageFactory->getPage( $spName );
if ( $spClass === null || $spClass instanceof DisabledSpecialPage ) {
continue; // Page explicitly disabled
}
$spTitle = $spClass->getPageTitle();
$tabs[$section][strtolower( $spName )] = [
'text' => $spClass->getDescription(),
'href' => $spTitle->getLocalURL(),
'class' => $alias === $spName ? 'selected' : '',
];
}
}
/**
* Check if the message definition is present as an incoming addition
* OR exists already on the wiki
*/
private static function isMessageDefinitionPresent(
MessageGroup $group,
MessageSourceChange $changes,
string $msgKey
): bool {
$sourceLanguage = $group->getSourceLanguage();
if ( $changes->findMessage( $sourceLanguage, $msgKey, [ MessageSourceChange::ADDITION ] ) ) {
return true;
}
$namespace = $group->getNamespace();
$sourceHandle = new MessageHandle( Title::makeTitle( $namespace, $msgKey ) );
return $sourceHandle->isValid();
}
private function showRenames(
MessageGroup $group,
MessageSourceChange $sourceChanges,
OutputPage $out,
string $language,
int &$limit
): void {
$changes = $sourceChanges->getRenames( $language );
foreach ( $changes as $key => $params ) {
// Since we're removing items from the array within the loop add
// a check here to ensure that the current key is still set.
if ( !isset( $changes[ $key ] ) ) {
continue;
}
if ( $group->getSourceLanguage() !== $language &&
$sourceChanges->isEqual( $language, $key ) ) {
// This is a translation rename, that does not have any changes.
// We can group this along with the source rename.
continue;
}
// Determine added key, and corresponding removed key.
$firstMsg = $params;
$secondKey = $sourceChanges->getMatchedKey( $language, $key ) ?? '';
$secondMsg = $sourceChanges->getMatchedMessage( $language, $key );
if ( $secondMsg === null ) {
throw new RuntimeException( "Could not find matched message for $key" );
}
if (
$sourceChanges->isPreviousState(
$language,
$key,
[ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
)
) {
$addedMsg = $firstMsg;
$deletedMsg = $secondMsg;
} else {
$addedMsg = $secondMsg;
$deletedMsg = $firstMsg;
}
$change = $this->formatRename(
$group,
$addedMsg,
$deletedMsg,
$language,
$sourceChanges->isEqual( $language, $key ),
$limit
);
$out->addHTML( $change );
// no need to process the second key again.
unset( $changes[$secondKey] );
if ( $limit <= 0 ) {
// We need to restrict the changes per page per form submission
// limitations as well as performance.
$out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
break;
}
}
}
private function formatRename(
MessageGroup $group,
array $addedMsg,
array $deletedMsg,
string $language,
bool $isEqual,
int &$limit
): string {
$addedKey = $addedMsg['key'];
$deletedKey = $deletedMsg['key'];
$actions = '';
$addedTitle = Title::makeTitleSafe( $group->getNamespace(), "$addedKey/$language" );
$deletedTitle = Title::makeTitleSafe( $group->getNamespace(), "$deletedKey/$language" );
$id = self::changeId( $group->getId(), $language, MessageSourceChange::RENAME, $addedKey );
$addedTitleLink = $this->getLinkRenderer()->makeLink( $addedTitle );
$deletedTitleLink = $this->getLinkRenderer()->makeLink( $deletedTitle );
$renameSelected = true;
if ( $group->getSourceLanguage() === $language ) {
if ( !$isEqual ) {
$renameSelected = false;
$label = $this->msg( 'translate-manage-action-rename-fuzzy' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "renamefuzzy", true );
}
$label = $this->msg( 'translate-manage-action-rename' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "rename", $renameSelected );
} else {
$label = $this->msg( 'translate-manage-action-import' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "import", true );
}
if ( $group->getSourceLanguage() !== $language ) {
// Allow user to ignore changes to non-source languages.
$label = $this->msg( 'translate-manage-action-ignore-change' )->text();
$actions .= $this->radioLabel( $label, "msg/$id", "ignore" );
}
$limit--;
$addedContent = ContentHandler::makeContent( (string)$addedMsg['content'], $addedTitle );
$deletedContent = ContentHandler::makeContent( (string)$deletedMsg['content'], $deletedTitle );
$this->diff->setContent( $deletedContent, $addedContent );
$menu = '';
if ( $group->getSourceLanguage() === $language && $this->hasRight ) {
// Only show rename and add as new option for source language.
$menu = Html::rawElement(
'button',
[
'class' => 'smg-rename-actions',
'type' => 'button',
'data-group-id' => $group->getId(),
'data-msgkey' => $addedKey,
'data-msgtitle' => $addedTitle->getFullText()
]
);
}
$actions = Html::rawElement( 'div', [ 'class' => 'smg-change-import-options' ], $actions );
$text = $this->diff->getDiff(
$deletedTitleLink,
$addedTitleLink . $menu . $actions,
$isEqual ? htmlspecialchars( $addedMsg['content'] ) : ''
);
$hidden = Html::hidden( $id, 1 );
$limit--;
$text .= $hidden;
return Html::rawElement(
'div',
[ 'class' => 'mw-translate-smg-change smg-change-rename' ],
$text
);
}
private function getRenameJobParams(
array $currentMsg,
MessageSourceChange $sourceChanges,
string $languageCode,
int $groupNamespace,
string $selectedVal,
bool $isSourceLang = true
): ?array {
if ( $selectedVal === 'ignore' ) {
return null;
}
$params = [];
$currentMsgKey = $currentMsg['key'];
$matchedMsg = $sourceChanges->getMatchedMessage( $languageCode, $currentMsgKey );
if ( $matchedMsg === null ) {
throw new RuntimeException( "Could not find matched message for $currentMsgKey." );
}
$matchedMsgKey = $matchedMsg['key'];
if (
$sourceChanges->isPreviousState(
$languageCode,
$currentMsgKey,
[ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
)
) {
$params['target'] = $matchedMsgKey;
$params['replacement'] = $currentMsgKey;
$replacementContent = $currentMsg['content'];
} else {
$params['target'] = $currentMsgKey;
$params['replacement'] = $matchedMsgKey;
$replacementContent = $matchedMsg['content'];
}
$params['fuzzy'] = $selectedVal === 'renamefuzzy';
$params['content'] = $replacementContent;
if ( $isSourceLang ) {
$params['targetTitle'] = Title::newFromText(
Utilities::title( $params['target'], $languageCode, $groupNamespace ),
$groupNamespace
);
$params['others'] = [];
}
return $params;
}
private function handleRenameSubmit(
MessageGroup $group,
MessageSourceChange $sourceChanges,
WebRequest $req,
string $language,
array &$postponed,
array &$jobData,
array &$modificationJobs
): void {
$groupId = $group->getId();
$renames = $sourceChanges->getRenames( $language );
$isSourceLang = $group->getSourceLanguage() === $language;
$groupNamespace = $group->getNamespace();
foreach ( $renames as $key => $params ) {
// Since we're removing items from the array within the loop add
// a check here to ensure that the current key is still set.
if ( !isset( $renames[$key] ) ) {
continue;
}
$id = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $key );
[ $renameMissing, $isCurrentKeyPresent ] = $this->isRenameMissing(
$req,
$sourceChanges,
$id,
$key,
$language,
$groupId,
$isSourceLang
);
if ( $renameMissing ) {
// we probably hit the limit with number of post parameters since neither
// addition nor deletion key is present.
$postponed[$groupId][$language][MessageSourceChange::RENAME][$key] = $params;
continue;
}
if ( !$isCurrentKeyPresent ) {
// still don't process this key, and wait for the matched rename
continue;
}
$selectedVal = $req->getVal( "msg/$id" );
$jobParams = $this->getRenameJobParams(
$params,
$sourceChanges,
$language,
$groupNamespace,
$selectedVal,
$isSourceLang
);
if ( $jobParams === null ) {
continue;
}
$targetStr = $jobParams[ 'target' ];
if ( $isSourceLang ) {
$jobData[ $targetStr ] = $jobParams;
// Send notification for fuzzy items
if ( isset( $jobParams[ 'targetTitle' ] ) && ( $jobParams[ 'fuzzy' ] ?? false ) ) {
$this->messageGroupSubscription->queueMessage(
$jobParams[ 'targetTitle' ],
MessageGroupSubscription::STATE_UPDATED,
[ $groupId ]
);
}
} elseif ( isset( $jobData[ $targetStr ] ) ) {
// We are grouping the source rename, and content changes in other languages
// for the message together into a single job in order to avoid race conditions
// since jobs are not guaranteed to be run in order.
$jobData[ $targetStr ][ 'others' ][ $language ] = $jobParams[ 'content' ];
} else {
// the source was probably ignored, we should add this as a modification instead,
// since the source is not going to be renamed.
$title = Title::newFromText(
Utilities::title( $targetStr, $language, $groupNamespace ),
$groupNamespace
);
$modificationJobs[] = UpdateMessageJob::newJob( $title, $jobParams['content'] );
}
// remove the matched key in order to avoid double processing.
$matchedKey = $sourceChanges->getMatchedKey( $language, $key );
unset( $renames[$matchedKey] );
}
}
private function handleModificationsSubmit(
MessageGroup $group,
MessageSourceChange $sourceChanges,
WebRequest $req,
string $language,
array &$postponed,
array &$messageUpdateJob
): void {
$groupId = $group->getId();
$subChanges = $sourceChanges->getModificationsForLanguage( $language );
$isSourceLanguage = $group->getSourceLanguage() === $language;
// Ignore renames
unset( $subChanges[ MessageSourceChange::RENAME ] );
// Handle additions, deletions, and changes.
foreach ( $subChanges as $type => $messages ) {
foreach ( $messages as $index => $params ) {
$key = $params['key'];
$id = self::changeId( $groupId, $language, $type, $key );
$title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
if ( !$this->isTitlePresent( $title, $type ) ) {
continue;
}
if ( !$req->getCheck( $id ) ) {
// We probably hit the limit with number of post parameters.
$postponed[$groupId][$language][$type][$index] = $params;
continue;
}
$selectedVal = $req->getVal( "msg/$id" );
if ( $type === MessageSourceChange::DELETION || $selectedVal === 'ignore' ) {
continue;
}
$fuzzy = $selectedVal === 'fuzzy';
$messageUpdateJob[] = UpdateMessageJob::newJob( $title, $params['content'], $fuzzy );
if ( $isSourceLanguage ) {
$this->sendNotificationsForChangedMessages( $groupId, $title, $type, $fuzzy );
}
}
}
}
/** @return UpdateMessageJob[][] */
private function createRenameJobs( array $jobParams ): array {
$jobs = [];
foreach ( $jobParams as $groupId => $groupJobParams ) {
$jobs[$groupId] ??= [];
foreach ( $groupJobParams as $params ) {
$jobs[$groupId][] = UpdateMessageJob::newRenameJob(
$params['targetTitle'],
$params['target'],
$params['replacement'],
$params['fuzzy'],
$params['content'],
$params['others']
);
}
}
return $jobs;
}
/** Checks if a title still exists and can be processed. */
private function isTitlePresent( Title $title, string $type ): bool {
// phpcs:ignore SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn
if (
( $type === MessageSourceChange::DELETION || $type === MessageSourceChange::CHANGE ) &&
!$title->exists()
) {
// This means that this change was probably introduced due to a rename
// which removed the key. No need to process.
return false;
}
return true;
}
/**
* Checks if a renamed message key is missing from the user request submission.
* Checks the current key and the matched key. This is needed because as the
* keys in the wiki are not submitted along with the request, only the incoming
* modified keys are submitted.
* @return bool[]
* $response = [
* 0 => (bool) True if rename is missing, false otherwise.
* 1 => (bool) Was the current $id found?
* ]
*/
private function isRenameMissing(
WebRequest $req,
MessageSourceChange $sourceChanges,
string $id,
string $key,
string $language,
string $groupId,
bool $isSourceLang
): array {
if ( $req->getCheck( $id ) ) {
return [ false, true ];
}
$isCurrentKeyPresent = false;
// Checked the matched key is also missing to confirm if its truly missing
$matchedKey = $sourceChanges->getMatchedKey( $language, $key );
$matchedId = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $matchedKey );
if ( $req->getCheck( $matchedId ) ) {
return [ false, $isCurrentKeyPresent ];
}
// For non source language, if strings are equal, they are not shown on the UI
// and hence not submitted.
return [
$isSourceLang || !$sourceChanges->isEqual( $language, $matchedKey ),
$isCurrentKeyPresent
];
}
private function getProcessingErrorMessage( array $errorGroups, int $totalGroupCount ): string {
// Number of error groups, are less than the total groups processed.
if ( count( $errorGroups ) < $totalGroupCount ) {
$errorMsg = $this->msg( 'translate-smg-submitted-with-failure' )
->numParams( count( $errorGroups ) )
->params(
$this->getLanguage()->commaList( $errorGroups ),
$this->msg( 'translate-smg-submitted-others-processing' )
)->parse();
} else {
$errorMsg = trim(
$this->msg( 'translate-smg-submitted-with-failure' )
->numParams( count( $errorGroups ) )
->params( $this->getLanguage()->commaList( $errorGroups ), '' )
->parse()
);
}
return $errorMsg;
}
/** @return array<int|string, MessageGroup> */
private function getGroupsFromCdb( Reader $reader ): array {
$groups = [];
$groupIds = Utilities::deserialize( $reader->get( '#keys' ) );
foreach ( $groupIds as $id ) {
$groups[$id] = MessageGroups::getGroup( $id );
}
return array_filter( $groups );
}
/**
* Add jobs to the queue, updates the interim cache, and start sync process for the group.
* @param UpdateMessageJob[][] $modificationJobs
* @param UpdateMessageJob[][] $renameJobs
*/
private function startSync( array $modificationJobs, array $renameJobs ): void {
// We are adding an empty array for groups that have no jobs. This is mainly done to
// avoid adding unnecessary checks. Remove those using array_filter
$modificationGroupIds = array_keys( array_filter( $modificationJobs ) );
$renameGroupIds = array_keys( array_filter( $renameJobs ) );
$uniqueGroupIds = array_unique( array_merge( $modificationGroupIds, $renameGroupIds ) );
$jobQueueInstance = $this->jobQueueGroup;
foreach ( $uniqueGroupIds as $groupId ) {
$messages = [];
$messageKeys = [];
$groupJobs = [];
$groupRenameJobs = $renameJobs[$groupId] ?? [];
/** @var UpdateMessageJob $job */
foreach ( $groupRenameJobs as $job ) {
$groupJobs[] = $job;
$messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
$messages[] = $messageUpdateParam;
// Build the handle to add the message key in interim cache
$replacement = $messageUpdateParam->getReplacementValue();
$targetTitle = Title::makeTitle( $job->getTitle()->getNamespace(), $replacement );
$messageKeys[] = ( new MessageHandle( $targetTitle ) )->getKey();
}
$groupModificationJobs = $modificationJobs[$groupId] ?? [];
/** @var UpdateMessageJob $job */
foreach ( $groupModificationJobs as $job ) {
$groupJobs[] = $job;
$messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
$messages[] = $messageUpdateParam;
$messageKeys[] = ( new MessageHandle( $job->getTitle() ) )->getKey();
}
// Store all message keys in the interim cache - we're particularly interested in new
// and renamed messages, but it's cleaner to just store everything.
$group = MessageGroups::getGroup( $groupId );
$this->messageIndex->storeInterim( $group, $messageKeys );
if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
$this->synchronizationCache->addMessages( $groupId, ...$messages );
$this->synchronizationCache->markGroupForSync( $groupId );
LoggerFactory::getInstance( 'Translate.GroupSynchronization' )->info(
'[' . __CLASS__ . '] Synchronization started for {groupId} by {user}',
[
'groupId' => $groupId,
'user' => $this->getUser()->getName()
]
);
}
// There is possibility for a race condition here: the translate_cache table / group sync
// cache is not yet populated with the messages to be processed, but the jobs start
// running and try to remove the message from the cache. This results in a "Key not found"
// error. Avoid this condition by using a DeferredUpdate.
DeferredUpdates::addCallableUpdate(
static function () use ( $jobQueueInstance, $groupJobs ) {
$jobQueueInstance->push( $groupJobs );
}
);
}
}
private function radioLabel(
string $label,
string $name,
string $value,
bool $checked = false
): string {
return Html::rawElement(
'label',
[],
Html::radio(
$name,
$checked,
[ 'value' => $value ]
) . "\u{00A0}" . $label
);
}
private function sendNotificationsForChangedMessages( string $groupId, Title $title, $type, bool $fuzzy ): void {
$subscriptionState = $type === MessageSourceChange::ADDITION ?
MessageGroupSubscription::STATE_ADDED :
MessageGroupSubscription::STATE_UPDATED;
if ( $subscriptionState === MessageGroupSubscription::STATE_UPDATED && !$fuzzy ) {
// If the state is updated, but the change has not been marked as fuzzy,
// lets not send a notification.
$subscriptionState = null;
}
if ( $subscriptionState ) {
$this->messageGroupSubscription->queueMessage( $title, $subscriptionState, [ $groupId ] );
}
}
}