summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'MLEB/Translate/src')
-rw-r--r--MLEB/Translate/src/Cache/PersistentCache.php4
-rw-r--r--MLEB/Translate/src/Cache/PersistentCacheEntry.php2
-rw-r--r--MLEB/Translate/src/Cache/PersistentDatabaseCache.php22
-rw-r--r--MLEB/Translate/src/Diagnostics/DeleteEqualTranslationsMaintenanceScript.php7
-rw-r--r--MLEB/Translate/src/Jobs/GenericTranslateJob.php2
-rw-r--r--MLEB/Translate/src/MessageBundleTranslation/Hooks.php93
-rw-r--r--MLEB/Translate/src/MessageBundleTranslation/MalformedBundle.php40
-rw-r--r--MLEB/Translate/src/MessageBundleTranslation/MessageBundleContent.php90
-rw-r--r--MLEB/Translate/src/MessageBundleTranslation/MessageBundleContentHandler.php27
-rw-r--r--MLEB/Translate/src/MessageGroupProcessing/AggregateGroupsSpecialPage.php281
-rw-r--r--MLEB/Translate/src/MessageSync/MessageSourceChange.php4
-rw-r--r--MLEB/Translate/src/PageTranslation/ImpossiblePageMove.php27
-rw-r--r--MLEB/Translate/src/PageTranslation/InvalidPageTitleRename.php15
-rw-r--r--MLEB/Translate/src/PageTranslation/MoveTranslatablePageMaintenanceScript.php160
-rw-r--r--MLEB/Translate/src/PageTranslation/MoveTranslatablePageSpecialPage.php402
-rw-r--r--MLEB/Translate/src/PageTranslation/PageMoveCollection.php144
-rw-r--r--MLEB/Translate/src/PageTranslation/PageMoveOperation.php61
-rw-r--r--MLEB/Translate/src/PageTranslation/PageTitleRenamer.php105
-rw-r--r--MLEB/Translate/src/PageTranslation/PageTranslationSpecialPage.php1271
-rw-r--r--MLEB/Translate/src/PageTranslation/ParserOutput.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/ParsingFailure.php2
-rw-r--r--MLEB/Translate/src/PageTranslation/Section.php2
-rw-r--r--MLEB/Translate/src/PageTranslation/TestingParsingPlaceholderFactory.php5
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageInsertablesSuggester.php29
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageMover.php373
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslatablePageParser.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationPage.php23
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnit.php56
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitIssue.php46
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitReader.php17
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitStore.php58
-rw-r--r--MLEB/Translate/src/PageTranslation/TranslationUnitStoreFactory.php42
-rw-r--r--MLEB/Translate/src/ServiceWiring.php66
-rw-r--r--MLEB/Translate/src/Services.php38
-rw-r--r--MLEB/Translate/src/Statistics/ActiveLanguagesSpecialPage.php46
-rw-r--r--MLEB/Translate/src/Statistics/CleanupTranslationProgressStatsMaintenanceScript.php4
-rw-r--r--MLEB/Translate/src/Statistics/QueryTranslationStatsActionApi.php4
-rw-r--r--MLEB/Translate/src/Statistics/ReviewPerLanguageStats.php13
-rw-r--r--MLEB/Translate/src/Statistics/StatisticsUnavailable.php2
-rw-r--r--MLEB/Translate/src/Statistics/TranslatePerLanguageStats.php26
-rw-r--r--MLEB/Translate/src/Statistics/TranslateRegistrationStats.php2
-rw-r--r--MLEB/Translate/src/Statistics/TranslationStatsBase.php6
-rw-r--r--MLEB/Translate/src/Statistics/TranslationStatsDataProvider.php29
-rw-r--r--MLEB/Translate/src/Statistics/TranslationStatsGraphOptions.php4
-rw-r--r--MLEB/Translate/src/Statistics/TranslationStatsInterface.php2
-rw-r--r--MLEB/Translate/src/Statistics/TranslationStatsSpecialPage.php250
-rw-r--r--MLEB/Translate/src/Statistics/TranslatorActivity.php7
-rw-r--r--MLEB/Translate/src/Statistics/TranslatorActivityQuery.php2
-rw-r--r--MLEB/Translate/src/Statistics/UpdateTranslatorActivityJob.php2
-rw-r--r--MLEB/Translate/src/Statistics/UpdateTranslatorActivityMaintenanceScript.php5
-rw-r--r--MLEB/Translate/src/Synchronization/BackportTranslationsMaintenanceScript.php304
-rw-r--r--MLEB/Translate/src/Synchronization/CompleteExternalTranslationMaintenanceScript.php10
-rw-r--r--MLEB/Translate/src/Synchronization/DisplayGroupSynchronizationInfo.php6
-rw-r--r--MLEB/Translate/src/Synchronization/ExportTranslationsMaintenanceScript.php196
-rw-r--r--MLEB/Translate/src/Synchronization/ExternalMessageSourceStateImporter.php273
-rw-r--r--MLEB/Translate/src/Synchronization/GroupSynchronizationCache.php100
-rw-r--r--MLEB/Translate/src/Synchronization/GroupSynchronizationResponse.php2
-rw-r--r--MLEB/Translate/src/Synchronization/ManageGroupSynchronizationCacheActionApi.php25
-rw-r--r--MLEB/Translate/src/Synchronization/MessageUpdateParameter.php4
-rw-r--r--MLEB/Translate/src/SystemUsers/FuzzyBot.php2
-rw-r--r--MLEB/Translate/src/SystemUsers/TranslateUserManager.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/CurrentTranslationAid.php36
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/DocumentationAid.php35
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/GettextDocumentationAid.php81
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/GroupsAid.php15
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/InOtherLanguagesAid.php80
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/InsertablesAid.php55
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/MachineTranslationAid.php100
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/MessageDefinitionAid.php23
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/QueryAggregatorAwareTranslationAid.php69
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/SupportAid.php87
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/TTMServerAid.php218
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/TranslationAid.php79
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidDataProvider.php148
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/TranslationAidsActionApi.php153
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/UnsupportedTranslationAid.php20
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Aid/UpdatedDefinitionAid.php91
-rw-r--r--MLEB/Translate/src/TranslatorInterface/EntitySearch.php111
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/CombinedInsertablesSuggester.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/HtmlTagInsertablesSuggester.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/Insertable.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/InsertableFactory.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/InsertablesSuggester.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/MediaWikiInsertablesSuggester.php18
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/NumericalParameterInsertablesSuggester.php7
-rw-r--r--MLEB/Translate/src/TranslatorInterface/Insertable/RegexInsertablesSuggester.php2
-rw-r--r--MLEB/Translate/src/TranslatorInterface/LegacyInterfaceHookHandler.php84
-rw-r--r--MLEB/Translate/src/TranslatorInterface/LegacyTranslationAids.php159
-rw-r--r--MLEB/Translate/src/TranslatorInterface/TranslationEntitySearchActionApi.php52
-rw-r--r--MLEB/Translate/src/TranslatorInterface/TranslationHelperException.php17
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/ManageTranslatorSandboxSpecialPage.php10
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/StashedTranslation.php2
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/TranslationStashReader.php2
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/TranslationStashSpecialPage.php6
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/TranslationStashStorage.php2
-rw-r--r--MLEB/Translate/src/TranslatorSandbox/TranslationStashWriter.php2
-rw-r--r--MLEB/Translate/src/TtmServer/ExportTtmServerDumpMaintenanceScript.php5
-rw-r--r--MLEB/Translate/src/Utilities/ConfigHelper.php53
-rw-r--r--MLEB/Translate/src/Utilities/GettextPlural.php2
-rw-r--r--MLEB/Translate/src/Utilities/Json/JsonCodec.php2
-rw-r--r--MLEB/Translate/src/Utilities/Json/JsonUnserializable.php2
-rw-r--r--MLEB/Translate/src/Utilities/Json/JsonUnserializableTrait.php2
-rw-r--r--MLEB/Translate/src/Utilities/LanguagesMultiselectWidget.php2
-rw-r--r--MLEB/Translate/src/Utilities/ParallelExecutor.php2
-rw-r--r--MLEB/Translate/src/Utilities/ParsingPlaceholderFactory.php2
-rw-r--r--MLEB/Translate/src/Utilities/SmartFormatPlural.php2
-rw-r--r--MLEB/Translate/src/Utilities/StringComparators/SimpleStringComparator.php2
-rw-r--r--MLEB/Translate/src/Utilities/StringComparators/StringComparator.php2
-rw-r--r--MLEB/Translate/src/Utilities/TranslateReplaceTitle.php4
-rw-r--r--MLEB/Translate/src/Utilities/UnicodePlural.php2
-rw-r--r--MLEB/Translate/src/Validation/LegacyValidatorAdapter.php63
-rw-r--r--MLEB/Translate/src/Validation/MessageValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/ValidationIssue.php2
-rw-r--r--MLEB/Translate/src/Validation/ValidationIssues.php2
-rw-r--r--MLEB/Translate/src/Validation/ValidationResult.php2
-rw-r--r--MLEB/Translate/src/Validation/ValidationRunner.php34
-rw-r--r--MLEB/Translate/src/Validation/Validator.php23
-rw-r--r--MLEB/Translate/src/Validation/ValidatorFactory.php12
-rw-r--r--MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php5
-rw-r--r--MLEB/Translate/src/Validation/Validators/IosVariableValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/MatchSetValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php4
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php3
-rw-r--r--MLEB/Translate/src/Validation/Validators/NewlineValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/NotEmptyValidator.php28
-rw-r--r--MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/PrintfValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/ReplacementValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php2
-rw-r--r--MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php2
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' );