Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
BackportTranslationsMaintenanceScript.php
1<?php
2declare( strict_types = 1 );
3
5
7use JsonFFS;
9use MediaWiki\Logger\LoggerFactory;
11use RuntimeException;
12use SimpleFFS;
14
23 public function __construct() {
24 parent::__construct();
25 $this->addDescription( 'Backport translations from one branch to another.' );
26
27 $this->addOption(
28 'group',
29 'Comma separated list of message group IDs (supports * wildcard) to backport',
30 self::REQUIRED,
31 self::HAS_ARG
32 );
33 $this->addOption(
34 'source-path',
35 'Source path for reading updated translations. Defaults to $wgTranslateGroupRoot.',
36 self::OPTIONAL,
37 self::HAS_ARG
38 );
39 $this->addOption(
40 'target-path',
41 'Target path for writing backported translations',
42 self::REQUIRED,
43 self::HAS_ARG
44 );
45 $this->addOption(
46 'filter-path',
47 'Only export a group if its export path matches this prefix (relative to target-path)',
48 self::OPTIONAL,
49 self::HAS_ARG
50 );
51 $this->addOption(
52 'never-export-languages',
53 'Languages to not export',
54 self::OPTIONAL,
55 self::HAS_ARG
56 );
57 $this->requireExtension( 'Translate' );
58 }
59
61 public function execute() {
62 $config = $this->getConfig();
63 $logger = LoggerFactory::getInstance( 'Translate.GroupSynchronization' );
64 $groupPattern = $this->getOption( 'group' ) ?? '';
65 $logger->info(
66 'Starting backports for groups {groups}',
67 [ 'groups' => $groupPattern ]
68 );
69
70 $sourcePath = $this->getOption( 'source-path' ) ?: $config->get( 'TranslateGroupRoot' );
71 if ( !is_readable( $sourcePath ) ) {
72 $this->fatalError( "Source directory is not readable ($sourcePath)." );
73 }
74
75 $targetPath = $this->getOption( 'target-path' );
76 if ( !is_writable( $targetPath ) ) {
77 $this->fatalError( "Target directory is not writable ($targetPath)." );
78 }
79
80 $groupIds = MessageGroups::expandWildcards( explode( ',', trim( $groupPattern ) ) );
81 $groups = MessageGroups::getGroupsById( $groupIds );
82 if ( $groups === [] ) {
83 $this->fatalError( "Pattern $groupPattern did not match any message groups." );
84 }
85
86 $neverExportLanguages = $this->csv2array(
87 $this->getOption( 'never-export-languages' ) ?? ''
88 );
89 $supportedLanguages = TranslateUtils::getLanguageNames( 'en' );
90
91 foreach ( $groups as $group ) {
92 $groupId = $group->getId();
93 if ( !$group instanceof FileBasedMessageGroup ) {
94 if ( !$this->hasOption( 'filter-path' ) ) {
95 $this->error( "Skipping $groupId: Not instance of FileBasedMessageGroup" );
96 }
97 continue;
98 }
99
100 if ( !$group->getFFS() instanceof JsonFFS ) {
101 $this->error( "Skipping $groupId: Only JSON format is supported" );
102 continue;
103 }
104
105 if ( $this->hasOption( 'filter-path' ) ) {
106 $filter = $this->getOption( 'filter-path' );
107 $exportPath = $group->getTargetFilename( '*' );
108 if ( !$this->matchPath( $filter, $exportPath ) ) {
109 continue;
110 }
111 }
112
114 $sourceLanguage = $group->getSourceLanguage();
115 try {
116 $sourceDefinitions = $this->loadDefinitions( $group, $sourcePath, $sourceLanguage );
117 $targetDefinitions = $this->loadDefinitions( $group, $targetPath, $sourceLanguage );
118 } catch ( RuntimeException $e ) {
119 $this->output(
120 "Skipping $groupId: Error while loading definitions: {$e->getMessage()}\n"
121 );
122 continue;
123 }
124
125 $keyCompatibilityMap = $this->getKeyCompatibilityMap(
126 $sourceDefinitions['MESSAGES'],
127 $targetDefinitions['MESSAGES'],
128 $group->getFFS()
129 );
130
131 if ( array_filter( $keyCompatibilityMap ) === [] ) {
132 $this->output( "Skipping $groupId: No compatible keys found\n" );
133 continue;
134 }
135
136 $summary = [];
137 $languages = array_keys( $group->getTranslatableLanguages() ?? $supportedLanguages );
138 $languagesToSkip = $neverExportLanguages;
139 $languagesToSkip[] = $sourceLanguage;
140 $languages = array_diff( $languages, $languagesToSkip );
141
142 foreach ( $languages as $language ) {
143 $status = $this->backport(
144 $group,
145 $sourcePath,
146 $targetPath,
147 $keyCompatibilityMap,
148 $language
149 );
150
151 $summary[$status][] = $language;
152 }
153
154 $numUpdated = count( $summary[ 'updated' ] ?? [] );
155 $numAdded = count( $summary[ 'new' ] ?? [] );
156 if ( ( $numUpdated + $numAdded ) > 0 ) {
157 $this->output(
158 sprintf(
159 "%s: Compatible keys: %d. Updated %d languages, %d new (%s)\n",
160 $group->getId(),
161 count( $keyCompatibilityMap ),
162 $numUpdated,
163 $numAdded,
164 implode( ', ', $summary[ 'new' ] ?? [] )
165 )
166 );
167 }
168 }
169 }
170
171 private function csv2array( string $input ): array {
172 return array_filter(
173 array_map( 'trim', explode( ',', $input ) ),
174 static function ( $v ) {
175 return $v !== '';
176 }
177 );
178 }
179
180 private function matchPath( string $prefix, string $full ): bool {
181 $prefix = "./$prefix";
182 $length = strlen( $prefix );
183 return substr( $full, 0, $length ) === $prefix;
184 }
185
186 private function loadDefinitions(
188 string $path,
189 string $language
190 ): array {
191 $file = $path . '/' . $group->getTargetFilename( $language );
192
193 if ( !file_exists( $file ) ) {
194 throw new RuntimeException( "File $file does not exist" );
195 }
196
197 $contents = file_get_contents( $file );
198 return $group->getFFS()->readFromVariable( $contents );
199 }
200
210 private function getKeyCompatibilityMap( array $source, array $target, SimpleFFS $ffs ): array {
211 $keys = [];
212 foreach ( $target as $key => $value ) {
213 $keys[$key] = isset( $source[ $key ] ) && $ffs->isContentEqual( $source[ $key ], $value );
214 }
215 return $keys;
216 }
217
218 private function backport(
220 string $source,
221 string $targetPath,
222 array $keyCompatibilityMap,
223 string $language
224 ): string {
225 try {
226 $sourceTemplate = $this->loadDefinitions( $group, $source, $language );
227 } catch ( RuntimeException $e ) {
228 return 'no definitions';
229 }
230
231 try {
232 $targetTemplate = $this->loadDefinitions( $group, $targetPath, $language );
233 } catch ( RuntimeException $e ) {
234 $targetTemplate = [
235 'MESSAGES' => [],
236 'AUTHORS' => [],
237 ];
238 }
239
240 // Amend the target with compatible things from the source
241 $hasUpdates = false;
242
243 $ffs = $group->getFFS();
244
245 // This has been checked before, but checking again to keep Phan and IDEs happy.
246 // Remove once support for other FFS are added.
247 if ( !$ffs instanceof JsonFFS ) {
248 throw new RuntimeException(
249 "Expected FFS type: " . JsonFFS::class . '; got: ' . get_class( $ffs )
250 );
251 }
252
253 $combinedMessages = [];
254 // $keyCompatibilityMap has the target (stable branch) source language key order
255 foreach ( $keyCompatibilityMap as $key => $isCompatible ) {
256 $sourceValue = $sourceTemplate['MESSAGES'][$key] ?? null;
257 $targetValue = $targetTemplate['MESSAGES'][$key] ?? null;
258
259 // Use existing translation value from the target (stable branch) as the default
260 if ( $targetValue !== null ) {
261 $combinedMessages[$key] = $targetValue;
262 }
263
264 // If the source (development branch) has a different translation for a compatible key
265 // replace the target (stable branch) translation with it.
266 if ( !$isCompatible ) {
267 continue;
268 }
269 if ( $sourceValue !== null && !$ffs->isContentEqual( $sourceValue, $targetValue ) ) {
270 // Keep track if we actually overwrote any values, so we can report back stats
271 $hasUpdates = true;
272 $combinedMessages[$key] = $sourceValue;
273 }
274 }
275
276 if ( !$hasUpdates ) {
277 return 'no updates';
278 }
279
280 // Copy over all authors (we do not know per-message level)
281 $combinedAuthors = array_merge(
282 $targetTemplate[ 'AUTHORS' ] ?? [],
283 $sourceTemplate[ 'AUTHORS' ] ?? []
284 );
285 $combinedAuthors = array_unique( $combinedAuthors );
286 $combinedAuthors = $ffs->filterAuthors( $combinedAuthors, $language );
287
288 $targetTemplate['AUTHORS'] = $combinedAuthors;
289 $targetTemplate['MESSAGES'] = $combinedMessages;
290
291 $backportedContent = $ffs->generateFile( $targetTemplate );
292
293 $targetFilename = $targetPath . '/' . $group->getTargetFilename( $language );
294 if ( file_exists( $targetFilename ) ) {
295 $currentContent = file_get_contents( $targetFilename );
296
297 if ( $ffs->shouldOverwrite( $currentContent, $backportedContent ) ) {
298 file_put_contents( $targetFilename, $backportedContent );
299 }
300 return 'updated';
301 } else {
302 file_put_contents( $targetFilename, $backportedContent );
303 return 'new';
304 }
305 }
306}
return[ 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->getMainConfig(), $services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance( 'Translate.GroupSynchronization'), MessageIndex::singleton());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore(new RevTagStore(), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReview'=> static function(MediaWikiServices $services):MessageGroupReview { return new MessageGroupReview($services->getDBLoadBalancer(), $services->getHookContainer());}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=$services->getMainConfig() ->get( 'TranslateMessageIndex');if(is_string( $params)) { $params=(array) $params;} $class=array_shift( $params);return new $class( $params);}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getDBLoadBalancer(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'));}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), new RevTagStore(), $services->getDBLoadBalancer());}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnectionRef(DB_REPLICA);return new TranslationStashStorage( $db);}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getDBLoadBalancer());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}]
@phpcs-require-sorted-array
This class implements default behavior for file based message groups.
JsonFFS implements a message format where messages are encoded as key-value pairs in JSON objects.
Definition JsonFFS.php:20
Constants for making code for maintenance scripts more readable.
Factory class for accessing message groups individually by id or all of them as an list.
filterAuthors(array $authors, $code)
Remove excluded authors.
shouldOverwrite( $a, $b)
Allows to skip writing the export output into a file.
isContentEqual( $a, $b)
Checks whether two strings are equal.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Finds external changes for file based message groups.