25 private const ACTION_DELETE =
'delete';
27 private const ACTION_CREATE =
'create';
29 private const ACTION_UPDATE =
'update';
31 public function __construct() {
32 parent::__construct();
33 $this->addDescription(
'Export translations to files.' );
37 'Comma separated list of message group IDs (supports * wildcard) to export',
43 'Comma separated list of language codes to export or * for all languages',
48 'always-export-languages',
49 '(optional) Comma separated list of languages to export ignoring export threshold',
54 'never-export-languages',
55 '(optional) Comma separated list of languages to never export (overrides everything else)',
60 'skip-source-language',
61 '(optional) Do not export the source language of each message group',
67 'Target directory for exported files',
73 '(deprecated) See --never-export-languages',
79 '(optional) Comma separated list of message group IDs (supports * wildcard) to not export',
85 '(optional) Threshold for translation completion percentage that must be exceeded for initial export',
91 '(optional) Threshold for translation completion percentage that must be exceeded to keep the file',
97 '(optional) Do not include any messages marked as fuzzy/outdated'
100 'offline-gettext-format',
101 '(optional) Export languages in offline Gettext format. Give a file pattern with '
102 .
'%GROUPID% and %CODE%. Empty pattern defaults to %GROUPID%/%CODE%.po.',
107 'skip-group-sync-check',
108 '(optional) Skip exporting group if synchronization is still in progress or if there ' .
109 'was an error during synchronization. See: ' .
110 'https://www.mediawiki.org/wiki/Help:Extension:Translate/Group_management#Strong_synchronization'
113 $this->requireExtension(
'Translate' );
116 public function execute() {
117 $logger = LoggerFactory::getInstance(
'Translate.GroupSynchronization' );
118 $groupPattern = $this->getOption(
'group' ) ??
'';
119 $groupSkipPattern = $this->getOption(
'skipgroup' ) ??
'';
120 $skipGroupSyncCheck = $this->hasOption(
'skip-group-sync-check' );
123 'Starting exports for groups {groups}',
124 [
'groups' => $groupPattern ]
126 $exportStartTime = microtime(
true );
128 $target = $this->getOption(
'target' );
129 if ( !is_writable( $target ) ) {
130 $this->fatalError(
"Target directory is not writable ($target)." );
133 $exportThreshold = $this->getOption(
'threshold' );
134 $removalThreshold = $this->getOption(
'removal-threshold' );
135 $noFuzzy = $this->hasOption(
'no-fuzzy' );
136 $requestedLanguages = $this->parseLanguageCodes( $this->getOption(
'lang' ) );
137 $alwaysExportLanguages = $this->csv2array(
138 $this->getOption(
'always-export-languages' ) ??
''
140 $neverExportLanguages = $this->csv2array(
141 $this->getOption(
'never-export-languages' ) ??
142 $this->getOption(
'skip' ) ??
145 $skipSourceLanguage = $this->hasOption(
'skip-source-language' );
147 $forOffline = $this->hasOption(
'offline-gettext-format' );
148 $offlineTargetPattern = $this->getOption(
'offline-gettext-format' ) ?:
"%GROUPID%/%CODE%.po";
150 $groups = $this->getMessageGroups( $groupPattern, $groupSkipPattern, $forOffline );
151 if ( $groups === [] ) {
152 $this->fatalError(
'EE1: No valid message groups identified.' );
155 $groupSyncCacheEnabled = MediaWikiServices::getInstance()->getMainConfig()
156 ->get(
'TranslateGroupSynchronizationCache' );
157 $groupSyncCache = Services::getInstance()->getGroupSynchronizationCache();
159 foreach ( $groups as $groupId => $group ) {
160 if ( $groupSyncCacheEnabled && !$skipGroupSyncCheck ) {
161 if ( !$this->canGroupBeExported( $groupSyncCache, $groupId ) ) {
166 if ( $exportThreshold !==
null || $removalThreshold !==
null ) {
167 $logger->info(
'Calculating stats for group {groupId}', [
'groupId' => $groupId ] );
168 $tStartTime = microtime(
true );
170 $languageExportActions = $this->getLanguageExportActions(
173 $alwaysExportLanguages,
174 (
int)$exportThreshold,
175 (
int)$removalThreshold
178 $tEndTime = microtime(
true );
180 'Finished calculating stats for group {groupId}. Time: {duration} secs',
182 'groupId' => $groupId,
183 'duration' => round( $tEndTime - $tStartTime, 3 ),
188 $languageExportActions = array_fill_keys( $requestedLanguages, self::ACTION_CREATE );
190 foreach ( $alwaysExportLanguages as $code ) {
191 $languageExportActions[ $code ] = self::ACTION_CREATE;
195 foreach ( $neverExportLanguages as $code ) {
196 unset( $languageExportActions[ $code ] );
199 if ( $skipSourceLanguage ) {
200 unset( $languageExportActions[ $group->getSourceLanguage() ] );
203 if ( $languageExportActions === [] ) {
207 $this->output(
"Exporting group $groupId\n" );
208 $logger->info(
'Exporting group {groupId}', [
'groupId' => $groupId ] );
211 $fileBasedGroup = FileBasedMessageGroup::newFromMessageGroup( $group, $offlineTargetPattern );
213 $ffs->setOfflineMode(
true );
215 $fileBasedGroup = $group;
219 $this->fatalError(
"EE2: Unexportable message group $groupId" );
221 $ffs = $fileBasedGroup->getFFS();
224 $ffs->setWritePath( $target );
225 $sourceLanguage = $group->getSourceLanguage();
226 $collection = $group->initCollection( $sourceLanguage );
228 $inclusionList = $group->getTranslatableLanguages();
235 $languagesExportedCount = 0;
237 $langStartTime = microtime(
true );
238 foreach ( $languageExportActions as $lang => $action ) {
242 if ( is_array( $inclusionList ) && !isset( $inclusionList[$lang] ) ) {
246 $targetFilePath = $target .
'/' . $fileBasedGroup->getTargetFilename( $lang );
247 if ( $action === self::ACTION_DELETE ) {
249 @$ok = unlink( $targetFilePath );
251 $logger->info(
"Removed $targetFilePath due to removal threshold" );
254 } elseif ( $action === self::ACTION_UPDATE && !file_exists( $targetFilePath ) ) {
256 $logger->info(
"Not creating $targetFilePath due to export threshold" );
260 $startTime = microtime(
true );
261 $collection->resetForNewLanguage( $lang );
262 $collection->loadTranslations();
265 global $wgTranslateDocumentationLanguageCode;
266 if ( $lang !== $wgTranslateDocumentationLanguageCode
267 && $lang !== $sourceLanguage
269 $collection->filter(
'ignored' );
273 $collection->filter(
'fuzzy' );
276 $languagesExportedCount++;
278 $endTime = microtime(
true );
279 $langExportTimes[
'collection'] += ( $endTime - $startTime );
281 $startTime = microtime(
true );
282 $ffs->write( $collection );
283 $endTime = microtime(
true );
284 $langExportTimes[
'ffs'] += ( $endTime - $startTime );
286 $langEndTime = microtime(
true );
289 'Done exporting {count} languages for group {groupId}. Time taken {duration} secs.',
291 'count' => $languagesExportedCount,
292 'groupId' => $groupId,
293 'duration' => round( $langEndTime - $langStartTime, 3 ),
297 foreach ( $langExportTimes as $type => $time ) {
299 'Time taken by "{type}" for group {groupId} – {duration} secs.',
301 'groupId' => $groupId,
303 'duration' => round( $time, 3 ),
309 $exportEndTime = microtime(
true );
311 'Finished export process for groups {groups}. Time: {duration} secs.',
313 'groups' => $groupPattern,
314 'duration' => round( $exportEndTime - $exportStartTime, 3 ),
320 private function getMessageGroups(
321 string $groupPattern,
322 string $excludePattern,
325 $groupIds = MessageGroups::expandWildcards( explode(
',', trim( $groupPattern ) ) );
326 $groups = MessageGroups::getGroupsById( $groupIds );
327 if ( !$forOffline ) {
328 foreach ( $groups as $groupId => $group ) {
329 if ( $group->isMeta() ) {
330 $this->output(
"Skipping meta message group $groupId.\n" );
331 unset( $groups[$groupId] );
336 $this->output(
"EE2: Unexportable message group $groupId.\n" );
337 unset( $groups[$groupId] );
342 $skipIds = MessageGroups::expandWildcards( explode(
',', trim( $excludePattern ) ) );
343 foreach ( $skipIds as $groupId ) {
344 if ( isset( $groups[$groupId] ) ) {
345 unset( $groups[$groupId] );
346 $this->output(
"Group $groupId is in skipgroup.\n" );
354 private function getLanguageExportActions(
356 array $requestedLanguages,
357 array $alwaysExportLanguages,
358 int $exportThreshold = 0,
359 int $removalThreshold = 0
361 $stats = MessageGroupStats::forGroup( $groupId );
365 foreach ( $requestedLanguages as $code ) {
367 if ( !isset( $stats[$code] ) ) {
371 $total = $stats[$code][MessageGroupStats::TOTAL];
372 $translated = $stats[$code][MessageGroupStats::TRANSLATED];
373 $percentage = $total === 0 ? 0 : $translated / $total * 100;
375 if ( $percentage === 0 || $percentage < $removalThreshold ) {
376 $languages[$code] = self::ACTION_DELETE;
377 } elseif ( $percentage > $exportThreshold ) {
378 $languages[$code] = self::ACTION_CREATE;
380 $languages[$code] = self::ACTION_UPDATE;
384 foreach ( $alwaysExportLanguages as $code ) {
385 $languages[$code] = self::ACTION_CREATE;
387 if ( ( $stats[$code][MessageGroupStats::TRANSLATED] ??
null ) === 0 ) {
388 $languages[$code] = self::ACTION_DELETE;
397 $this->error(
"Group $groupId is currently being synchronized; skipping exports\n" );
402 $this->error(
"Skipping $groupId due to synchronization error\n" );
406 if ( $groupSyncCache->isGroupInReview( $groupId ) ) {
407 $this->error(
"Group $groupId is currently in review. Review changes on Special:ManageMessageGroups\n" );
414 private function csv2array(
string $input ): array {
416 array_map(
'trim', explode(
',', $input ) ),
417 static function ( $v ) {
424 private function parseLanguageCodes(
string $input ): array {
425 if ( $input ===
'*' ) {
426 $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
427 $languages = $languageNameUtils->getLanguageNames();
429 return array_keys( $languages );
432 return $this->csv2array( $input );