diff options
Diffstat (limited to 'MLEB/Translate/src')
139 files changed, 6204 insertions, 744 deletions
diff --git a/MLEB/Translate/src/Cache/PersistentCache.php b/MLEB/Translate/src/Cache/PersistentCache.php index 723c19db..28d91c75 100644 --- a/MLEB/Translate/src/Cache/PersistentCache.php +++ b/MLEB/Translate/src/Cache/PersistentCache.php @@ -16,6 +16,8 @@ interface PersistentCache { public function hasExpiredEntry( string $keyname ): bool; + public function setExpiry( string $keyname, int $expiryTime ): void; + /** @return PersistentCacheEntry[] */ public function getByTag( string $tag ): array; @@ -27,5 +29,3 @@ interface PersistentCache { public function clear(): void; } - -class_alias( PersistentCache::class, '\MediaWiki\Extensions\Translate\PersistentCache' ); diff --git a/MLEB/Translate/src/Cache/PersistentCacheEntry.php b/MLEB/Translate/src/Cache/PersistentCacheEntry.php index 8cecf8f0..a84a1fdc 100644 --- a/MLEB/Translate/src/Cache/PersistentCacheEntry.php +++ b/MLEB/Translate/src/Cache/PersistentCacheEntry.php @@ -74,5 +74,3 @@ class PersistentCacheEntry { return false; } } - -class_alias( PersistentCacheEntry::class, '\MediaWiki\Extensions\Translate\PersistentCacheEntry' ); diff --git a/MLEB/Translate/src/Cache/PersistentDatabaseCache.php b/MLEB/Translate/src/Cache/PersistentDatabaseCache.php index 6585d89b..3c14d300 100644 --- a/MLEB/Translate/src/Cache/PersistentDatabaseCache.php +++ b/MLEB/Translate/src/Cache/PersistentDatabaseCache.php @@ -41,7 +41,7 @@ class PersistentDatabaseCache implements PersistentCache { } public function getWithLock( string $keyname ): ?PersistentCacheEntry { - $dbr = $this->loadBalancer->getConnectionRef( DB_MASTER ); + $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); $conds = [ 'tc_key' => $keyname ]; @@ -113,7 +113,7 @@ class PersistentDatabaseCache implements PersistentCache { } public function set( PersistentCacheEntry ...$entries ): void { - $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER ); + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); foreach ( $entries as $entry ) { $value = $this->jsonCodec->serialize( $entry->value() ); @@ -140,8 +140,18 @@ class PersistentDatabaseCache implements PersistentCache { } } + public function setExpiry( string $keyname, int $expiryTime ): void { + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); + $dbw->update( + self::TABLE_NAME, + [ 'tc_exptime' => $expiryTime ], + [ 'tc_key' => $keyname ], + __METHOD__ + ); + } + public function delete( string ...$keynames ): void { - $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER ); + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); $dbw->delete( self::TABLE_NAME, [ 'tc_key' => $keynames ], @@ -150,7 +160,7 @@ class PersistentDatabaseCache implements PersistentCache { } public function deleteEntriesWithTag( string $tag ): void { - $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER ); + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); $dbw->delete( self::TABLE_NAME, [ 'tc_tag' => $tag ], @@ -159,7 +169,7 @@ class PersistentDatabaseCache implements PersistentCache { } public function clear(): void { - $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER ); + $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY ); $dbw->delete( self::TABLE_NAME, '*', @@ -182,5 +192,3 @@ class PersistentDatabaseCache implements PersistentCache { return $entries; } } - -class_alias( PersistentDatabaseCache::class, '\MediaWiki\Extensions\Translate\PersistentDatabaseCache' ); diff --git a/MLEB/Translate/src/Diagnostics/DeleteEqualTranslationsMaintenanceScript.php b/MLEB/Translate/src/Diagnostics/DeleteEqualTranslationsMaintenanceScript.php index 771a86b6..b50f540f 100644 --- a/MLEB/Translate/src/Diagnostics/DeleteEqualTranslationsMaintenanceScript.php +++ b/MLEB/Translate/src/Diagnostics/DeleteEqualTranslationsMaintenanceScript.php @@ -62,7 +62,7 @@ class DeleteEqualTranslationsMaintenanceScript extends BaseMaintenanceScript { $equalMessageCount = count( $equalMessages ); if ( $equalMessageCount === 0 ) { $this->output( "No translations equal to definition found\n" ); - return true; + return; } $stats = $this->getUserStats( $equalMessages ); @@ -147,8 +147,3 @@ class DeleteEqualTranslationsMaintenanceScript extends BaseMaintenanceScript { } } } - -class_alias( - DeleteEqualTranslationsMaintenanceScript::class, - '\MediaWiki\Extensions\Translate\DeleteEqualTranslationsMaintenanceScript' -); diff --git a/MLEB/Translate/src/Jobs/GenericTranslateJob.php b/MLEB/Translate/src/Jobs/GenericTranslateJob.php index 91d1b294..e154cca9 100644 --- a/MLEB/Translate/src/Jobs/GenericTranslateJob.php +++ b/MLEB/Translate/src/Jobs/GenericTranslateJob.php @@ -71,5 +71,3 @@ abstract class GenericTranslateJob extends Job { $this->getLogger()->warning( $this->getLogPrefix() . $msg, $context ); } } - -class_alias( GenericTranslateJob::class, '\MediaWiki\Extensions\Translate\GenericTranslateJob' ); diff --git a/MLEB/Translate/src/MessageBundleTranslation/Hooks.php b/MLEB/Translate/src/MessageBundleTranslation/Hooks.php new file mode 100644 index 00000000..4e35534b --- /dev/null +++ b/MLEB/Translate/src/MessageBundleTranslation/Hooks.php @@ -0,0 +1,93 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\MessageBundleTranslation; + +use MediaWiki\Hook\EditFilterMergedContentHook; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Revision\SlotRecord; +use MediaWiki\Storage\Hook\PageSaveCompleteHook; +use Psr\Log\LoggerInterface; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class Hooks implements EditFilterMergedContentHook, PageSaveCompleteHook { + /** @var ?self */ + private static $instance; + /** @var LoggerInterface */ + private $logger; + + public function __construct( LoggerInterface $logger ) { + $this->logger = $logger; + } + + public static function getInstance(): self { + self::$instance = self::$instance ?? new self( LoggerFactory::getInstance( 'Translate.MessageBundle' ) ); + return self::$instance; + } + + // Typehints skipped on-purpose to maintain support for MW 1.35 + + /** @inheritDoc */ + public function onEditFilterMergedContent( + $context, + $content, + $status, + $summary, + $user, + $minoredit + ): void { + if ( !$content instanceof MessageBundleContent ) { + return; + } + /** @var MessageBundleContent $content */ + try { + $content->validate(); + } catch ( MalformedBundle $e ) { + // MalformedBundle implements MessageSpecifier, but for unknown reason it gets + // casted to a string if we don't convert it to a proper message. + $status->fatal( 'translate-messagebundle-validation-error', $context->msg( $e ) ); + } + } + + /** @inheritDoc */ + public function onPageSaveComplete( + $wikiPage, + $user, + $summary, + $flags, + $revisionRecord, + $editResult + ): void { + $method = __METHOD__; + $content = $revisionRecord->getContent( SlotRecord::MAIN ); + + if ( $content === null ) { + $this->logger->debug( "Unable to access content of page {pageName} in $method", [ + 'pageName' => $wikiPage->getTitle()->getPrefixedText() + ] ); + return; + } + + if ( !$content instanceof MessageBundleContent ) { + return; + } + + /** @var MessageBundleContent $content */ + try { + $content->validate(); + } catch ( MalformedBundle $e ) { + $this->logger->warning( "Page {pageName} is not a valid message bundle", [ + 'pageName' => $wikiPage->getTitle()->getPrefixedText(), + 'exception' => $e, + ] ); + return; + } + + // We have a valid content here + // TODO: Implement registration as a message group + } +} diff --git a/MLEB/Translate/src/MessageBundleTranslation/MalformedBundle.php b/MLEB/Translate/src/MessageBundleTranslation/MalformedBundle.php new file mode 100644 index 00000000..3e453c1c --- /dev/null +++ b/MLEB/Translate/src/MessageBundleTranslation/MalformedBundle.php @@ -0,0 +1,40 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\MessageBundleTranslation; + +use Exception; +use MessageSpecifier; +use Throwable; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class MalformedBundle extends Exception implements MessageSpecifier { + /** @var string */ + private $key; + /** @var array */ + private $params; + + public function __construct( + string $key, + array $params = [], + ?Throwable $previous = null + ) { + parent::__construct( $key, 0, $previous ); + $this->key = $key; + $this->params = $params; + } + + /** @inheritDoc */ + public function getKey() { + return $this->key; + } + + /** @inheritDoc */ + public function getParams() { + return $this->params; + } +} diff --git a/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContent.php b/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContent.php new file mode 100644 index 00000000..af843380 --- /dev/null +++ b/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContent.php @@ -0,0 +1,90 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\MessageBundleTranslation; + +use JsonContent; +use Status; +use User; +use WikiPage; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class MessageBundleContent extends JsonContent { + public const CONTENT_MODEL_ID = 'translate-messagebundle'; + + public function __construct( $text, $modelId = self::CONTENT_MODEL_ID ) { + parent::__construct( $text, $modelId ); + } + + public function isValid(): bool { + try { + return parent::isValid() && $this->validate(); + } catch ( MalformedBundle $e ) { + return false; + } + } + + /** @throws MalformedBundle */ + public function validate(): bool { + $data = json_decode( $this->getText(), true ); + + // Crude check that we have an associative array (or empty array) + if ( !is_array( $data ) || ( $data !== [] && array_values( $data ) === $data ) ) { + throw new MalformedBundle( + 'translate-messagebundle-error-invalid-array', + [ gettype( $data ) ] + ); + } + + foreach ( $data as $key => $value ) { + if ( $key === '' ) { + throw new MalformedBundle( 'translate-messagebundle-error-key-empty' ); + } + + if ( strlen( $key ) > 100 ) { + throw new MalformedBundle( + 'translate-messagebundle-error-key-too-long', + [ $key ] + ); + } + + if ( !preg_match( '/^[a-zA-Z0-9-_.]+$/', $key ) ) { + throw new MalformedBundle( + 'translate-messagebundle-error-key-invalid-characters', + [ $key ] + ); + } + + if ( !is_string( $value ) ) { + throw new MalformedBundle( + 'translate-messagebundle-error-invalid-value', + [ $key ] + ); + } + + if ( trim( $value ) === '' ) { + throw new MalformedBundle( + 'translate-messagebundle-error-empty-value', + [ $key ] + ); + } + } + + return true; + } + + public function prepareSave( WikiPage $page, $flags, $parentRevId, User $user ) { + // This will give an informative error message when trying to change the content model + try { + $this->validate(); + return Status::newGood(); + } catch ( MalformedBundle $e ) { + // XXX: We have no context source nor is there Message::messageParam :( + return Status::newFatal( 'translate-messagebundle-validation-error', wfMessage( $e ) ); + } + } +} diff --git a/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContentHandler.php b/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContentHandler.php new file mode 100644 index 00000000..166e15ec --- /dev/null +++ b/MLEB/Translate/src/MessageBundleTranslation/MessageBundleContentHandler.php @@ -0,0 +1,27 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\MessageBundleTranslation; + +use TextContentHandler; +use const CONTENT_FORMAT_JSON; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class MessageBundleContentHandler extends TextContentHandler { + public function __construct( $modelId = MessageBundleContent::CONTENT_MODEL_ID ) { + parent::__construct( $modelId, [ CONTENT_FORMAT_JSON ] ); + } + + protected function getContentClass() { + return MessageBundleContent::class; + } + + public function makeEmptyContent() { + $class = $this->getContentClass(); + return new $class( '{}' ); + } +} diff --git a/MLEB/Translate/src/MessageGroupProcessing/AggregateGroupsSpecialPage.php b/MLEB/Translate/src/MessageGroupProcessing/AggregateGroupsSpecialPage.php new file mode 100644 index 00000000..64bcd6f6 --- /dev/null +++ b/MLEB/Translate/src/MessageGroupProcessing/AggregateGroupsSpecialPage.php @@ -0,0 +1,281 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\MessageGroupProcessing; + +use AggregateMessageGroup; +use Html; +use MediaWiki\Cache\LinkBatchFactory; +use MessageGroup; +use MessageGroups; +use SpecialPage; +use TranslateMetadata; +use WikiPageMessageGroup; +use Xml; + +/** + * Contains logic for special page Special:AggregateGroups. + * + * @author Santhosh Thottingal + * @author Niklas Laxström + * @author Siebrand Mazeland + * @author Kunal Grover + * @license GPL-2.0-or-later + */ +class AggregateGroupsSpecialPage extends SpecialPage { + /** @var bool */ + private $hasPermission = false; + /** @var LinkBatchFactory */ + private $linkBatchFactory; + + public function __construct( LinkBatchFactory $linkBatchFactory ) { + parent::__construct( 'AggregateGroups', 'translate-manage' ); + $this->linkBatchFactory = $linkBatchFactory; + } + + protected function getGroupName(): string { + return 'translation'; + } + + public function execute( $parameters ) { + $this->setHeaders(); + $this->addHelpLink( 'Help:Extension:Translate/Page translation administration' ); + + $out = $this->getOutput(); + $out->addModuleStyles( 'ext.translate.specialpages.styles' ); + + // Check permissions + if ( $this->getUser()->isAllowed( 'translate-manage' ) ) { + $this->hasPermission = true; + } + + $groupsPreload = MessageGroups::getGroupsByType( AggregateMessageGroup::class ); + TranslateMetadata::preloadGroups( array_keys( $groupsPreload ), __METHOD__ ); + + $groups = MessageGroups::getAllGroups(); + uasort( $groups, [ MessageGroups::class, 'groupLabelSort' ] ); + $aggregates = []; + $pages = []; + foreach ( $groups as $group ) { + if ( $group instanceof WikiPageMessageGroup ) { + $pages[] = $group; + } elseif ( $group instanceof AggregateMessageGroup ) { + // Filter out AggregateGroups configured in YAML + $subgroups = TranslateMetadata::getSubgroups( $group->getId() ); + if ( $subgroups !== null ) { + $aggregates[] = $group; + } + } + } + + if ( !$pages ) { + // @todo Use different message + $out->addWikiMsg( 'tpt-list-nopages' ); + + return; + } + + $this->showAggregateGroups( $aggregates ); + } + + protected function showAggregateGroup( AggregateMessageGroup $group ): string { + $out = ''; + $id = $group->getId(); + $label = $group->getLabel(); + $desc = $group->getDescription( $this->getContext() ); + + $out .= Html::openElement( + 'div', + [ 'class' => 'mw-tpa-group', 'data-groupid' => $id, 'data-id' => $this->htmlIdForGroup( $group ) ] + ); + + $edit = ''; + $remove = ''; + $editGroup = ''; + $select = ''; + $addButton = ''; + + // Add divs for editing Aggregate Groups + if ( $this->hasPermission ) { + // Group edit and remove buttons + $edit = Html::element( 'span', [ 'class' => 'tp-aggregate-edit-ag-button' ] ); + $remove = Html::element( 'span', [ 'class' => 'tp-aggregate-remove-ag-button' ] ); + + // Edit group div + $editGroupNameLabel = $this->msg( 'tpt-aggregategroup-edit-name' )->escaped(); + $editGroupName = Html::input( + 'tp-agg-name', + $label, + 'text', + [ 'class' => 'tp-aggregategroup-edit-name', 'maxlength' => '200' ] + ); + $editGroupDescriptionLabel = $this->msg( 'tpt-aggregategroup-edit-description' )->escaped(); + $editGroupDescription = Html::input( + 'tp-agg-desc', + $desc, + 'text', + [ 'class' => 'tp-aggregategroup-edit-description' ] + ); + $saveButton = Xml::submitButton( + $this->msg( 'tpt-aggregategroup-update' )->text(), + [ 'class' => 'tp-aggregategroup-update' ] + ); + $cancelButton = Xml::submitButton( + $this->msg( 'tpt-aggregategroup-update-cancel' )->text(), + [ 'class' => 'tp-aggregategroup-update-cancel' ] + ); + $editGroup = Html::rawElement( + 'div', + [ 'class' => 'tp-edit-group hidden' ], + $editGroupNameLabel . + $editGroupName . + '<br />' . + $editGroupDescriptionLabel . + $editGroupDescription . + $saveButton . + $cancelButton + ); + + // Subgroups selector + $select = Html::input( 'tp-subgroups-input', '', 'text', [ 'class' => 'tp-group-input' ] ); + $addButton = Html::element( 'input', + [ + 'type' => 'button', + 'value' => $this->msg( 'tpt-aggregategroup-add' )->text(), + 'class' => 'tp-aggregate-add-button' + ] + ); + } + + // Aggregate Group info div + $groupName = Html::rawElement( + 'h2', + [ 'class' => 'tp-name' ], + htmlspecialchars( $label ) . $edit . $remove + ); + $groupDesc = Html::element( + 'p', + [ 'class' => 'tp-desc' ], + $desc + ); + $groupInfo = Html::rawElement( + 'div', + [ 'class' => 'tp-display-group' ], + $groupName . $groupDesc + ); + + $out .= $groupInfo; + $out .= $editGroup; + $out .= $this->listSubgroups( $group ); + $out .= $select . $addButton; + $out .= '</div>'; + + return $out; + } + + /** @param AggregateMessageGroup[] $aggregates */ + private function showAggregateGroups( array $aggregates ): void { + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.aggregategroups' ); + + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + + $out->addHTML( $nojs ); + + /** @var AggregateMessageGroup $group */ + foreach ( $aggregates as $group ) { + $out->addHTML( $this->showAggregateGroup( $group ) ); + } + + // Add new group if user has permissions + if ( $this->hasPermission ) { + $out->addHTML( + "<br/><a class='tpt-add-new-group' href='#'>" . + $this->msg( 'tpt-aggregategroup-add-new' )->escaped() . + '</a>' + ); + $newGroupNameLabel = $this->msg( 'tpt-aggregategroup-new-name' )->escaped(); + $newGroupName = Html::element( 'input', [ 'class' => 'tp-aggregategroup-add-name', 'maxlength' => '200' ] ); + $newGroupDescriptionLabel = $this->msg( 'tpt-aggregategroup-new-description' )->escaped(); + $newGroupDescription = Html::element( 'input', [ 'class' => 'tp-aggregategroup-add-description' ] ); + $saveButton = Html::element( + 'input', + [ + 'type' => 'button', + 'value' => $this->msg( 'tpt-aggregategroup-save' )->text(), + 'id' => 'tpt-aggregategroups-save', + 'class' => 'tp-aggregate-save-button' + ] + ); + $newGroupDiv = Html::rawElement( + 'div', + [ 'class' => 'tpt-add-new-group hidden' ], + "$newGroupNameLabel $newGroupName<br />" . + "$newGroupDescriptionLabel $newGroupDescription<br />$saveButton" + ); + $out->addHTML( $newGroupDiv ); + } + } + + private function listSubgroups( AggregateMessageGroup $parent ): string { + $id = $this->htmlIdForGroup( $parent, 'mw-tpa-grouplist-' ); + $out = Html::openElement( 'ol', [ 'id' => $id ] ); + + // Not calling $parent->getGroups() because it has done filtering already + $subgroupIds = TranslateMetadata::getSubgroups( $parent->getId() ) ?? []; + + // Get the respective groups and sort them + $subgroups = MessageGroups::getGroupsById( $subgroupIds ); + '@phan-var WikiPageMessageGroup[] $subgroups'; + uasort( $subgroups, [ MessageGroups::class, 'groupLabelSort' ] ); + + // Avoid potentially thousands of separate database queries + $lb = $this->linkBatchFactory->newLinkBatch(); + foreach ( $subgroups as $group ) { + $lb->addObj( $group->getTitle() ); + } + $lb->setCaller( __METHOD__ ); + $lb->execute(); + + // Add missing invalid group ids back, not returned by getGroupsById + foreach ( $subgroupIds as $id ) { + if ( !isset( $subgroups[$id] ) ) { + $subgroups[$id] = null; + } + } + + foreach ( $subgroups as $id => $group ) { + $remove = ''; + if ( $this->hasPermission ) { + $remove = Html::element( + 'span', + [ 'class' => 'tp-aggregate-remove-button', 'data-groupid' => $id ] + ); + } + + if ( $group ) { + $text = $this->getLinkRenderer()->makeKnownLink( $group->getTitle() ); + $note = htmlspecialchars( MessageGroups::getPriority( $id ) ); + } else { + $text = htmlspecialchars( $id ); + $note = $this->msg( 'tpt-aggregategroup-invalid-group' )->escaped(); + } + + $out .= Html::rawElement( 'li', [], "$text$remove $note" ); + } + $out .= Html::closeElement( 'ol' ); + + return $out; + } + + private function htmlIdForGroup( MessageGroup $group, string $prefix = '' ): string { + $id = sha1( $group->getId() ); + $id = substr( $id, 5, 8 ); + + return $prefix . $id; + } +} diff --git a/MLEB/Translate/src/MessageSync/MessageSourceChange.php b/MLEB/Translate/src/MessageSync/MessageSourceChange.php index c5b69784..ee16f4a4 100644 --- a/MLEB/Translate/src/MessageSync/MessageSourceChange.php +++ b/MLEB/Translate/src/MessageSync/MessageSourceChange.php @@ -373,7 +373,7 @@ class MessageSourceChange { } else { $this->changes[$language][$type] = array_filter( $this->changes[$language][$type], - function ( $change ) use ( $keysToRemove ) { + static function ( $change ) use ( $keysToRemove ) { return !in_array( $change['key'], $keysToRemove, true ); } ); @@ -551,5 +551,3 @@ class MessageSourceChange { return $similarity === 1; } } - -class_alias( MessageSourceChange::class, '\MediaWiki\Extensions\Translate\MessageSourceChange' ); diff --git a/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php b/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php new file mode 100644 index 00000000..425cef7b --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php @@ -0,0 +1,27 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Exception; +use SplObjectStorage; + +/** + * Exception thrown when a translatable page move is not possible + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class ImpossiblePageMove extends Exception { + /** @var SplObjectStorage */ + private $blockers; + + public function __construct( SplObjectStorage $blockers ) { + parent::__construct(); + $this->blockers = $blockers; + } + + public function getBlockers(): SplObjectStorage { + return $this->blockers; + } +} diff --git a/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php b/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php new file mode 100644 index 00000000..4fbb01ef --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php @@ -0,0 +1,15 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Exception; + +/** + * Exception thrown when a rename for a title fails + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class InvalidPageTitleRename extends Exception { +} diff --git a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php index 1314e0e4..29f4d531 100644 --- a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php +++ b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php @@ -13,8 +13,6 @@ use SplObjectStorage; use Status; use Title; use TitleParser; -use TranslatablePage; -use TranslateUtils; class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { /** @var TranslatablePageMover */ @@ -52,8 +50,13 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { ); $this->addOption( - 'move-subpages', - 'Move subpages under the current page' + 'skip-subpages', + 'Skip moving subpages under the current page' + ); + + $this->addOption( + 'skip-talkpages', + 'Skip moving talk pages under pages being moved' ); $this->requireExtension( 'Translate' ); @@ -70,29 +73,28 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { $newPagename = $this->getArg( 1 ); $username = $this->getArg( 2 ); $reason = $this->getOption( 'reason', '' ); - $moveSubpages = $this->hasOption( 'move-subpages' ); + $moveSubpages = !$this->hasOption( 'skip-subpages' ); + $moveTalkpages = !$this->hasOption( 'skip-talkpages' ); - if ( is_callable( [ $mwService, 'getUserFactory' ] ) ) { - // MW 1.35+ - $userFactory = $mwService->getUserFactory(); - $user = $userFactory->newFromName( $username ); - } else { - $user = \User::newFromName( $username ); - // Set to null if $user is false - $user = $user ?: null; - } + $userFactory = $mwService->getUserFactory(); + $user = $userFactory->newFromName( $username ); if ( $user === null || !$user->isRegistered() ) { $this->fatalError( "User $username does not exist." ); } $outputMsg = "Check if '$currentPagename' can be moved to '$newPagename'"; - $subpageMsg = '(excluding subpages)'; + $subpageMsg = 'excluding subpages'; if ( $moveSubpages ) { - $subpageMsg = '(including subpages)'; + $subpageMsg = 'including subpages'; } - $this->output( "$outputMsg $subpageMsg\n" ); + $talkpageMsg = 'excluding talkpages'; + if ( $moveTalkpages ) { + $talkpageMsg = 'including talkpages'; + } + + $this->output( "$outputMsg ($subpageMsg; $talkpageMsg)\n" ); try { $currentTitle = $this->getTitleFromInput( $currentPagename ?? '' ); @@ -105,21 +107,21 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { // When moving translatable pages from script, remove all limits on the number of // pages that can be moved $this->pageMover->disablePageMoveLimit(); - $blockers = $this->pageMover->checkMoveBlockers( - $currentTitle, - $newTitle, - $user, - $reason, - $moveSubpages - ); - - if ( count( $blockers ) ) { - $fatalErrorMsg = $this->parseErrorMessage( $blockers ); + try { + $pageCollection = $this->pageMover->getPageMoveCollection( + $currentTitle, + $newTitle, + $user, + $reason, + $moveSubpages, + $moveTalkpages + ); + } catch ( ImpossiblePageMove $e ) { + $fatalErrorMsg = $this->parseErrorMessage( $e->getBlockers() ); $this->fatalError( $fatalErrorMsg ); } - $groupedPagesToMove = $this->getGroupedPagesToMove( $currentTitle ); - $this->displayPagesToMove( $currentTitle, $newTitle, $groupedPagesToMove ); + $this->displayPagesToMove( $pageCollection ); $haveConfirmation = $this->getConfirmation(); if ( !$haveConfirmation ) { @@ -129,7 +131,7 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { $this->output( "Starting page move\n" ); - $pagesToMove = $this->pageMover->getPagesToMove( $currentTitle, $newTitle, $moveSubpages ); + $pagesToMove = $pageCollection->getListOfPages(); $this->pageMover->moveSynchronously( $currentTitle, @@ -170,77 +172,95 @@ class MoveTranslatablePageMaintenanceScript extends BaseMaintenanceScript { } } - /** @return Title[][] */ - private function getGroupedPagesToMove( Title $source ): array { - $page = TranslatablePage::newFromTitle( $source ); + private function displayPagesToMove( PageMoveCollection $pageCollection ): void { + $infoMessage = "\nThe following pages will be moved:\n"; + $count = 0; + $subpagesCount = 0; + $talkpagesCount = 0; - $types = [ - 'pt-movepage-list-pages' => [ $source ], - 'pt-movepage-list-translation' => $page->getTranslationPages(), - 'pt-movepage-list-section' => $page->getTranslationUnitPages( 'all' ), - 'pt-movepage-list-translatable' => $this->pageMover->getTranslatableSubpages( $page ) + /** @var PageMoveOperation[][] */ + $pagesToMove = [ + 'pt-movepage-list-pages' => [ $pageCollection->getTranslatablePage() ], + 'pt-movepage-list-translation' => $pageCollection->getTranslationPagesPair(), + 'pt-movepage-list-section' => $pageCollection->getUnitPagesPair() ]; - if ( TranslateUtils::allowsSubpages( $source ) ) { - $types[ 'pt-movepage-list-other'] = $this->pageMover->getNormalSubpages( $page ); + $subpages = $pageCollection->getSubpagesPair(); + if ( $subpages ) { + $pagesToMove[ 'pt-movepage-list-other'] = $subpages; } - return $types; - } - - private function displayPagesToMove( Title $currentTitle, Title $newTitle, array $pagesToMove ): void { - $infoMessage = "\nThe following pages will be moved:\n"; - $count = 0; - $subpagesCount = 0; - $base = $currentTitle->getPrefixedText(); - foreach ( $pagesToMove as $type => $pages ) { - $infoMessage .= $this->getSeparator(); - $pageCount = count( $pages ); - $infoMessage .= $this->message( $type )->numParams( $pageCount )->text() . "\n\n"; - if ( !$pageCount ) { - $infoMessage .= $this->message( 'pt-movepage-list-no-pages' )->text() . "\n"; + $lines = []; + $infoMessage .= $this->getSectionHeader( $type, $pages ); + if ( !count( $pages ) ) { continue; } - if ( $type === 'pt-movepage-list-translatable' ) { - $infoMessage .= $this->message( 'pt-movepage-list-translatable-note' )->text() . "\n"; - } - - $canBeMoved = $type !== 'pt-movepage-list-translatable'; - $lines = []; - foreach ( $pages as $currentPage ) { - if ( $canBeMoved ) { - $count++; - } + foreach ( $pages as $pagePairs ) { + $count++; if ( $type === 'pt-movepage-list-other' ) { $subpagesCount++; } - if ( $canBeMoved ) { - $to = $this->pageMover->newPageTitle( $base, $currentPage, $newTitle ); - $lines[] = '* ' . $currentPage->getPrefixedText() . ' → ' . $to; - } else { - $lines[] = '* ' . $currentPage->getPrefixedText(); + $old = $pagePairs->getOldTitle(); + $new = $pagePairs->getNewTitle(); + + if ( $new ) { + $line = '* ' . $old->getPrefixedText() . ' → ' . $new->getPrefixedText(); + if ( $pagePairs->hasTalkpage() ) { + $count++; + $talkpagesCount++; + $line .= ' ' . $this->message( 'pt-movepage-talkpage-exists' )->text(); + } + + $lines[] = $line; } } $infoMessage .= implode( "\n", $lines ) . "\n"; } + $translatableSubpages = $pageCollection->getTranslatableSubpages(); + $infoMessage .= $this->getSectionHeader( 'pt-movepage-list-translatable', $translatableSubpages ); + + if ( $translatableSubpages ) { + $lines = []; + $infoMessage .= $this->message( 'pt-movepage-list-translatable-note' )->text() . "\n"; + foreach ( $translatableSubpages as $page ) { + $lines[] = '* ' . $page->getPrefixedText(); + } + + $infoMessage .= implode( "\n", $lines ) . "\n"; + } + $this->output( $infoMessage ); $this->logSeparator(); $this->output( $this->message( 'pt-movepage-list-count' ) - ->numParams( $count, $subpagesCount ) + ->numParams( $count, $subpagesCount, $talkpagesCount ) ->text() . "\n" ); $this->logSeparator(); $this->output( "\n" ); } + private function getSectionHeader( string $type, array $pages ): string { + $infoMessage = $this->getSeparator(); + $pageCount = count( $pages ); + + // $type can be: pt-movepage-list-pages, pt-movepage-list-translation, pt-movepage-list-section + // pt-movepage-list-other + $infoMessage .= $this->message( $type )->numParams( $pageCount )->text() . "\n\n"; + if ( !$pageCount ) { + $infoMessage .= $this->message( 'pt-movepage-list-no-pages' )->text() . "\n"; + } + + return $infoMessage; + } + private function getConfirmation(): bool { $line = self::readconsole( 'Type "MOVE" to begin the move operation: ' ); return strtolower( $line ) === 'move'; diff --git a/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php new file mode 100644 index 00000000..1197aa31 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php @@ -0,0 +1,402 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use CommentStore; +use ErrorPageError; +use Html; +use HTMLForm; +use MediaWiki\Permissions\PermissionManager; +use OutputPage; +use PermissionsError; +use ReadOnlyError; +use SplObjectStorage; +use ThrottledError; +use Title; +use TranslatablePage; +use UnlistedSpecialPage; +use Wikimedia\ObjectFactory; + +/** + * Replacement for Special:Movepage to allow renaming a translatable page and + * all pages associated with it. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @ingroup SpecialPage PageTranslation + */ +class MoveTranslatablePageSpecialPage extends UnlistedSpecialPage { + // Form parameters both as text and as titles + /** @var string */ + private $oldText; + /** @var string */ + private $reason; + /** @var bool */ + private $moveTalkpages = true; + /** @var bool */ + private $moveSubpages = true; + // Dependencies + /** @var ObjectFactory */ + private $objectFactory; + /** @var TranslatablePageMover */ + private $pageMover; + /** @var PermissionManager */ + private $permissionManager; + private $movePageSpec; + // Other + /** @var Title */ + private $oldTitle; + + public function __construct( + ObjectFactory $objectFactory, + PermissionManager $permissionManager, + TranslatablePageMover $pageMover, + $movePageSpec + ) { + parent::__construct( 'Movepage' ); + $this->objectFactory = $objectFactory; + $this->permissionManager = $permissionManager; + $this->pageMover = $pageMover; + // SpecialMovepage started using service injection in + // I6d4fe09891a126d803fee90bc3bb4959e8b29eb9 + // Needed for MW < 1.36 + if ( is_string( $movePageSpec ) ) { + $this->movePageSpec = [ 'class' => $movePageSpec ]; + } else { + $this->movePageSpec = $movePageSpec; + } + } + + public function doesWrites(): bool { + return true; + } + + protected function getGroupName(): string { + return 'pagetools'; + } + + /** @inheritDoc */ + public function execute( $par ) { + $request = $this->getRequest(); + $user = $this->getUser(); + $this->addHelpLink( 'Help:Extension:Translate/Move_translatable_page' ); + + $this->oldText = $request->getText( 'wpOldTitle', $request->getText( 'target', $par ) ); + $newText = $request->getText( 'wpNewTitle' ); + + $this->oldTitle = Title::newFromText( $this->oldText ); + $newTitle = Title::newFromText( $newText ); + // Normalize input + if ( $this->oldTitle ) { + $this->oldText = $this->oldTitle->getPrefixedText(); + } + + $this->reason = $request->getText( 'reason' ); + + // This will throw exceptions if there is an error. + $this->doBasicChecks(); + + // Real stuff starts here + $page = TranslatablePage::newFromTitle( $this->oldTitle ); + if ( $page->getMarkedTag() !== false ) { + $this->getOutput()->setPageTitle( $this->msg( 'pt-movepage-title', $this->oldText ) ); + + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + // Is there really no better way to do this? + $subactionText = $request->getText( 'subaction' ); + switch ( $subactionText ) { + case $this->msg( 'pt-movepage-action-check' )->text(): + $subaction = 'check'; + break; + case $this->msg( 'pt-movepage-action-perform' )->text(): + $subaction = 'perform'; + break; + case $this->msg( 'pt-movepage-action-other' )->text(): + $subaction = ''; + break; + default: + $subaction = ''; + } + + if ( $subaction === 'check' && $this->checkToken() && $request->wasPosted() ) { + try { + $pageCollection = $this->pageMover->getPageMoveCollection( + $this->oldTitle, + $newTitle, + $user, + $this->reason, + $this->moveSubpages, + $this->moveTalkpages + ); + } catch ( ImpossiblePageMove $e ) { + $this->showErrors( $e->getBlockers() ); + $this->showForm( [] ); + return; + } + + $this->showConfirmation( $pageCollection ); + } elseif ( $subaction === 'perform' && $this->checkToken() && $request->wasPosted() ) { + $this->moveSubpages = $request->getBool( 'subpages' ); + $this->moveTalkpages = $request->getBool( 'talkpages' ); + + $this->pageMover->moveAsynchronously( + $this->oldTitle, + $newTitle, + $this->moveSubpages, + $this->getUser(), + $this->msg( 'pt-movepage-logreason', $this->oldTitle )->inContentLanguage()->text(), + $this->moveTalkpages + ); + $this->getOutput()->addWikiMsg( 'pt-movepage-started' ); + } else { + $this->showForm( [] ); + } + } else { + // Delegate... don't want to reimplement this + $sp = $this->objectFactory->createObject( $this->movePageSpec ); + $sp->execute( $par ); + } + } + + /** + * Do the basic checks whether moving is possible and whether + * the input looks anywhere near sane. + * @throws PermissionsError|ErrorPageError|ReadOnlyError|ThrottledError + */ + protected function doBasicChecks(): void { + $this->checkReadOnly(); + + if ( $this->oldTitle === null ) { + throw new ErrorPageError( 'notargettitle', 'notargettext' ); + } + + if ( !$this->oldTitle->exists() ) { + throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); + } + + if ( $this->getUser()->pingLimiter( 'move' ) ) { + throw new ThrottledError; + } + + // Check rights + $permErrors = $this->permissionManager + ->getPermissionErrors( 'move', $this->getUser(), $this->oldTitle ); + if ( count( $permErrors ) ) { + throw new PermissionsError( 'move', $permErrors ); + } + } + + /** + * Checks token to protect against CSRF. + * + * FIXME: make this a form special page instead of manually checking stuff? + * @return bool + */ + protected function checkToken(): bool { + return $this->getUser()->matchEditToken( $this->getRequest()->getVal( 'wpEditToken' ) ); + } + + /** + * Pretty-print the list of errors. + * @param SplObjectStorage $errors Array with message key and parameters + */ + protected function showErrors( SplObjectStorage $errors ): void { + $out = $this->getOutput(); + + $out->addHTML( Html::openElement( 'div', [ 'class' => 'errorbox' ] ) ); + $out->addWikiMsg( + 'pt-movepage-blockers', + $this->getLanguage()->formatNum( count( $errors ) ) + ); + + // If there are many errors, for performance reasons we must parse them all at once + $s = ''; + $context = 'pt-movepage-error-placeholder'; + foreach ( $errors as $title ) { + $titleText = $title->getPrefixedText(); + $s .= "'''$titleText'''\n\n"; + $s .= $errors[ $title ]->getWikiText( false, $context ); + } + + $out->addWikiTextAsInterface( $s ); + $out->addHTML( Html::closeElement( 'div' ) ); + } + + /** + * The query form. + * @param array $err Unused. + * @param bool $isPermError Unused. + */ + public function showForm( $err, $isPermError = false ): void { + $this->getOutput()->addWikiMsg( 'pt-movepage-intro' ); + + HTMLForm::factory( 'ooui', $this->getCommonFormFields(), $this->getContext() ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-movepage-action-check' ) + ->setWrapperLegendMsg( 'pt-movepage-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * The second form, which still allows changing some things. + * Lists all the action which would take place. + * @param PageMoveCollection $pageCollection + */ + protected function showConfirmation( PageMoveCollection $pageCollection ): void { + $out = $this->getOutput(); + + $out->addWikiMsg( 'pt-movepage-intro' ); + + $count = 0; + $subpagesCount = 0; + $talkpagesCount = 0; + + /** @var PageMoveOperation[][] */ + $pagesToMove = [ + 'pt-movepage-list-pages' => [ $pageCollection->getTranslatablePage() ], + 'pt-movepage-list-translation' => $pageCollection->getTranslationPagesPair(), + 'pt-movepage-list-section' => $pageCollection->getUnitPagesPair() + ]; + + $subpages = $pageCollection->getSubpagesPair(); + if ( $subpages ) { + $pagesToMove[ 'pt-movepage-list-other'] = $subpages; + } + + foreach ( $pagesToMove as $type => $pages ) { + $this->addSectionHeader( $out, $type, $pages ); + + if ( !$pages ) { + $out->addWikiMsg( 'pt-movepage-list-no-pages' ); + continue; + } + + $lines = []; + + foreach ( $pages as $pagePairs ) { + $count++; + + if ( $type === 'pt-movepage-list-other' ) { + $subpagesCount++; + } + + $old = $pagePairs->getOldTitle(); + $new = $pagePairs->getNewTitle(); + $line = '* ' . $old->getPrefixedText() . ' → ' . $new->getPrefixedText(); + if ( $pagePairs->hasTalkpage() ) { + $count++; + $talkpagesCount++; + $line .= ' ' . $this->msg( 'pt-movepage-talkpage-exists' )->text(); + } + + $lines[] = $line; + } + + $out->addWikiTextAsInterface( implode( "\n", $lines ) ); + } + + $translatableSubpages = $pageCollection->getTranslatableSubpages(); + $sectionType = 'pt-movepage-list-translatable'; + $this->addSectionHeader( $out, $sectionType, $translatableSubpages ); + if ( $translatableSubpages ) { + $lines = []; + $out->wrapWikiMsg( "'''$1'''", $this->msg( 'pt-movepage-list-translatable-note' ) ); + foreach ( $translatableSubpages as $page ) { + $lines[] = '* ' . $page->getPrefixedText(); + } + $out->addWikiTextAsInterface( implode( "\n", $lines ) ); + } + + $out->addWikiTextAsInterface( "----\n" ); + $out->addWikiMsg( + 'pt-movepage-list-count', + $this->getLanguage()->formatNum( $count ), + $this->getLanguage()->formatNum( $subpagesCount ), + $this->getLanguage()->formatNum( $talkpagesCount ) + ); + + $formDescriptor = array_merge( + $this->getCommonFormFields(), + [ + 'subpages' => [ + 'type' => 'check', + 'name' => 'subpages', + 'id' => 'mw-subpages', + 'label-message' => 'pt-movepage-subpages', + 'default' => $this->moveSubpages, + ], + 'talkpages' => [ + 'type' => 'check', + 'name' => 'talkpages', + 'id' => 'mw-talkpages', + 'label-message' => 'pt-movepage-talkpages', + 'default' => $this->moveTalkpages + ] + ] + ); + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->addButton( [ + 'name' => 'subaction', + 'value' => $this->msg( 'pt-movepage-action-other' )->text(), + ] ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-movepage-action-perform' ) + ->setWrapperLegendMsg( 'pt-movepage-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + private function addSectionHeader( OutputPage $out, string $type, array $pages ): void { + $pageCount = count( $pages ); + $out->wrapWikiMsg( '=== $1 ===', [ $type, $pageCount ] ); + + if ( !$pageCount ) { + $out->addWikiMsg( 'pt-movepage-list-no-pages' ); + } + } + + private function getCommonFormFields(): array { + return [ + 'wpOldTitle' => [ + 'type' => 'text', + 'name' => 'wpOldTitle', + 'label-message' => 'pt-movepage-current', + 'default' => $this->oldText, + 'readonly' => true, + ], + 'wpNewTitle' => [ + 'type' => 'text', + 'name' => 'wpNewTitle', + 'label-message' => 'pt-movepage-new', + ], + 'reason' => [ + 'type' => 'text', + 'name' => 'reason', + 'label-message' => 'pt-movepage-reason', + 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT, + 'default' => $this->reason, + ], + 'subpages' => [ + 'type' => 'hidden', + 'name' => 'subpages', + 'default' => $this->moveSubpages, + ], + 'talkpages' => [ + 'type' => 'hidden', + 'name' => 'talkpages', + 'default' => $this->moveTalkpages + ] + ]; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageMoveCollection.php b/MLEB/Translate/src/PageTranslation/PageMoveCollection.php new file mode 100644 index 00000000..338b914c --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageMoveCollection.php @@ -0,0 +1,144 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Collection of pages potentially affected by a page move operation. + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageMoveCollection { + /** @var PageMoveOperation|null */ + private $translatablePage; + /** @var PageMoveOperation[] */ + private $translationPagePairs; + /** @var PageMoveOperation[] */ + private $unitPagesPairs; + /** @var PageMoveOperation[] */ + private $subpagesPairs; + /** @var PageMoveOperation[] */ + private $talkpagesPairs; + /** @var Title[] */ + private $translatableSubpages; + + /** + * @param PageMoveOperation $translatablePage Translatable page + * @param PageMoveOperation[] $translationPagePairs Translation pages + * @param PageMoveOperation[] $unitPagesPairs Translation unit pages + * @param PageMoveOperation[] $subpagesPairs Non translatable sub pages + * @param array $translatableSubpages Translatable sub pages + */ + public function __construct( + PageMoveOperation $translatablePage, + array $translationPagePairs, + array $unitPagesPairs, + array $subpagesPairs, + array $translatableSubpages + ) { + $this->translatablePage = $translatablePage; + $this->translationPagePairs = $translationPagePairs; + $this->unitPagesPairs = $unitPagesPairs; + $this->subpagesPairs = $subpagesPairs; + $this->translatableSubpages = $translatableSubpages; + + // Populate the talk pages from the various inputs. + $this->talkpagesPairs = $this->getTalkpages( + $this->translatablePage, ...$translationPagePairs, ...$unitPagesPairs, ...$subpagesPairs + ); + } + + public function getTranslatablePage(): PageMoveOperation { + return $this->translatablePage; + } + + /** @return PageMoveOperation[] */ + public function getTranslationPagesPair(): array { + return $this->translationPagePairs; + } + + /** @return PageMoveOperation[] */ + public function getUnitPagesPair(): array { + return $this->unitPagesPairs; + } + + /** @return PageMoveOperation[] */ + public function getSubpagesPair(): array { + return $this->subpagesPairs; + } + + /** @return Title[] */ + public function getTranslatableSubpages(): array { + return $this->translatableSubpages; + } + + /** @return Title[] */ + public function getTranslationPages(): array { + return $this->getOldPagesFromList( $this->translationPagePairs ); + } + + /** @return Title[] */ + public function getUnitPages(): array { + return $this->getOldPagesFromList( $this->unitPagesPairs ); + } + + /** @return Title[] */ + public function getSubpages(): array { + return $this->getOldPagesFromList( $this->subpagesPairs ); + } + + /** @return string[] */ + public function getListOfPages(): array { + $pageList = [ + $this->translatablePage->getOldTitle()->getPrefixedText() => + $this->translatablePage->getNewTitle() ? + $this->translatablePage->getNewTitle()->getPrefixedText() : null + ]; + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->translationPagePairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->unitPagesPairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->subpagesPairs ) ); + $pageList = array_merge( $pageList, $this->getPagePairFromList( $this->talkpagesPairs ) ); + + return $pageList; + } + + /** + * @param PageMoveOperation[] $pagePairs + * @return Title[] + */ + private function getOldPagesFromList( array $pagePairs ): array { + $oldTitles = []; + foreach ( $pagePairs as $pair ) { + $oldTitles[] = $pair->getOldTitle(); + } + + return $oldTitles; + } + + /** @return string[] */ + private function getPagePairFromList( array $pagePairs ): array { + $pairs = []; + foreach ( $pagePairs as $pair ) { + $pairs[ $pair->getOldTitle()->getPrefixedText() ] = + $pair->getNewTitle() ? $pair->getNewTitle()->getPrefixedText() : null; + } + + return $pairs; + } + + /** @return PageMoveOperation[] */ + private function getTalkpages( PageMoveOperation ...$allMoveOperations ): array { + $talkpagesPairs = []; + foreach ( $allMoveOperations as $moveOperation ) { + if ( $moveOperation->hasTalkpage() ) { + $talkpagesPairs[] = new PageMoveOperation( + $moveOperation->getOldTalkpage(), $moveOperation->getNewTalkpage() + ); + } + } + return $talkpagesPairs; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageMoveOperation.php b/MLEB/Translate/src/PageTranslation/PageMoveOperation.php new file mode 100644 index 00000000..2cfbb5ea --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageMoveOperation.php @@ -0,0 +1,61 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Represents a single page being moved including the talk page. + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageMoveOperation { + /** @var Title */ + private $old; + /** @var Title|null */ + private $new; + /** @var Title|null */ + private $oldTalkpage; + /** @var Title|null */ + private $newTalkpage; + /** @var InvalidPageTitleRename|null */ + private $invalidPageTitleRename; + + public function __construct( Title $old, ?Title $new, ?InvalidPageTitleRename $e = null ) { + $this->old = $old; + $this->new = $new; + $this->invalidPageTitleRename = $e; + } + + public function getOldTitle(): Title { + return $this->old; + } + + public function getNewTitle(): ?Title { + return $this->new; + } + + public function getOldTalkpage(): ?Title { + return $this->oldTalkpage; + } + + public function getNewTalkpage(): ?Title { + return $this->newTalkpage; + } + + public function hasTalkpage(): bool { + return $this->oldTalkpage !== null; + } + + public function getRenameErrorCode(): int { + return $this->invalidPageTitleRename ? + $this->invalidPageTitleRename->getCode() : PageTitleRenamer::NO_ERROR; + } + + public function setTalkpage( Title $oldTalkpage, ?Title $newTalkpage ): void { + $this->oldTalkpage = $oldTalkpage; + $this->newTalkpage = $newTalkpage; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php b/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php new file mode 100644 index 00000000..45ccee6d --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageTitleRenamer.php @@ -0,0 +1,105 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Title; + +/** + * Contains logic to determine the new title of translatable pages and + * dependent pages being moved + * @author Niklas Laxström + * @author Abijeet Patro + * @license GPL-2.0-or-later + * @since 2021.09 + */ +class PageTitleRenamer { + public const NO_ERROR = 0; + public const UNKNOWN_PAGE = 1; + public const NS_TALK_UNSUPPORTED = 2; + public const RENAME_FAILED = 3; + public const INVALID_TITLE = 4; + + private const IMPOSSIBLE = null; + private $map = []; + + public function __construct( Title $source, Title $target ) { + $this->map[$source->getNamespace()] = [ + $target->getNamespace(), + $source->getText(), + $target->getText(), + ]; + + $sourceTalkPage = $source->getTalkPageIfDefined(); + $targetTalkPage = $target->getTalkPageIfDefined(); + if ( $sourceTalkPage ) { + if ( !$targetTalkPage ) { + $this->map[$sourceTalkPage->getNamespace()] = [ + self::IMPOSSIBLE, + null, + null, + ]; + } else { + $this->map[$sourceTalkPage->getNamespace()] = [ + $targetTalkPage->getNamespace(), + $source->getText(), + $target->getText(), + ]; + } + } + + $this->map[NS_TRANSLATIONS] = [ + NS_TRANSLATIONS, + $source->getPrefixedText(), + $target->getPrefixedText(), + ]; + + $this->map[NS_TRANSLATIONS_TALK] = [ + NS_TRANSLATIONS_TALK, + $source->getPrefixedText(), + $target->getPrefixedText(), + ]; + } + + public function getNewTitle( Title $title ): Title { + $instructions = $this->map[$title->getNamespace()] ?? null; + if ( $instructions === null ) { + throw new InvalidPageTitleRename( + 'Trying to move a page which is not part of the translatable page', self::UNKNOWN_PAGE + ); + } + + [ $newNamespace, $search, $replace ] = $instructions; + + if ( $newNamespace === self::IMPOSSIBLE ) { + throw new InvalidPageTitleRename( + 'Trying to move a talk page to a namespace which does not have talk pages', + self::NS_TALK_UNSUPPORTED + ); + } + + $oldTitleText = $title->getText(); + + // Check if the old title matches the string being replaced, if so there is no + // need to run preg_replace. This will happen if the page is being moved from + // one namespace to another. + if ( $oldTitleText === $replace ) { + return Title::makeTitleSafe( $newNamespace, $replace ); + } + + $searchQuoted = preg_quote( $search, '~' ); + $newText = preg_replace( "~^$searchQuoted~", $replace, $oldTitleText, 1 ); + + // If old and new title + namespace are same, the renaming failed. + if ( $oldTitleText === $newText && $newNamespace === $title->getNamespace() ) { + throw new InvalidPageTitleRename( 'Renaming failed', self::RENAME_FAILED ); + } + + $title = Title::makeTitleSafe( $newNamespace, $newText ); + if ( $title === null ) { + throw new InvalidPageTitleRename( 'Invalid target title', self::INVALID_TITLE ); + } + + return $title; + } +} diff --git a/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php b/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php new file mode 100644 index 00000000..730f69ea --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php @@ -0,0 +1,1271 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use ContentHandler; +use DifferenceEngine; +use Html; +use JobQueueGroup; +use ManualLogEntry; +use MediaWiki\Cache\LinkBatchFactory; +use MediaWiki\Extension\Translate\Utilities\LanguagesMultiselectWidget; +use MediaWiki\Hook\BeforeParserFetchTemplateRevisionRecordHook; +use MediaWiki\Languages\LanguageFactory; +use MediaWiki\Languages\LanguageNameUtils; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\User\UserIdentity; +use MessageGroups; +use MessageGroupStatsRebuildJob; +use MessageIndex; +use MessageWebImporter; +use MWException; +use OOUI\ButtonInputWidget; +use OOUI\CheckboxInputWidget; +use OOUI\FieldLayout; +use OOUI\FieldsetLayout; +use OOUI\TextInputWidget; +use PermissionsError; +use RevTag; +use SpecialNotifyTranslators; +use SpecialPage; +use Title; +use TranslatablePage; +use TranslateMetadata; +use TranslateUtils; +use TranslationsUpdateJob; +use WebRequest; +use Wikimedia\Rdbms\IResultWrapper; +use WikiPage; +use Xml; +use function count; +use function wfEscapeWikiText; +use function wfGetDB; +use const EDIT_FORCE_BOT; +use const EDIT_UPDATE; + +/** + * A special page for marking revisions of pages for translation. + * + * This page is the main tool for translation administrators in the wiki. + * It will list all pages in their various states and provides actions + * that are suitable for given translatable page. + * + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ +class PageTranslationSpecialPage extends SpecialPage { + private const LATEST_SYNTAX_VERSION = '2'; + private const DEFAULT_SYNTAX_VERSION = '1'; + /** @var LanguageNameUtils */ + private $languageNameUtils; + /** @var LanguageFactory */ + private $languageFactory; + /** @var TranslationUnitStoreFactory */ + private $translationUnitStoreFactory; + /** @var TranslatablePageParser */ + private $translatablePageParser; + /** @var LinkBatchFactory */ + private $linkBatchFactory; + + public function __construct( + LanguageNameUtils $languageNameUtils, + LanguageFactory $languageFactory, + TranslationUnitStoreFactory $translationUnitStoreFactory, + TranslatablePageParser $translatablePageParser, + LinkBatchFactory $linkBatchFactory + ) { + parent::__construct( 'PageTranslation' ); + $this->languageNameUtils = $languageNameUtils; + $this->languageFactory = $languageFactory; + $this->translationUnitStoreFactory = $translationUnitStoreFactory; + $this->translatablePageParser = $translatablePageParser; + $this->linkBatchFactory = $linkBatchFactory; + } + + public function doesWrites(): bool { + return true; + } + + protected function getGroupName(): string { + return 'translation'; + } + + public function execute( $parameters ) { + $this->setHeaders(); + + $user = $this->getUser(); + $request = $this->getRequest(); + + $target = $request->getText( 'target', $parameters ); + $revision = $request->getInt( 'revision', 0 ); + $action = $request->getVal( 'do' ); + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.pagetranslation' ); + $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' ); + $out->enableOOUI(); + + if ( $target === '' ) { + $this->listPages(); + + return; + } + + // Anything else than listing the pages need permissions + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + $title = Title::newFromText( $target ); + if ( !$title ) { + $out->wrapWikiMsg( Html::errorBox( '$1' ), [ 'tpt-badtitle', $target ] ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } elseif ( !$title->exists() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-nosuchpage', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + // Check token for all POST actions here + if ( $request->wasPosted() && !$user->matchEditToken( $request->getText( 'token' ) ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + if ( $action === 'mark' ) { + // Has separate form + $this->onActionMark( $title, $revision ); + + return; + } + + // On GET requests, show form which has token + if ( !$request->wasPosted() ) { + if ( $action === 'unlink' ) { + $this->showUnlinkConfirmation( $title ); + } else { + $params = [ + 'do' => $action, + 'target' => $title->getPrefixedText(), + 'revision' => $revision, + ]; + $this->showGenericConfirmation( $params ); + } + + return; + } + + if ( $action === 'discourage' || $action === 'encourage' ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $title ); + $current = MessageGroups::getPriority( $id ); + + if ( $action === 'encourage' ) { + $new = ''; + } else { + $new = 'discouraged'; + } + + if ( $new !== $current ) { + MessageGroups::setPriority( $id, $new ); + $entry = new ManualLogEntry( 'pagetranslation', $action ); + $entry->setPerformer( $user ); + $entry->setTarget( $title ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + // Defer stats purging of parent aggregate groups. Shared groups can contain other + // groups as well, which we do not need to update. We could filter non-aggregate + // groups out, or use MessageGroups::getParentGroups, though it has an inconvenient + // return value format for this use case. + $group = MessageGroups::getGroup( $id ); + $sharedGroupIds = MessageGroups::getSharedGroups( $group ); + if ( $sharedGroupIds !== [] ) { + $job = MessageGroupStatsRebuildJob::newRefreshGroupsJob( $sharedGroupIds ); + JobQueueGroup::singleton()->push( $job ); + } + + // Show updated page with a notice + $this->listPages(); + + return; + } + + if ( $action === 'unlink' ) { + $page = TranslatablePage::newFromTitle( $title ); + + $content = ContentHandler::makeContent( + $page->getStrippedSourcePageText(), + $title + ); + + $status = TranslateUtils::doPageEdit( + WikiPage::factory( $title ), + $content, + $this->getUser(), + $this->msg( 'tpt-unlink-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-edit-failed', $status->getWikiText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + if ( $action === 'unmark' ) { + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + } + } + + protected function onActionMark( Title $title, int $revision ): void { + $request = $this->getRequest(); + $out = $this->getOutput(); + + $out->addModuleStyles( 'ext.translate.specialpages.styles' ); + + if ( $revision === 0 ) { + // Get the latest revision + $revision = (int)$title->getLatestRevID(); + } + + // This also catches the case where revision does not belong to the title + if ( $revision !== (int)$title->getLatestRevID() ) { + // We do want to notify the reviewer if the underlying page changes during review + $target = $title->getFullURL( [ 'oldid' => $revision ] ); + $link = "<span class='plainlinks'>[$target $revision]</span>"; + $out->wrapWikiMsg( + Html::warningBox( '$1' ), + [ 'tpt-oldrevision', $title->getPrefixedText(), $link ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + // newFromRevision never fails, but getReadyTag might fail if revision does not belong + // to the page (checked above) + $page = TranslatablePage::newFromRevision( $title, $revision ); + if ( $page->getReadyTag() !== $title->getLatestRevID() ) { + $out->wrapWikiMsg( + Html::errorBox( '$1' ), + [ 'tpt-notsuitable', $title->getPrefixedText() ] + ); + $out->addWikiMsg( 'tpt-list-pages-in-translations' ); + + return; + } + + $firstMark = $page->getMarkedTag() === false; + + $parse = $this->translatablePageParser->parse( $page->getText() ); + [ $units, $deletedUnits ] = $this->prepareTranslationUnits( $page, $parse ); + + $error = $this->validateUnitIds( $units ); + + // Non-fatal error which prevents saving + if ( !$error && $request->wasPosted() ) { + // Check if user wants to translate title + // If not, remove it from the list of units + if ( !$request->getCheck( 'translatetitle' ) ) { + $units = array_filter( $units, static function ( $s ) { + return $s->id !== TranslatablePage::DISPLAY_TITLE_UNIT_ID; + } ); + } + + $setVersion = $firstMark || $request->getCheck( 'use-latest-syntax' ); + $transclusion = $request->getCheck( 'transclusion' ); + + $err = $this->markForTranslation( $page, $parse, $units, $setVersion, $transclusion ); + + if ( $err ) { + call_user_func_array( [ $out, 'addWikiMsg' ], $err ); + } else { + $this->showSuccess( $page, $firstMark, count( $units ) ); + } + + return; + } + + $this->showPage( $page, $parse, $units, $deletedUnits, $firstMark ); + } + + /** + * Displays success message and other instructions after a page has been marked for translation. + * @param TranslatablePage $page + * @param bool $firstMark true if it is the first time the page is being marked for translation. + * @param int $unitCount + * @return void + */ + private function showSuccess( + TranslatablePage $page, bool $firstMark, int $unitCount + ): void { + $titleText = $page->getTitle()->getPrefixedText(); + $num = $this->getLanguage()->formatNum( $unitCount ); + $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [ + 'group' => $page->getMessageGroupId(), + 'action' => 'page', + 'filter' => '', + ] ); + + $this->getOutput()->wrapWikiMsg( + Html::successBox( '$1' ), + [ 'tpt-saveok', $titleText, $num, $link ] + ); + + // If the page is being marked for translation for the first time + // add a link to Special:PageMigration. + if ( $firstMark ) { + $this->getOutput()->addWikiMsg( 'tpt-saveok-first' ); + } + + // If TranslationNotifications is installed, and the user can notify + // translators, add a convenience link. + if ( method_exists( SpecialNotifyTranslators::class, 'execute' ) && + $this->getUser()->isAllowed( SpecialNotifyTranslators::$right ) + ) { + $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL( + [ 'tpage' => $page->getTitle()->getArticleID() ] + ); + $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link ); + } + + $this->getOutput()->addWikiMsg( 'tpt-list-pages-in-translations' ); + } + + protected function showGenericConfirmation( array $params ): void { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $params['title'] = $this->getPageTitle()->getPrefixedText(); + $params['token'] = $this->getUser()->getEditToken(); + + $hidden = ''; + foreach ( $params as $key => $value ) { + $hidden .= Html::hidden( $key, $value ); + } + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + $hidden . + $this->msg( 'tpt-generic-confirm' )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-generic-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-progressive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function showUnlinkConfirmation( Title $target ): void { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'unlink' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'target', $target->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-unlink-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-destructive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function unmarkPage( TranslatablePage $page, UserIdentity $user ): void { + $page->unmarkTranslatablePage(); + $page->getTitle()->invalidateCache(); + + $entry = new ManualLogEntry( 'pagetranslation', 'unmark' ); + $entry->setPerformer( $user ); + $entry->setTarget( $page->getTitle() ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + public function loadPagesFromDB(): IResultWrapper { + $dbr = TranslateUtils::getSafeReadDB(); + $tables = [ 'page', 'revtag' ]; + $vars = [ + 'page_id', + 'page_namespace', + 'page_title', + 'page_latest', + 'MAX(rt_revision) AS rt_revision', + 'rt_type' + ]; + $conds = [ + 'page_id=rt_page', + 'rt_type' => [ RevTag::getType( 'tp:mark' ), RevTag::getType( 'tp:tag' ) ], + ]; + $options = [ + 'ORDER BY' => 'page_namespace, page_title', + 'GROUP BY' => 'page_id, page_namespace, page_title, page_latest, rt_type', + ]; + + return $dbr->select( $tables, $vars, $conds, __METHOD__, $options ); + } + + protected function buildPageArray( IResultWrapper $res ): array { + $pages = []; + foreach ( $res as $r ) { + // We have multiple rows for same page, because of different tags + if ( !isset( $pages[$r->page_id] ) ) { + $pages[$r->page_id] = []; + $title = Title::newFromRow( $r ); + $pages[$r->page_id]['title'] = $title; + $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID(); + } + + $tag = RevTag::typeToTag( $r->rt_type ); + $pages[$r->page_id][$tag] = (int)$r->rt_revision; + } + + return $pages; + } + + /** + * Classify a list of pages and amend them with additional metadata. + * + * @param array[] $pages + * @return array[] + * @phan-return array{proposed:array[],active:array[],broken:array[],outdated:array[]} + */ + private function classifyPages( array $pages ): array { + // Preload stuff for performance + $messageGroupIdsForPreload = []; + foreach ( $pages as $i => $page ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] ); + $messageGroupIdsForPreload[] = $id; + $pages[$i]['groupid'] = $id; + } + // Performance optimization: load only data we need to classify the pages + $metadata = TranslateMetadata::loadBasicMetadataForTranslatablePages( + $messageGroupIdsForPreload, + [ 'transclusion', 'version' ] + ); + + $out = [ + // The ideal state for pages: marked and up to date + 'active' => [], + 'proposed' => [], + 'outdated' => [], + 'broken' => [], + ]; + + foreach ( $pages as $page ) { + $groupId = $page['groupid']; + $group = MessageGroups::getGroup( $groupId ); + $page['discouraged'] = MessageGroups::getPriority( $group ) === 'discouraged'; + $page['version'] = $metadata[$groupId]['version'] ?? self::DEFAULT_SYNTAX_VERSION; + $page['transclusion'] = $metadata[$groupId]['transclusion'] ?? false; + + if ( !isset( $page['tp:mark'] ) ) { + // Never marked, check that the latest version is ready + if ( $page['tp:tag'] === $page['latest'] ) { + $out['proposed'][] = $page; + } // Otherwise, ignore such pages + } elseif ( $page['tp:tag'] === $page['latest'] ) { + if ( $page['tp:mark'] === $page['tp:tag'] ) { + // Marked and latest version is fine + $out['active'][] = $page; + } else { + $out['outdated'][] = $page; + } + } else { + // Marked but latest version is not fine + $out['broken'][] = $page; + } + } + + return $out; + } + + public function listPages(): void { + $out = $this->getOutput(); + + $res = $this->loadPagesFromDB(); + $allPages = $this->buildPageArray( $res ); + if ( !count( $allPages ) ) { + $out->addWikiMsg( 'tpt-list-nopages' ); + + return; + } + + $lb = $this->linkBatchFactory->newLinkBatch(); + $lb->setCaller( __METHOD__ ); + foreach ( $allPages as $page ) { + $lb->addObj( $page['title'] ); + } + $lb->execute(); + + $types = $this->classifyPages( $allPages ); + + $pages = $types['proposed']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' ); + $out->addWikiMsg( 'tpt-new-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'proposed' ) ); + } + + $pages = $types['broken']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' ); + $out->addWikiMsg( 'tpt-other-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'broken' ) ); + } + + $pages = $types['outdated']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-outdated-pages-title' ); + $out->addWikiMsg( 'tpt-outdated-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'outdated' ) ); + } + + $pages = $types['active']; + if ( $pages ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' ); + $out->addWikiMsg( 'tpt-old-pages', count( $pages ) ); + $out->addHTML( $this->getPageList( $pages, 'active' ) ); + } + } + + private function actionLinks( array $page, string $type ): string { + // Performance optimization to avoid calling $this->msg in a loop + static $messageCache = null; + if ( $messageCache === null ) { + $messageCache = [ + 'mark' => $this->msg( 'tpt-rev-mark' )->text(), + 'mark-tooltip' => $this->msg( 'tpt-rev-mark-tooltip' )->text(), + 'encourage' => $this->msg( 'tpt-rev-encourage' )->text(), + 'encourage-tooltip' => $this->msg( 'tpt-rev-encourage-tooltip' )->text(), + 'discourage' => $this->msg( 'tpt-rev-discourage' )->text(), + 'discourage-tooltip' => $this->msg( 'tpt-rev-discourage-tooltip' )->text(), + 'unmark' => $this->msg( 'tpt-rev-unmark' )->text(), + 'unmark-tooltip' => $this->msg( 'tpt-rev-unmark-tooltip' )->text(), + 'pipe-separator' => $this->msg( 'pipe-separator' )->escaped(), + ]; + } + + $actions = []; + /** @var Title $title */ + $title = $page['title']; + $user = $this->getUser(); + + // Class to allow one-click POSTs + $js = [ 'class' => 'mw-translate-jspost' ]; + + if ( $user->isAllowed( 'pagetranslation' ) ) { + // Enable re-marking of all pages to allow changing of priority languages + // or migration to the new syntax version + if ( $type !== 'broken' ) { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['mark'], + [ 'title' => $messageCache['mark-tooltip'] ], + [ + 'do' => 'mark', + 'target' => $title->getPrefixedText(), + 'revision' => $title->getLatestRevID(), + ] + ); + } + + if ( $type !== 'proposed' ) { + if ( $page['discouraged'] ) { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['encourage'], + [ 'title' => $messageCache['encourage-tooltip'] ] + $js, + [ + 'do' => 'encourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } else { + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['discourage'], + [ 'title' => $messageCache['discourage-tooltip'] ] + $js, + [ + 'do' => 'discourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + + $actions[] = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $messageCache['unmark'], + [ 'title' => $messageCache['unmark-tooltip'] ], + [ + 'do' => $type === 'broken' ? 'unmark' : 'unlink', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + } + + if ( !$actions ) { + return ''; + } + + return '<div>' . implode( $messageCache['pipe-separator'], $actions ) . '</div>'; + } + + public function validateUnitIds( array $units ): bool { + $usedNames = []; + $error = false; + + $ic = preg_quote( TranslationUnit::UNIT_MARKER_INVALID_CHARS, '~' ); + foreach ( $units as $s ) { + if ( preg_match( "~[$ic]~", $s->id ) ) { + $this->getOutput()->addElement( + 'p', + [ 'class' => 'errorbox' ], + $this->msg( 'tpt-invalid' )->params( $s->id )->text() + ); + $error = true; + } + + // We need to do checks for both new and existing units. + // Someone might have tampered with the page source adding + // duplicate or invalid markers. + $usedNames[$s->id] = ( $usedNames[$s->id] ?? 0 ) + 1; + } + foreach ( $usedNames as $name => $count ) { + if ( $count > 1 ) { + // Only show error once per duplicated translation unit + $this->getOutput()->addElement( + 'p', + [ 'class' => 'errorbox' ], + $this->msg( 'tpt-duplicate' )->params( $name )->text() + ); + $error = true; + } + } + + return $error; + } + + /** @return TranslationUnit[][] */ + private function prepareTranslationUnits( TranslatablePage $page, ParserOutput $parse ): array { + $highest = (int)TranslateMetadata::get( $page->getMessageGroupId(), 'maxid' ); + + $store = $this->translationUnitStoreFactory->getReader( $page->getTitle() ); + $storedUnits = $store->getUnits(); + $parsedUnits = $parse->units(); + + // Prepend the display title unit, which is not part of the page contents + $displayTitle = new TranslationUnit( + $page->getTitle()->getPrefixedText(), + TranslatablePage::DISPLAY_TITLE_UNIT_ID + ); + $parsedUnits = [ TranslatablePage::DISPLAY_TITLE_UNIT_ID => $displayTitle ] + $parsedUnits; + + // Figure out the largest used translation unit id + foreach ( array_keys( $storedUnits ) as $key ) { + $highest = max( $highest, (int)$key ); + } + foreach ( $parsedUnits as $_ ) { + $highest = max( $highest, (int)$_->id ); + } + + foreach ( $parsedUnits as $s ) { + $s->type = 'old'; + + if ( $s->id === TranslationUnit::NEW_UNIT_ID ) { + $s->type = 'new'; + $s->id = (string)( ++$highest ); + } else { + if ( isset( $storedUnits[$s->id] ) ) { + $storedText = $storedUnits[$s->id]->text; + if ( $s->text !== $storedText ) { + $s->type = 'changed'; + $s->oldText = $storedText; + } + } + } + } + + // Figure out which units were deleted by removing the still existing units + $deletedUnits = $storedUnits; + foreach ( $parsedUnits as $s ) { + unset( $deletedUnits[$s->id] ); + } + + return [ $parsedUnits, $deletedUnits ]; + } + + private function showPage( + TranslatablePage $page, + ParserOutput $parse, + array $sections, + array $deletedUnits, + bool $firstMark + ): void { + $out = $this->getOutput(); + $out->setSubtitle( $this->getLinkRenderer()->makeKnownLink( $page->getTitle() ) ); + $out->addWikiMsg( 'tpt-showpage-intro' ); + + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + 'class' => 'mw-tpt-sp-markform', + ]; + + $out->addHTML( + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'mark' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'revision', $page->getRevision() ) . + Html::hidden( 'target', $page->getTitle()->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) + ); + + $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' ); + + $diffOld = $this->msg( 'tpt-diff-old' )->escaped(); + $diffNew = $this->msg( 'tpt-diff-new' )->escaped(); + $hasChanges = false; + + // Check whether page title was previously marked for translation. + // If the page is marked for translation the first time, default to checked. + $defaultChecked = $firstMark || $page->hasPageDisplayTitle(); + + $sourceLanguage = $this->languageFactory->getLanguage( $page->getSourceLanguageCode() ); + + foreach ( $sections as $s ) { + if ( $s->id === TranslatablePage::DISPLAY_TITLE_UNIT_ID ) { + // Set section type as new if title previously unchecked + $s->type = $defaultChecked ? $s->type : 'new'; + + // Checkbox for page title optional translation + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'translatetitle', + 'selected' => $defaultChecked, + ] ), + [ + 'label' => $this->msg( 'tpt-translate-title' )->text(), + 'align' => 'inline', + 'classes' => [ 'mw-tpt-m-vertical' ] + ] + ); + $out->addHTML( $checkBox->toString() ); + } + + if ( $s->type === 'new' ) { + $hasChanges = true; + $name = $this->msg( 'tpt-section-new', $s->id )->escaped(); + } else { + $name = $this->msg( 'tpt-section', $s->id )->escaped(); + } + + if ( $s->type === 'changed' ) { + $hasChanges = true; + $diff = new DifferenceEngine(); + $diff->setTextLanguage( $sourceLanguage ); + $diff->setReducedLineNumbers(); + + $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( $diffOld, $diffNew ); + $diffOld = $diffNew = null; + $diff->showDiffStyle(); + + $id = "tpt-sect-{$s->id}-action-nofuzzy"; + $checkLabel = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => $id, + 'selected' => false, + ] ), + [ + 'label' => $this->msg( 'tpt-action-nofuzzy' )->text(), + 'align' => 'inline', + 'classes' => [ 'mw-tpt-m-vertical' ] + ] + ); + $text = $checkLabel->toString() . $text; + } else { + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + } + + # For changed text, the language is set by $diff->setTextLanguage() + $lang = $s->type === 'changed' ? null : $sourceLanguage; + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + $s->type, + $text, + $lang + ) ); + + foreach ( $s->getIssues() as $issue ) { + $severity = $issue->getSeverity(); + if ( $severity === TranslationUnitIssue::WARNING ) { + $box = Html::warningBox( $this->msg( $issue )->escaped() ); + } elseif ( $severity === TranslationUnitIssue::ERROR ) { + $box = Html::errorBox( $this->msg( $issue )->escaped() ); + } else { + throw new MWException( + "Unknown severity: $severity for key: {$issue->getKey()}" + ); + } + + $out->addHTML( $box ); + } + } + + if ( $deletedUnits ) { + $hasChanges = true; + $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' ); + + foreach ( $deletedUnits as $s ) { + $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped(); + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + 'deleted', + $text, + $sourceLanguage + ) ); + } + } + + // Display template changes if applicable + if ( $page->getMarkedTag() !== false ) { + $hasChanges = true; + $newTemplate = $parse->sourcePageTemplateForDiffs(); + $oldPage = TranslatablePage::newFromRevision( + $page->getTitle(), + $page->getMarkedTag() + ); + $oldTemplate = $this->translatablePageParser + ->parse( $oldPage->getText() ) + ->sourcePageTemplateForDiffs(); + + if ( $oldTemplate !== $newTemplate ) { + $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' ); + + $diff = new DifferenceEngine(); + $diff->setTextLanguage( $sourceLanguage ); + + $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( + $this->msg( 'tpt-diff-old' )->escaped(), + $this->msg( 'tpt-diff-new' )->escaped() + ); + $diff->showDiffStyle(); + $diff->setReducedLineNumbers(); + + $out->addHTML( Xml::tags( 'div', [], $text ) ); + } + } + + if ( !$hasChanges ) { + $out->wrapWikiMsg( Html::successBox( '$1' ), 'tpt-mark-nochanges' ); + } + + $this->priorityLanguagesForm( $page ); + + // If an existing page does not have the supportsTransclusion flag, keep the checkbox unchecked, + // If the page is being marked for translation for the first time, the checkbox can be checked + $this->templateTransclusionForm( $page->supportsTransclusion() ?? $firstMark ); + + $version = TranslateMetadata::getWithDefaultValue( + $page->getMessageGroupId(), 'version', self::DEFAULT_SYNTAX_VERSION + ); + $this->syntaxVersionForm( $version, $firstMark ); + + $submitButton = new FieldLayout( + new ButtonInputWidget( [ + 'label' => $this->msg( 'tpt-submit' )->text(), + 'type' => 'submit', + 'flags' => [ 'primary', 'progressive' ], + ] ), + [ + 'label' => null, + 'align' => 'top', + ] + ); + + $out->addHTML( $submitButton->toString() ); + $out->addHTML( '</form>' ); + } + + private function priorityLanguagesForm( TranslatablePage $page ): void { + $groupId = $page->getMessageGroupId(); + $interfaceLanguage = $this->getLanguage()->getCode(); + $storedLanguages = (string)TranslateMetadata::get( $groupId, 'prioritylangs' ); + $default = $storedLanguages !== '' ? explode( ',', $storedLanguages ) : []; + + $form = new FieldsetLayout( [ + 'items' => [ + new FieldLayout( + new LanguagesMultiselectWidget( [ + 'infusable' => true, + 'name' => 'prioritylangs', + 'id' => 'mw-translate-SpecialPageTranslation-prioritylangs', + 'languages' => TranslateUtils::getLanguageNames( $interfaceLanguage ), + 'default' => $default, + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs' )->text(), + 'align' => 'top', + ] + ), + new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'forcelimit', + 'selected' => TranslateMetadata::get( $groupId, 'priorityforce' ) === 'on', + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs-force' )->text(), + 'align' => 'inline', + ] + ), + new FieldLayout( + new TextInputWidget( [ + 'name' => 'priorityreason', + ] ), + [ + 'label' => $this->msg( 'tpt-select-prioritylangs-reason' )->text(), + 'align' => 'top', + ] + ), + + ], + ] ); + + $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' ); + $this->getOutput()->addHTML( $form->toString() ); + } + + private function syntaxVersionForm( string $version, bool $firstMark ): void { + $out = $this->getOutput(); + + if ( $version === self::LATEST_SYNTAX_VERSION || $firstMark ) { + return; + } + + $out->wrapWikiMsg( '==$1==', 'tpt-sections-syntaxversion' ); + $out->addWikiMsg( + 'tpt-syntaxversion-text', + '<code>' . wfEscapeWikiText( '<span lang="en" dir="ltr">...</span>' ) . '</code>', + '<code>' . wfEscapeWikiText( '<translate nowrap>...</translate>' ) . '</code>' + ); + + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'use-latest-syntax' + ] ), + [ + 'label' => $out->msg( 'tpt-syntaxversion-label' )->text(), + 'align' => 'inline', + ] + ); + + $out->addHTML( $checkBox->toString() ); + } + + private function templateTransclusionForm( bool $supportsTransclusion ): void { + // Transclusion is only supported if this hook is available so avoid showing the + // form if it's not. This hook should be available for MW >= 1.36 + if ( !interface_exists( BeforeParserFetchTemplateRevisionRecordHook::class ) ) { + return; + } + + $out = $this->getOutput(); + $out->wrapWikiMsg( '==$1==', 'tpt-transclusion' ); + + $checkBox = new FieldLayout( + new CheckboxInputWidget( [ + 'name' => 'transclusion', + 'selected' => $supportsTransclusion + ] ), + [ + 'label' => $out->msg( 'tpt-transclusion-label' )->text(), + 'align' => 'inline', + ] + ); + + $out->addHTML( $checkBox->toString() ); + } + + /** + * This function does the heavy duty of marking a page. + * - Updates the source page with section markers. + * - Updates translate_sections table + * - Updates revtags table + * - Sets up renderjobs to update the translation pages + * - Invalidates caches + * - Adds interim cache for MessageIndex + * + * @param TranslatablePage $page + * @param ParserOutput $parse + * @param TranslationUnit[] $sections + * @param bool $updateVersion + * @param bool $transclusion + * @return array|bool + */ + protected function markForTranslation( + TranslatablePage $page, + ParserOutput $parse, + array $sections, + bool $updateVersion, + bool $transclusion + ) { + // Add the section markers to the source page + $wikiPage = WikiPage::factory( $page->getTitle() ); + $content = ContentHandler::makeContent( + $parse->sourcePageTextForSaving(), + $page->getTitle() + ); + + $status = TranslateUtils::doPageEdit( + $wikiPage, + $content, + $this->getUser(), + $this->msg( 'tpt-mark-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + return [ 'tpt-edit-failed', $status->getWikiText() ]; + } + + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $newRevisionRecord = $status->value['revision-record']; + // In theory it is either null or RevisionRecord object, + // not a RevisionRecord object with null id, but who knows + if ( $newRevisionRecord instanceof RevisionRecord ) { + $newRevisionId = $newRevisionRecord->getId(); + } else { + $newRevisionId = null; + } + + // Probably a no-change edit, so no new revision was assigned. + // Get the latest revision manually + // Could also occur on the off chance $newRevisionRecord->getId() returns null + if ( $newRevisionId === null ) { + $newRevisionId = $page->getTitle()->getLatestRevID(); + } + + $inserts = []; + $changed = []; + $groupId = $page->getMessageGroupId(); + $maxid = (int)TranslateMetadata::get( $groupId, 'maxid' ); + + $pageId = $page->getTitle()->getArticleID(); + /** @var TranslationUnit $s */ + foreach ( array_values( $sections ) as $index => $s ) { + $maxid = max( $maxid, (int)$s->id ); + $changed[] = $s->id; + + if ( $this->getRequest()->getCheck( "tpt-sect-{$s->id}-action-nofuzzy" ) ) { + // TranslationsUpdateJob will only fuzzy when type is changed + $s->type = 'old'; + } + + $inserts[] = [ + 'trs_page' => $pageId, + 'trs_key' => $s->id, + 'trs_text' => $s->getText(), + 'trs_order' => $index + ]; + } + + $dbw = wfGetDB( DB_PRIMARY ); + $dbw->delete( + 'translate_sections', + [ 'trs_page' => $page->getTitle()->getArticleID() ], + __METHOD__ + ); + $dbw->insert( 'translate_sections', $inserts, __METHOD__ ); + TranslateMetadata::set( $groupId, 'maxid', $maxid ); + if ( $updateVersion ) { + TranslateMetadata::set( $groupId, 'version', self::LATEST_SYNTAX_VERSION ); + } + + $page->setTransclusion( $transclusion ); + + $page->addMarkedTag( $newRevisionId ); + MessageGroups::singleton()->recache(); + + // Store interim cache + $group = $page->getMessageGroup(); + $newKeys = $group->makeGroupKeys( $changed ); + MessageIndex::singleton()->storeInterim( $group, $newKeys ); + + $job = TranslationsUpdateJob::newFromPage( $page, $sections ); + JobQueueGroup::singleton()->push( $job ); + + $this->handlePriorityLanguages( $this->getRequest(), $page ); + + // Logging + $entry = new ManualLogEntry( 'pagetranslation', 'mark' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( [ + 'revision' => $newRevisionId, + 'changed' => count( $changed ), + ] ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + // Clear more caches + $page->getTitle()->invalidateCache(); + + return false; + } + + /** + * @param WebRequest $request + * @param TranslatablePage $page + * @return void + */ + protected function handlePriorityLanguages( WebRequest $request, TranslatablePage $page ): void { + // Get the priority languages from the request + // We've to do some extra work here because if JS is disabled, we will be getting + // the values split by newline. + $npLangs = rtrim( trim( $request->getVal( 'prioritylangs', '' ) ), ',' ); + $npLangs = implode( ',', explode( "\n", $npLangs ) ); + $npLangs = array_map( 'trim', explode( ',', $npLangs ) ); + $npLangs = array_unique( $npLangs ); + + $npForce = $request->getCheck( 'forcelimit' ) ? 'on' : 'off'; + $npReason = trim( $request->getText( 'priorityreason' ) ); + + // Remove invalid language codes. + $languages = $this->languageNameUtils->getLanguageNames(); + foreach ( $npLangs as $index => $language ) { + if ( !array_key_exists( $language, $languages ) ) { + unset( $npLangs[$index] ); + } + } + $npLangs = implode( ',', $npLangs ); + if ( $npLangs === '' ) { + $npLangs = false; + $npForce = false; + $npReason = false; + } + + $groupId = $page->getMessageGroupId(); + // old priority languages + $opLangs = TranslateMetadata::get( $groupId, 'prioritylangs' ); + $opForce = TranslateMetadata::get( $groupId, 'priorityforce' ); + $opReason = TranslateMetadata::get( $groupId, 'priorityreason' ); + + TranslateMetadata::set( $groupId, 'prioritylangs', $npLangs ); + TranslateMetadata::set( $groupId, 'priorityforce', $npForce ); + TranslateMetadata::set( $groupId, 'priorityreason', $npReason ); + + if ( $opLangs !== $npLangs || $opForce !== $npForce || $opReason !== $npReason ) { + $params = [ + 'languages' => $npLangs, + 'force' => $npForce, + 'reason' => $npReason, + ]; + + $entry = new ManualLogEntry( 'pagetranslation', 'prioritylanguages' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( $params ); + $entry->setComment( $npReason ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + } + + private function getPageList( array $pages, string $type ): string { + $items = []; + $tagsTextCache = []; + + $tagDiscouraged = $this->msg( 'tpt-tag-discouraged' )->escaped(); + $tagOldSyntax = $this->msg( 'tpt-tag-oldsyntax' )->escaped(); + $tagNoTransclusionSupport = $this->msg( 'tpt-tag-no-transclusion-support' )->escaped(); + + foreach ( $pages as $page ) { + $link = $this->getLinkRenderer()->makeKnownLink( $page['title'] ); + $acts = $this->actionLinks( $page, $type ); + $tags = []; + if ( $page['discouraged'] ) { + $tags[] = $tagDiscouraged; + } + if ( $type !== 'proposed' ) { + if ( $page['version'] !== self::LATEST_SYNTAX_VERSION ) { + $tags[] = $tagOldSyntax; + } + + if ( $page['transclusion'] !== '1' ) { + $tags[] = $tagNoTransclusionSupport; + } + } + + $tagList = ''; + if ( $tags ) { + // Performance optimization to avoid calling $this->msg in a loop + $tagsKey = implode( '', $tags ); + $tagsTextCache[$tagsKey] = $tagsTextCache[$tagsKey] ?? + $this->msg( 'parentheses' ) + ->rawParams( $this->getLanguage()->pipeList( $tags ) ) + ->escaped(); + + $tagList = Html::rawElement( + 'span', + [ 'class' => 'mw-tpt-actions' ], + $tagsTextCache[$tagsKey] + ); + } + + $items[] = "<li>$link $tagList $acts</li>"; + } + + return '<ol>' . implode( "", $items ) . '</ol>'; + } +} diff --git a/MLEB/Translate/src/PageTranslation/ParserOutput.php b/MLEB/Translate/src/PageTranslation/ParserOutput.php index b9624509..3a61aace 100644 --- a/MLEB/Translate/src/PageTranslation/ParserOutput.php +++ b/MLEB/Translate/src/PageTranslation/ParserOutput.php @@ -59,7 +59,7 @@ class ParserOutput { } /** Returns the source page wikitext used for rendering the page. */ - public function sourcePageTextForRendering( Language $sourceLanguage ) { + public function sourcePageTextForRendering( Language $sourceLanguage ): string { $text = $this->translationPageTemplate(); foreach ( $this->unitMap as $ph => $s ) { @@ -71,7 +71,7 @@ class ParserOutput { } /** Returns the source page with translation unit markers. */ - public function sourcePageTextForSaving() { + public function sourcePageTextForSaving(): string { $text = $this->sourcePageTemplate(); foreach ( $this->unitMap as $ph => $s ) { @@ -81,6 +81,17 @@ class ParserOutput { return $text; } + /** Returns the page text with translation tags and unit placeholders for easy diffs */ + public function sourcePageTemplateForDiffs(): string { + $text = $this->sourcePageTemplate(); + + foreach ( $this->unitMap as $ph => $s ) { + $text = str_replace( $ph, "<!--T:{$s->id}-->", $text ); + } + + return $text; + } + private function assertContainsOnlyInstancesOf( string $expected, string $name, @@ -96,5 +107,3 @@ class ParserOutput { } } } - -class_alias( ParserOutput::class, '\MediaWiki\Extensions\Translate\ParserOutput' ); diff --git a/MLEB/Translate/src/PageTranslation/ParsingFailure.php b/MLEB/Translate/src/PageTranslation/ParsingFailure.php index a3012c7b..3fe615ab 100644 --- a/MLEB/Translate/src/PageTranslation/ParsingFailure.php +++ b/MLEB/Translate/src/PageTranslation/ParsingFailure.php @@ -27,5 +27,3 @@ class ParsingFailure extends RuntimeException { return $this->messageSpec; } } - -class_alias( ParsingFailure::class, '\MediaWiki\Extensions\Translate\ParsingFailure' ); diff --git a/MLEB/Translate/src/PageTranslation/Section.php b/MLEB/Translate/src/PageTranslation/Section.php index b4e30469..9e065ae0 100644 --- a/MLEB/Translate/src/PageTranslation/Section.php +++ b/MLEB/Translate/src/PageTranslation/Section.php @@ -32,5 +32,3 @@ class Section { return $this->open . $this->contents . $this->close; } } - -class_alias( Section::class, '\MediaWiki\Extensions\Translate\Section' ); diff --git a/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php b/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php index 9c914d18..fb0e0b63 100644 --- a/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php +++ b/MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php @@ -17,8 +17,3 @@ class TestingParsingPlaceholderFactory extends ParsingPlaceholderFactory { return '<' . $this->i++ . '>'; } } - -class_alias( - TestingParsingPlaceholderFactory::class, - '\MediaWiki\Extensions\Translate\TestingParsingPlaceholderFactory' -); diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php b/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php index 516a31bd..1c77f32e 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php @@ -7,24 +7,30 @@ use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\Insertable; use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\MediaWikiInsertablesSuggester; /** - * Special insertables for translatable pages. + * Insertables for translation variables in translatable pages. * @author Niklas Laxström * @license GPL-2.0-or-later * @since 2013.11 */ class TranslatablePageInsertablesSuggester extends MediaWikiInsertablesSuggester { + /** + * Translatable pages allow naming the variables. Almost anything is + * allowed in a variable name, but here we are stricter to avoid too many + * incorrect matches when variable name is followed by non-space characters. + * @internal For use in this namespace only + */ + public const NAME_PATTERN = '\$[\pL\pN_$-]+'; + public function getInsertables( string $text ): array { $insertables = parent::getInsertables( $text ); - // Translatable pages allow naming the variables. Basically anything is - // allowed in a variable name, but here we are stricter to avoid too many - // false positives. $matches = []; - preg_match_all( '/\$([a-zA-Z0-9-_]+)/', $text, $matches, PREG_SET_ORDER ); + $pattern = '/' . self::NAME_PATTERN . '/'; + preg_match_all( $pattern, $text, $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { + $new = array_map( static function ( $match ) { // Numerical ones are already handled by parent - if ( ctype_digit( $match[1] ) ) { + if ( ctype_digit( substr( $match[0], 1 ) ) ) { return null; } @@ -32,13 +38,6 @@ class TranslatablePageInsertablesSuggester extends MediaWikiInsertablesSuggester }, $matches ); $new = array_filter( $new ); - $insertables = array_merge( $insertables, $new ); - - return $insertables; + return array_merge( $insertables, $new ); } } - -class_alias( - TranslatablePageInsertablesSuggester::class, - '\MediaWiki\Extensions\Translate\TranslatablePageInsertablesSuggester' -); diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php b/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php index 2729e4f0..8e8de8b9 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageMover.php @@ -5,8 +5,9 @@ namespace MediaWiki\Extension\Translate\PageTranslation; use AggregateMessageGroup; use JobQueueGroup; -use LinkBatch; +use LogicException; use ManualLogEntry; +use MediaWiki\Cache\LinkBatchFactory; use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot; use MediaWiki\Page\MovePageFactory; use Message; @@ -20,6 +21,7 @@ use Title; use TranslatablePage; use TranslatablePageMoveJob; use TranslateMetadata; +use TranslateUtils; use TranslationsUpdateJob; use Traversable; use User; @@ -32,111 +34,87 @@ use User; */ class TranslatablePageMover { private const LOCK_TIMEOUT = 3600 * 2; + private const FETCH_TRANSLATABLE_SUBPAGES = true; /** @var MovePageFactory */ private $movePageFactory; /** @var int|null */ private $pageMoveLimit; /** @var JobQueueGroup */ private $jobQueue; + /** @var LinkBatchFactory */ + private $linkBatchFactory; /** @var bool */ private $pageMoveLimitEnabled = true; - public function __construct( MovePageFactory $movePageFactory, JobQueueGroup $jobQueue, ?int $pageMoveLimit ) { + public function __construct( + MovePageFactory $movePageFactory, + JobQueueGroup $jobQueue, + LinkBatchFactory $linkBatchFactory, + ?int $pageMoveLimit + ) { $this->movePageFactory = $movePageFactory; $this->jobQueue = $jobQueue; $this->pageMoveLimit = $pageMoveLimit; + $this->linkBatchFactory = $linkBatchFactory; } - /** Makes old title into a new title by replacing $base part of old title with $target. */ - public function newPageTitle( string $base, Title $old, Title $target ): Title { - $search = preg_quote( $base, '~' ); - - if ( $old->inNamespace( NS_TRANSLATIONS ) ) { - $new = $old->getText(); - $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); - - return Title::makeTitleSafe( NS_TRANSLATIONS, $new ); - } else { - $new = $old->getPrefixedText(); - $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); - - return Title::newFromText( $new ); - } - } - - /** @return SplObjectStorage Title => Status */ - public function checkMoveBlockers( + public function getPageMoveCollection( Title $source, ?Title $target, User $user, string $reason, - bool $moveSubPages - ): SplObjectStorage { + bool $moveSubPages, + bool $moveTalkPages + ): PageMoveCollection { $blockers = new SplObjectStorage(); - $page = TranslatablePage::newFromTitle( $source ); - if ( !$target ) { $blockers[$source] = Status::newFatal( 'pt-movepage-block-base-invalid' ); - return $blockers; + throw new ImpossiblePageMove( $blockers ); } if ( $target->inNamespaces( NS_MEDIAWIKI, NS_TRANSLATIONS ) ) { $blockers[$source] = Status::newFatal( 'immobile-target-namespace', $target->getNsText() ); - return $blockers; + throw new ImpossiblePageMove( $blockers ); } - if ( $target->exists() ) { - $blockers[$source] = Status::newFatal( - 'pt-movepage-block-base-exists', $target->getPrefixedText() - ); - } else { - $movePage = $this->movePageFactory->newMovePage( $source, $target ); - $status = $movePage->isValidMove(); - $status->merge( $movePage->checkPermissions( $user, $reason ) ); - if ( !$status->isOK() ) { - $blockers[$source] = $status; - } + $movePage = $this->movePageFactory->newMovePage( $source, $target ); + $status = $movePage->isValidMove(); + $status->merge( $movePage->checkPermissions( $user, $reason ) ); + if ( !$status->isOK() ) { + $blockers[$source] = $status; } // Don't spam the same errors for all pages if base page fails if ( count( $blockers ) ) { - return $blockers; + throw new ImpossiblePageMove( $blockers ); } - // Collect all the old and new titles for checks - $titles = []; - $base = $source->getPrefixedText(); - $pages = $page->getTranslationPages(); - foreach ( $pages as $old ) { - $titles['tp'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } - - $subpages = $moveSubPages ? $this->getNormalSubpages( $page ) : []; - foreach ( $subpages as $old ) { - $titles['subpage'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } + $pageCollection = $this->getPagesToMove( + $source, $target, $moveSubPages, self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages + ); - $pages = $page->getTranslationUnitPages( 'all' ); - foreach ( $pages as $old ) { - $titles['section'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; - } + // Collect all the old and new titles for checks + $titles = [ + 'tp' => $pageCollection->getTranslationPagesPair(), + 'subpage' => $pageCollection->getSubpagesPair(), + 'section' => $pageCollection->getUnitPagesPair() + ]; // Check that all new titles are valid and count them. Add 1 for source page. $moveCount = 1; - $lb = new LinkBatch(); + $lb = $this->linkBatchFactory->newLinkBatch(); foreach ( $titles as $type => $list ) { $moveCount += count( $list ); // Give grep a chance to find the usages: // pt-movepage-block-tp-invalid, pt-movepage-block-section-invalid, // pt-movepage-block-subpage-invalid foreach ( $list as $pair ) { - [ $old, $new ] = $pair; + $old = $pair->getOldTitle(); + $new = $pair->getNewTitle(); + if ( $new === null ) { - $blockers[$old] = Status::newFatal( - "pt-movepage-block-$type-invalid", - $old->getPrefixedText() - ); + $blockers[$old] = $this->getRenameMoveBlocker( $old, $type, $pair->getRenameErrorCode() ); continue; } $lb->addObj( $old ); @@ -153,48 +131,44 @@ class TranslatablePageMover { } } + // Stop further validation if there are blockers already. if ( count( $blockers ) ) { - return $blockers; + throw new ImpossiblePageMove( $blockers ); } // Check that there are no move blockers - $lb->execute(); + $lb->setCaller( __METHOD__ )->execute(); foreach ( $titles as $type => $list ) { - // Give grep a chance to find the usages: - // pt-movepage-block-tp-exists, pt-movepage-block-section-exists, - // pt-movepage-block-subpage-exists foreach ( $list as $pair ) { - list( $old, $new ) = $pair; - if ( $new->exists() ) { - $blockers[$old] = Status::newFatal( - "pt-movepage-block-$type-exists", - $old->getPrefixedText(), - $new->getPrefixedText() - ); - } else { - /* This method has terrible performance: - * - 2 queries by core - * - 3 queries by lqt - * - and no obvious way to preload the data! */ - $movePage = $this->movePageFactory->newMovePage( $old, $target ); - $status = $movePage->isValidMove(); - // Do not check for permissions here, as these pages are not editable/movable - // in regular use - if ( !$status->isOK() ) { - $blockers[$old] = $status; - } - - /* Because of the poor performance, check only one of the possibly thousands - * of section pages and assume rest are fine. This assumes section pages are - * listed last in the array. */ - if ( $type === 'section' ) { - break; - } + $old = $pair->getOldTitle(); + $new = $pair->getNewTitle(); + + /* This method has terrible performance: + * - 2 queries by core + * - 3 queries by lqt + * - and no obvious way to preload the data! */ + $movePage = $this->movePageFactory->newMovePage( $old, $new ); + $status = $movePage->isValidMove(); + // Do not check for permissions here, as these pages are not editable/movable + // in regular use + if ( !$status->isOK() ) { + $blockers[$old] = $status; + } + + /* Because of the poor performance, check only one of the possibly thousands + * of section pages and assume rest are fine. This assumes section pages are + * listed last in the array. */ + if ( $type === 'section' ) { + break; } } } - return $blockers; + if ( count( $blockers ) ) { + throw new ImpossiblePageMove( $blockers ); + } + + return $pageCollection; } public function moveAsynchronously( @@ -202,13 +176,17 @@ class TranslatablePageMover { Title $target, bool $moveSubPages, User $user, - string $summary + string $summary, + bool $moveTalkPages ): void { - $pageMoves = $this->getPagesToMove( $source, $target, $moveSubPages ); + $pageCollection = $this->getPagesToMove( + $source, $target, $moveSubPages, !self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages + ); + $pagesToMove = $pageCollection->getListOfPages(); - $job = TranslatablePageMoveJob::newJob( $source, $target, $pageMoves, $summary, $user ); - $this->lock( array_keys( $pageMoves ) ); - $this->lock( array_values( $pageMoves ) ); + $job = TranslatablePageMoveJob::newJob( $source, $target, $pagesToMove, $summary, $user ); + $this->lock( array_keys( $pagesToMove ) ); + $this->lock( array_values( $pagesToMove ) ); $this->jobQueue->push( $job ); } @@ -243,6 +221,8 @@ class TranslatablePageMover { $this->moveMetadata( $sourcePage->getMessageGroupId(), $targetPage->getMessageGroupId() ); + TranslatablePage::clearSourcePageCache(); + // Re-render the pages to get everything in sync MessageGroups::singleton()->recache(); // Update message index now so that, when after this job the MoveTranslationUnits hook @@ -253,64 +233,111 @@ class TranslatablePageMover { $this->jobQueue->push( $job ); } - /** @return Title[] */ - public function getNormalSubpages( TranslatablePage $page ): array { - return array_filter( - $this->getSubpages( $page ), - function ( $page ) { - return !( - TranslatablePage::isTranslationPage( $page ) || - TranslatablePage::isSourcePage( $page ) - ); - } - ); + public function disablePageMoveLimit(): void { + $this->pageMoveLimitEnabled = false; } - /** @return Title[] */ - public function getTranslatableSubpages( TranslatablePage $page ): array { - return array_filter( - $this->getSubpages( $page ), - function ( $page ) { - return TranslatablePage::isSourcePage( $page ); - } - ); + public function enablePageMoveLimit(): void { + $this->pageMoveLimitEnabled = true; } - /** @return string[] */ - public function getPagesToMove( Title $source, Title $target, bool $moveSubPages ): array { + private function getPagesToMove( + Title $source, + Title $target, + bool $moveSubPages, + bool $fetchTranslatableSubpages, + bool $moveTalkPages + ): PageMoveCollection { $page = TranslatablePage::newFromTitle( $source ); - $base = $source->getPrefixedText(); - - $moves = []; - $moves[$base] = $target->getPrefixedText(); + $translatableMovePage = new PageMoveOperation( $source, $target ); + $pageTitleRenamer = new PageTitleRenamer( $source, $target ); + $translationPageList = []; foreach ( $page->getTranslationPages() as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translationPageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); } + $translationUnitPageList = []; foreach ( $page->getTranslationUnitPages( 'all' ) as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translationUnitPageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); + } + + $subpageList = []; + if ( $moveSubPages && TranslateUtils::allowsSubpages( $source ) ) { + $currentSubpages = $this->getNormalSubpages( $page ); + foreach ( $currentSubpages as $from ) { + $subpageList[] = $this->createPageMoveOperation( $pageTitleRenamer, $from ); + } } - if ( $moveSubPages ) { - $subpages = $this->getNormalSubpages( $page ); - foreach ( $subpages as $from ) { - $to = $this->newPageTitle( $base, $from, $target ); - $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + $translatableTalkpageList = []; + // If the source page is a talk page itself, no point looking for more talk pages + if ( $moveTalkPages && !$source->isTalkPage() ) { + $possiblePagesToBeMoved = array_merge( + [ $translatableMovePage ], + $translationPageList, + $translationUnitPageList, + $subpageList + ); + + $talkPages = $this->getTalkPagesForMove( $possiblePagesToBeMoved ); + foreach ( $possiblePagesToBeMoved as $index => $pageOperation ) { + $currentTalkPage = $talkPages[$index] ?? null; + if ( $currentTalkPage === null ) { + continue; + } + + // If the talk page is translatable, we do not move it, and inform the user + // that this needs to be moved separately. + if ( TranslatablePage::isSourcePage( $currentTalkPage ) ) { + $translatableTalkpageList[] = $currentTalkPage; + continue; + } + + $pageOperation->setTalkpage( + $currentTalkPage, $pageTitleRenamer->getNewTitle( $currentTalkPage ) + ); } } - return $moves; + $relatedTranslatablePageList = $translatableTalkpageList; + if ( $fetchTranslatableSubpages ) { + $relatedTranslatablePageList = array_merge( + $relatedTranslatablePageList, + $this->getTranslatableSubpages( TranslatablePage::newFromTitle( $source ) ) + ); + } + + return new PageMoveCollection( + $translatableMovePage, + $translationPageList, + $translationUnitPageList, + $subpageList, + $relatedTranslatablePageList + ); } - public function disablePageMoveLimit(): void { - $this->pageMoveLimitEnabled = false; + /** @return Title[] */ + private function getNormalSubpages( TranslatablePage $page ): array { + return array_filter( + $this->getSubpages( $page ), + static function ( $page ) { + return !( + TranslatablePage::isTranslationPage( $page ) || + TranslatablePage::isSourcePage( $page ) + ); + } + ); } - public function enablePageMoveLimit(): void { - $this->pageMoveLimitEnabled = true; + /** @return Title[] */ + private function getTranslatableSubpages( TranslatablePage $page ): array { + return array_filter( + $this->getSubpages( $page ), + static function ( $page ) { + return TranslatablePage::isSourcePage( $page ); + } + ); } /** @@ -411,7 +438,7 @@ class TranslatablePageMover { } private function moveMetadata( string $oldGroupId, string $newGroupId ): void { - TranslateMetadata::preloadGroups( [ $oldGroupId, $newGroupId ] ); + TranslateMetadata::preloadGroups( [ $oldGroupId, $newGroupId ], __METHOD__ ); foreach ( TranslatablePage::METADATA_KEYS as $type ) { $value = TranslateMetadata::get( $oldGroupId, $type ); if ( $value !== false ) { @@ -422,7 +449,7 @@ class TranslatablePageMover { // Make the changes in aggregate groups metadata, if present in any of them. $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class ); - TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ) ); + TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ), __METHOD__ ); foreach ( $aggregateGroups as $id => $group ) { $subgroups = TranslateMetadata::get( $id, 'subgroups' ); @@ -443,5 +470,73 @@ class TranslatablePageMover { ); } } + + // Move discouraged status + $priority = MessageGroups::getPriority( $oldGroupId ); + if ( $priority !== '' ) { + MessageGroups::setPriority( $newGroupId, $priority ); + MessageGroups::setPriority( $oldGroupId, '' ); + } + } + + /** + * To identify the talk pages, we first gather the possible talk pages into + * and then check that they exist. Title::exists perform a database check so + * we gather them into LinkBatch to reduce the performance impact. + * @param PageMoveOperation[] $pageMoveOperations + * @return Title[] + */ + private function getTalkPagesForMove( array $pageMoveOperations ): array { + $lb = $this->linkBatchFactory->newLinkBatch(); + $talkPageList = []; + + foreach ( $pageMoveOperations as $pageOperation ) { + $talkPage = $pageOperation->getOldTitle()->getTalkPageIfDefined(); + $talkPageList[] = $talkPage; + if ( $talkPage ) { + $lb->addObj( $talkPage ); + } + } + + $lb->setCaller( __METHOD__ )->execute(); + foreach ( $talkPageList as $index => $talkPage ) { + if ( !$talkPage || !$talkPage->exists() ) { + $talkPageList[$index] = null; + } + } + + return $talkPageList; + } + + private function createPageMoveOperation( PageTitleRenamer $renamer, Title $from ): PageMoveOperation { + try { + $to = $renamer->getNewTitle( $from ); + $operation = new PageMoveOperation( $from, $to ); + } catch ( InvalidPageTitleRename $e ) { + $operation = new PageMoveOperation( $from, null, $e ); + } + + return $operation; + } + + private function getRenameMoveBlocker( Title $old, string $pageType, int $renameError ): Status { + if ( $renameError === PageTitleRenamer::NO_ERROR ) { + throw new LogicException( + 'Trying to fetch MoveBlocker when there was no error during rename. Title: ' . + $old->getPrefixedText() . ', page type: ' . $pageType + ); + } + + if ( $renameError === PageTitleRenamer::UNKNOWN_PAGE ) { + $status = Status::newFatal( 'pt-movepage-block-unknown-page', $old->getPrefixedText() ); + } elseif ( $renameError === PageTitleRenamer::NS_TALK_UNSUPPORTED ) { + $status = Status::newFatal( 'pt-movepage-block-ns-talk-unsupported', $old->getPrefixedText() ); + } elseif ( $renameError === PageTitleRenamer::RENAME_FAILED ) { + $status = Status::newFatal( 'pt-movepage-block-rename-failed', $old->getPrefixedText() ); + } else { + return Status::newFatal( "pt-movepage-block-$pageType-invalid", $old->getPrefixedText() ); + } + + return $status; } } diff --git a/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php b/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php index 78ffb4ae..ff52eb3b 100644 --- a/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php +++ b/MLEB/Translate/src/PageTranslation/TranslatablePageParser.php @@ -39,9 +39,7 @@ class TranslatablePageParser { $text = preg_replace( "~(^=.*=) <!--T:[^$ic]+-->$~um", '\1', $text ); $text = preg_replace( "~<!--T:[^$ic]+-->[\n ]?~um", '', $text ); // Remove variables - $unit = new TranslationUnit(); - $unit->id = 'XXX'; - $unit->text = $text; + $unit = new TranslationUnit( $text ); $text = $unit->getTextForTrans(); $text = $this->unarmourNowiki( $nowiki, $text ); @@ -168,11 +166,11 @@ class TranslatablePageParser { ); } - $section = new TranslationUnit(); + // If no id given in the source, default to a new section id + $id = TranslationUnit::NEW_UNIT_ID; if ( $count === 1 ) { foreach ( $matches as $match ) { [ /*full*/, $id ] = $match; - $section->id = $id; // Currently handle only these two standard places. // Is this too strict? @@ -193,14 +191,9 @@ class TranslatablePageParser { ); } } - } else { - // New section - $section->id = -1; } - $section->text = $content; - - return $section; + return new TranslationUnit( $content, $id ); } /** @internal */ @@ -221,5 +214,3 @@ class TranslatablePageParser { return strtr( $text, $holders ); } } - -class_alias( TranslatablePageParser::class, '\MediaWiki\Extensions\Translate\TranslatablePageParser' ); diff --git a/MLEB/Translate/src/PageTranslation/TranslationPage.php b/MLEB/Translate/src/PageTranslation/TranslationPage.php index 5efb7a47..39c6336f 100644 --- a/MLEB/Translate/src/PageTranslation/TranslationPage.php +++ b/MLEB/Translate/src/PageTranslation/TranslationPage.php @@ -3,8 +3,11 @@ declare( strict_types = 1 ); namespace MediaWiki\Extension\Translate\PageTranslation; +use Content; +use ContentHandler; use Language; use MessageCollection; +use Title; use TMessage; use WikiPageMessageGroup; @@ -30,8 +33,8 @@ class TranslationPage { private $showOutdated; /** @var bool */ private $wrapUntranslated; - /** @var string */ - private $prefix; + /** @var Title */ + private $sourcePageTitle; public function __construct( ParserOutput $output, @@ -40,7 +43,7 @@ class TranslationPage { Language $sourceLanguage, bool $showOutdated, bool $wrapUntranslated, - string $prefix + Title $sourcePageTitle ) { $this->output = $output; $this->group = $group; @@ -48,7 +51,7 @@ class TranslationPage { $this->sourceLanguage = $sourceLanguage; $this->showOutdated = $showOutdated; $this->wrapUntranslated = $wrapUntranslated; - $this->prefix = $prefix; + $this->sourcePageTitle = $sourcePageTitle; } /** Generate translation page source using default options. */ @@ -59,6 +62,13 @@ class TranslationPage { return $this->generateSourceFromTranslations( $messages ); } + /** @since 2021.07 */ + public function getPageContent(): Content { + $text = $this->generateSource(); + $model = $this->sourcePageTitle->getContentModel(); + return ContentHandler::makeContent( $text, null, $model ); + } + public function getMessageCollection(): MessageCollection { return $this->group->initCollection( $this->targetLanguage->getCode() ); } @@ -75,8 +85,9 @@ class TranslationPage { /** @return TMessage[] */ public function extractMessages( MessageCollection $collection ): array { $messages = []; + $prefix = $this->sourcePageTitle->getPrefixedDBkey() . '/'; foreach ( $this->output->units() as $unit ) { - $messages[$unit->id] = $collection[$this->prefix . $unit->id] ?? null; + $messages[$unit->id] = $collection[$prefix . $unit->id] ?? null; } return $messages; @@ -100,5 +111,3 @@ class TranslationPage { return strtr( $template, $replacements ); } } - -class_alias( TranslationPage::class, '\MediaWiki\Extensions\Translate\TranslationPage' ); diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnit.php b/MLEB/Translate/src/PageTranslation/TranslationUnit.php index 4006f458..b70682d0 100644 --- a/MLEB/Translate/src/PageTranslation/TranslationUnit.php +++ b/MLEB/Translate/src/PageTranslation/TranslationUnit.php @@ -17,10 +17,9 @@ use const PREG_SET_ORDER; */ class TranslationUnit { public const UNIT_MARKER_INVALID_CHARS = "_/\n<>"; + public const NEW_UNIT_ID = '-1'; /** @var string Unit name */ public $id; - /** @var ?string New name of the unit, that will be saved to database. */ - public $name = null; /** @var string Unit text. */ public $text; /** @var string Is this new, existing, changed or deleted unit. */ @@ -37,7 +36,19 @@ class TranslationUnit { /** @var int Version number for the serialization. */ private $version = 1; /** @var string[] List of properties to serialize. */ - private static $properties = [ 'version', 'id', 'name', 'text', 'type', 'oldText', 'inline' ]; + private static $properties = [ 'version', 'id', 'text', 'type', 'oldText', 'inline' ]; + + public function __construct( + string $text, + string $id = self::NEW_UNIT_ID, + string $type = 'new', + string $oldText = null + ) { + $this->text = $text; + $this->id = $id; + $this->type = $type; + $this->oldText = $oldText; + } public function setIsInline( bool $value ): void { $this->inline = $value; @@ -82,8 +93,8 @@ class TranslationUnit { /** Returns the unit text with updated or added unit marker */ public function getMarkedText(): string { - $id = $this->name ?? $this->id; - $header = "<!--T:{$id}-->"; + $id = $this->id; + $header = "<!--T:$id-->"; $re = '~^(=+.*?=+\s*?$)~m'; $rep = "\\1 $header"; $count = 0; @@ -153,7 +164,8 @@ REGEXP; } public static function unserializeFromArray( array $data ): self { - $unit = new self(); + // Give dummy default text, will be overridden + $unit = new self( '' ); foreach ( self::$properties as $index => $property ) { $unit->$property = $data[$index]; } @@ -209,4 +221,36 @@ REGEXP; return $content; } + + /** @return TranslationUnitIssue[] */ + public function getIssues(): array { + $issues = $usedNames = []; + foreach ( $this->getVariables() as $variable ) { + $name = $variable->getName(); + $pattern = '/^' . TranslatablePageInsertablesSuggester::NAME_PATTERN . '$/u'; + if ( !preg_match( $pattern, $name ) ) { + // Key by name to avoid multiple issues of the same name + $issues[$name] = new TranslationUnitIssue( + TranslationUnitIssue::WARNING, + 'tpt-validation-not-insertable', + [ wfEscapeWikiText( $name ) ] + ); + } + + $usedNames[ $name ][] = $variable->getValue(); + } + + foreach ( $usedNames as $name => $contents ) { + $uniqueValueCount = count( array_unique( $contents ) ); + if ( $uniqueValueCount > 1 ) { + $issues[] = new TranslationUnitIssue( + TranslationUnitIssue::ERROR, + 'tpt-validation-name-reuse', + [ wfEscapeWikiText( $name ) ] + ); + } + } + + return array_values( $issues ); + } } diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php b/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php new file mode 100644 index 00000000..65f9508b --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php @@ -0,0 +1,46 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use InvalidArgumentException; +use MediaWiki\Extension\Translate\Validation\ValidationIssue; +use MessageSpecifier; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + * @see ValidationIssue (similar, but different use case) + */ +class TranslationUnitIssue implements MessageSpecifier { + public const ERROR = 'error'; + public const WARNING = 'warning'; + /** @var string self::ERROR|self::WARNING */ + private $severity; + /** @var string */ + private $messageKey; + /** @var array */ + private $messageParams; + + public function __construct( string $severity, string $messageKey, array $messageParams = [] ) { + if ( !in_array( $severity, [ self::ERROR, self::WARNING ] ) ) { + throw new InvalidArgumentException( 'Invalid value for severity: ' . $severity ); + } + $this->severity = $severity; + $this->messageKey = $messageKey; + $this->messageParams = $messageParams; + } + + public function getSeverity(): string { + return $this->severity; + } + + public function getKey(): string { + return $this->messageKey; + } + + public function getParams(): array { + return $this->messageParams; + } +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php b/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php new file mode 100644 index 00000000..fbaf462a --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitReader.php @@ -0,0 +1,17 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +/** + * @license GPL-2.0-or-later + * @author Niklas Laxström + * @since 2021.05 + */ +interface TranslationUnitReader { + /** @return TranslationUnit[] */ + public function getUnits(): array; + + /** @return string[] */ + public function getNames(): array; +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php b/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php new file mode 100644 index 00000000..878d1ef0 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitStore.php @@ -0,0 +1,58 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use Wikimedia\Rdbms\IDatabase; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class TranslationUnitStore implements TranslationUnitReader { + private const TABLE = 'translate_sections'; + /** @var IDatabase */ + private $db; + /** @var int */ + private $pageId; + + public function __construct( IDatabase $db, int $pageId ) { + $this->db = $db; + $this->pageId = $pageId; + } + + public function getUnits(): array { + $res = $this->db->select( + self::TABLE, + [ 'trs_key', 'trs_text' ], + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + + $units = []; + foreach ( $res as $row ) { + $units[$row->trs_key] = new TranslationUnit( $row->trs_text, $row->trs_key ); + } + + return $units; + } + + /** @return string[] */ + public function getNames(): array { + return $this->db->selectFieldValues( + self::TABLE, + 'trs_key', + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + } + + public function delete(): void { + $this->db->delete( + self::TABLE, + [ 'trs_page' => $this->pageId ], + __METHOD__ + ); + } +} diff --git a/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php b/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php new file mode 100644 index 00000000..3c2cefb2 --- /dev/null +++ b/MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php @@ -0,0 +1,42 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\PageTranslation; + +use LogicException; +use Title; +use Wikimedia\Rdbms\ILoadBalancer; +use const DB_PRIMARY; +use const DB_REPLICA; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class TranslationUnitStoreFactory { + /** @var ILoadBalancer */ + private $lb; + + public function __construct( ILoadBalancer $lb ) { + $this->lb = $lb; + } + + public function getReader( Title $page ): TranslationUnitReader { + $pageId = $page->getArticleID(); + if ( $pageId === 0 ) { + throw new LogicException( 'Page must exist' ); + } + + return new TranslationUnitStore( $this->lb->getConnectionRef( DB_REPLICA ), $pageId ); + } + + public function getWriter( Title $page ): TranslationUnitStore { + $pageId = $page->getArticleID(); + if ( $pageId === 0 ) { + throw new LogicException( 'Page must exist' ); + } + + return new TranslationUnitStore( $this->lb->getConnectionRef( DB_PRIMARY ), $pageId ); + } +} diff --git a/MLEB/Translate/src/ServiceWiring.php b/MLEB/Translate/src/ServiceWiring.php index a38ed81c..44bbb4f0 100644 --- a/MLEB/Translate/src/ServiceWiring.php +++ b/MLEB/Translate/src/ServiceWiring.php @@ -12,34 +12,70 @@ use MediaWiki\Extension\Translate\Cache\PersistentCache; use MediaWiki\Extension\Translate\Cache\PersistentDatabaseCache; use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageMover; use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageParser; +use MediaWiki\Extension\Translate\PageTranslation\TranslationUnitStoreFactory; use MediaWiki\Extension\Translate\Statistics\TranslationStatsDataProvider; use MediaWiki\Extension\Translate\Statistics\TranslatorActivity; use MediaWiki\Extension\Translate\Statistics\TranslatorActivityQuery; +use MediaWiki\Extension\Translate\Synchronization\ExternalMessageSourceStateImporter; use MediaWiki\Extension\Translate\Synchronization\GroupSynchronizationCache; +use MediaWiki\Extension\Translate\TranslatorInterface\EntitySearch; use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashReader; use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashStorage; use MediaWiki\Extension\Translate\TtmServer\TtmServerFactory; +use MediaWiki\Extension\Translate\Utilities\ConfigHelper; use MediaWiki\Extension\Translate\Utilities\Json\JsonCodec; use MediaWiki\Extension\Translate\Utilities\ParsingPlaceholderFactory; +use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; /** @phpcs-require-sorted-array */ return [ - 'Translate:GroupSynchronizationCache' => function ( + 'Translate:ConfigHelper' => static function (): ConfigHelper { + return new ConfigHelper(); + }, + + 'Translate:EntitySearch' => static function ( MediaWikiServices $services ): EntitySearch { + // BC for MW <= 1.36 + if ( method_exists( $services, 'getCollationFactory' ) ) { + $collation = $services->getCollationFactory()->makeCollation( 'uca-default-u-kn' ); + } else { + $collation = Collation::factory( 'uca-default-u-kn' ); + } + + return new EntitySearch( + $services->getMainWANObjectCache(), + $collation, + MessageGroups::singleton() + ); + }, + + 'Translate:ExternalMessageSourceStateImporter' => static function ( + MediaWikiServices $services + ): ExternalMessageSourceStateImporter { + return new ExternalMessageSourceStateImporter( + $services->getMainConfig(), + $services->get( 'Translate:GroupSynchronizationCache' ), + JobQueueGroup::singleton(), + LoggerFactory::getInstance( 'Translate.GroupSynchronization' ), + MessageIndex::singleton() + ); + }, + + 'Translate:GroupSynchronizationCache' => static function ( MediaWikiServices $services ): GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache' ) ); }, - 'Translate:JsonCodec' => function (): JsonCodec { + 'Translate:JsonCodec' => static function (): JsonCodec { return new JsonCodec(); }, - 'Translate:ParsingPlaceholderFactory' => function (): ParsingPlaceholderFactory { + 'Translate:ParsingPlaceholderFactory' => static function (): ParsingPlaceholderFactory { return new ParsingPlaceholderFactory(); }, - 'Translate:PersistentCache' => function ( MediaWikiServices $services ): PersistentCache { + 'Translate:PersistentCache' => static function ( MediaWikiServices $services ): PersistentCache { return new PersistentDatabaseCache( $services->getDBLoadBalancer(), // TODO: Since we have a similar interface, see if we can load the JsonCodec @@ -48,30 +84,32 @@ return [ ); }, - 'Translate:TranslatablePageMover' => function ( MediaWikiServices $services ): TranslatablePageMover + 'Translate:TranslatablePageMover' => static function ( MediaWikiServices $services ): TranslatablePageMover { return new TranslatablePageMover( $services->getMovePageFactory(), JobQueueGroup::singleton(), + $services->getLinkBatchFactory(), $services->getMainConfig()->get( 'TranslatePageMoveLimit' ) ); }, - 'Translate:TranslatablePageParser' => function ( MediaWikiServices $services ): TranslatablePageParser + 'Translate:TranslatablePageParser' => static function ( MediaWikiServices $services ): TranslatablePageParser { return new TranslatablePageParser( $services->get( 'Translate:ParsingPlaceholderFactory' ) ); }, - 'Translate:TranslationStashReader' => function ( MediaWikiServices $services ): TranslationStashReader + 'Translate:TranslationStashReader' => static function ( MediaWikiServices $services ): TranslationStashReader { $db = $services->getDBLoadBalancer()->getConnectionRef( DB_REPLICA ); return new TranslationStashStorage( $db ); }, - 'Translate:TranslationStatsDataProvider' => function ( MediaWikiServices $services ): TranslationStatsDataProvider - { + 'Translate:TranslationStatsDataProvider' => static function ( + MediaWikiServices $services + ): TranslationStatsDataProvider { return new TranslationStatsDataProvider( new ServiceOptions( TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, @@ -81,7 +119,13 @@ return [ ); }, - 'Translate:TranslatorActivity' => function ( MediaWikiServices $services ): TranslatorActivity { + '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() @@ -95,7 +139,7 @@ return [ ); }, - 'Translate:TtmServerFactory' => function ( MediaWikiServices $services ): TtmServerFactory { + 'Translate:TtmServerFactory' => static function ( MediaWikiServices $services ): TtmServerFactory { $config = $services->getMainConfig(); $default = $config->get( 'TranslateTranslationDefaultService' ); diff --git a/MLEB/Translate/src/Services.php b/MLEB/Translate/src/Services.php index 2c843239..0560940d 100644 --- a/MLEB/Translate/src/Services.php +++ b/MLEB/Translate/src/Services.php @@ -1,19 +1,20 @@ <?php -/** - * @file - * @author Niklas Laxström - * @license GPL-2.0-or-later - */ +declare( strict_types = 1 ); + namespace MediaWiki\Extension\Translate; use MediaWiki\Extension\Translate\Cache\PersistentCache; use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageMover; use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageParser; +use MediaWiki\Extension\Translate\PageTranslation\TranslationUnitStoreFactory; use MediaWiki\Extension\Translate\Statistics\TranslationStatsDataProvider; use MediaWiki\Extension\Translate\Statistics\TranslatorActivity; +use MediaWiki\Extension\Translate\Synchronization\ExternalMessageSourceStateImporter; use MediaWiki\Extension\Translate\Synchronization\GroupSynchronizationCache; +use MediaWiki\Extension\Translate\TranslatorInterface\EntitySearch; use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashReader; use MediaWiki\Extension\Translate\TtmServer\TtmServerFactory; +use MediaWiki\Extension\Translate\Utilities\ConfigHelper; use MediaWiki\Extension\Translate\Utilities\Json\JsonCodec; use MediaWiki\Extension\Translate\Utilities\ParsingPlaceholderFactory; use MediaWiki\MediaWikiServices; @@ -24,11 +25,11 @@ use Psr\Container\ContainerInterface; * * Main purpose is to give type-hinted getters for services defined in this extensions. * + * @author Niklas Laxström + * @license GPL-2.0-or-later * @since 2020.04 */ class Services implements ContainerInterface { - /** @var self */ - private static $instance; /** @var ContainerInterface */ private $container; @@ -37,8 +38,7 @@ class Services implements ContainerInterface { } public static function getInstance(): Services { - self::$instance = self::$instance ?? new self( MediaWikiServices::getInstance() ); - return self::$instance; + return new self( MediaWikiServices::getInstance() ); } /** @inheritDoc */ @@ -51,6 +51,19 @@ class Services implements ContainerInterface { return $this->container->has( $id ); } + public function getConfigHelper(): ConfigHelper { + return $this->get( 'Translate:ConfigHelper' ); + } + + /** @since 2021.10 */ + public function getEntitySearch(): EntitySearch { + return $this->get( 'Translate:EntitySearch' ); + } + + public function getExternalMessageSourceStateImporter(): ExternalMessageSourceStateImporter { + return $this->get( 'Translate:ExternalMessageSourceStateImporter' ); + } + public function getGroupSynchronizationCache(): GroupSynchronizationCache { return $this->get( 'Translate:GroupSynchronizationCache' ); } @@ -89,6 +102,11 @@ class Services implements ContainerInterface { return $this->get( 'Translate:TranslationStatsDataProvider' ); } + /** @since 2021.05 */ + public function getTranslationUnitStoreFactory(): TranslationUnitStoreFactory { + return $this->get( 'Translate:TranslationUnitStoreFactory' ); + } + public function getTranslatorActivity(): TranslatorActivity { return $this->get( 'Translate:TranslatorActivity' ); } @@ -98,5 +116,3 @@ class Services implements ContainerInterface { return $this->get( 'Translate:TtmServerFactory' ); } } - -class_alias( Services::class, '\MediaWiki\Extensions\Translate\Services' ); diff --git a/MLEB/Translate/src/Statistics/ActiveLanguagesSpecialPage.php b/MLEB/Translate/src/Statistics/ActiveLanguagesSpecialPage.php index 00b25f7f..fb95bbc3 100644 --- a/MLEB/Translate/src/Statistics/ActiveLanguagesSpecialPage.php +++ b/MLEB/Translate/src/Statistics/ActiveLanguagesSpecialPage.php @@ -6,8 +6,10 @@ namespace MediaWiki\Extension\Translate\Statistics; use Config; use Html; use HtmlArmor; +use Language; use LinkBatch; use MediaWiki\Config\ServiceOptions; +use MediaWiki\Extension\Translate\Utilities\ConfigHelper; use MediaWiki\Languages\LanguageNameUtils; use MediaWiki\Logger\LoggerFactory; use ObjectCache; @@ -33,11 +35,14 @@ class ActiveLanguagesSpecialPage extends SpecialPage { private $langNameUtils; /** @var ILoadBalancer */ private $loadBalancer; + /** @var ConfigHelper */ + private $configHelper; + /** @var Language */ + private $contentLanguage; /** @var int Cutoff time for inactivity in days */ private $period = 180; public const CONSTRUCTOR_OPTIONS = [ - 'TranslateAuthorBlacklist', 'TranslateMessageNamespaces', ]; @@ -45,13 +50,17 @@ class ActiveLanguagesSpecialPage extends SpecialPage { Config $config, TranslatorActivity $translatorActivity, LanguageNameUtils $langNameUtils, - ILoadBalancer $loadBalancer + ILoadBalancer $loadBalancer, + ConfigHelper $configHelper, + Language $contentLanguage ) { parent::__construct( 'SupportedLanguages' ); $this->options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config ); $this->translatorActivity = $translatorActivity; $this->langNameUtils = $langNameUtils; $this->loadBalancer = $loadBalancer; + $this->configHelper = $configHelper; + $this->contentLanguage = $contentLanguage; } protected function getGroupName() { @@ -205,28 +214,10 @@ class ActiveLanguagesSpecialPage extends SpecialPage { } protected function filterUsers( array $users, string $code ): array { - $exclusionList = $this->options->get( 'TranslateAuthorBlacklist' ); - foreach ( $users as $index => $user ) { $username = $user[TranslatorActivityQuery::USER_NAME]; - # We do not know the group - $hash = "#;$code;$username"; - - $excluded = false; - foreach ( $exclusionList as $rule ) { - [ $type, $regex ] = $rule; - - if ( preg_match( $regex, $hash ) ) { - if ( $type === 'white' ) { - $excluded = false; - break; - } else { - $excluded = true; - } - } - } - - if ( $excluded ) { + // We do not know the group + if ( $this->configHelper->isAuthorExcluded( '#', $code, $username ) ) { unset( $users[$index] ); } } @@ -235,19 +226,26 @@ class ActiveLanguagesSpecialPage extends SpecialPage { } protected function outputLanguageCloud( array $languages, array $names ) { + global $wgTranslateDocumentationLanguageCode; + $out = $this->getOutput(); $out->addHTML( '<div class="tagcloud autonym">' ); foreach ( $languages as $k => $v ) { $name = $names[$k]; + $langAttribute = $k; $size = round( log( $v ) * 20 ) + 10; + if ( $langAttribute === $wgTranslateDocumentationLanguageCode ) { + $langAttribute = $this->contentLanguage->getHtmlCode(); + } + $params = [ 'href' => $this->getPageTitle( $k )->getLocalURL(), 'class' => 'tag', 'style' => "font-size:$size%", - 'lang' => $k, + 'lang' => $langAttribute, ]; $tag = Html::element( 'a', $params, $name ); @@ -267,7 +265,7 @@ class ActiveLanguagesSpecialPage extends SpecialPage { $statsTable = new StatsTable(); // List users in descending order by number of translations in this language - usort( $userStats, function ( $a, $b ) { + usort( $userStats, static function ( $a, $b ) { return -( $a[TranslatorActivityQuery::USER_TRANSLATIONS] <=> diff --git a/MLEB/Translate/src/Statistics/CleanupTranslationProgressStatsMaintenanceScript.php b/MLEB/Translate/src/Statistics/CleanupTranslationProgressStatsMaintenanceScript.php index 764af9e5..41da617f 100644 --- a/MLEB/Translate/src/Statistics/CleanupTranslationProgressStatsMaintenanceScript.php +++ b/MLEB/Translate/src/Statistics/CleanupTranslationProgressStatsMaintenanceScript.php @@ -8,7 +8,7 @@ use MediaWiki\MediaWikiServices; use MessageGroups; use RawMessage; use TranslateUtils; -use const DB_MASTER; +use const DB_PRIMARY; /** * @since 2021.03 @@ -24,7 +24,7 @@ class CleanupTranslationProgressStatsMaintenanceScript extends Maintenance { public function execute() { $services = MediaWikiServices::getInstance(); - $db = $services->getDBLoadBalancer()->getConnectionRef( DB_MASTER ); + $db = $services->getDBLoadBalancer()->getConnectionRef( DB_PRIMARY ); $dbGroupIds = $db->selectFieldValues( 'translate_groupstats', diff --git a/MLEB/Translate/src/Statistics/QueryTranslationStatsActionApi.php b/MLEB/Translate/src/Statistics/QueryTranslationStatsActionApi.php index f6278297..ff0fc438 100644 --- a/MLEB/Translate/src/Statistics/QueryTranslationStatsActionApi.php +++ b/MLEB/Translate/src/Statistics/QueryTranslationStatsActionApi.php @@ -50,7 +50,7 @@ class QueryTranslationStatsActionApi extends ApiBase { ApiBase::PARAM_REQUIRED => true, ApiBase::PARAM_DFLT => 30, ApiBase::PARAM_MIN => 1, - ApiBase::PARAM_MAX => 1000, + ApiBase::PARAM_MAX => 10000, ApiBase::PARAM_RANGE_ENFORCE => true ], 'group' => [ @@ -80,5 +80,3 @@ class QueryTranslationStatsActionApi extends ApiBase { ]; } } - -class_alias( QueryTranslationStatsActionApi::class, '\MediaWiki\Extensions\Translate\QueryTranslationStatsActionApi' ); diff --git a/MLEB/Translate/src/Statistics/ReviewPerLanguageStats.php b/MLEB/Translate/src/Statistics/ReviewPerLanguageStats.php index 7b3a2862..c0efa0e5 100644 --- a/MLEB/Translate/src/Statistics/ReviewPerLanguageStats.php +++ b/MLEB/Translate/src/Statistics/ReviewPerLanguageStats.php @@ -53,24 +53,21 @@ class ReviewPerLanguageStats extends TranslatePerLanguageStats { } if ( $this->opts->getValue( 'count' ) === 'reviewers' ) { - $tables[] = 'actor'; - $joins['actor'] = [ 'JOIN', 'actor_id=log_actor' ]; - $fields['log_user_text'] = 'actor_name'; + $fields[] = 'log_actor'; } $type .= '-reviews'; } public function indexOf( $row ) { - // We need to check that there is only one user per day. if ( $this->opts->getValue( 'count' ) === 'reviewers' ) { $date = $this->formatTimestamp( $row->log_timestamp ); - if ( isset( $this->usercache[$date][$row->log_user_text] ) ) { + if ( isset( $this->seenUsers[$date][$row->log_actor] ) ) { return false; - } else { - $this->usercache[$date][$row->log_user_text] = 1; } + + $this->seenUsers[$date][$row->log_actor] = 1; } // Do not consider language-less pages. @@ -111,5 +108,3 @@ class ReviewPerLanguageStats extends TranslatePerLanguageStats { return $row->log_timestamp; } } - -class_alias( ReviewPerLanguageStats::class, '\MediaWiki\Extensions\Translate\ReviewPerLanguageStats' ); diff --git a/MLEB/Translate/src/Statistics/StatisticsUnavailable.php b/MLEB/Translate/src/Statistics/StatisticsUnavailable.php index 6321b47d..53edb371 100644 --- a/MLEB/Translate/src/Statistics/StatisticsUnavailable.php +++ b/MLEB/Translate/src/Statistics/StatisticsUnavailable.php @@ -12,5 +12,3 @@ use RuntimeException; /** @since 2020.04 */ class StatisticsUnavailable extends RuntimeException { } - -class_alias( StatisticsUnavailable::class, '\MediaWiki\Extensions\Translate\StatisticsUnavailable' ); diff --git a/MLEB/Translate/src/Statistics/TranslatePerLanguageStats.php b/MLEB/Translate/src/Statistics/TranslatePerLanguageStats.php index 2461be89..e7488744 100644 --- a/MLEB/Translate/src/Statistics/TranslatePerLanguageStats.php +++ b/MLEB/Translate/src/Statistics/TranslatePerLanguageStats.php @@ -13,14 +13,14 @@ use TranslateUtils; * @since 2010.07 */ class TranslatePerLanguageStats extends TranslationStatsBase { - /** @var int[][] array( string => int ) Cache used to count active users only once per day. */ - protected $usercache; + /** @var array For client side group by time period */ + protected $seenUsers; protected $groups; public function __construct( TranslationStatsGraphOptions $opts ) { parent::__construct( $opts ); - // This query is slow... ensure a lower limit. - $opts->boundValue( 'days', 1, 200 ); + // This query is slow. Set a lower limit, but allow seeing one year at once. + $opts->boundValue( 'days', 1, 400 ); } public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { @@ -65,24 +65,21 @@ class TranslatePerLanguageStats extends TranslationStatsBase { } if ( $this->opts->getValue( 'count' ) === 'users' ) { - $tables[] = 'actor'; - $joins['actor'] = [ 'JOIN', 'actor_id=rc_actor' ]; - $fields['rc_user_text'] = 'actor_name'; + $fields[] = 'rc_actor'; } $type .= '-perlang'; } public function indexOf( $row ) { - // We need to check that there is only one user per day. if ( $this->opts->getValue( 'count' ) === 'users' ) { $date = $this->formatTimestamp( $row->rc_timestamp ); - if ( isset( $this->usercache[$date][$row->rc_user_text] ) ) { + if ( isset( $this->seenUsers[$date][$row->rc_actor] ) ) { return false; - } else { - $this->usercache[$date][$row->rc_user_text] = 1; } + + $this->seenUsers[$date][$row->rc_actor] = true; } // Do not consider language-less pages. @@ -96,7 +93,7 @@ class TranslatePerLanguageStats extends TranslationStatsBase { } // The key-building needs to be in sync with ::labels(). - list( $key, $code ) = TranslateUtils::figureMessage( $row->rc_title ); + [ $key, $code ] = TranslateUtils::figureMessage( $row->rc_title ); $groups = []; $codes = []; @@ -183,6 +180,9 @@ class TranslatePerLanguageStats extends TranslationStatsBase { case 'months': $cut = 8; break; + case 'years': + $cut = 10; + break; default: return MediaWikiServices::getInstance()->getContentLanguage() ->sprintfDate( $this->getDateFormat(), $timestamp ); @@ -191,5 +191,3 @@ class TranslatePerLanguageStats extends TranslationStatsBase { return substr( $timestamp, 0, -$cut ); } } - -class_alias( TranslatePerLanguageStats::class, '\MediaWiki\Extensions\Translate\TranslatePerLanguageStats' ); diff --git a/MLEB/Translate/src/Statistics/TranslateRegistrationStats.php b/MLEB/Translate/src/Statistics/TranslateRegistrationStats.php index aca7506b..fe605f15 100644 --- a/MLEB/Translate/src/Statistics/TranslateRegistrationStats.php +++ b/MLEB/Translate/src/Statistics/TranslateRegistrationStats.php @@ -23,5 +23,3 @@ class TranslateRegistrationStats extends TranslationStatsBase { return $row->user_registration; } } - -class_alias( TranslateRegistrationStats::class, '\MediaWiki\Extensions\Translate\TranslateRegistrationStats' ); diff --git a/MLEB/Translate/src/Statistics/TranslationStatsBase.php b/MLEB/Translate/src/Statistics/TranslationStatsBase.php index 3960231d..8c150a2a 100644 --- a/MLEB/Translate/src/Statistics/TranslationStatsBase.php +++ b/MLEB/Translate/src/Statistics/TranslationStatsBase.php @@ -30,7 +30,9 @@ abstract class TranslationStatsBase implements TranslationStatsInterface { public function getDateFormat() { $dateFormat = 'Y-m-d'; $scale = $this->opts->getValue( 'scale' ); - if ( $scale === 'months' ) { + if ( $scale === 'years' ) { + $dateFormat = 'Y'; + } elseif ( $scale === 'months' ) { $dateFormat = 'Y-m'; } elseif ( $scale === 'weeks' ) { $dateFormat = 'Y-\WW'; @@ -73,5 +75,3 @@ abstract class TranslationStatsBase implements TranslationStatsInterface { return array_keys( $namespaces ); } } - -class_alias( TranslationStatsBase::class, '\MediaWiki\Extensions\Translate\TranslationStatsBase' ); diff --git a/MLEB/Translate/src/Statistics/TranslationStatsDataProvider.php b/MLEB/Translate/src/Statistics/TranslationStatsDataProvider.php index 20118822..132fb865 100644 --- a/MLEB/Translate/src/Statistics/TranslationStatsDataProvider.php +++ b/MLEB/Translate/src/Statistics/TranslationStatsDataProvider.php @@ -8,6 +8,7 @@ use MediaWiki\Config\ServiceOptions; use MessageGroups; use TranslateUtils; use Wikimedia\ObjectFactory; +use const TS_MW; /** * Provides translation stats data @@ -44,7 +45,7 @@ class TranslationStatsDataProvider { * @param Language $language * @return array ( string => array ) Data indexed by their date labels. */ - public function getGraphData( TranslationStatsGraphOptions $opts, Language $language ) { + public function getGraphData( TranslationStatsGraphOptions $opts, Language $language ): array { $dbr = wfGetDB( DB_REPLICA ); $so = $this->getStatsProvider( $opts->getValue( 'count' ), $opts ); @@ -97,6 +98,8 @@ class TranslationStatsDataProvider { $cutoff += $increment; $data[$date] = $defaults; } + // Ensure $lastValue is within range, in case the loop above jumped over it + $data[$language->sprintfDate( $dateFormat, wfTimestamp( TS_MW, $lastValue ) )] = $defaults; // Processing $labelToIndex = array_flip( $labels ); @@ -107,7 +110,7 @@ class TranslationStatsDataProvider { continue; } - foreach ( (array)$indexLabels as $i ) { + foreach ( $indexLabels as $i ) { if ( !isset( $labelToIndex[$i] ) ) { continue; } @@ -144,10 +147,13 @@ class TranslationStatsDataProvider { } } + // Indicator that the last value is not full if ( $end === null ) { - $last = array_splice( $data, -1, 1 ); - // Indicator that the last value is not full - $data[key( $last ) . '*'] = current( $last ); + // Warning: do not user array_splice, which does not preserve numerical keys + $last = end( $data ); + $key = key( $data ); + unset( $data[$key] ); + $data[ "$key*" ] = $last; } return [ $labels, $data ]; @@ -195,6 +201,13 @@ class TranslationStatsDataProvider { } // Round to nearest day $cutoff -= ( $cutoff % 86400 ); + } elseif ( $scale === 'years' ) { + // Go Xwards/ day by day until we are on the first day of the year + while ( date( 'z', $cutoff ) !== '0' ) { + $cutoff += $dir * 86400; + } + // Round to nearest day + $cutoff -= ( $cutoff % 86400 ); } return $cutoff; @@ -216,7 +229,9 @@ class TranslationStatsDataProvider { */ private static function getIncrement( string $scale ): int { $increment = 3600 * 24; - if ( $scale === 'months' ) { + if ( $scale === 'years' ) { + $increment = 3600 * 24 * 350; + } elseif ( $scale === 'months' ) { /* We use increment to fill up the values. Use number small enough * to ensure we hit each month */ $increment = 3600 * 24 * 15; @@ -229,5 +244,3 @@ class TranslationStatsDataProvider { return $increment; } } - -class_alias( TranslationStatsDataProvider::class, '\MediaWiki\Extensions\Translate\TranslationStatsDataProvider' ); diff --git a/MLEB/Translate/src/Statistics/TranslationStatsGraphOptions.php b/MLEB/Translate/src/Statistics/TranslationStatsGraphOptions.php index 250c664c..3eb6b8a2 100644 --- a/MLEB/Translate/src/Statistics/TranslationStatsGraphOptions.php +++ b/MLEB/Translate/src/Statistics/TranslationStatsGraphOptions.php @@ -15,7 +15,7 @@ class TranslationStatsGraphOptions { /** @var FormOptions */ private $formOptions; /** @var string[] */ - public const VALID_SCALES = [ 'months', 'weeks', 'days', 'hours' ]; + public const VALID_SCALES = [ 'years', 'months', 'weeks', 'days', 'hours' ]; public function __construct() { $this->formOptions = new FormOptions(); @@ -108,5 +108,3 @@ class TranslationStatsGraphOptions { $this->formOptions->validateIntBounds( $key, $min, $max ); } } - -class_alias( TranslationStatsGraphOptions::class, '\MediaWiki\Extensions\Translate\TranslationStatsGraphOptions' ); diff --git a/MLEB/Translate/src/Statistics/TranslationStatsInterface.php b/MLEB/Translate/src/Statistics/TranslationStatsInterface.php index e6f503a2..97cc9b05 100644 --- a/MLEB/Translate/src/Statistics/TranslationStatsInterface.php +++ b/MLEB/Translate/src/Statistics/TranslationStatsInterface.php @@ -64,5 +64,3 @@ interface TranslationStatsInterface { */ public function getDateFormat(); } - -class_alias( TranslationStatsInterface::class, '\MediaWiki\Extensions\Translate\TranslationStatsInterface' ); diff --git a/MLEB/Translate/src/Statistics/TranslationStatsSpecialPage.php b/MLEB/Translate/src/Statistics/TranslationStatsSpecialPage.php new file mode 100644 index 00000000..5b537c9d --- /dev/null +++ b/MLEB/Translate/src/Statistics/TranslationStatsSpecialPage.php @@ -0,0 +1,250 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\Statistics; + +use FormOptions; +use Html; +use JsSelectToInput; +use MessageGroup; +use MessageGroups; +use SpecialPage; +use TranslateUtils; +use Xml; +use XmlSelect; +use function wfEscapeWikiText; + +/** + * Includable special page for generating graphs for statistics. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ +class TranslationStatsSpecialPage extends SpecialPage { + /** @var TranslationStatsDataProvider */ + private $dataProvider; + private const GRAPH_CONTAINER_ID = 'translationStatsGraphContainer'; + private const GRAPH_CONTAINER_CLASS = 'mw-translate-translationstats-container'; + + public function __construct( TranslationStatsDataProvider $dataProvider ) { + parent::__construct( 'TranslationStats' ); + $this->dataProvider = $dataProvider; + } + + /** @inheritDoc */ + public function isIncludable(): bool { + return true; + } + + /** @inheritDoc */ + protected function getGroupName(): string { + return 'translation'; + } + + /** @inheritDoc */ + public function execute( $par ): void { + $graphOpts = new TranslationStatsGraphOptions(); + $graphOpts->bindArray( $this->getRequest()->getValues() ); + + $pars = explode( ';', (string)$par ); + foreach ( $pars as $item ) { + if ( strpos( $item, '=' ) === false ) { + continue; + } + + [ $key, $value ] = array_map( 'trim', explode( '=', $item, 2 ) ); + if ( $graphOpts->hasValue( $key ) ) { + $graphOpts->setValue( $key, $value ); + } + } + + $graphOpts->normalize( $this->dataProvider->getGraphTypes() ); + $opts = $graphOpts->getFormOptions(); + + if ( $this->including() ) { + $this->getOutput()->addHTML( $this->embed( $opts ) ); + } else { + $this->form( $opts ); + } + } + + /** + * Constructs the form which can be used to generate custom graphs. + * + * @suppress SecurityCheck-DoubleEscaped Intentionally outputting what user should type + */ + private function form( FormOptions $opts ): void { + $script = $this->getConfig()->get( 'Script' ); + + $this->setHeaders(); + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.translationstats' ); + $out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' ); + $out->addWikiMsg( 'translate-statsf-intro' ); + $out->addHTML( + Xml::fieldset( $this->msg( 'translate-statsf-options' )->text() ) . Html::openElement( + 'form', + [ 'action' => $script, 'id' => 'translationStatsConfig' ] + ) . Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'preview', 1 ) . '<table>' + ); + $submit = Xml::submitButton( $this->msg( 'translate-statsf-submit' )->text() ); + $out->addHTML( + $this->eInput( 'width', $opts ) . $this->eInput( 'height', $opts ) . + '<tr><td colspan="2"><hr /></td></tr>' . $this->eInput( 'start', $opts, 24 ) . + $this->eInput( 'days', $opts ) . + $this->eRadio( 'scale', $opts, [ 'years', 'months', 'weeks', 'days', 'hours' ] ) . + $this->eRadio( 'count', $opts, $this->dataProvider->getGraphTypes() ) . + '<tr><td colspan="2"><hr /></td></tr>' . $this->eLanguage( 'language', $opts ) . + $this->eGroup( 'group', $opts ) . '<tr><td colspan="2"><hr /></td></tr>' . + '<tr><td colspan="2">' . $submit . '</td></tr>' + ); + $out->addHTML( '</table></form></fieldset>' ); + if ( !$opts['preview'] ) { + return; + } + $spiParams = []; + foreach ( $opts->getChangedValues() as $key => $v ) { + if ( $key === 'preview' ) { + continue; + } + if ( is_array( $v ) ) { + $v = implode( ',', $v ); + if ( !strlen( $v ) ) { + continue; + } + } + $spiParams[] = $key . '=' . wfEscapeWikiText( $v ); + } + if ( $spiParams ) { + $spiParams = '/' . implode( ';', $spiParams ); + } + $titleText = $this->getPageTitle()->getPrefixedText(); + $out->addHTML( Html::element( 'hr' ) ); + // Element to render the graph + $out->addHTML( + Html::rawElement( + 'div', + [ + 'id' => self::GRAPH_CONTAINER_ID, + 'style' => 'margin: 2em auto; display: block', + 'class' => self::GRAPH_CONTAINER_CLASS, + ] + ) + ); + + $out->addHTML( + Html::element( + 'pre', + [ 'aria-label' => $this->msg( 'translate-statsf-embed' )->text() ], + "{{{$titleText}{$spiParams}}}" + ) + ); + } + + /// Construct HTML for a table row with label and input in two columns. + private function eInput( string $name, FormOptions $opts, int $width = 4 ): string { + $value = $opts[$name]; + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . + Xml::input( $name, $width, $value, [ 'id' => $name ] ) . '</td></tr>' . "\n"; + } + + /// Construct HTML for a label for option. + private function eLabel( string $name ): string { + // Give grep a chance to find the usages: + // translate-statsf-width, translate-statsf-height, translate-statsf-start, + // translate-statsf-days, translate-statsf-scale, translate-statsf-count, + // translate-statsf-language, translate-statsf-group + $label = 'translate-statsf-' . $name; + $label = $this->msg( $label )->escaped(); + return Xml::tags( 'label', [ 'for' => $name ], $label ); + } + + /// Construct HTML for a table row with label and radio input in two columns. + private function eRadio( string $name, FormOptions $opts, array $alts ): string { + // Give grep a chance to find the usages: + // translate-statsf-scale, translate-statsf-count + $label = 'translate-statsf-' . $name; + $label = $this->msg( $label )->escaped(); + $s = '<tr><td>' . $label . '</td><td>'; + $options = []; + foreach ( $alts as $alt ) { + $id = "$name-$alt"; + $radio = Xml::radio( + $name, + $alt, + $alt === $opts[$name], + [ 'id' => $id ] + ) . ' '; + $options[] = $radio . ' ' . $this->eLabel( $id ); + } + $s .= implode( ' ', $options ); + $s .= '</td></tr>' . "\n"; + return $s; + } + + /// Construct HTML for a table row with label and language selector in two columns. + private function eLanguage( string $name, FormOptions $opts ): string { + $value = implode( ',', $opts[$name] ); + + $select = $this->languageSelector(); + $select->setTargetId( 'language' ); + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . $select->getHtmlAndPrepareJS() . + '<br />' . Xml::input( $name, 20, $value, [ 'id' => $name ] ) . '</td></tr>' . "\n"; + } + + /// Construct a JavaScript enhanced language selector. + private function languageSelector(): JsSelectToInput { + $languages = TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() ); + ksort( $languages ); + $selector = new XmlSelect( 'mw-language-selector', 'mw-language-selector' ); + foreach ( $languages as $code => $name ) { + $selector->addOption( "$code - $name", $code ); + } + return new JsSelectToInput( $selector ); + } + + /// Constructs HTML for a table row with label and group selector in two columns. + private function eGroup( string $name, FormOptions $opts ): string { + $value = implode( ',', $opts[$name] ); + + $select = $this->groupSelector(); + $select->setTargetId( 'group' ); + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . $select->getHtmlAndPrepareJS() . + '<br />' . Xml::input( $name, 20, $value, [ 'id' => $name ] ) . '</td></tr>' . "\n"; + } + + /// Construct a JavaScript enhanced group selector. + private function groupSelector(): JsSelectToInput { + $groups = MessageGroups::singleton()->getGroups(); + /** @var MessageGroup $group */ + foreach ( $groups as $key => $group ) { + if ( !$group->exists() ) { + unset( $groups[$key] ); + } + } + ksort( $groups ); + $selector = new XmlSelect( 'mw-group-selector', 'mw-group-selector' ); + /** @var MessageGroup $name */ + foreach ( $groups as $code => $name ) { + $selector->addOption( $name->getLabel(), $code ); + } + return new JsSelectToInput( $selector ); + } + + private function embed( FormOptions $opts ): string { + $this->getOutput()->addModules( 'ext.translate.translationstats.embedded' ); + return Html::rawElement( + 'div', + [ + 'class' => self::GRAPH_CONTAINER_CLASS, + ], + Html::hidden( + 'translationStatsGraphOptions', + json_encode( $opts->getAllValues() ) + ) + ); + } +} diff --git a/MLEB/Translate/src/Statistics/TranslatorActivity.php b/MLEB/Translate/src/Statistics/TranslatorActivity.php index 754821bd..9db590a4 100644 --- a/MLEB/Translate/src/Statistics/TranslatorActivity.php +++ b/MLEB/Translate/src/Statistics/TranslatorActivity.php @@ -40,9 +40,6 @@ class TranslatorActivity { /** * Get translations activity for a given language. - * - * @param string $language Language tag. - * @return array Array with keys users and asOfTime * @throws StatisticsUnavailable If loading statistics is temporarily not possible. */ public function inLanguage( string $language ): array { @@ -129,8 +126,6 @@ class TranslatorActivity { /** * Update cache for one language, even if not stale. - * - * @param string $language Language tag * @throws StatisticsUnavailable If loading statistics is temporarily not possible. */ public function updateLanguage( string $language ): void { @@ -148,5 +143,3 @@ class TranslatorActivity { return $this->languageNameUtils->isKnownLanguageTag( $language ); } } - -class_alias( TranslatorActivity::class, '\MediaWiki\Extensions\Translate\TranslatorActivity' ); diff --git a/MLEB/Translate/src/Statistics/TranslatorActivityQuery.php b/MLEB/Translate/src/Statistics/TranslatorActivityQuery.php index 9b2ad8ad..65e84b15 100644 --- a/MLEB/Translate/src/Statistics/TranslatorActivityQuery.php +++ b/MLEB/Translate/src/Statistics/TranslatorActivityQuery.php @@ -121,5 +121,3 @@ class TranslatorActivityQuery { return $data; } } - -class_alias( TranslatorActivityQuery::class, '\MediaWiki\Extensions\Translate\TranslatorActivityQuery' ); diff --git a/MLEB/Translate/src/Statistics/UpdateTranslatorActivityJob.php b/MLEB/Translate/src/Statistics/UpdateTranslatorActivityJob.php index 06ec60f4..8ed24361 100644 --- a/MLEB/Translate/src/Statistics/UpdateTranslatorActivityJob.php +++ b/MLEB/Translate/src/Statistics/UpdateTranslatorActivityJob.php @@ -36,5 +36,3 @@ class UpdateTranslatorActivityJob extends GenericTranslateJob implements Generic return true; } } - -class_alias( UpdateTranslatorActivityJob::class, '\MediaWiki\Extensions\Translate\UpdateTranslatorActivityJob' ); diff --git a/MLEB/Translate/src/Statistics/UpdateTranslatorActivityMaintenanceScript.php b/MLEB/Translate/src/Statistics/UpdateTranslatorActivityMaintenanceScript.php index 7f92692d..7ebd0d40 100644 --- a/MLEB/Translate/src/Statistics/UpdateTranslatorActivityMaintenanceScript.php +++ b/MLEB/Translate/src/Statistics/UpdateTranslatorActivityMaintenanceScript.php @@ -23,8 +23,3 @@ class UpdateTranslatorActivityMaintenanceScript extends Maintenance { $this->output( "Done.\n" ); } } - -class_alias( - UpdateTranslatorActivityMaintenanceScript::class, - '\MediaWiki\Extensions\Translate\UpdateTranslatorActivityMaintenanceScript' -); diff --git a/MLEB/Translate/src/Synchronization/BackportTranslationsMaintenanceScript.php b/MLEB/Translate/src/Synchronization/BackportTranslationsMaintenanceScript.php new file mode 100644 index 00000000..0ab61e50 --- /dev/null +++ b/MLEB/Translate/src/Synchronization/BackportTranslationsMaintenanceScript.php @@ -0,0 +1,304 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\Synchronization; + +use FileBasedMessageGroup; +use JsonFFS; +use MediaWiki\Extension\Translate\Utilities\BaseMaintenanceScript; +use MediaWiki\Logger\LoggerFactory; +use MessageGroups; +use RuntimeException; +use SimpleFFS; +use TranslateUtils; + +/** + * Script to backport translations from one branch to another. + * + * @since 2021.05 + * @license GPL-2.0-or-later + * @author Niklas Laxström + */ +class BackportTranslationsMaintenanceScript extends BaseMaintenanceScript { + public function __construct() { + parent::__construct(); + $this->addDescription( 'Backport translations from one branch to another.' ); + + $this->addOption( + 'group', + 'Comma separated list of message group IDs (supports * wildcard) to backport', + self::REQUIRED, + self::HAS_ARG + ); + $this->addOption( + 'source-path', + 'Source path for reading updated translations. Defaults to $wgTranslateGroupRoot.', + self::OPTIONAL, + self::HAS_ARG + ); + $this->addOption( + 'target-path', + 'Target path for writing backported translations', + self::REQUIRED, + self::HAS_ARG + ); + $this->addOption( + 'filter-path', + 'Only export a group if its export path matches this prefix (relative to target-path)', + self::OPTIONAL, + self::HAS_ARG + ); + $this->addOption( + 'never-export-languages', + 'Languages to not export', + self::OPTIONAL, + self::HAS_ARG + ); + $this->requireExtension( 'Translate' ); + } + + /** @inheritDoc */ + public function execute() { + $config = $this->getConfig(); + $logger = LoggerFactory::getInstance( 'Translate.GroupSynchronization' ); + $groupPattern = $this->getOption( 'group' ) ?? ''; + $logger->info( + 'Starting backports for groups {groups}', + [ 'groups' => $groupPattern ] + ); + + $sourcePath = $this->getOption( 'source-path' ) ?: $config->get( 'TranslateGroupRoot' ); + if ( !is_readable( $sourcePath ) ) { + $this->fatalError( "Source directory is not readable ($sourcePath)." ); + } + + $targetPath = $this->getOption( 'target-path' ); + if ( !is_writable( $targetPath ) ) { + $this->fatalError( "Target directory is not writable ($targetPath)." ); + } + + $groupIds = MessageGroups::expandWildcards( explode( ',', trim( $groupPattern ) ) ); + $groups = MessageGroups::getGroupsById( $groupIds ); + if ( $groups === [] ) { + $this->fatalError( "Pattern $groupPattern did not match any message groups." ); + } + + $neverExportLanguages = $this->csv2array( + $this->getOption( 'never-export-languages' ) ?? '' + ); + $supportedLanguages = array_keys( TranslateUtils::getLanguageNames( 'en' ) ); + + foreach ( $groups as $group ) { + $groupId = $group->getId(); + if ( !$group instanceof FileBasedMessageGroup ) { + $this->error( "Skipping $groupId: Not instance of FileBasedMessageGroup" ); + continue; + } + + if ( !$group->getFFS() instanceof JsonFFS ) { + $this->error( "Skipping $groupId: Only JSON format is supported" ); + continue; + } + + if ( $this->hasOption( 'filter-path' ) ) { + $filter = $this->getOption( 'filter-path' ); + $exportPath = $group->getTargetFilename( '*' ); + if ( !$this->matchPath( $filter, $exportPath ) ) { + continue; + } + } + + /** @var FileBasedMessageGroup $group */ + $sourceLanguage = $group->getSourceLanguage(); + try { + $sourceDefinitions = $this->loadDefinitions( $group, $sourcePath, $sourceLanguage ); + $targetDefinitions = $this->loadDefinitions( $group, $targetPath, $sourceLanguage ); + } catch ( RuntimeException $e ) { + $this->output( + "Skipping $groupId: Error while loading definitions: {$e->getMessage()}\n" + ); + continue; + } + + $keyCompatibilityMap = $this->getKeyCompatibilityMap( + $sourceDefinitions['MESSAGES'], + $targetDefinitions['MESSAGES'], + $group->getFFS() + ); + + if ( array_filter( $keyCompatibilityMap ) === [] ) { + $this->output( "Skipping $groupId: No compatible keys found\n" ); + continue; + } + + $summary = []; + $languages = $group->getTranslatableLanguages() ?? $supportedLanguages; + $languagesToSkip = $neverExportLanguages; + $languagesToSkip[] = $sourceLanguage; + $languages = array_diff( $languages, $languagesToSkip ); + + foreach ( $languages as $language ) { + $status = $this->backport( + $group, + $sourcePath, + $targetPath, + $keyCompatibilityMap, + $language + ); + + $summary[$status][] = $language; + } + + $numUpdated = count( $summary[ 'updated' ] ?? [] ); + $numAdded = count( $summary[ 'new' ] ?? [] ); + if ( ( $numUpdated + $numAdded ) > 0 ) { + $this->output( + sprintf( + "%s: Compatible keys: %d. Updated %d languages, %d new (%s)\n", + $group->getId(), + count( $keyCompatibilityMap ), + $numUpdated, + $numAdded, + implode( ', ', $summary[ 'new' ] ?? [] ) + ) + ); + } + } + } + + private function csv2array( string $input ): array { + return array_filter( + array_map( 'trim', explode( ',', $input ) ), + static function ( $v ) { + return $v !== ''; + } + ); + } + + private function matchPath( string $prefix, string $full ): bool { + $prefix = "./$prefix"; + $length = strlen( $prefix ); + return substr( $full, 0, $length ) === $prefix; + } + + private function loadDefinitions( + FileBasedMessageGroup $group, + string $path, + string $language + ): array { + $file = $path . '/' . $group->getTargetFilename( $language ); + + if ( !file_exists( $file ) ) { + throw new RuntimeException( "File $file does not exist" ); + } + + $contents = file_get_contents( $file ); + return $group->getFFS()->readFromVariable( $contents ); + } + + /** + * Compares two arrays and returns a new array with keys from the target array with associated values + * being a boolean indicating whether the source array value is compatible with the target array value. + * + * Target array key order was chosen because in backporting we want to use the order of keys in the + * backport target (stable branch). Comparison is done with SimpleFFS::isContentEqual. + * + * @return array<string,bool> Keys in target order + */ + private function getKeyCompatibilityMap( array $source, array $target, SimpleFFS $ffs ): array { + $keys = []; + foreach ( $target as $key => $value ) { + $keys[$key] = isset( $source[ $key ] ) && $ffs->isContentEqual( $source[ $key ], $value ); + } + return $keys; + } + + private function backport( + FileBasedMessageGroup $group, + string $source, + string $targetPath, + array $keyCompatibilityMap, + string $language + ): string { + try { + $sourceTemplate = $this->loadDefinitions( $group, $source, $language ); + } catch ( RuntimeException $e ) { + return 'no definitions'; + } + + try { + $targetTemplate = $this->loadDefinitions( $group, $targetPath, $language ); + } catch ( RuntimeException $e ) { + $targetTemplate = [ + 'MESSAGES' => [], + 'AUTHORS' => [], + ]; + } + + // Amend the target with compatible things from the source + $hasUpdates = false; + + $ffs = $group->getFFS(); + + // This has been checked before, but checking again to keep Phan and IDEs happy. + // Remove once support for other FFS are added. + if ( !$ffs instanceof JsonFFS ) { + throw new RuntimeException( + "Expected FFS type: " . JsonFFS::class . '; got: ' . get_class( $ffs ) + ); + } + + $combinedMessages = []; + // $keyCompatibilityMap has the target (stable branch) source language key order + foreach ( $keyCompatibilityMap as $key => $isCompatible ) { + $sourceValue = $sourceTemplate['MESSAGES'][$key] ?? null; + $targetValue = $targetTemplate['MESSAGES'][$key] ?? null; + + // Use existing translation value from the target (stable branch) as the default + if ( $targetValue !== null ) { + $combinedMessages[$key] = $targetValue; + } + + // If the source (development branch) has a different translation for a compatible key + // replace the target (stable branch) translation with it. + if ( !$isCompatible ) { + continue; + } + if ( $sourceValue !== null && !$ffs->isContentEqual( $sourceValue, $targetValue ) ) { + // Keep track if we actually overwrote any values, so we can report back stats + $hasUpdates = true; + $combinedMessages[$key] = $sourceValue; + } + } + + if ( !$hasUpdates ) { + return 'no updates'; + } + + // Copy over all authors (we do not know per-message level) + $combinedAuthors = array_merge( + $targetTemplate[ 'AUTHORS' ] ?? [], + $sourceTemplate[ 'AUTHORS' ] ?? [] + ); + $combinedAuthors = array_unique( $combinedAuthors ); + $combinedAuthors = $ffs->filterAuthors( $combinedAuthors, $language ); + + $targetTemplate['AUTHORS'] = $combinedAuthors; + $targetTemplate['MESSAGES'] = $combinedMessages; + + $backportedContent = $ffs->generateFile( $targetTemplate ); + + $targetFilename = $targetPath . '/' . $group->getTargetFilename( $language ); + if ( file_exists( $targetFilename ) ) { + $currentContent = file_get_contents( $targetFilename ); + + if ( $ffs->shouldOverwrite( $currentContent, $backportedContent ) ) { + file_put_contents( $targetFilename, $backportedContent ); + } + return 'updated'; + } else { + file_put_contents( $targetFilename, $backportedContent ); + return 'new'; + } + } +} diff --git a/MLEB/Translate/src/Synchronization/CompleteExternalTranslationMaintenanceScript.php b/MLEB/Translate/src/Synchronization/CompleteExternalTranslationMaintenanceScript.php index eb8ce627..0e3f46ea 100644 --- a/MLEB/Translate/src/Synchronization/CompleteExternalTranslationMaintenanceScript.php +++ b/MLEB/Translate/src/Synchronization/CompleteExternalTranslationMaintenanceScript.php @@ -4,10 +4,12 @@ declare( strict_types = 1 ); namespace MediaWiki\Extension\Translate\Synchronization; +use JobQueueGroup; use Maintenance; use MediaWiki\Extension\Translate\Services; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MessageIndexRebuildJob; /** * @author Abijeet Patro @@ -42,10 +44,8 @@ class CompleteExternalTranslationMaintenanceScript extends Maintenance { $logger->info( 'Group synchronization is in progress' ); $groupsInProgress = []; - $groupResponses = []; foreach ( $groupsInSync as $groupId ) { $groupResponse = $groupSyncCache->getSynchronizationStatus( $groupId ); - $groupResponses[] = $groupResponse; if ( $groupResponse->isDone() ) { $groupSyncCache->endSync( $groupId ); @@ -79,6 +79,7 @@ class CompleteExternalTranslationMaintenanceScript extends Maintenance { if ( !$groupsInProgress ) { // No groups in progress. $logger->info( 'All message groups are now in sync.' ); + JobQueueGroup::singleton()->push( MessageIndexRebuildJob::newJob() ); } $logger->info( @@ -90,8 +91,3 @@ class CompleteExternalTranslationMaintenanceScript extends Maintenance { ); } } - -class_alias( - CompleteExternalTranslationMaintenanceScript::class, - '\MediaWiki\Extensions\Translate\CompleteExternalTranslationMaintenanceScript' -); diff --git a/MLEB/Translate/src/Synchronization/DisplayGroupSynchronizationInfo.php b/MLEB/Translate/src/Synchronization/DisplayGroupSynchronizationInfo.php index 3eea143e..e86634c8 100644 --- a/MLEB/Translate/src/Synchronization/DisplayGroupSynchronizationInfo.php +++ b/MLEB/Translate/src/Synchronization/DisplayGroupSynchronizationInfo.php @@ -140,7 +140,7 @@ class DisplayGroupSynchronizationInfo { 'span', [ 'class' => "{$wrapperClass}__sync-actions" ], $this->localizer->msg( 'parentheses' ) - ->params( $groupResolveAction )->text() + ->rawParams( $groupResolveAction )->escaped() ) ); @@ -196,7 +196,7 @@ class DisplayGroupSynchronizationInfo { 'span', [ 'class' => "{$wrapperClass}__sync-actions" ], $this->localizer->msg( 'parentheses' ) - ->params( $language->pipeList( $actions ) )->text() + ->rawParams( $language->pipeList( $actions ) )->escaped() ); $output .= $this->getMessageInfoHtml( $message, $language ); @@ -245,7 +245,7 @@ class DisplayGroupSynchronizationInfo { $this->localizer->msg( 'translate-smg-group-message-message-other-langs' )->text(), implode( $this->localizer->msg( 'comma-separator' )->text(), - $message->getOtherLangs() + array_keys( $message->getOtherLangs() ) ) ); } diff --git a/MLEB/Translate/src/Synchronization/ExportTranslationsMaintenanceScript.php b/MLEB/Translate/src/Synchronization/ExportTranslationsMaintenanceScript.php index 39f7e1e8..6c24add7 100644 --- a/MLEB/Translate/src/Synchronization/ExportTranslationsMaintenanceScript.php +++ b/MLEB/Translate/src/Synchronization/ExportTranslationsMaintenanceScript.php @@ -4,14 +4,13 @@ namespace MediaWiki\Extension\Translate\Synchronization; use FileBasedMessageGroup; use GettextFFS; +use MediaWiki\Extension\Translate\Services; use MediaWiki\Extension\Translate\Utilities\BaseMaintenanceScript; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; use MessageGroup; use MessageGroups; use MessageGroupStats; -use MessageHandle; -use Title; -use TranslateUtils; /** * Script to export translations of message groups to files. @@ -41,11 +40,29 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { ); $this->addOption( 'lang', - 'Comma separated list of language codes or *', + 'Comma separated list of language codes to export or * for all languages', self::REQUIRED, self::HAS_ARG ); $this->addOption( + 'always-export-languages', + '(optional) Comma separated list of languages to export ignoring export threshold', + self::OPTIONAL, + self::HAS_ARG + ); + $this->addOption( + 'never-export-languages', + '(optional) Comma separated list of languages to never export (overrides everything else)', + self::OPTIONAL, + self::HAS_ARG + ); + $this->addOption( + 'skip-source-language', + '(optional) Do not export the source language of each message group', + self::OPTIONAL, + self::NO_ARG + ); + $this->addOption( 'target', 'Target directory for exported files', self::REQUIRED, @@ -53,7 +70,7 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { ); $this->addOption( 'skip', - '(optional) Languages to skip, comma separated list', + '(deprecated) See --never-export-languages', self::OPTIONAL, self::HAS_ARG ); @@ -76,12 +93,6 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { self::HAS_ARG ); $this->addOption( - 'hours', - '(optional) Only export languages with changes in the last given number of hours', - self::OPTIONAL, - self::HAS_ARG - ); - $this->addOption( 'no-fuzzy', '(optional) Do not include any messages marked as fuzzy/outdated' ); @@ -92,6 +103,12 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { self::OPTIONAL, self::HAS_ARG ); + $this->addOption( + 'skip-group-sync-check', + '(optional) Skip exporting group if synchronization is still in progress or if there ' . + 'was an error during synchronization. See: ' . + 'https://www.mediawiki.org/wiki/Help:Extension:Translate/Group_management#Strong_synchronization' + ); $this->requireExtension( 'Translate' ); } @@ -100,6 +117,7 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { $logger = LoggerFactory::getInstance( 'Translate.GroupSynchronization' ); $groupPattern = $this->getOption( 'group' ) ?? ''; $groupSkipPattern = $this->getOption( 'skipgroup' ) ?? ''; + $skipGroupSyncCheck = $this->hasOption( 'skip-group-sync-check' ); $logger->info( 'Starting exports for groups {groups}', @@ -115,12 +133,16 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { $exportThreshold = $this->getOption( 'threshold' ); $removalThreshold = $this->getOption( 'removal-threshold' ); $noFuzzy = $this->hasOption( 'no-fuzzy' ); - - $reqLangs = TranslateUtils::parseLanguageCodes( $this->getOption( 'lang' ) ); - if ( $this->hasOption( 'skip' ) ) { - $skipLangs = array_map( 'trim', explode( ',', $this->getOption( 'skip' ) ) ); - $reqLangs = array_diff( $reqLangs, $skipLangs ); - } + $requestedLanguages = $this->parseLanguageCodes( $this->getOption( 'lang' ) ); + $alwaysExportLanguages = $this->csv2array( + $this->getOption( 'always-export-languages' ) ?? '' + ); + $neverExportLanguages = $this->csv2array( + $this->getOption( 'never-export-languages' ) ?? + $this->getOption( 'skip' ) ?? + '' + ); + $skipSourceLanguage = $this->hasOption( 'skip-source-language' ); $forOffline = $this->hasOption( 'offline-gettext-format' ); $offlineTargetPattern = $this->getOption( 'offline-gettext-format' ) ?: "%GROUPID%/%CODE%.po"; @@ -130,28 +152,25 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { $this->fatalError( 'EE1: No valid message groups identified.' ); } - $changeFilter = null; - if ( $this->hasOption( 'hours' ) ) { - $changeFilter = $this->getRecentlyChangedItems( - (int)$this->getOption( 'hours' ), - $this->getNamespacesForGroups( $groups ) - ); - } + $groupSyncCacheEnabled = MediaWikiServices::getInstance()->getMainConfig() + ->get( 'TranslateGroupSynchronizationCache' ); + $groupSyncCache = Services::getInstance()->getGroupSynchronizationCache(); foreach ( $groups as $groupId => $group ) { - // No changes to this group at all - if ( is_array( $changeFilter ) && !isset( $changeFilter[$groupId] ) ) { - $this->output( "No recent changes to $groupId.\n" ); - continue; + if ( $groupSyncCacheEnabled && !$skipGroupSyncCheck ) { + if ( !$this->canGroupBeExported( $groupSyncCache, $groupId ) ) { + continue; + } } - if ( $exportThreshold || $removalThreshold ) { + if ( $exportThreshold !== null || $removalThreshold !== null ) { $logger->info( 'Calculating stats for group {groupId}', [ 'groupId' => $groupId ] ); $tStartTime = microtime( true ); $languageExportActions = $this->getLanguageExportActions( $groupId, - $reqLangs, + $requestedLanguages, + $alwaysExportLanguages, (int)$exportThreshold, (int)$removalThreshold ); @@ -165,8 +184,20 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { ] ); } else { - // Convert list to an associate array - $languageExportActions = array_fill_keys( $reqLangs, self::ACTION_CREATE ); + // Convert list to an associative array + $languageExportActions = array_fill_keys( $requestedLanguages, self::ACTION_CREATE ); + + foreach ( $alwaysExportLanguages as $code ) { + $languageExportActions[ $code ] = self::ACTION_CREATE; + } + } + + foreach ( $neverExportLanguages as $code ) { + unset( $languageExportActions[ $code ] ); + } + + if ( $skipSourceLanguage ) { + unset( $languageExportActions[ $group->getSourceLanguage() ] ); } if ( $languageExportActions === [] ) { @@ -176,14 +207,18 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { $this->output( "Exporting group $groupId\n" ); $logger->info( 'Exporting group {groupId}', [ 'groupId' => $groupId ] ); - /** @var FileBasedMessageGroup $fileBasedGroup */ if ( $forOffline ) { $fileBasedGroup = FileBasedMessageGroup::newFromMessageGroup( $group, $offlineTargetPattern ); $ffs = new GettextFFS( $fileBasedGroup ); $ffs->setOfflineMode( true ); } else { $fileBasedGroup = $group; - $ffs = $group->getFFS(); + // At this point $group should be an instance of FileBasedMessageGroup + // This is primarily to keep linting tools / IDE happy. + if ( !$fileBasedGroup instanceof FileBasedMessageGroup ) { + $this->fatalError( "EE2: Unexportable message group $groupId" ); + } + $ffs = $fileBasedGroup->getFFS(); } $ffs->setWritePath( $target ); @@ -208,11 +243,6 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { continue; } - // Skip languages not present in recent changes - if ( is_array( $changeFilter ) && !isset( $changeFilter[$groupId][$lang] ) ) { - continue; - } - $targetFilePath = $target . '/' . $fileBasedGroup->getTargetFilename( $lang ); if ( $action === self::ACTION_DELETE ) { // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged @@ -319,47 +349,11 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { return $groups; } - /** - * @param int $hours - * @param int[] $namespaces - * @return array[] - */ - private function getRecentlyChangedItems( int $hours, array $namespaces ): array { - $bots = true; - $changeFilter = []; - $rows = TranslateUtils::translationChanges( $hours, $bots, $namespaces ); - foreach ( $rows as $row ) { - $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); - $handle = new MessageHandle( $title ); - $code = $handle->getCode(); - if ( !$code ) { - continue; - } - $groupIds = $handle->getGroupIds(); - foreach ( $groupIds as $groupId ) { - $changeFilter[$groupId][$code] = true; - } - } - - return $changeFilter; - } - - /** - * @param MessageGroup[] $groups - * @return int[] - */ - private function getNamespacesForGroups( array $groups ): array { - $namespaces = []; - foreach ( $groups as $group ) { - $namespaces[$group->getNamespace()] = true; - } - - return array_keys( $namespaces ); - } - + /** @return string[] */ private function getLanguageExportActions( string $groupId, array $requestedLanguages, + array $alwaysExportLanguages, int $exportThreshold = 0, int $removalThreshold = 0 ): array { @@ -386,6 +380,54 @@ class ExportTranslationsMaintenanceScript extends BaseMaintenanceScript { } } + foreach ( $alwaysExportLanguages as $code ) { + $languages[$code] = self::ACTION_CREATE; + // DWIM: Do not export languages with zero translations, even if requested + if ( ( $stats[$code][MessageGroupStats::TRANSLATED] ?? null ) === 0 ) { + $languages[$code] = self::ACTION_DELETE; + } + } + return $languages; } + + private function canGroupBeExported( GroupSynchronizationCache $groupSyncCache, string $groupId ): bool { + if ( $groupSyncCache->isGroupBeingProcessed( $groupId ) ) { + $this->error( "Group $groupId is currently being synchronized; skipping exports\n" ); + return false; + } + + if ( $groupSyncCache->groupHasErrors( $groupId ) ) { + $this->error( "Skipping $groupId due to synchronization error\n" ); + return false; + } + + if ( $groupSyncCache->isGroupInReview( $groupId ) ) { + $this->error( "Group $groupId is currently in review. Review changes on Special:ManageMessageGroups\n" ); + return false; + } + return true; + } + + /** @return string[] */ + private function csv2array( string $input ): array { + return array_filter( + array_map( 'trim', explode( ',', $input ) ), + static function ( $v ) { + return $v !== ''; + } + ); + } + + /** @return string[] */ + private function parseLanguageCodes( string $input ): array { + if ( $input === '*' ) { + $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils(); + $languages = $languageNameUtils->getLanguageNames(); + ksort( $languages ); + return array_keys( $languages ); + } + + return $this->csv2array( $input ); + } } diff --git a/MLEB/Translate/src/Synchronization/ExternalMessageSourceStateImporter.php b/MLEB/Translate/src/Synchronization/ExternalMessageSourceStateImporter.php new file mode 100644 index 00000000..05b1b479 --- /dev/null +++ b/MLEB/Translate/src/Synchronization/ExternalMessageSourceStateImporter.php @@ -0,0 +1,273 @@ +<?php +declare( strict_types = 1 ); + +/** + * Finds external changes for file based message groups. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2016.02 + */ + +namespace MediaWiki\Extension\Translate\Synchronization; + +use Config; +use FileBasedMessageGroup; +use JobQueueGroup; +use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange; +use MessageChangeStorage; +use MessageGroups; +use MessageHandle; +use MessageIndex; +use MessageUpdateJob; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Title; +use function wfWarn; + +class ExternalMessageSourceStateImporter { + /** @var Config */ + private $config; + /** @var GroupSynchronizationCache */ + private $groupSynchronizationCache; + /** @var JobQueueGroup */ + private $jobQueueGroup; + /** @var LoggerInterface */ + private $logger; + /** @var MessageIndex */ + private $messageIndex; + + public function __construct( + Config $config, + GroupSynchronizationCache $groupSynchronizationCache, + JobQueueGroup $jobQueueGroup, + LoggerInterface $logger, + MessageIndex $messageIndex + ) { + $this->config = $config; + $this->groupSynchronizationCache = $groupSynchronizationCache; + $this->jobQueueGroup = $jobQueueGroup; + $this->logger = $logger; + $this->messageIndex = $messageIndex; + } + + /** + * @param MessageSourceChange[] $changeData + * @param string $name + * @return array + */ + public function importSafe( array $changeData, string $name ): array { + $processed = []; + $skipped = []; + $jobs = []; + + $groupSyncCacheEnabled = $this->config->get( 'TranslateGroupSynchronizationCache' ); + + foreach ( $changeData as $groupId => $changesForGroup ) { + $group = MessageGroups::getGroup( $groupId ); + if ( !$group ) { + unset( $changeData[$groupId] ); + continue; + } + + if ( !$group instanceof FileBasedMessageGroup ) { + $this->logger->warning( + '[ExternalMessageSourceStateImporter] Expected FileBasedMessageGroup, ' . + 'but got {class} for group {groupId}', + [ + 'class' => get_class( $group ), + 'groupId' => $groupId + ] + ); + unset( $changeData[$groupId] ); + continue; + } + + $processed[$groupId] = []; + $languages = $changesForGroup->getLanguages(); + $groupJobs = []; + + $groupSafeLanguages = self::identifySafeLanguages( $group, $changesForGroup ); + + foreach ( $languages as $language ) { + if ( !$groupSafeLanguages[ $language ] ) { + $skipped[$groupId] = true; + continue; + } + + $additions = $changesForGroup->getAdditions( $language ); + if ( $additions === [] ) { + continue; + } + + [ $groupLanguageJobs, $groupProcessed ] = $this->createMessageUpdateJobs( + $group, $additions, $language + ); + + $groupJobs = array_merge( $groupJobs, $groupLanguageJobs ); + $processed[$groupId][$language] = $groupProcessed; + + $changesForGroup->removeChangesForLanguage( $language ); + $group->getMessageGroupCache( $language )->create(); + } + + // Mark the skipped group as in review + if ( $groupSyncCacheEnabled && isset( $skipped[$groupId] ) ) { + $this->groupSynchronizationCache->markGroupAsInReview( $groupId ); + } + + if ( $groupJobs !== [] ) { + if ( $groupSyncCacheEnabled ) { + $this->updateGroupSyncInfo( $groupId, $groupJobs ); + } + $jobs = array_merge( $jobs, $groupJobs ); + } + } + + // Remove groups where everything was imported + $changeData = array_filter( $changeData, static function ( MessageSourceChange $change ) { + return $change->getAllModifications() !== []; + } ); + + // Remove groups with no imports + $processed = array_filter( $processed ); + + $file = MessageChangeStorage::getCdbPath( $name ); + MessageChangeStorage::writeChanges( $changeData, $file ); + $this->jobQueueGroup->push( $jobs ); + + return [ + 'processed' => $processed, + 'skipped' => $skipped, + 'name' => $name, + ]; + } + + /** Creates MessageUpdateJobs additions for a language under a group */ + private function createMessageUpdateJobs( + FileBasedMessageGroup $group, + array $additions, + string $language + ): array { + $groupId = $group->getId(); + $jobs = []; + $processed = 0; + foreach ( $additions as $addition ) { + $namespace = $group->getNamespace(); + $name = "{$addition['key']}/$language"; + + $title = Title::makeTitleSafe( $namespace, $name ); + if ( !$title ) { + wfWarn( "Invalid title for group $groupId key {$addition['key']}" ); + continue; + } + + $jobs[] = MessageUpdateJob::newJob( $title, $addition['content'] ); + $processed++; + } + + return [ $jobs, $processed ]; + } + + /** + * @param string $groupId + * @param MessageUpdateJob[] $groupJobs + */ + private function updateGroupSyncInfo( string $groupId, array $groupJobs ): void { + $messageParams = []; + $groupMessageKeys = []; + foreach ( $groupJobs as $job ) { + $messageParams[] = MessageUpdateParameter::createFromJob( $job ); + // Ensure there are no duplicates as the same key may be present in + // multiple languages + $groupMessageKeys[( new MessageHandle( $job->getTitle() ) )->getKey()] = true; + } + + $group = MessageGroups::getGroup( $groupId ); + if ( $group === null ) { + // How did we get here? This should never happen. + throw new RuntimeException( "Did not find group $groupId" ); + } + + $this->messageIndex->storeInterim( $group, array_keys( $groupMessageKeys ) ); + + $this->groupSynchronizationCache->addMessages( $groupId, ...$messageParams ); + $this->groupSynchronizationCache->markGroupForSync( $groupId ); + + $this->logger->info( + '[ExternalMessageSourceStateImporter] Synchronization started for {groupId}', + [ 'groupId' => $groupId ] + ); + } + + /** + * Identifies languages in a message group that are safe to import + * @return array<string,bool> + */ + private static function identifySafeLanguages( + FileBasedMessageGroup $group, + MessageSourceChange $changesForGroup + ): array { + $sourceLanguage = $group->getSourceLanguage(); + $safeLanguagesMap = []; + $modifiedLanguages = $changesForGroup->getLanguages(); + + // Set all languages to not safe to start with. + $safeLanguagesMap[ $sourceLanguage ] = false; + foreach ( $modifiedLanguages as $language ) { + $safeLanguagesMap[ $language ] = false; + } + + if ( !$changesForGroup->hasOnly( $sourceLanguage, MessageSourceChange::ADDITION ) ) { + return $safeLanguagesMap; + } + + $sourceLanguageKeyCache = []; + foreach ( $changesForGroup->getAdditions( $sourceLanguage ) as $change ) { + if ( $change['content'] === '' ) { + return $safeLanguagesMap; + } + + $sourceLanguageKeyCache[ $change['key'] ] = true; + } + + $safeLanguagesMap[ $sourceLanguage ] = true; + + $groupNamespace = $group->getNamespace(); + + // Remove source language from the modifiedLanguage list if present since it's already processed. + // The $sourceLanguageKeyCache will only have values if sourceLanguage has safe changes. + if ( $sourceLanguageKeyCache ) { + array_splice( $modifiedLanguages, array_search( $sourceLanguage, $modifiedLanguages ), 1 ); + } + + foreach ( $modifiedLanguages as $language ) { + if ( !$changesForGroup->hasOnly( $language, MessageSourceChange::ADDITION ) ) { + continue; + } + + foreach ( $changesForGroup->getAdditions( $language ) as $change ) { + if ( $change['content'] === '' ) { + continue 2; + } + + $msgKey = $change['key']; + + if ( !isset( $sourceLanguageKeyCache[ $msgKey ] ) ) { + // This is either a new external translation which is not added in the same sync + // as the source language key, or this translation does not have a corresponding + // definition. We will check the message index to determine which of the two. + $sourceHandle = new MessageHandle( Title::makeTitle( $groupNamespace, $msgKey ) ); + $sourceLanguageKeyCache[ $msgKey ] = $sourceHandle->isValid(); + } + + if ( !$sourceLanguageKeyCache[ $msgKey ] ) { + continue 2; + } + } + + $safeLanguagesMap[ $language ] = true; + } + + return $safeLanguagesMap; + } +} diff --git a/MLEB/Translate/src/Synchronization/GroupSynchronizationCache.php b/MLEB/Translate/src/Synchronization/GroupSynchronizationCache.php index 64ea5ad0..d581ff69 100644 --- a/MLEB/Translate/src/Synchronization/GroupSynchronizationCache.php +++ b/MLEB/Translate/src/Synchronization/GroupSynchronizationCache.php @@ -35,18 +35,28 @@ class GroupSynchronizationCache { /** @var PersistentCache */ private $cache; /** @var int */ - private $timeoutSeconds; + private $initialTimeoutSeconds; + /** @var int */ + private $incrementalTimeoutSeconds; /** @var string Cache tag used for groups */ private const GROUP_LIST_TAG = 'gsc_%group_in_sync%'; /** @var string Cache tag used for tracking groups that have errors */ private const GROUP_ERROR_TAG = 'gsc_%group_with_error%'; + /** @var string Cache tag used for tracking groups that are in review */ + private const GROUP_IN_REVIEW_TAG = 'gsc_%group_in_review%'; + + // The timeout is set to 40 minutes initially, and then incremented by 10 minutes + // each time a message is marked as processed if group is about to expire. + public function __construct( + PersistentCache $cache, + int $initialTimeoutSeconds = 2400, + int $incrementalTimeoutSeconds = 600 - // TODO: Decide timeout based on monitoring. Also check if it needs to be configurable - // based on the number of messages in the group. - public function __construct( PersistentCache $cache, int $timeoutSeconds = 2400 ) { + ) { $this->cache = $cache; - $this->timeoutSeconds = $timeoutSeconds; + $this->initialTimeoutSeconds = $initialTimeoutSeconds; + $this->incrementalTimeoutSeconds = $incrementalTimeoutSeconds; } /** @@ -66,7 +76,7 @@ class GroupSynchronizationCache { /** Start synchronization process for a group and starts the expiry time */ public function markGroupForSync( string $groupId ): void { - $expTime = $this->getExpireTime(); + $expTime = $this->getExpireTime( $this->initialTimeoutSeconds ); $this->cache->set( new PersistentCacheEntry( $this->getGroupKey( $groupId ), @@ -327,6 +337,12 @@ class GroupSynchronizationCache { $this->cache->delete( $messageErrorKey ); } + /** Checks if the group has errors */ + public function groupHasErrors( string $groupId ): bool { + $groupErrorKey = $this->getGroupErrorKey( $groupId ); + return $this->cache->has( $groupErrorKey ); + } + /** Checks if group has unresolved error messages. If not clears the group from error list */ public function syncGroupErrors( string $groupId ): GroupSynchronizationResponse { $groupSyncResponse = $this->getGroupErrorInfo( $groupId ); @@ -341,14 +357,75 @@ class GroupSynchronizationCache { return $groupSyncResponse; } + public function markGroupAsInReview( string $groupId ): void { + $groupReviewKey = $this->getGroupReviewKey( $groupId ); + $this->cache->set( + new PersistentCacheEntry( + $groupReviewKey, + $groupId, + null, + self::GROUP_IN_REVIEW_TAG + ) + ); + } + + public function markGroupAsReviewed( string $groupId ): void { + $groupReviewKey = $this->getGroupReviewKey( $groupId ); + $this->cache->delete( $groupReviewKey ); + } + + public function isGroupInReview( string $groupId ): bool { + return $this->cache->has( $this->getGroupReviewKey( $groupId ) ); + } + + public function extendGroupExpiryTime( string $groupId ): void { + $groupKey = $this->getGroupKey( $groupId ); + $groupEntry = $this->cache->get( $groupKey ); + + if ( $groupEntry === [] ) { + // Group is currently not being processed. + throw new LogicException( + 'Requested extension of expiry time for a group that is not being processed. ' . + 'Check if group is being processed by calling isGroupBeingProcessed() first' + ); + } + + if ( $groupEntry[0]->hasExpired() ) { + throw new InvalidArgumentException( + 'Cannot extend expiry time for a group that has already expired.' + ); + } + + $newExpiryTime = $this->getExpireTime( $this->incrementalTimeoutSeconds ); + + // We start with the initial timeout minutes, we only change the timeout if the group + // is actually about to expire. + if ( $newExpiryTime < $groupEntry[0]->exptime() ) { + return; + } + + $this->cache->setExpiry( $groupKey, $newExpiryTime ); + } + + /** @internal - Internal; For testing use only */ + public function getGroupExpiryTime( $groupId ): int { + $groupKey = $this->getGroupKey( $groupId ); + $groupEntry = $this->cache->get( $groupKey ); + if ( $groupEntry === [] ) { + throw new InvalidArgumentException( "$groupId currently not in processing!" ); + } + + return $groupEntry[0]->exptime(); + } + private function hasGroupTimedOut( int $syncExpTime ): bool { return ( new DateTime() )->getTimestamp() > $syncExpTime; } - private function getExpireTime(): int { + private function getExpireTime( int $timeoutSeconds ): int { $currentTime = ( new DateTime() )->getTimestamp(); $expTime = ( new DateTime() ) - ->setTimestamp( $currentTime + $this->timeoutSeconds ) + ->setTimestamp( $currentTime + $timeoutSeconds ) ->getTimestamp(); return $expTime; @@ -404,6 +481,9 @@ class GroupSynchronizationCache { private function getGroupMessageErrorTag( string $groupId ): string { return "gsc_%error%_$groupId"; } -} -class_alias( GroupSynchronizationCache::class, '\MediaWiki\Extensions\Translate\GroupSynchronizationCache' ); + private function getGroupReviewKey( string $groupId ): string { + $hash = substr( hash( 'sha256', $groupId ), 0, 40 ); + return substr( "{$hash}_gsc_%review%_$groupId", 0, 255 ); + } +} diff --git a/MLEB/Translate/src/Synchronization/GroupSynchronizationResponse.php b/MLEB/Translate/src/Synchronization/GroupSynchronizationResponse.php index 069be720..1b2d29cb 100644 --- a/MLEB/Translate/src/Synchronization/GroupSynchronizationResponse.php +++ b/MLEB/Translate/src/Synchronization/GroupSynchronizationResponse.php @@ -63,5 +63,3 @@ class GroupSynchronizationResponse implements JsonSerializable, JsonUnserializab ); } } - -class_alias( GroupSynchronizationResponse::class, '\MediaWiki\Extensions\Translate\GroupSynchronizationResponse' ); diff --git a/MLEB/Translate/src/Synchronization/ManageGroupSynchronizationCacheActionApi.php b/MLEB/Translate/src/Synchronization/ManageGroupSynchronizationCacheActionApi.php index 9c6127bf..903aa7e2 100644 --- a/MLEB/Translate/src/Synchronization/ManageGroupSynchronizationCacheActionApi.php +++ b/MLEB/Translate/src/Synchronization/ManageGroupSynchronizationCacheActionApi.php @@ -9,6 +9,7 @@ use Exception; use FormatJson; use MediaWiki\Logger\LoggerFactory; use MessageGroups; +use Psr\Log\LoggerInterface; /** * Api module for managing group synchronization cache @@ -22,10 +23,13 @@ class ManageGroupSynchronizationCacheActionApi extends ApiBase { private const VALID_OPS = [ 'resolveMessage', 'resolveGroup' ]; /** @var GroupSynchronizationCache */ private $groupSyncCache; + /** @var LoggerInterface */ + private $groupSyncLog; public function __construct( ApiMain $mainModule, $moduleName, GroupSynchronizationCache $groupSyncCache ) { parent::__construct( $mainModule, $moduleName ); $this->groupSyncCache = $groupSyncCache; + $this->groupSyncLog = LoggerFactory::getInstance( 'Translate.GroupSynchronization' ); } public function execute() { @@ -38,13 +42,13 @@ class ManageGroupSynchronizationCacheActionApi extends ApiBase { $group = MessageGroups::getGroup( $groupId ); if ( $group === null ) { - return $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' ); + $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' ); } try { if ( $operation === 'resolveMessage' ) { if ( $titleStr === null ) { - return $this->dieWithError( [ 'apierror-missingparam', 'title' ] ); + $this->dieWithError( [ 'apierror-missingparam', 'title' ] ); } $this->markAsResolved( $groupId, $titleStr ); } elseif ( $operation === 'resolveGroup' ) { @@ -56,7 +60,7 @@ class ManageGroupSynchronizationCacheActionApi extends ApiBase { 'exceptionMessage' => $e->getMessage() ]; - LoggerFactory::getInstance( 'Translate.GroupSynchronization' )->error( + $this->groupSyncLog->error( "Error while running: ManageGroupSynchronizationCacheActionApi::execute. Details: \n" . FormatJson::encode( $data, true ) ); @@ -73,9 +77,24 @@ class ManageGroupSynchronizationCacheActionApi extends ApiBase { private function markAsResolved( string $groupId, ?string $messageTitle = null ): void { if ( $messageTitle === null ) { $currentGroupStatus = $this->groupSyncCache->markGroupAsResolved( $groupId ); + $this->groupSyncLog->info( + '{user} resolved group {groupId}.', + [ + 'user' => $this->getUser()->getName(), + 'groupId' => $groupId + ] + ); } else { $this->groupSyncCache->markMessageAsResolved( $groupId, $messageTitle ); $currentGroupStatus = $this->groupSyncCache->syncGroupErrors( $groupId ); + $this->groupSyncLog->info( + '{user} resolved message {messageTitle} in group {groupId}.', + [ + 'user' => $this->getUser()->getName(), + 'groupId' => $groupId, + 'messageTitle' => $messageTitle + ] + ); } $this->getResult()->addValue( null, $this->getModuleName(), [ diff --git a/MLEB/Translate/src/Synchronization/MessageUpdateParameter.php b/MLEB/Translate/src/Synchronization/MessageUpdateParameter.php index 6874caef..d5d4d516 100644 --- a/MLEB/Translate/src/Synchronization/MessageUpdateParameter.php +++ b/MLEB/Translate/src/Synchronization/MessageUpdateParameter.php @@ -41,7 +41,7 @@ class MessageUpdateParameter implements JsonSerializable, JsonUnserializable { } public function isRename(): bool { - return boolval( $this->rename ); + return $this->rename; } public function getReplacementValue(): string { @@ -96,5 +96,3 @@ class MessageUpdateParameter implements JsonSerializable, JsonUnserializable { return new self( $jobParams ); } } - -class_alias( MessageUpdateParameter::class, '\MediaWiki\Extensions\Translate\MessageUpdateParameter' ); diff --git a/MLEB/Translate/src/SystemUsers/FuzzyBot.php b/MLEB/Translate/src/SystemUsers/FuzzyBot.php index dee12f35..a6d337d5 100644 --- a/MLEB/Translate/src/SystemUsers/FuzzyBot.php +++ b/MLEB/Translate/src/SystemUsers/FuzzyBot.php @@ -23,5 +23,3 @@ class FuzzyBot { return $wgTranslateFuzzyBotName; } } - -class_alias( FuzzyBot::class, '\MediaWiki\Extensions\Translate\FuzzyBot' ); diff --git a/MLEB/Translate/src/SystemUsers/TranslateUserManager.php b/MLEB/Translate/src/SystemUsers/TranslateUserManager.php index 8aea6775..52dd3db3 100644 --- a/MLEB/Translate/src/SystemUsers/TranslateUserManager.php +++ b/MLEB/Translate/src/SystemUsers/TranslateUserManager.php @@ -23,5 +23,3 @@ class TranslateUserManager { return $wgTranslateUserManagerName; } } - -class_alias( TranslateUserManager::class, '\MediaWiki\Extensions\Translate\TranslateUserManager' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/CurrentTranslationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/CurrentTranslationAid.php new file mode 100644 index 00000000..9ed3f761 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/CurrentTranslationAid.php @@ -0,0 +1,36 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use Hooks; +use MessageHandle; +use TranslateUtils; + +/** + * Translation aid that provides the current saved translation. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + * @ingroup TranslationAids + */ +class CurrentTranslationAid extends TranslationAid { + public function getData(): array { + $title = $this->handle->getTitle(); + $translation = TranslateUtils::getMessageContent( + $this->handle->getKey(), + $this->handle->getCode(), + $title->getNamespace() + ); + + Hooks::run( 'TranslatePrefillTranslation', [ &$translation, $this->handle ] ); + $fuzzy = MessageHandle::hasFuzzyString( $translation ) || $this->handle->isFuzzy(); + $translation = str_replace( TRANSLATE_FUZZY, '', $translation ); + + return [ + 'language' => $this->handle->getCode(), + 'fuzzy' => $fuzzy, + 'value' => $translation, + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/DocumentationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/DocumentationAid.php new file mode 100644 index 00000000..1884f386 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/DocumentationAid.php @@ -0,0 +1,35 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MediaWiki\MediaWikiServices; +use TranslateUtils; + +/** + * Translation aid that provides the message documentation. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + * @ingroup TranslationAids + */ +class DocumentationAid extends TranslationAid { + public function getData(): array { + global $wgTranslateDocumentationLanguageCode; + if ( !$wgTranslateDocumentationLanguageCode ) { + throw new TranslationHelperException( 'Message documentation is disabled' ); + } + + $page = $this->handle->getKey(); + $ns = $this->handle->getTitle()->getNamespace(); + + $info = TranslateUtils::getMessageContent( $page, $wgTranslateDocumentationLanguageCode, $ns ); + + return [ + 'language' => MediaWikiServices::getInstance()->getContentLanguage()->getCode(), + 'value' => $info, + 'html' => $this->context->getOutput()->parseAsInterface( $info ) + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/GettextDocumentationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/GettextDocumentationAid.php new file mode 100644 index 00000000..68d3dc0d --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/GettextDocumentationAid.php @@ -0,0 +1,81 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use FileBasedMessageGroup; +use GettextFFS; +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MediaWiki\MediaWikiServices; + +/** + * Translation aid that provides Gettext documentation. + * @ingroup TranslationAids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + */ +class GettextDocumentationAid extends TranslationAid { + public function getData(): array { + // We need to get the primary group to get the correct file + // So $group can be different from $this->group + $group = $this->handle->getGroup(); + if ( !$group instanceof FileBasedMessageGroup ) { + throw new TranslationHelperException( 'Not a FileBasedMessageGroup group' ); + } + + $ffs = $group->getFFS(); + if ( !$ffs instanceof GettextFFS ) { + throw new TranslationHelperException( 'Group is not using GettextFFS' ); + } + + $cache = $group->getMessageGroupCache( $group->getSourceLanguage() ); + if ( !$cache->exists() ) { + throw new TranslationHelperException( 'Definitions are not cached' ); + } + + $extra = $cache->getExtra(); + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $messageKey = $contLang->lcfirst( $this->handle->getKey() ); + $messageKey = str_replace( ' ', '_', $messageKey ); + + $help = $extra['TEMPLATE'][$messageKey]['comments'] ?? null; + if ( !$help ) { + throw new TranslationHelperException( "No comments found for key '$messageKey'" ); + } + + $conf = $group->getConfiguration(); + if ( isset( $conf['BASIC']['codeBrowser'] ) ) { + $pattern = $conf['BASIC']['codeBrowser']; + $pattern = str_replace( '%FILE%', '\1', $pattern ); + $pattern = str_replace( '%LINE%', '\2', $pattern ); + $pattern = "[$pattern \\1:\\2]"; + } else { + $pattern = "\\1:\\2"; + } + + $out = ''; + foreach ( $help as $type => $lines ) { + if ( $type === ':' ) { + $files = ''; + foreach ( $lines as $line ) { + $files .= ' ' . preg_replace( '/([^ :]+):(\d+)/', $pattern, $line ); + } + $out .= "<nowiki>#:</nowiki> $files<br />"; + } else { + foreach ( $lines as $line ) { + $out .= "<nowiki>#$type</nowiki> $line<br />"; + } + } + } + + $html = $this->context->getOutput()->parseAsContent( $out ); + + return [ + 'language' => $contLang->getCode(), + // @todo Provide raw data when possible + // 'value' => $help, + 'html' => $html, + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/GroupsAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/GroupsAid.php new file mode 100644 index 00000000..c775788c --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/GroupsAid.php @@ -0,0 +1,15 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2021.05 + */ +class GroupsAid extends TranslationAid { + public function getData(): array { + return [ '**' => 'group' ] + $this->handle->getGroupIds(); + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/InOtherLanguagesAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/InOtherLanguagesAid.php new file mode 100644 index 00000000..a8eafcfa --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/InOtherLanguagesAid.php @@ -0,0 +1,80 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\MediaWikiServices; + +/** + * Translation aid that provides the "in other languages" suggestions. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + * @ingroup TranslationAids + */ +class InOtherLanguagesAid extends TranslationAid { + public function getData(): array { + $suggestions = [ + '**' => 'suggestion', + ]; + + // Fuzzy translations are not included in these + $translations = $this->dataProvider->getGoodTranslations(); + $code = $this->handle->getCode(); + + $sourceLanguage = $this->handle->getGroup()->getSourceLanguage(); + + foreach ( $this->getFallbacks( $code ) as $fallbackCode ) { + if ( !isset( $translations[$fallbackCode] ) ) { + continue; + } + + if ( $fallbackCode === $sourceLanguage ) { + continue; + } + + $suggestions[] = [ + 'language' => $fallbackCode, + 'value' => $translations[$fallbackCode], + ]; + } + + return $suggestions; + } + + /** + * Get the languages for "in other languages". That would be translation + * assistant languages with defined language fallbacks additionally. + * @param string $code + * @return string[] List of language codes + */ + protected function getFallbacks( string $code ): array { + global $wgTranslateLanguageFallbacks; + $mwServices = MediaWikiServices::getInstance(); + + // User preference has the final say + $userOptionLookup = $mwServices->getUserOptionsLookup(); + $preference = $userOptionLookup->getOption( $this->context->getUser(), 'translate-editlangs' ); + if ( $preference !== 'default' ) { + $fallbacks = array_map( 'trim', explode( ',', $preference ) ); + foreach ( $fallbacks as $k => $v ) { + if ( $v === $code ) { + unset( $fallbacks[$k] ); + } + } + + return $fallbacks; + } + + // Global configuration settings + $fallbacks = []; + if ( isset( $wgTranslateLanguageFallbacks[$code] ) ) { + $fallbacks = (array)$wgTranslateLanguageFallbacks[$code]; + } + + $list = $mwServices->getLanguageFallback()->getAll( $code ); + $fallbacks = array_merge( $list, $fallbacks ); + + return array_unique( $fallbacks ); + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/InsertablesAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/InsertablesAid.php new file mode 100644 index 00000000..aaa4b620 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/InsertablesAid.php @@ -0,0 +1,55 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; + +/** + * Translation aid that suggests insertables. Insertable is a string that + * usually does not need translation and is difficult to type manually. + * @ingroup TranslationAids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013.09 + */ +class InsertablesAid extends TranslationAid { + public function getData(): array { + // We need to get the primary group to get the correct file + // So $group can be different from $this->group + $group = $this->handle->getGroup(); + + // This was added later, so not all classes have it. In addition + // the message group class hierarchy doesn't lend itself easily + // to the user of interfaces for this purpose. + if ( !is_callable( [ $group, 'getInsertablesSuggester' ] ) ) { + throw new TranslationHelperException( 'Group does not have insertable suggesters' ); + } + + // @phan-suppress-next-line PhanUndeclaredMethod + $suggester = $group->getInsertablesSuggester(); + + // It is okay to return null suggester + if ( !$suggester ) { + throw new TranslationHelperException( 'Group does not have insertable suggesters' ); + } + + $insertables = $suggester->getInsertables( $this->dataProvider->getDefinition() ); + $blob = []; + foreach ( $insertables as $insertable ) { + $displayText = $insertable->getDisplayText(); + + // The keys are used for de-duplication + $blob[$displayText] = [ + 'display' => $displayText, + 'pre' => $insertable->getPreText(), + 'post' => $insertable->getPostText(), + ]; + } + + $blob = array_values( $blob ); + $blob['**'] = 'insertable'; + + return $blob; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/MachineTranslationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/MachineTranslationAid.php new file mode 100644 index 00000000..4d727730 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/MachineTranslationAid.php @@ -0,0 +1,100 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use TranslationWebService; +use TranslationWebServiceConfigurationException; + +/** + * Translation aid that provides suggestion from machine translation services. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 | 2015.02 extends QueryAggregatorAwareTranslationAid + * @ingroup TranslationAids + */ +class MachineTranslationAid extends QueryAggregatorAwareTranslationAid { + public function populateQueries(): void { + $definition = $this->dataProvider->getDefinition(); + $translations = $this->dataProvider->getGoodTranslations(); + $from = $this->group->getSourceLanguage(); + $to = $this->handle->getCode(); + + if ( trim( $definition ) === '' ) { + return; + } + + foreach ( $this->getWebServices() as $service ) { + if ( $service->checkTranslationServiceFailure() ) { + continue; + } + + try { + if ( $service->isSupportedLanguagePair( $from, $to ) ) { + $this->storeQuery( $service, $from, $to, $definition ); + continue; + } + + // Search for translations which we can use as a source for MT + // @todo: Support setting priority of languages like Yandex used to have + foreach ( $translations as $from => $text ) { + if ( !$service->isSupportedLanguagePair( $from, $to ) ) { + continue; + } + + $this->storeQuery( $service, $from, $to, $text ); + break; + } + } catch ( TranslationWebServiceConfigurationException $e ) { + throw new TranslationHelperException( $service->getName() . ': ' . $e->getMessage() ); + } + } + } + + public function getData(): array { + $suggestions = [ '**' => 'suggestion' ]; + + foreach ( $this->getQueryData() as $queryData ) { + $suggestions[] = $this->formatSuggestion( $queryData ); + } + + return array_filter( $suggestions ); + } + + protected function formatSuggestion( array $queryData ): ?array { + $service = $queryData['service']; + $response = $queryData['response']; + $sourceLanguage = $queryData['language']; + $sourceText = $queryData['text']; + + $result = $service->getResultData( $response ); + if ( $result === null ) { + return null; + } + + return [ + 'target' => $result, + 'service' => $service->getName(), + 'source_language' => $sourceLanguage, + 'source' => $sourceText, + ]; + } + + /** @return TranslationWebService[] */ + private function getWebServices(): array { + global $wgTranslateTranslationServices; + + $services = []; + foreach ( $wgTranslateTranslationServices as $name => $config ) { + $service = TranslationWebService::factory( $name, $config ); + if ( !$service || $service->getType() !== 'mt' ) { + continue; + } + + $services[$name] = $service; + } + + return $services; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/MessageDefinitionAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/MessageDefinitionAid.php new file mode 100644 index 00000000..070ed297 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/MessageDefinitionAid.php @@ -0,0 +1,23 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +/** + * Translation aid that provides the message definition. + * This usually matches the content of the page ns:key/source_language. + * @ingroup TranslationAids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + */ +class MessageDefinitionAid extends TranslationAid { + public function getData(): array { + $language = $this->group->getSourceLanguage(); + + return [ + 'value' => $this->dataProvider->getDefinition(), + 'language' => $language, + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/QueryAggregatorAwareTranslationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/QueryAggregatorAwareTranslationAid.php new file mode 100644 index 00000000..9a5dca2a --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/QueryAggregatorAwareTranslationAid.php @@ -0,0 +1,69 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use QueryAggregator; +use QueryAggregatorAware; +use TranslationWebService; + +/** + * Helper class for translation aids that use web services. + * @ingroup TranslationAids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2015.02 + */ +abstract class QueryAggregatorAwareTranslationAid + extends TranslationAid + implements QueryAggregatorAware +{ + private $queries = []; + /** @var QueryAggregator */ + private $aggregator; + + public function setQueryAggregator( QueryAggregator $aggregator ): void { + $this->aggregator = $aggregator; + } + + /** + * Stores a web service query for later execution. + * @param TranslationWebService $service + * @param string $from + * @param string $to + * @param string $text + * @return void + */ + protected function storeQuery( + TranslationWebService $service, + string $from, + string $to, + string $text + ): void { + $queries = $service->getQueries( $text, $from, $to ); + foreach ( $queries as $query ) { + $this->queries[] = [ + 'id' => $this->aggregator->addQuery( $query ), + 'language' => $from, + 'text' => $text, + 'service' => $service, + ]; + } + } + + /** + * Returns all stored queries. + * @return array Map of executed queries: + * - language: string: source language + * - text: string: source text + * - response: TranslationQueryResponse + */ + protected function getQueryData(): array { + foreach ( $this->queries as &$queryData ) { + $queryData['response'] = $this->aggregator->getResponse( $queryData['id'] ); + unset( $queryData['id'] ); + } + + return $this->queries; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/SupportAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/SupportAid.php new file mode 100644 index 00000000..9ef907d3 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/SupportAid.php @@ -0,0 +1,87 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MessageHandle; +use Title; +use TranslateUtils; + +/** + * Translation aid that provides an url where users can ask for help + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-02 + * @ingroup TranslationAids + */ +class SupportAid extends TranslationAid { + public function getData(): array { + return [ + 'url' => self::getSupportUrl( $this->handle ), + ]; + } + + /** + * Target URL for a link provided by a support button/aid. + * @param MessageHandle $handle MessageHandle object for the translation message. + * @return string + * @throws TranslationHelperException + */ + public static function getSupportUrl( MessageHandle $handle ): string { + $title = $handle->getTitle(); + $config = self::getConfig( $handle ); + + $placeholders = [ + '%MESSAGE%' => $title->getPrefixedText(), + '%MESSAGE_URL%' => TranslateUtils::getEditorUrl( $handle ) + ]; + + // Preprocess params + $params = []; + if ( isset( $config['params'] ) ) { + foreach ( $config['params'] as $key => $value ) { + $params[$key] = strtr( $value, $placeholders ); + } + } + + // Return the URL or make one from the page + if ( isset( $config['url'] ) ) { + return wfAppendQuery( $config['url'], $params ); + } elseif ( isset( $config['page'] ) ) { + $page = Title::newFromText( $config['page'] ); + if ( $page ) { + return $page->getFullURL( $params ); + } + } + + throw new TranslationHelperException( 'Support page not configured properly' ); + } + + /** + * Fetches Support URL config + * @param MessageHandle $handle + * @return array + * @throws TranslationHelperException + */ + private static function getConfig( MessageHandle $handle ): array { + global $wgTranslateSupportUrl, $wgTranslateSupportUrlNamespace; + + if ( !$handle->isValid() ) { + throw new TranslationHelperException( 'Invalid MessageHandle' ); + } + + // Fetch group level configuration if possible, fallback to namespace based, or default + $group = $handle->getGroup(); + $namespace = $handle->getTitle()->getNamespace(); + $config = $group->getSupportConfig() + ?? $wgTranslateSupportUrlNamespace[$namespace] + ?? $wgTranslateSupportUrl; + + if ( !$config ) { + throw new TranslationHelperException( 'Support page not configured' ); + } + + return $config; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/TTMServerAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/TTMServerAid.php new file mode 100644 index 00000000..9a22574c --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/TTMServerAid.php @@ -0,0 +1,218 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use Exception; +use IContextSource; +use MediaWiki\Extension\Translate\Services; +use MediaWiki\Extension\Translate\TtmServer\TtmServerFactory; +use MessageGroup; +use MessageHandle; +use ReadableTTMServer; +use RemoteTTMServerWebService; +use Title; +use TranslateUtils; +use TranslationWebService; +use TTMServer; + +/** + * Translation aid that provides suggestion from translation memory. + * @ingroup TranslationAids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 | 2015.02 extends QueryAggregatorAwareTranslationAid + */ +class TTMServerAid extends QueryAggregatorAwareTranslationAid { + /** @var array[] */ + private $services; + /** @var TtmServerFactory */ + private $ttmServerFactory; + + public function __construct( + MessageGroup $group, + MessageHandle $handle, + IContextSource $context, + TranslationAidDataProvider $dataProvider + ) { + parent::__construct( $group, $handle, $context, $dataProvider ); + $this->ttmServerFactory = Services::getInstance()->getTtmServerFactory(); + } + + public function populateQueries(): void { + $text = $this->dataProvider->getDefinition(); + $from = $this->group->getSourceLanguage(); + $to = $this->handle->getCode(); + + if ( trim( $text ) === '' ) { + return; + } + + foreach ( $this->getWebServices() as $service ) { + $this->storeQuery( $service, $from, $to, $text ); + } + } + + public function getData(): array { + $text = $this->dataProvider->getDefinition(); + if ( trim( $text ) === '' ) { + return []; + } + + $suggestions = []; + $from = $this->group->getSourceLanguage(); + $to = $this->handle->getCode(); + + foreach ( $this->getInternalServices() as $name => $service ) { + try { + $queryData = $service->query( $from, $to, $text ); + } catch ( Exception $e ) { + // Not ideal to catch all exceptions + continue; + } + + $sugs = $this->formatInternalSuggestions( $queryData, $service, $name, $from ); + $suggestions = array_merge( $suggestions, $sugs ); + } + + // Results from web services + foreach ( $this->getQueryData() as $queryData ) { + $sugs = $this->formatWebSuggestions( $queryData ); + $suggestions = array_merge( $suggestions, $sugs ); + } + + $suggestions = TTMServer::sortSuggestions( $suggestions ); + // Must be here to not mess up the sorting function + $suggestions['**'] = 'suggestion'; + + return $suggestions; + } + + protected function formatWebSuggestions( array $queryData ): array { + $service = $queryData['service']; + $response = $queryData['response']; + $sourceLanguage = $queryData['language']; + $sourceText = $queryData['text']; + + // getResultData returns a null on failure instead of throwing an exception + $items = $service->getResultData( $response ); + if ( $items === null ) { + return []; + } + + $localPrefix = Title::makeTitle( NS_MAIN, '' )->getFullURL( '', false, PROTO_CANONICAL ); + $localPrefixLength = strlen( $localPrefix ); + + foreach ( $items as &$item ) { + $local = strncmp( $item['uri'], $localPrefix, $localPrefixLength ) === 0; + $item = array_merge( $item, [ + 'service' => $service->getName(), + 'source_language' => $sourceLanguage, + 'source' => $sourceText, + 'local' => $local, + ] ); + + // ApiTTMServer expands this... need to fix it again to be the bare name + if ( $local ) { + $pagename = urldecode( substr( $item['location'], $localPrefixLength ) ); + $item['location'] = $pagename; + $handle = new MessageHandle( Title::newfromText( $pagename ) ); + $item['editorUrl'] = TranslateUtils::getEditorUrl( $handle ); + } + } + return $items; + } + + protected function formatInternalSuggestions( + array $queryData, + ReadableTTMServer $s, + string $serviceName, + string $sourceLanguage + ): array { + $items = []; + + foreach ( $queryData as $item ) { + $local = $s->isLocalSuggestion( $item ); + + $item['service'] = $serviceName; + $item['source_language'] = $sourceLanguage; + $item['local'] = $local; + // Likely only needed for non-public DatabaseTTMServer + $item['uri'] = $item['uri'] ?? $s->expandLocation( $item ); + if ( $local ) { + $handle = new MessageHandle( Title::newfromText( $item[ 'location' ] ) ); + $item['editorUrl'] = TranslateUtils::getEditorUrl( $handle ); + } + $items[] = $item; + } + + return $items; + } + + /** @return ReadableTTMServer[] */ + private function getInternalServices(): array { + $services = $this->getQueryableServices(); + foreach ( $services as $name => $config ) { + if ( $config['type'] === 'ttmserver' ) { + $services[$name] = $this->ttmServerFactory->create( $name ); + } else { + unset( $services[$name] ); + } + } + + return $services; + } + + /** @return RemoteTTMServerWebService[] */ + private function getWebServices(): array { + $services = $this->getQueryableServices(); + foreach ( $services as $name => $config ) { + if ( $config['type'] === 'remote-ttmserver' ) { + $services[$name] = TranslationWebService::factory( $name, $config ); + } else { + unset( $services[$name] ); + } + } + + return $services; + } + + private function getQueryableServices(): array { + if ( !$this->services ) { + global $wgTranslateTranslationServices; + $this->services = $this->getQueryableServicesUncached( + $wgTranslateTranslationServices ); + } + + return $this->services; + } + + private function getQueryableServicesUncached( array $services ): array { + // First remove mirrors of the primary service + $primary = $this->ttmServerFactory->getDefault(); + $mirrors = $primary->getMirrors(); + foreach ( $mirrors as $mirrorName ) { + unset( $services[$mirrorName] ); + } + + // Then remove non-ttmservers + foreach ( $services as $name => $config ) { + $type = $config['type']; + if ( $type !== 'ttmserver' && $type !== 'remote-ttmserver' ) { + unset( $services[$name] ); + } + } + + // Then determine the query method. Prefer HTTP queries that can be run parallel. + foreach ( $services as $name => &$config ) { + $public = $config['public'] ?? false; + if ( $config['type'] === 'ttmserver' && $public ) { + $config['type'] = 'remote-ttmserver'; + $config['service'] = $name; + $config['url'] = wfExpandUrl( wfScript( 'api' ), PROTO_CANONICAL ); + } + } + + return $services; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAid.php new file mode 100644 index 00000000..4b11ae3a --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAid.php @@ -0,0 +1,79 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use IContextSource; +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MessageGroup; +use MessageHandle; + +/** + * Multipurpose class for translation aids: + * - interface for translation aid classes + * - listing of available translation aids + * + * @defgroup TranslationAids Translation Aids + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + */ +abstract class TranslationAid { + /** @var MessageGroup */ + protected $group; + /** @var MessageHandle */ + protected $handle; + /** @var IContextSource */ + protected $context; + /** @var TranslationAidDataProvider */ + protected $dataProvider; + + public function __construct( + MessageGroup $group, + MessageHandle $handle, + IContextSource $context, + TranslationAidDataProvider $dataProvider + ) { + $this->group = $group; + $this->handle = $handle; + $this->context = $context; + $this->dataProvider = $dataProvider; + } + + /** + * Translation aid class should implement this function. Return value should + * be an array with keys and values. Because these are used in the MediaWiki + * API, lists (numeric keys) should have key '**' set to element name that + * describes the list values. For example if the translation aid provides + * translation suggestions, it would return an array which has key '**' set + * to 'suggestion' and then list of arrays, each containing fields for the + * information of the suggestions. See InOtherLanguagesAid for example. + * + * @throws TranslationHelperException Used to signal unexpected errors to aid + * debugging + * @return array + */ + abstract public function getData(): array; + + /** + * List of available message types mapped to the classes + * implementing them. + * + * @return array + */ + public static function getTypes(): array { + return [ + 'groups' => GroupsAid::class, + 'definition' => MessageDefinitionAid::class, + 'translation' => CurrentTranslationAid::class, + 'inotherlanguages' => InOtherLanguagesAid::class, + 'documentation' => DocumentationAid::class, + 'mt' => MachineTranslationAid::class, + 'definitiondiff' => UpdatedDefinitionAid::class, + 'ttmserver' => TTMServerAid::class, + 'support' => SupportAid::class, + 'gettext' => GettextDocumentationAid::class, + 'insertables' => InsertablesAid::class, + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidDataProvider.php b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidDataProvider.php new file mode 100644 index 00000000..c8f01096 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidDataProvider.php @@ -0,0 +1,148 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use Content; +use ContentHandler; +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; +use MessageGroup; +use MessageHandle; +use TextContent; +use Wikimedia\Rdbms\IDatabase; + +/** + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2018.01 + */ +class TranslationAidDataProvider { + /** @var MessageHandle */ + private $handle; + /** @var MessageGroup */ + private $group; + /** @var string|null */ + private $definition; + /** @var array */ + private $translations; + + public function __construct( MessageHandle $handle ) { + $this->handle = $handle; + $this->group = $handle->getGroup(); + } + + /** + * Get the message definition. Cached for performance. + * @return string + */ + public function getDefinition(): string { + if ( $this->definition !== null ) { + return $this->definition; + } + + // Optional performance optimization + if ( is_callable( [ $this->group, 'getMessageContent' ] ) ) { + // @phan-suppress-next-line PhanUndeclaredMethod + $this->definition = $this->group->getMessageContent( $this->handle ); + } else { + $this->definition = $this->group->getMessage( + $this->handle->getKey(), + $this->group->getSourceLanguage() + ); + } + + if ( $this->definition === null ) { + throw new TranslationHelperException( + 'Did not find message definition for ' . $this->handle->getTitle()->getPrefixedText() . + ' in group ' . $this->group->getId() + ); + } + return $this->definition; + } + + public function hasDefinition(): bool { + try { + $this->getDefinition(); + return true; + } catch ( TranslationHelperException $e ) { + return false; + } + } + + public function getDefinitionContent(): Content { + return ContentHandler::makeContent( $this->getDefinition(), $this->handle->getTitle() ); + } + + /** + * Get the translations in all languages. Cached for performance. + * Fuzzy translation are not included. + * @return array Language code => Translation + */ + public function getGoodTranslations(): array { + if ( $this->translations !== null ) { + return $this->translations; + } + + $data = self::loadTranslationData( wfGetDB( DB_REPLICA ), $this->handle ); + $translations = []; + $prefixLength = strlen( $this->handle->getTitleForBase()->getDBKey() . '/' ); + $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils(); + + foreach ( $data as $page => $translation ) { + // Could use MessageHandle here, but that queries the message index. + // Instead we can get away with simple string manipulation. + $code = substr( $page, $prefixLength ); + if ( !$languageNameUtils->isKnownLanguageTag( $code ) ) { + continue; + } + + $translations[ $code ] = $translation; + } + + $this->translations = $translations; + + return $translations; + } + + private static function loadTranslationData( IDatabase $db, MessageHandle $handle ): array { + $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $revisionStore->getQueryInfo( [ 'page' ] ); + $tables = $queryInfo[ 'tables' ]; + $fields = $queryInfo[ 'fields' ]; + $conds = []; + $options = []; + $joins = $queryInfo[ 'joins' ]; + + // The list of pages we want to select, and their latest versions + $conds['page_namespace'] = $handle->getTitle()->getNamespace(); + $base = $handle->getKey(); + $conds[] = 'page_title ' . $db->buildLike( "$base/", $db->anyString() ); + $conds[] = 'rev_id=page_latest'; + + // For fuzzy tags we also need: + $tables[] = 'revtag'; + $conds[ 'rt_type' ] = null; + $joins[ 'revtag' ] = [ + 'LEFT JOIN', + [ 'page_id=rt_page', 'page_latest=rt_revision', 'rt_type' => 'fuzzy' ] + ]; + + $rows = $db->select( $tables, $fields, $conds, __METHOD__, $options, $joins ); + + $pages = []; + $revisions = $revisionStore->newRevisionsFromBatch( $rows, [ 'slots' => [ SlotRecord::MAIN ] ] ) + ->getValue(); + foreach ( $rows as $row ) { + /** @var RevisionRecord|null $rev */ + $rev = $revisions[$row->rev_id]; + if ( $rev && $rev->getContent( SlotRecord::MAIN ) instanceof TextContent ) { + $pages[$row->page_title] = $rev->getContent( SlotRecord::MAIN )->getText(); + } + } + + return $pages; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidsActionApi.php b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidsActionApi.php new file mode 100644 index 00000000..2d5f763d --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidsActionApi.php @@ -0,0 +1,153 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use ApiBase; +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MediaWiki\Logger\LoggerFactory; +use MessageGroups; +use MessageHandle; +use QueryAggregator; +use QueryAggregatorAware; +use Title; + +/** + * Api module for querying message aids. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @ingroup API TranslateAPI + */ +class TranslationAidsActionApi extends ApiBase { + public function execute() { + $params = $this->extractRequestParams(); + + $title = Title::newFromText( $params['title'] ); + if ( !$title ) { + $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] ); + } + + $handle = new MessageHandle( $title ); + if ( !$handle->isValid() ) { + $this->dieWithError( 'apierror-translate-nomessagefortitle', 'nomessagefortitle' ); + } + + if ( (string)$params['group'] !== '' ) { + $group = MessageGroups::getGroup( $params['group'] ); + } else { + $group = $handle->getGroup(); + } + + if ( !$group ) { + $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' ); + } + + $data = []; + $times = []; + + $props = $params['prop']; + $aggregator = new QueryAggregator(); + + // Figure out the intersection of supported and requested aids + $types = TranslationAid::getTypes(); + $props = array_intersect( $props, array_keys( $types ) ); + + $result = $this->getResult(); + + // Create list of aids, populate web services queries + /** @var TranslationAid[] $aids */ + $aids = []; + + $dataProvider = new TranslationAidDataProvider( $handle ); + + // Message definition should not be empty, but sometimes is. + // See: https://phabricator.wikimedia.org/T285830 + // Identify and log. + if ( !$dataProvider->hasDefinition() ) { + LoggerFactory::getInstance( 'Translate' )->warning( + 'Message definition is empty! Title: {title}, group: {group}, key: {key}', + [ + 'title' => $handle->getTitle()->getPrefixedText(), + 'group' => $group->getId(), + 'key' => $handle->getKey() + ] + ); + } + + foreach ( $props as $type ) { + // Do not proceed if translation aid is not supported for this message group + if ( !isset( $types[$type] ) ) { + $types[$type] = UnsupportedTranslationAid::class; + } + + $class = $types[$type]; + $obj = new $class( $group, $handle, $this, $dataProvider ); + + if ( $obj instanceof QueryAggregatorAware ) { + $obj->setQueryAggregator( $aggregator ); + try { + $obj->populateQueries(); + } catch ( TranslationHelperException $e ) { + $data[$type] = [ 'error' => $e->getMessage() ]; + // Prevent processing this aids and thus overwriting our error + continue; + } + } + + $aids[$type] = $obj; + } + + // Execute all web service queries asynchronously to save time + $start = microtime( true ); + $aggregator->run(); + $times['query_aggregator'] = round( microtime( true ) - $start, 3 ); + + // Construct the result data structure + foreach ( $aids as $type => $obj ) { + $start = microtime( true ); + + try { + $aid = $obj->getData(); + } catch ( TranslationHelperException $e ) { + $aid = [ 'error' => $e->getMessage() ]; + } + + if ( isset( $aid['**'] ) ) { + $result->setIndexedTagName( $aid, $aid['**'] ); + unset( $aid['**'] ); + } + + $data[$type] = $aid; + $times[$type] = round( microtime( true ) - $start, 3 ); + } + + $result->addValue( null, 'helpers', $data ); + $result->addValue( null, 'times', $times ); + } + + protected function getAllowedParams(): array { + $props = array_keys( TranslationAid::getTypes() ); + + return [ + 'title' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true, + ], + 'group' => [ + ApiBase::PARAM_TYPE => 'string', + ], + 'prop' => [ + ApiBase::PARAM_DFLT => implode( '|', $props ), + ApiBase::PARAM_TYPE => $props, + ApiBase::PARAM_ISMULTI => true, + ], + ]; + } + + protected function getExamplesMessages() { + return [ + 'action=translationaids&title=MediaWiki:January/fi' + => 'apihelp-translationaids-example-1', + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/UnsupportedTranslationAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/UnsupportedTranslationAid.php new file mode 100644 index 00000000..38c3530a --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/UnsupportedTranslationAid.php @@ -0,0 +1,20 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; + +/** + * Dummy translation aid that always errors + * @author Harry Burt + * @license GPL-2.0-or-later + * @since 2013-03-29 + * @ingroup TranslationAids + * @phan-file-suppress PhanPluginNeverReturnMethod + */ +class UnsupportedTranslationAid extends TranslationAid { + public function getData(): array { + throw new TranslationHelperException( 'This translation aid is disabled' ); + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Aid/UpdatedDefinitionAid.php b/MLEB/Translate/src/TranslatorInterface/Aid/UpdatedDefinitionAid.php new file mode 100644 index 00000000..f25a8a05 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/Aid/UpdatedDefinitionAid.php @@ -0,0 +1,91 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface\Aid; + +use DifferenceEngine; +use MediaWiki\Extension\Translate\TranslatorInterface\TranslationHelperException; +use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\SlotRecord; +use RevTag; +use Title; +use TranslateUtils; +use WikitextContent; + +/** + * Translation aid that provides the message definition. + * This usually matches the content of the page ns:key/source_language. + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013-01-01 + * @ingroup TranslationAids + */ +class UpdatedDefinitionAid extends TranslationAid { + public function getData(): array { + $db = TranslateUtils::getSafeReadDB(); + $conds = [ + 'rt_page' => $this->handle->getTitle()->getArticleID(), + 'rt_type' => RevTag::getType( 'tp:transver' ), + ]; + $options = [ + 'ORDER BY' => 'rt_revision DESC', + ]; + + $translationRevision = $db->selectField( 'revtag', 'rt_value', $conds, __METHOD__, $options ); + if ( $translationRevision === false ) { + throw new TranslationHelperException( 'No definition revision recorded' ); + } + + $definitionTitle = Title::makeTitleSafe( + $this->handle->getTitle()->getNamespace(), + $this->handle->getKey() . '/' . $this->group->getSourceLanguage() + ); + + if ( !$definitionTitle || !$definitionTitle->exists() ) { + throw new TranslationHelperException( 'Definition page does not exist' ); + } + + // Using getRevisionById instead of byTitle, because the page might have been renamed + $oldRevRecord = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( $translationRevision ); + if ( !$oldRevRecord ) { + throw new TranslationHelperException( 'Old definition version does not exist anymore' ); + } + + $oldContent = $oldRevRecord->getContent( SlotRecord::MAIN ); + $newContent = $this->dataProvider->getDefinitionContent(); + + if ( !$oldContent ) { + throw new TranslationHelperException( 'Old definition version does not exist anymore' ); + } + + if ( !$oldContent instanceof WikitextContent || !$newContent instanceof WikitextContent ) { + throw new TranslationHelperException( 'Can only work on Wikitext content' ); + } + + if ( $oldContent->equals( $newContent ) ) { + throw new TranslationHelperException( 'No changes' ); + } + + $diff = new DifferenceEngine( $this->context ); + $diff->setTextLanguage( wfGetLangObj( $this->group->getSourceLanguage() ) ); + $diff->setContent( $oldContent, $newContent ); + $diff->setReducedLineNumbers(); + $diff->showDiffStyle(); + + $html = $diff->getDiff( + $this->context->msg( 'tpt-diff-old' )->escaped(), + $this->context->msg( 'tpt-diff-new' )->escaped() + ); + + return [ + 'value_old' => $oldContent->getText(), + 'value_new' => $newContent->getText(), + 'revisionid_old' => $oldRevRecord->getId(), + 'revisionid_new' => $definitionTitle->getLatestRevID(), + 'language' => $this->group->getSourceLanguage(), + 'html' => $html, + ]; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/EntitySearch.php b/MLEB/Translate/src/TranslatorInterface/EntitySearch.php new file mode 100644 index 00000000..08035abb --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/EntitySearch.php @@ -0,0 +1,111 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface; + +use Collation; +use MessageGroups; +use SplMinHeap; +use WANObjectCache; +use Wikimedia\LightweightObjectStore\ExpirationAwareness; + +/** + * Service for searching message groups and message keys. + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ +class EntitySearch { + private const FIELD_DELIMITER = "\x7F"; + /** @var WANObjectCache */ + private $cache; + /** @var Collation */ + private $collation; + /** @var MessageGroups */ + private $messageGroupFactory; + + public function __construct( WANObjectCache $cache, Collation $collation, MessageGroups $messageGroupFactory ) { + $this->cache = $cache; + $this->collation = $collation; + $this->messageGroupFactory = $messageGroupFactory; + } + + public function searchStaticMessageGroups( string $query, int $maxResults ): array { + $cache = $this->cache; + // None of the static groups currently use language-dependent labels. This + // may need revisiting later and splitting the cache by language. + $key = $cache->makeKey( 'Translate', 'EntitySearch', 'static-groups' ); + $haystack = $cache->getWithSetCallback( + $key, + ExpirationAwareness::TTL_HOUR, + function (): string { + return $this->getStaticMessageGroupsHaystack(); + }, + [ + // Calling touchCheckKey() on this key purges the cache + 'checkKeys' => [ $this->messageGroupFactory->getCacheKey() ], + // Avoid querying cache servers multiple times in a web request + 'pcTTL' => $cache::TTL_PROC_LONG + ] + ); + + // Algorithm: Construct one big string with one entity per line. Then run + // preg_match_all twice over it, first to collect prefix match (to show them + // first), then to match words if more results are needed. + $results = []; + + $delimiter = self::FIELD_DELIMITER; + $anything = "[^$delimiter\n]"; + $query = preg_quote( $query, '/' ); + // Prefix match + $pattern = "/^($query$anything*)$delimiter($anything+)$/miu"; + preg_match_all( $pattern, $haystack, $matches, PREG_SET_ORDER ); + foreach ( $matches as [ , $label, $groupId ] ) { + // Index by $groupId to avoid duplicates from the prefix match and the word match + $results[$groupId] = [ + 'label' => $label, + 'group' => $groupId, + ]; + + if ( count( $results ) >= $maxResults ) { + return array_values( $results ); + } + } + + // Word match + $pattern = "/^($anything*\b$query$anything*)$delimiter($anything+)$/miu"; + preg_match_all( $pattern, $haystack, $matches, PREG_SET_ORDER ); + foreach ( $matches as [ , $label, $groupId ] ) { + $results[$groupId] = [ + 'label' => $label, + 'group' => $groupId, + ]; + + if ( count( $results ) >= $maxResults ) { + return array_values( $results ); + } + } + + return array_values( $results ); + } + + private function getStaticMessageGroupsHaystack(): string { + $groups = $this->messageGroupFactory->getGroups(); + $data = new SplMinHeap(); + foreach ( $groups as $group ) { + $label = $group->getLabel(); + // Ensure there are no special chars that will break matching + $label = strtr( $label, [ self::FIELD_DELIMITER => '', "\n" => '' ] ); + $sortKey = $this->collation->getSortKey( $label ); + // It is unlikely that different groups have the same label (or sort key), + // but it's possible. + $data->insert( [ $sortKey, $label, $group->getId() ] ); + } + + $haystack = ''; + foreach ( $data as [ , $label, $groupId ] ) { + $haystack .= $label . self::FIELD_DELIMITER . $groupId . "\n"; + } + + return $haystack; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/CombinedInsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/CombinedInsertablesSuggester.php index 4f42d033..c4a2662e 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/CombinedInsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/CombinedInsertablesSuggester.php @@ -29,5 +29,3 @@ class CombinedInsertablesSuggester implements InsertablesSuggester { return array_unique( $insertables, SORT_REGULAR ); } } - -class_alias( CombinedInsertablesSuggester::class, '\MediaWiki\Extensions\Translate\CombinedInsertablesSuggester' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/HtmlTagInsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/HtmlTagInsertablesSuggester.php index 6852d9f8..d2599396 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/HtmlTagInsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/HtmlTagInsertablesSuggester.php @@ -26,5 +26,3 @@ class HtmlTagInsertablesSuggester implements InsertablesSuggester { return $this->suggester->getInsertables( $text ); } } - -class_alias( HtmlTagInsertablesSuggester::class, '\MediaWiki\Extensions\Translate\HtmlTagInsertablesSuggester' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/Insertable.php b/MLEB/Translate/src/TranslatorInterface/Insertable/Insertable.php index 1a2bd1c1..70e68d08 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/Insertable.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/Insertable.php @@ -41,5 +41,3 @@ class Insertable { return $this->display; } } - -class_alias( Insertable::class, '\MediaWiki\Extensions\Translate\Insertable' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/InsertableFactory.php b/MLEB/Translate/src/TranslatorInterface/Insertable/InsertableFactory.php index aa869ce8..f723b6ce 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/InsertableFactory.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/InsertableFactory.php @@ -46,5 +46,3 @@ class InsertableFactory { return $suggester; } } - -class_alias( InsertableFactory::class, '\MediaWiki\Extensions\Translate\InsertableFactory' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/InsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/InsertablesSuggester.php index a327fb6b..39a39dc4 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/InsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/InsertablesSuggester.php @@ -17,5 +17,3 @@ interface InsertablesSuggester { */ public function getInsertables( string $text ): array; } - -class_alias( InsertablesSuggester::class, '\MediaWiki\Extensions\Translate\InsertablesSuggester' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/MediaWikiInsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/MediaWikiInsertablesSuggester.php index 5454e72f..e8471217 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/MediaWikiInsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/MediaWikiInsertablesSuggester.php @@ -17,7 +17,7 @@ class MediaWikiInsertablesSuggester implements InsertablesSuggester { // MediaWiki apihelp messages often have parameters like $1user, which should // be unchanged in translation. preg_match_all( '/\$(1[a-z]+|[0-9]+)/', $text, $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { + $new = array_map( static function ( $match ) { return new Insertable( $match[0], $match[0] ); }, $matches ); $insertables = array_merge( $insertables, $new ); @@ -29,20 +29,14 @@ class MediaWikiInsertablesSuggester implements InsertablesSuggester { $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { + $new = array_map( static function ( $match ) { return new Insertable( $match[2], $match[1], $match[3] ); }, $matches ); $insertables = array_merge( $insertables, $new ); - $matches = []; - preg_match_all( '/<\/?[a-z]+>/', $text, $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { - return new Insertable( $match[0], $match[0] ); - }, $matches ); - $insertables = array_merge( $insertables, $new ); - - return $insertables; + return array_merge( + $insertables, + ( new HtmlTagInsertablesSuggester() )->getInsertables( $text ) + ); } } - -class_alias( MediaWikiInsertablesSuggester::class, '\MediaWiki\Extensions\Translate\MediaWikiInsertablesSuggester' ); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/NumericalParameterInsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/NumericalParameterInsertablesSuggester.php index 1d327354..7a8e15a4 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/NumericalParameterInsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/NumericalParameterInsertablesSuggester.php @@ -21,7 +21,7 @@ class NumericalParameterInsertablesSuggester implements InsertablesSuggester { $matches, PREG_SET_ORDER ); - $new = array_map( function ( $match ) { + $new = array_map( static function ( $match ) { return new Insertable( $match[0], $match[0] ); }, $matches ); $insertables = array_merge( $insertables, $new ); @@ -29,8 +29,3 @@ class NumericalParameterInsertablesSuggester implements InsertablesSuggester { return $insertables; } } - -class_alias( - NumericalParameterInsertablesSuggester::class, - '\MediaWiki\Extensions\Translate\NumericalParameterInsertablesSuggester' -); diff --git a/MLEB/Translate/src/TranslatorInterface/Insertable/RegexInsertablesSuggester.php b/MLEB/Translate/src/TranslatorInterface/Insertable/RegexInsertablesSuggester.php index b95efd6a..7b789ec1 100644 --- a/MLEB/Translate/src/TranslatorInterface/Insertable/RegexInsertablesSuggester.php +++ b/MLEB/Translate/src/TranslatorInterface/Insertable/RegexInsertablesSuggester.php @@ -122,5 +122,3 @@ class RegexInsertablesSuggester implements InsertablesSuggester { return new Insertable( $displayVal, $preVal, $postVal ); } } - -class_alias( RegexInsertablesSuggester::class, '\MediaWiki\Extensions\Translate\RegexInsertablesSuggester' ); diff --git a/MLEB/Translate/src/TranslatorInterface/LegacyInterfaceHookHandler.php b/MLEB/Translate/src/TranslatorInterface/LegacyInterfaceHookHandler.php new file mode 100644 index 00000000..d2c79438 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/LegacyInterfaceHookHandler.php @@ -0,0 +1,84 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface; + +use DifferenceEngine; +use EditPage; +use MediaWiki\Diff\Hook\ArticleContentOnDiffHook; +use MediaWiki\Hook\AlternateEditHook; +use MediaWiki\Hook\EditPage__showEditForm_initialHook; +use MediaWiki\Languages\LanguageFactory; +use MessageHandle; +use OutputPage; + +/** + * Integration point to MediaWiki for the legacy translation aids. + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ +class LegacyInterfaceHookHandler + implements AlternateEditHook, ArticleContentOnDiffHook, EditPage__showEditForm_initialHook +{ + /** @var LanguageFactory */ + private $languageFactory; + + public function __construct( LanguageFactory $languageFactory ) { + $this->languageFactory = $languageFactory; + } + + /** + * Do not show the usual introductory messages on edit page for messages. + * @param EditPage $editPage + */ + public function onAlternateEdit( $editPage ): void { + $handle = new MessageHandle( $editPage->getTitle() ); + if ( $handle->isValid() ) { + $editPage->suppressIntro = true; + } + } + + /** + * Enhances the action=edit view for wikitext editor with some translation aids + * @param EditPage $editPage + * @param OutputPage $out + */ + // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName + public function onEditPage__showEditForm_initial( $editPage, $out ): void { + // phpcs:enable + $handle = new MessageHandle( $editPage->getTitle() ); + if ( !$handle->isValid() ) { + return; + } + + $context = $out->getContext(); + $request = $context->getRequest(); + + if ( $editPage->firsttime && !$request->getCheck( 'oldid' ) && + !$request->getCheck( 'undo' ) ) { + if ( $handle->isFuzzy() ) { + $editPage->textbox1 = TRANSLATE_FUZZY . $editPage->textbox1; + } + } + + $th = new LegacyTranslationAids( $handle, $context, $this->languageFactory ); + $editPage->editFormTextTop .= $th->getBoxes(); + } + + /** + * Enhances the action=diff view with some translations aids + * @param DifferenceEngine $diffEngine + * @param OutputPage $output + */ + public function onArticleContentOnDiff( $diffEngine, $output ): void { + $title = $diffEngine->getTitle(); + $handle = new MessageHandle( $title ); + + if ( !$handle->isValid() ) { + return; + } + + $th = new LegacyTranslationAids( $handle, $diffEngine->getContext(), $this->languageFactory ); + $output->addHTML( $th->getBoxes() ); + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/LegacyTranslationAids.php b/MLEB/Translate/src/TranslatorInterface/LegacyTranslationAids.php new file mode 100644 index 00000000..6dc9ff9a --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/LegacyTranslationAids.php @@ -0,0 +1,159 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface; + +use Html; +use IContextSource; +use MediaWiki\Extension\Translate\TranslatorInterface\Aid\MessageDefinitionAid; +use MediaWiki\Extension\Translate\TranslatorInterface\Aid\TranslationAidDataProvider; +use MediaWiki\Languages\LanguageFactory; +use MessageGroup; +use MessageHandle; +use Title; +use TranslateUtils; + +/** + * Provides minimal translation aids which integrate with the edit page and on diffs for + * translatable messages. + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ +class LegacyTranslationAids { + /** @var MessageHandle */ + private $handle; + /** @var MessageGroup */ + private $group; + /** @var IContextSource */ + private $context; + /** @var LanguageFactory */ + private $languageFactory; + + public function __construct( + MessageHandle $handle, + IContextSource $context, + LanguageFactory $languageFactory + ) { + $this->handle = $handle; + $this->context = $context; + $this->group = $handle->getGroup(); + $this->languageFactory = $languageFactory; + } + + private function getDefinition(): ?string { + $obj = new MessageDefinitionAid( + $this->group, + $this->handle, + $this->context, + new TranslationAidDataProvider( $this->handle ) + ); + + return $obj->getData()['value']; + } + + /** + * Returns block element HTML snippet that contains the translation aids. + * Not all boxes are shown all the time depending on whether they have + * any information to show and on configuration variables. + * @return string Block level HTML snippet or empty string. + */ + public function getBoxes(): string { + $boxes = []; + + try { + $boxes[] = $this->getDocumentationBox(); + } catch ( TranslationHelperException $e ) { + $boxes[] = "<!-- Documentation not available: {$e->getMessage()} -->"; + } + + try { + $boxes[] = $this->getDefinitionBox(); + } catch ( TranslationHelperException $e ) { + $boxes[] = "<!-- Definition not available: {$e->getMessage()} -->"; + } + + $this->context->getOutput()->addModuleStyles( 'ext.translate.quickedit' ); + return Html::rawElement( + 'div', + [ 'class' => 'mw-sp-translate-edit-fields' ], + implode( "\n\n", $boxes ) + ); + } + + private function getDefinitionBox(): string { + $definition = $this->getDefinition(); + if ( (string)$definition === '' ) { + throw new TranslationHelperException( 'Message lacks definition' ); + } + + $linkTag = self::ajaxEditLink( $this->handle->getTitle(), $this->group->getLabel() ); + $label = + wfMessage( 'translate-edit-definition' )->escaped() . + wfMessage( 'word-separator' )->escaped() . + wfMessage( 'parentheses' )->rawParams( $linkTag )->escaped(); + + $sl = $this->languageFactory->getLanguage( $this->group->getSourceLanguage() ); + + $msg = Html::rawElement( 'div', + [ + 'class' => 'mw-translate-edit-deftext', + 'dir' => $sl->getDir(), + 'lang' => $sl->getHtmlCode(), + ], + TranslateUtils::convertWhiteSpaceToHTML( $definition ) + ); + + $class = [ 'class' => 'mw-sp-translate-edit-definition' ]; + + return TranslateUtils::fieldset( $label, $msg, $class ); + } + + private function getDocumentationBox(): string { + global $wgTranslateDocumentationLanguageCode; + + if ( !$wgTranslateDocumentationLanguageCode ) { + throw new TranslationHelperException( 'Message documentation language code is not defined' ); + } + + $page = $this->handle->getKey(); + $ns = $this->handle->getTitle()->getNamespace(); + + $title = $this->handle->getTitleForLanguage( $wgTranslateDocumentationLanguageCode ); + $edit = $this->ajaxEditLink( + $title, + $this->context->msg( 'translate-edit-contribute' )->text() + ); + $info = TranslateUtils::getMessageContent( $page, $wgTranslateDocumentationLanguageCode, $ns ); + + $class = 'mw-sp-translate-edit-info'; + + // The information is most likely in English + $divAttribs = [ 'dir' => 'ltr', 'lang' => 'en', 'class' => 'mw-content-ltr' ]; + + if ( (string)$info === '' ) { + $info = $this->context->msg( 'translate-edit-no-information' )->plain(); + $class = 'mw-sp-translate-edit-noinfo'; + $lang = $this->context->getLanguage(); + // The message saying that there's no info, should be translated + $divAttribs = [ 'dir' => $lang->getDir(), 'lang' => $lang->getHtmlCode() ]; + } + $class .= ' mw-sp-translate-message-documentation'; + + $contents = $this->context->getOutput()->parseInlineAsInterface( $info ); + + return TranslateUtils::fieldset( + $this->context->msg( 'translate-edit-information' )->rawParams( $edit )->escaped(), + Html::rawElement( 'div', $divAttribs, $contents ), [ 'class' => $class ] + ); + } + + private function ajaxEditLink( Title $target, string $linkText ): string { + $handle = new MessageHandle( $target ); + $uri = TranslateUtils::getEditorUrl( $handle ); + return Html::element( + 'a', + [ 'href' => $uri ], + $linkText + ); + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/TranslationEntitySearchActionApi.php b/MLEB/Translate/src/TranslatorInterface/TranslationEntitySearchActionApi.php new file mode 100644 index 00000000..151fda03 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/TranslationEntitySearchActionApi.php @@ -0,0 +1,52 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface; + +use ApiBase; +use ApiMain; +use Wikimedia\ParamValidator\ParamValidator; +use Wikimedia\ParamValidator\TypeDef\NumericDef; + +/** + * Action API module for searching message groups and message keys. + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ +class TranslationEntitySearchActionApi extends ApiBase { + /** @var EntitySearch */ + private $entitySearch; + + public function __construct( ApiMain $mainModule, $moduleName, EntitySearch $entitySearch ) { + parent::__construct( $mainModule, $moduleName ); + $this->entitySearch = $entitySearch; + } + + public function execute() { + $query = $this->getParameter( 'query' ); + $maxResults = $this->getParameter( 'limit' ); + + $searchResults = $this->entitySearch->searchStaticMessageGroups( $query, $maxResults ); + $this->getResult()->addValue( null, $this->getModuleName(), array_values( $searchResults ) ); + } + + protected function getAllowedParams(): array { + return [ + 'query' => [ + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => true + ], + 'limit' => [ + ParamValidator::PARAM_TYPE => 'limit', + ParamValidator::PARAM_DEFAULT => 10, + NumericDef::PARAM_MAX => ApiBase::LIMIT_SML1, + ParamValidator::PARAM_REQUIRED => false, + ], + ]; + } + + public function isInternal(): bool { + // Temporarily until stable + return true; + } +} diff --git a/MLEB/Translate/src/TranslatorInterface/TranslationHelperException.php b/MLEB/Translate/src/TranslatorInterface/TranslationHelperException.php new file mode 100644 index 00000000..bf395b37 --- /dev/null +++ b/MLEB/Translate/src/TranslatorInterface/TranslationHelperException.php @@ -0,0 +1,17 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\TranslatorInterface; + +use MWException; + +/** + * Translation helpers can throw this exception when they cannot do + * anything useful with the current message. This helps in debugging + * why some fields are not shown. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ +class TranslationHelperException extends MWException { +} diff --git a/MLEB/Translate/src/TranslatorSandbox/ManageTranslatorSandboxSpecialPage.php b/MLEB/Translate/src/TranslatorSandbox/ManageTranslatorSandboxSpecialPage.php index d17a4602..9e253b49 100644 --- a/MLEB/Translate/src/TranslatorSandbox/ManageTranslatorSandboxSpecialPage.php +++ b/MLEB/Translate/src/TranslatorSandbox/ManageTranslatorSandboxSpecialPage.php @@ -150,7 +150,6 @@ HTML; usort( $requests, [ $this, 'translatorRequestSort' ] ); foreach ( $requests as $request ) { - // @phan-suppress-next-line SecurityCheck-DoubleEscaped $items[] = $this->makeRequestItem( $request ); } @@ -164,7 +163,7 @@ HTML; </button> </div> <div class="five columns request-count"></div> - <div class="three columns center"> + <div class="three columns text-center"> <input class="request-selector-all" name="request" type="checkbox" /> </div> </div> @@ -195,7 +194,7 @@ HTML; <div class="row username">$nameEnc</div> <div class="row email" dir="ltr">$emailEnc</div> </div> - <div class="three columns approval center"> + <div class="three columns approval text-center"> <input class="row request-selector" name="request" type="checkbox" /> <div class="row signup-age">$agoEnc</div> </div> @@ -217,8 +216,3 @@ HTML; ?: strnatcasecmp( $a['username'], $b['username'] ); } } - -class_alias( - ManageTranslatorSandboxSpecialPage::class, - '\MediaWiki\Extensions\Translate\ManageTranslatorSandboxSpecialPage' -); diff --git a/MLEB/Translate/src/TranslatorSandbox/StashedTranslation.php b/MLEB/Translate/src/TranslatorSandbox/StashedTranslation.php index 251c6d60..b9b3104a 100644 --- a/MLEB/Translate/src/TranslatorSandbox/StashedTranslation.php +++ b/MLEB/Translate/src/TranslatorSandbox/StashedTranslation.php @@ -46,5 +46,3 @@ class StashedTranslation { return $this->metadata; } } - -class_alias( StashedTranslation::class, '\MediaWiki\Extensions\Translate\StashedTranslation' ); diff --git a/MLEB/Translate/src/TranslatorSandbox/TranslationStashReader.php b/MLEB/Translate/src/TranslatorSandbox/TranslationStashReader.php index e9e041e0..9ad6a0f6 100644 --- a/MLEB/Translate/src/TranslatorSandbox/TranslationStashReader.php +++ b/MLEB/Translate/src/TranslatorSandbox/TranslationStashReader.php @@ -18,5 +18,3 @@ interface TranslationStashReader { */ public function getTranslations( User $user ): array; } - -class_alias( TranslationStashReader::class, '\MediaWiki\Extensions\Translate\TranslationStashReader' ); diff --git a/MLEB/Translate/src/TranslatorSandbox/TranslationStashSpecialPage.php b/MLEB/Translate/src/TranslatorSandbox/TranslationStashSpecialPage.php index 1915a0d5..ccf5e00e 100644 --- a/MLEB/Translate/src/TranslatorSandbox/TranslationStashSpecialPage.php +++ b/MLEB/Translate/src/TranslatorSandbox/TranslationStashSpecialPage.php @@ -59,7 +59,7 @@ class TranslationStashSpecialPage extends SpecialPage { $this->setHeaders(); $out = $this->getOutput(); - $this->stash = new TranslationStashStorage( wfGetDB( DB_MASTER ) ); + $this->stash = new TranslationStashStorage( wfGetDB( DB_PRIMARY ) ); if ( !$this->hasPermissionToUse() ) { if ( $secondaryPermissionUrl && $this->getUser()->isRegistered() ) { @@ -76,7 +76,7 @@ class TranslationStashSpecialPage extends SpecialPage { } $out->addJsConfigVars( 'wgTranslateSandboxLimit', $limit ); - $out->addModules( 'ext.translate.special.translationstash' ); + $out->addModules( 'ext.translate.specialTranslationStash' ); $out->addModuleStyles( 'mediawiki.ui.button' ); $this->showPage(); } @@ -202,5 +202,3 @@ HTML return Language::factory( $source->getCode() ); } } - -class_alias( TranslationStashSpecialPage::class, '\MediaWiki\Extensions\Translate\TranslationStashSpecialPage' ); diff --git a/MLEB/Translate/src/TranslatorSandbox/TranslationStashStorage.php b/MLEB/Translate/src/TranslatorSandbox/TranslationStashStorage.php index db30852a..5220966e 100644 --- a/MLEB/Translate/src/TranslatorSandbox/TranslationStashStorage.php +++ b/MLEB/Translate/src/TranslatorSandbox/TranslationStashStorage.php @@ -65,5 +65,3 @@ class TranslationStashStorage implements TranslationStashReader, TranslationStas $this->db->delete( $this->dbTable, $conds, __METHOD__ ); } } - -class_alias( TranslationStashStorage::class, '\MediaWiki\Extensions\Translate\TranslationStashStorage' ); diff --git a/MLEB/Translate/src/TranslatorSandbox/TranslationStashWriter.php b/MLEB/Translate/src/TranslatorSandbox/TranslationStashWriter.php index aca19268..3f0427a4 100644 --- a/MLEB/Translate/src/TranslatorSandbox/TranslationStashWriter.php +++ b/MLEB/Translate/src/TranslatorSandbox/TranslationStashWriter.php @@ -15,5 +15,3 @@ interface TranslationStashWriter { /** Delete all stashed translations for the given user. */ public function deleteTranslations( User $user ): void; } - -class_alias( TranslationStashWriter::class, '\MediaWiki\Extensions\Translate\TranslationStashWriter' ); diff --git a/MLEB/Translate/src/TtmServer/ExportTtmServerDumpMaintenanceScript.php b/MLEB/Translate/src/TtmServer/ExportTtmServerDumpMaintenanceScript.php index f2f70b4e..5817f4aa 100644 --- a/MLEB/Translate/src/TtmServer/ExportTtmServerDumpMaintenanceScript.php +++ b/MLEB/Translate/src/TtmServer/ExportTtmServerDumpMaintenanceScript.php @@ -191,8 +191,3 @@ class ExportTtmServerDumpMaintenanceScript extends BaseMaintenanceScript { return array_values( $out ); } } - -class_alias( - ExportTtmServerDumpMaintenanceScript::class, - '\MediaWiki\Extensions\Translate\ExportTtmServerDumpMaintenanceScript' -); diff --git a/MLEB/Translate/src/Utilities/ConfigHelper.php b/MLEB/Translate/src/Utilities/ConfigHelper.php new file mode 100644 index 00000000..e028d38c --- /dev/null +++ b/MLEB/Translate/src/Utilities/ConfigHelper.php @@ -0,0 +1,53 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\Utilities; + +/** + * A helper class added to work with configuration values of the Translate Extension + * + * Also used temporarily to simplify deprecation of old configuration variables. New + * variable names, if set, are given preference over the old ones. + * See: https://phabricator.wikimedia.org/T277965 + * + * @author Abijeet Patro. + * @license GPL-2.0-or-later + * @since 2021.06 + */ +class ConfigHelper { + /** @return bool|string */ + public function getValidationExclusionFile() { + global $wgTranslateValidationExclusionFile; + return $wgTranslateValidationExclusionFile; + } + + public function getTranslateAuthorExclusionList(): array { + global $wgTranslateAuthorExclusionList; + return $wgTranslateAuthorExclusionList; + } + + public function getDisabledTargetLanguages(): array { + global $wgTranslateDisabledTargetLanguages; + return $wgTranslateDisabledTargetLanguages; + } + + public function isAuthorExcluded( string $groupId, string $languageCode, string $username ): bool { + $hash = "$groupId;$languageCode;$username"; + $authorExclusionList = $this->getTranslateAuthorExclusionList(); + $excluded = false; + + foreach ( $authorExclusionList as $rule ) { + list( $type, $regex ) = $rule; + + if ( preg_match( $regex, $hash ) ) { + if ( $type === 'include' ) { + return false; + } else { + $excluded = true; + } + } + } + + return $excluded; + } +} diff --git a/MLEB/Translate/src/Utilities/GettextPlural.php b/MLEB/Translate/src/Utilities/GettextPlural.php index 1f2dd517..f14dba66 100644 --- a/MLEB/Translate/src/Utilities/GettextPlural.php +++ b/MLEB/Translate/src/Utilities/GettextPlural.php @@ -199,5 +199,3 @@ class GettextPlural { return $formArray; } } - -class_alias( GettextPlural::class, '\MediaWiki\Extensions\Translate\GettextPlural' ); diff --git a/MLEB/Translate/src/Utilities/Json/JsonCodec.php b/MLEB/Translate/src/Utilities/Json/JsonCodec.php index 2d1bb693..09b4c248 100644 --- a/MLEB/Translate/src/Utilities/Json/JsonCodec.php +++ b/MLEB/Translate/src/Utilities/Json/JsonCodec.php @@ -85,5 +85,3 @@ class JsonCodec { return false; } } - -class_alias( JsonCodec::class, '\MediaWiki\Extensions\Translate\JsonCodec' ); diff --git a/MLEB/Translate/src/Utilities/Json/JsonUnserializable.php b/MLEB/Translate/src/Utilities/Json/JsonUnserializable.php index 2a6c0cd0..d3d57332 100644 --- a/MLEB/Translate/src/Utilities/Json/JsonUnserializable.php +++ b/MLEB/Translate/src/Utilities/Json/JsonUnserializable.php @@ -15,5 +15,3 @@ interface JsonUnserializable { /** Restore an array to an instance of the current class */ public static function newFromJsonArray( array $json ); } - -class_alias( JsonUnserializable::class, '\MediaWiki\Extensions\Translate\JsonUnserializable' ); diff --git a/MLEB/Translate/src/Utilities/Json/JsonUnserializableTrait.php b/MLEB/Translate/src/Utilities/Json/JsonUnserializableTrait.php index 2db96ca1..417dd39e 100644 --- a/MLEB/Translate/src/Utilities/Json/JsonUnserializableTrait.php +++ b/MLEB/Translate/src/Utilities/Json/JsonUnserializableTrait.php @@ -33,5 +33,3 @@ trait JsonUnserializableTrait { */ abstract protected function toJsonArray(): array; } - -class_alias( JsonUnserializableTrait::class, '\MediaWiki\Extensions\Translate\JsonUnserializableTrait' ); diff --git a/MLEB/Translate/src/Utilities/LanguagesMultiselectWidget.php b/MLEB/Translate/src/Utilities/LanguagesMultiselectWidget.php index ea9c8a38..bd91a77e 100644 --- a/MLEB/Translate/src/Utilities/LanguagesMultiselectWidget.php +++ b/MLEB/Translate/src/Utilities/LanguagesMultiselectWidget.php @@ -31,5 +31,3 @@ class LanguagesMultiselectWidget extends TagMultiselectWidget { return parent::getConfig( $config ); } } - -class_alias( LanguagesMultiselectWidget::class, '\MediaWiki\Extensions\Translate\LanguagesMultiselectWidget' ); diff --git a/MLEB/Translate/src/Utilities/ParallelExecutor.php b/MLEB/Translate/src/Utilities/ParallelExecutor.php index 7b6e7186..e53b9c3c 100644 --- a/MLEB/Translate/src/Utilities/ParallelExecutor.php +++ b/MLEB/Translate/src/Utilities/ParallelExecutor.php @@ -52,5 +52,3 @@ class ParallelExecutor { } } } - -class_alias( ParallelExecutor::class, '\MediaWiki\Extensions\Translate\ParallelExecutor' ); diff --git a/MLEB/Translate/src/Utilities/ParsingPlaceholderFactory.php b/MLEB/Translate/src/Utilities/ParsingPlaceholderFactory.php index fc6c19c4..9bb381ce 100644 --- a/MLEB/Translate/src/Utilities/ParsingPlaceholderFactory.php +++ b/MLEB/Translate/src/Utilities/ParsingPlaceholderFactory.php @@ -21,5 +21,3 @@ class ParsingPlaceholderFactory { $this->i++; } } - -class_alias( ParsingPlaceholderFactory::class, '\MediaWiki\Extensions\Translate\ParsingPlaceholderFactory' ); diff --git a/MLEB/Translate/src/Utilities/SmartFormatPlural.php b/MLEB/Translate/src/Utilities/SmartFormatPlural.php index 49936444..c307b9ab 100644 --- a/MLEB/Translate/src/Utilities/SmartFormatPlural.php +++ b/MLEB/Translate/src/Utilities/SmartFormatPlural.php @@ -62,5 +62,3 @@ class SmartFormatPlural { return $ldns; } } - -class_alias( SmartFormatPlural::class, '\MediaWiki\Extensions\Translate\SmartFormatPlural' ); diff --git a/MLEB/Translate/src/Utilities/StringComparators/SimpleStringComparator.php b/MLEB/Translate/src/Utilities/StringComparators/SimpleStringComparator.php index 740eea32..c2f24bbb 100644 --- a/MLEB/Translate/src/Utilities/StringComparators/SimpleStringComparator.php +++ b/MLEB/Translate/src/Utilities/StringComparators/SimpleStringComparator.php @@ -25,5 +25,3 @@ class SimpleStringComparator implements StringComparator { return 0; } } - -class_alias( SimpleStringComparator::class, '\MediaWiki\Extensions\Translate\SimpleStringComparator' ); diff --git a/MLEB/Translate/src/Utilities/StringComparators/StringComparator.php b/MLEB/Translate/src/Utilities/StringComparators/StringComparator.php index d92b3e76..15bbbb38 100644 --- a/MLEB/Translate/src/Utilities/StringComparators/StringComparator.php +++ b/MLEB/Translate/src/Utilities/StringComparators/StringComparator.php @@ -16,5 +16,3 @@ interface StringComparator { */ public function getSimilarity( $a, $b ); } - -class_alias( StringComparator::class, '\MediaWiki\Extensions\Translate\StringComparator' ); diff --git a/MLEB/Translate/src/Utilities/TranslateReplaceTitle.php b/MLEB/Translate/src/Utilities/TranslateReplaceTitle.php index 136b251d..c0cd3640 100644 --- a/MLEB/Translate/src/Utilities/TranslateReplaceTitle.php +++ b/MLEB/Translate/src/Utilities/TranslateReplaceTitle.php @@ -57,7 +57,7 @@ class TranslateReplaceTitle { * @return TitleArrayFromResult */ private static function getMatchingTitles( MessageHandle $handle ) { - $dbr = wfGetDB( DB_MASTER ); + $dbr = wfGetDB( DB_PRIMARY ); $tables = [ 'page' ]; $vars = [ 'page_title', 'page_namespace', 'page_id' ]; @@ -76,5 +76,3 @@ class TranslateReplaceTitle { return TitleArray::newFromResult( $result ); } } - -class_alias( TranslateReplaceTitle::class, '\MediaWiki\Extensions\Translate\TranslateReplaceTitle' ); diff --git a/MLEB/Translate/src/Utilities/UnicodePlural.php b/MLEB/Translate/src/Utilities/UnicodePlural.php index 6ebbd556..8e7788ed 100644 --- a/MLEB/Translate/src/Utilities/UnicodePlural.php +++ b/MLEB/Translate/src/Utilities/UnicodePlural.php @@ -189,5 +189,3 @@ class UnicodePlural { return $sortedFormMap; } } - -class_alias( UnicodePlural::class, '\MediaWiki\Extensions\Translate\UnicodePlural' ); diff --git a/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php b/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php deleted file mode 100644 index 1b194334..00000000 --- a/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php -/** - * @file - * @author Niklas Laxström - * @license GPL-2.0-or-later - */ - -declare( strict_types = 1 ); - -namespace MediaWiki\Extension\Translate\Validation; - -use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester; -use TMessage; - -/** - * Object adapter for message validators that implement the deprecated interface. - * - * @since 2020.06 - */ -class LegacyValidatorAdapter implements MessageValidator, InsertablesSuggester { - /** @var Validator */ - private $validator; - - public function __construct( Validator $validator ) { - $this->validator = $validator; - } - - /** @inheritDoc */ - public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues { - $notices = []; - $this->validator->validate( $message, $targetLanguage, $notices ); - return $this->convertNoticesToValidationIssues( $notices, $message->key() ); - } - - private function convertNoticesToValidationIssues( - array $notices, - string $messageKey - ): ValidationIssues { - $issues = new ValidationIssues(); - foreach ( $notices[$messageKey] ?? [] as $notice ) { - $issue = new ValidationIssue( - $notice[0][0], - $notice[0][1], - $notice[1], - array_slice( $notice, 2 ) - ); - $issues->add( $issue ); - } - - return $issues; - } - - /** @inheritDoc */ - public function getInsertables( string $text ): array { - if ( $this->validator instanceof InsertablesSuggester ) { - return $this->validator->getInsertables( $text ); - } - - return []; - } -} - -class_alias( LegacyValidatorAdapter::class, '\MediaWiki\Extensions\Translate\LegacyValidatorAdapter' ); diff --git a/MLEB/Translate/src/Validation/MessageValidator.php b/MLEB/Translate/src/Validation/MessageValidator.php index 4946d0a9..b7f1cd1f 100644 --- a/MLEB/Translate/src/Validation/MessageValidator.php +++ b/MLEB/Translate/src/Validation/MessageValidator.php @@ -22,5 +22,3 @@ use TMessage; interface MessageValidator { public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues; } - -class_alias( MessageValidator::class, '\MediaWiki\Extensions\Translate\MessageValidator' ); diff --git a/MLEB/Translate/src/Validation/ValidationIssue.php b/MLEB/Translate/src/Validation/ValidationIssue.php index 822aeb1c..1d544894 100644 --- a/MLEB/Translate/src/Validation/ValidationIssue.php +++ b/MLEB/Translate/src/Validation/ValidationIssue.php @@ -52,5 +52,3 @@ class ValidationIssue { return $this->messageParams; } } - -class_alias( ValidationIssue::class, '\MediaWiki\Extensions\Translate\ValidationIssue' ); diff --git a/MLEB/Translate/src/Validation/ValidationIssues.php b/MLEB/Translate/src/Validation/ValidationIssues.php index f9278995..fdaa0069 100644 --- a/MLEB/Translate/src/Validation/ValidationIssues.php +++ b/MLEB/Translate/src/Validation/ValidationIssues.php @@ -51,5 +51,3 @@ class ValidationIssues implements Countable, IteratorAggregate { return count( $this->issues ); } } - -class_alias( ValidationIssues::class, '\MediaWiki\Extensions\Translate\ValidationIssues' ); diff --git a/MLEB/Translate/src/Validation/ValidationResult.php b/MLEB/Translate/src/Validation/ValidationResult.php index 2b5a62d7..ec7d3e0a 100644 --- a/MLEB/Translate/src/Validation/ValidationResult.php +++ b/MLEB/Translate/src/Validation/ValidationResult.php @@ -100,5 +100,3 @@ class ValidationResult { return $out; } } - -class_alias( ValidationResult::class, '\MediaWiki\Extensions\Translate\ValidationResult' ); diff --git a/MLEB/Translate/src/Validation/ValidationRunner.php b/MLEB/Translate/src/Validation/ValidationRunner.php index b5b345bc..5e6c41da 100644 --- a/MLEB/Translate/src/Validation/ValidationRunner.php +++ b/MLEB/Translate/src/Validation/ValidationRunner.php @@ -14,6 +14,7 @@ namespace MediaWiki\Extension\Translate\Validation; use Exception; use FormatJson; use InvalidArgumentException; +use MediaWiki\Extension\Translate\Services; use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester; use PHPVariableLoader; use RuntimeException; @@ -105,7 +106,8 @@ class ValidationRunner { 'instance' => $validator, 'insertable' => $isInsertable, 'enforce' => $validatorConfig['enforce'] ?? false, - 'keymatch' => $validatorConfig['keymatch'] ?? false, + 'include' => $validatorConfig['keymatch'] ?? $validatorConfig['include'] ?? false, + 'exclude' => $validatorConfig['exclude'] ?? false ]; } @@ -116,7 +118,7 @@ class ValidationRunner { */ public function getValidators(): array { return array_map( - function ( $validator ) { + static function ( $validator ) { return $validator['instance']; }, $this->validators @@ -202,19 +204,25 @@ class ValidationRunner { /** @internal Should only be used by tests and inside this class. */ public static function reloadIgnorePatterns(): void { - global $wgTranslateCheckBlacklist; + $validationExclusionFile = Services::getInstance()->getConfigHelper()->getValidationExclusionFile(); - if ( $wgTranslateCheckBlacklist === false ) { + if ( $validationExclusionFile === false ) { self::$ignorePatterns = []; return; } $list = PHPVariableLoader::loadVariableFromPHPFile( - $wgTranslateCheckBlacklist, - 'checkBlacklist' + $validationExclusionFile, + 'validationExclusionList' ); $keys = [ 'group', 'check', 'subcheck', 'code', 'message' ]; + if ( $list && !is_array( $list ) ) { + throw new InvalidArgumentException( + "validationExclusionList defined in $validationExclusionFile must be an array" + ); + } + foreach ( $list as $key => $pattern ) { foreach ( $keys as $checkKey ) { if ( !isset( $pattern[$checkKey] ) ) { @@ -287,8 +295,7 @@ class ValidationRunner { /** * Check if key matches validator's key patterns. - * - * Only relevant if the 'keymatch' option is specified in the validator. + * Only relevant if the 'include' or 'exclude' option is specified in the validator. * * @param string $key * @param string[] $keyMatches @@ -352,8 +359,13 @@ class ValidationRunner { } try { - $keyMatches = $validatorData['keymatch']; - if ( $keyMatches !== false && !$this->doesKeyMatch( $message->key(), $keyMatches ) ) { + $includedKeys = $validatorData['include']; + if ( $includedKeys !== false && !$this->doesKeyMatch( $message->key(), $includedKeys ) ) { + return; + } + + $excludedKeys = $validatorData['exclude']; + if ( $excludedKeys !== false && $this->doesKeyMatch( $message->key(), $excludedKeys ) ) { return; } @@ -371,5 +383,3 @@ class ValidationRunner { } } } - -class_alias( ValidationRunner::class, '\MediaWiki\Extensions\Translate\ValidationRunner' ); diff --git a/MLEB/Translate/src/Validation/Validator.php b/MLEB/Translate/src/Validation/Validator.php deleted file mode 100644 index c89f349b..00000000 --- a/MLEB/Translate/src/Validation/Validator.php +++ /dev/null @@ -1,23 +0,0 @@ -<?php -/** - * Interface to be implemented by Validators. - * - * @file - * @author Abijeet Patro - * @license GPL-2.0-or-later - */ - -namespace MediaWiki\Extension\Translate\Validation; - -use TMessage; - -/** - * Interface class built to be implement by validators - * @since 2019.06 - * @deprecated since 2020.06 - */ -interface Validator { - public function validate( TMessage $message, $code, array &$notices ); -} - -class_alias( Validator::class, '\MediaWiki\Extensions\Translate\Validator' ); diff --git a/MLEB/Translate/src/Validation/ValidatorFactory.php b/MLEB/Translate/src/Validation/ValidatorFactory.php index 20561f08..b0dcb040 100644 --- a/MLEB/Translate/src/Validation/ValidatorFactory.php +++ b/MLEB/Translate/src/Validation/ValidatorFactory.php @@ -18,6 +18,7 @@ use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiParameterValida use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiPluralValidator; use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiTimeListValidator; use MediaWiki\Extension\Translate\Validation\Validators\NewlineValidator; +use MediaWiki\Extension\Translate\Validation\Validators\NotEmptyValidator; use MediaWiki\Extension\Translate\Validation\Validators\NumericalParameterValidator; use MediaWiki\Extension\Translate\Validation\Validators\PrintfValidator; use MediaWiki\Extension\Translate\Validation\Validators\PythonInterpolationValidator; @@ -50,6 +51,7 @@ class ValidatorFactory { 'MediaWikiPlural' => MediaWikiPluralValidator::class, 'MediaWikiTimeList' => MediaWikiTimeListValidator::class, 'Newline' => NewlineValidator::class, + 'NotEmpty' => NotEmptyValidator::class, 'NumericalParameter' => NumericalParameterValidator::class, 'Printf' => PrintfValidator::class, 'PythonInterpolation' => PythonInterpolationValidator::class, @@ -91,13 +93,7 @@ class ValidatorFactory { throw new InvalidArgumentException( "Could not find validator class - '$class'. " ); } - $validator = new $class( $params ); - - if ( $validator instanceof Validator ) { - return new LegacyValidatorAdapter( $validator ); - } - - return $validator; + return new $class( $params ); } /** @@ -114,5 +110,3 @@ class ValidatorFactory { self::$validators[ $id ] = $ns . $validator; } } - -class_alias( ValidatorFactory::class, '\MediaWiki\Extensions\Translate\ValidatorFactory' ); diff --git a/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php b/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php index 2cc25114..32cd3ec0 100644 --- a/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php +++ b/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php @@ -53,5 +53,3 @@ class BraceBalanceValidator implements MessageValidator { return substr_count( $source, $str1 ) - substr_count( $source, $str2 ); } } - -class_alias( BraceBalanceValidator::class, '\MediaWiki\Extensions\Translate\BraceBalanceValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php b/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php index c333ca98..08a466ef 100644 --- a/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php +++ b/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php @@ -87,5 +87,3 @@ class EscapeCharacterValidator implements MessageValidator { return $prefix . $regex; } } - -class_alias( EscapeCharacterValidator::class, '\MediaWiki\Extensions\Translate\EscapeCharacterValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php b/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php index 3b52859d..46172b04 100644 --- a/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php +++ b/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php @@ -45,5 +45,3 @@ class GettextNewlineValidator extends NewlineValidator { return $str; } } - -class_alias( GettextNewlineValidator::class, '\MediaWiki\Extensions\Translate\GettextNewlineValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php b/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php index 36c6affd..0fdc5a59 100644 --- a/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php +++ b/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php @@ -104,5 +104,3 @@ class GettextPluralValidator implements MessageValidator { return [ 'ok', [] ]; } } - -class_alias( GettextPluralValidator::class, '\MediaWiki\Extensions\Translate\GettextPluralValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php b/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php index fb056873..2c6f1198 100644 --- a/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php +++ b/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php @@ -76,5 +76,3 @@ class InsertableRegexValidator extends RegexInsertablesSuggester implements Mess return $issues; } } - -class_alias( InsertableRegexValidator::class, '\MediaWiki\Extensions\Translate\InsertableRegexValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php b/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php index 50b67214..eda700a1 100644 --- a/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php +++ b/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php @@ -14,8 +14,3 @@ class InsertableRubyVariableValidator extends InsertableRegexValidator { parent::__construct( '/%{[a-zA-Z_]+}/' ); } } - -class_alias( - InsertableRubyVariableValidator::class, - '\MediaWiki\Extensions\Translate\InsertableRubyVariableValidator' -); diff --git a/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php b/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php index c981e194..dcb0142d 100644 --- a/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php +++ b/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php @@ -19,5 +19,3 @@ class IosVariableValidator extends InsertableRegexValidator { ); } } - -class_alias( IosVariableValidator::class, '\MediaWiki\Extensions\Translate\IosVariableValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php b/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php index a81bba20..70c4f836 100644 --- a/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php @@ -62,5 +62,3 @@ class MatchSetValidator implements MessageValidator { return $issues; } } - -class_alias( MatchSetValidator::class, '\MediaWiki\Extensions\Translate\MatchSetValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php index 0f872c6e..5392ca8e 100644 --- a/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php @@ -70,5 +70,3 @@ class MediaWikiLinkValidator implements MessageValidator { return $links; } } - -class_alias( MediaWikiLinkValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiLinkValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php index 344c5108..815d1963 100644 --- a/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php @@ -37,5 +37,3 @@ class MediaWikiPageNameValidator implements MessageValidator { return $issues; } } - -class_alias( MediaWikiPageNameValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiPageNameValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php index 5c953b4c..66d9aecb 100644 --- a/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php @@ -13,5 +13,3 @@ class MediaWikiParameterValidator extends InsertableRegexValidator { parent::__construct( '/\$[1-9]/' ); } } - -class_alias( MediaWikiParameterValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiParameterValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php index b7606d8f..678f5042 100644 --- a/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php @@ -98,7 +98,7 @@ class MediaWikiPluralValidator implements MessageValidator { // Stores the forms from plural invocations $plurals = []; - $cb = function ( $parser, $frame, $args ) use ( &$plurals ) { + $cb = static function ( $parser, $frame, $args ) use ( &$plurals ) { $forms = []; foreach ( $args as $index => $form ) { @@ -143,5 +143,3 @@ class MediaWikiPluralValidator implements MessageValidator { return array_values( $forms ); } } - -class_alias( MediaWikiPluralValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiPluralValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php index e1fb1279..fad5fd70 100644 --- a/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php +++ b/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php @@ -73,12 +73,9 @@ class MediaWikiTimeListValidator implements MessageValidator { ); $issues->add( $issue ); - continue; } } return $issues; } } - -class_alias( MediaWikiTimeListValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiTimeListValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/NewlineValidator.php b/MLEB/Translate/src/Validation/Validators/NewlineValidator.php index 36525569..367a546d 100644 --- a/MLEB/Translate/src/Validation/Validators/NewlineValidator.php +++ b/MLEB/Translate/src/Validation/Validators/NewlineValidator.php @@ -98,5 +98,3 @@ class NewlineValidator implements MessageValidator { return $issues; } } - -class_alias( NewlineValidator::class, '\MediaWiki\Extensions\Translate\NewlineValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/NotEmptyValidator.php b/MLEB/Translate/src/Validation/Validators/NotEmptyValidator.php new file mode 100644 index 00000000..53bc591f --- /dev/null +++ b/MLEB/Translate/src/Validation/Validators/NotEmptyValidator.php @@ -0,0 +1,28 @@ +<?php +declare( strict_types = 1 ); + +namespace MediaWiki\Extension\Translate\Validation\Validators; + +use MediaWiki\Extension\Translate\Validation\MessageValidator; +use MediaWiki\Extension\Translate\Validation\ValidationIssue; +use MediaWiki\Extension\Translate\Validation\ValidationIssues; +use TMessage; + +class NotEmptyValidator implements MessageValidator { + public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues { + $translation = $message->translation(); + $issues = new ValidationIssues(); + + if ( $translation !== null && trim( $translation ) === '' ) { + $issues->add( + new ValidationIssue( + 'empty', + 'empty', + 'translate-checks-empty' + ) + ); + } + + return $issues; + } +} diff --git a/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php b/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php index 84bbf0e3..a26f1509 100644 --- a/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php +++ b/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php @@ -13,5 +13,3 @@ class NumericalParameterValidator extends InsertableRegexValidator { parent::__construct( '/\$\d+/' ); } } - -class_alias( NumericalParameterValidator::class, '\MediaWiki\Extensions\Translate\NumericalParameterValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/PrintfValidator.php b/MLEB/Translate/src/Validation/Validators/PrintfValidator.php index 6121e002..2f150f6d 100644 --- a/MLEB/Translate/src/Validation/Validators/PrintfValidator.php +++ b/MLEB/Translate/src/Validation/Validators/PrintfValidator.php @@ -14,5 +14,3 @@ class PrintfValidator extends InsertableRegexValidator { parent::__construct( '/%(\d+\$)?(\.\d+)?[sduf]/U' ); } } - -class_alias( PrintfValidator::class, '\MediaWiki\Extensions\Translate\PrintfValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php b/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php index ca9a98e3..990cf319 100644 --- a/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php +++ b/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php @@ -14,5 +14,3 @@ class PythonInterpolationValidator extends InsertableRegexValidator { parent::__construct( '/\%(?:\([a-zA-Z0-9_]*?\))?[diouxXeEfFgGcrs]/U' ); } } - -class_alias( PythonInterpolationValidator::class, '\MediaWiki\Extensions\Translate\PythonInterpolationValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php b/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php index 46b5382c..72fc1791 100644 --- a/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php +++ b/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php @@ -50,5 +50,3 @@ class ReplacementValidator implements MessageValidator { return $issues; } } - -class_alias( ReplacementValidator::class, '\MediaWiki\Extensions\Translate\ReplacementValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php b/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php index 41d3ea44..969032aa 100644 --- a/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php +++ b/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php @@ -108,5 +108,3 @@ class SmartFormatPluralValidator implements MessageValidator, InsertablesSuggest return $insertables; } } - -class_alias( SmartFormatPluralValidator::class, '\MediaWiki\Extensions\Translate\SmartFormatPluralValidator' ); diff --git a/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php b/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php index da6a9eef..ddd9baa1 100644 --- a/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php +++ b/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php @@ -108,5 +108,3 @@ class UnicodePluralValidator implements MessageValidator { return [ 'ok', [] ]; } } - -class_alias( UnicodePluralValidator::class, '\MediaWiki\Extensions\Translate\UnicodePluralValidator' ); |