27 private const ACTION_DELETE =
'delete';
29 private const ACTION_CREATE =
'create';
31 private const ACTION_UPDATE =
'update';
33 public function __construct() {
34 parent::__construct();
35 $this->addDescription(
'Export translations to files.' );
39 'Comma separated list of message group IDs (supports * wildcard) to export',
45 'Comma separated list of language codes to export or * for all languages',
50 'always-export-languages',
51 '(optional) Comma separated list of languages to export ignoring export threshold',
56 'never-export-languages',
57 '(optional) Comma separated list of languages to never export (overrides everything else)',
62 'skip-source-language',
63 '(optional) Do not export the source language of each message group',
69 'Target directory for exported files',
75 '(deprecated) See --never-export-languages',
81 '(optional) Comma separated list of message group IDs (supports * wildcard) to not export',
87 '(optional) Threshold for translation completion percentage that must be exceeded for initial export',
93 '(optional) Threshold for translation completion percentage that must be exceeded to keep the file',
99 '(optional) Do not include any messages marked as fuzzy/outdated'
102 'offline-gettext-format',
103 '(optional) Export languages in offline Gettext format. Give a file pattern with '
104 .
'%GROUPID% and %CODE%. Empty pattern defaults to %GROUPID%/%CODE%.po.',
109 'skip-group-sync-check',
110 '(optional) Skip exporting group if synchronization is still in progress or if there ' .
111 'was an error during synchronization. See: ' .
112 'https://www.mediawiki.org/wiki/Help:Extension:Translate/Group_management#Strong_synchronization'
115 $this->requireExtension(
'Translate' );
118 public function execute() {
120 $groupPattern = $this->getOption(
'group' ) ??
'';
121 $groupSkipPattern = $this->getOption(
'skipgroup' ) ??
'';
122 $skipGroupSyncCheck = $this->hasOption(
'skip-group-sync-check' );
125 'Starting exports for groups {groups}',
126 [
'groups' => $groupPattern ]
128 $exportStartTime = microtime(
true );
130 $target = $this->getOption(
'target' );
131 if ( !is_writable( $target ) ) {
132 $this->fatalError(
"Target directory is not writable ($target)." );
135 $exportThreshold = $this->getOption(
'threshold' );
136 $removalThreshold = $this->getOption(
'removal-threshold' );
137 $noFuzzy = $this->hasOption(
'no-fuzzy' );
138 $requestedLanguages = $this->parseLanguageCodes( $this->getOption(
'lang' ) );
139 $alwaysExportLanguages = $this->csv2array(
140 $this->getOption(
'always-export-languages' ) ??
''
142 $neverExportLanguages = $this->csv2array(
143 $this->getOption(
'never-export-languages' ) ??
144 $this->getOption(
'skip' ) ??
147 $skipSourceLanguage = $this->hasOption(
'skip-source-language' );
149 $forOffline = $this->hasOption(
'offline-gettext-format' );
150 $offlineTargetPattern = $this->getOption(
'offline-gettext-format' ) ?:
"%GROUPID%/%CODE%.po";
152 $groups = $this->getMessageGroups( $groupPattern, $groupSkipPattern, $forOffline );
153 if ( $groups === [] ) {
154 $this->fatalError(
'EE1: No valid message groups identified.' );
157 $groupSyncCacheEnabled = MediaWikiServices::getInstance()->getMainConfig()
158 ->get(
'TranslateGroupSynchronizationCache' );
159 $groupSyncCache = Services::getInstance()->getGroupSynchronizationCache();
161 foreach ( $groups as $groupId => $group ) {
162 if ( $groupSyncCacheEnabled && !$skipGroupSyncCheck ) {
163 if ( !$this->canGroupBeExported( $groupSyncCache, $groupId ) ) {
168 if ( $exportThreshold !==
null || $removalThreshold !==
null ) {
169 $logger->info(
'Calculating stats for group {groupId}', [
'groupId' => $groupId ] );
170 $tStartTime = microtime(
true );
172 $languageExportActions = $this->getLanguageExportActions(
175 $alwaysExportLanguages,
176 (
int)$exportThreshold,
177 (
int)$removalThreshold
180 $tEndTime = microtime(
true );
182 'Finished calculating stats for group {groupId}. Time: {duration} secs',
184 'groupId' => $groupId,
185 'duration' => round( $tEndTime - $tStartTime, 3 ),
190 $languageExportActions = array_fill_keys( $requestedLanguages, self::ACTION_CREATE );
192 foreach ( $alwaysExportLanguages as $code ) {
193 $languageExportActions[ $code ] = self::ACTION_CREATE;
197 foreach ( $neverExportLanguages as $code ) {
198 unset( $languageExportActions[ $code ] );
201 if ( $skipSourceLanguage ) {
202 unset( $languageExportActions[ $group->getSourceLanguage() ] );
205 if ( $languageExportActions === [] ) {
209 $this->output(
"Exporting group $groupId\n" );
210 $logger->info(
'Exporting group {groupId}', [
'groupId' => $groupId ] );
213 $fileBasedGroup = FileBasedMessageGroup::newFromMessageGroup( $group, $offlineTargetPattern );
215 $fileFormat->setOfflineMode(
true );
217 $fileBasedGroup = $group;
221 $this->fatalError(
"EE2: Unexportable message group $groupId" );
223 $fileFormat = $fileBasedGroup->getFFS();
226 $fileFormat->setWritePath( $target );
227 $sourceLanguage = $group->getSourceLanguage();
228 $collection = $group->initCollection( $sourceLanguage );
230 $inclusionList = $group->getTranslatableLanguages();
237 $languagesExportedCount = 0;
239 $langStartTime = microtime(
true );
240 foreach ( $languageExportActions as $lang => $action ) {
244 if ( is_array( $inclusionList ) && !isset( $inclusionList[$lang] ) ) {
248 $targetFilePath = $target .
'/' . $fileBasedGroup->getTargetFilename( $lang );
249 if ( $action === self::ACTION_DELETE ) {
251 @$ok = unlink( $targetFilePath );
253 $logger->info(
"Removed $targetFilePath due to removal threshold" );
256 } elseif ( $action === self::ACTION_UPDATE && !file_exists( $targetFilePath ) ) {
258 $logger->info(
"Not creating $targetFilePath due to export threshold" );
262 $startTime = microtime(
true );
263 $collection->resetForNewLanguage( $lang );
264 $collection->loadTranslations();
267 global $wgTranslateDocumentationLanguageCode;
268 if ( $lang !== $wgTranslateDocumentationLanguageCode
269 && $lang !== $sourceLanguage
271 $collection->filter( MessageCollection::FILTER_IGNORED, MessageCollection::EXCLUDE_MATCHING );
275 $collection->filter( MessageCollection::FILTER_FUZZY, MessageCollection::EXCLUDE_MATCHING );
278 $languagesExportedCount++;
280 $endTime = microtime(
true );
281 $langExportTimes[
'collection'] += ( $endTime - $startTime );
283 $startTime = microtime(
true );
284 $fileFormat->write( $collection );
285 $endTime = microtime(
true );
286 $langExportTimes[
'ffs'] += ( $endTime - $startTime );
288 $langEndTime = microtime(
true );
291 'Done exporting {count} languages for group {groupId}. Time taken {duration} secs.',
293 'count' => $languagesExportedCount,
294 'groupId' => $groupId,
295 'duration' => round( $langEndTime - $langStartTime, 3 ),
299 foreach ( $langExportTimes as $type => $time ) {
301 'Time taken by "{type}" for group {groupId} – {duration} secs.',
303 'groupId' => $groupId,
305 'duration' => round( $time, 3 ),
311 $exportEndTime = microtime(
true );
313 'Finished export process for groups {groups}. Time: {duration} secs.',
315 'groups' => $groupPattern,
316 'duration' => round( $exportEndTime - $exportStartTime, 3 ),
322 private function getMessageGroups(
323 string $groupPattern,
324 string $excludePattern,
327 $groupIds = MessageGroups::expandWildcards( explode(
',', trim( $groupPattern ) ) );
328 $groups = MessageGroups::getGroupsById( $groupIds );
329 if ( !$forOffline ) {
330 foreach ( $groups as $groupId => $group ) {
331 if ( $group->isMeta() ) {
332 $this->output(
"Skipping meta message group $groupId.\n" );
333 unset( $groups[$groupId] );
338 $this->output(
"EE2: Unexportable message group $groupId.\n" );
339 unset( $groups[$groupId] );
344 $skipIds = MessageGroups::expandWildcards( explode(
',', trim( $excludePattern ) ) );
345 foreach ( $skipIds as $groupId ) {
346 if ( isset( $groups[$groupId] ) ) {
347 unset( $groups[$groupId] );
348 $this->output(
"Group $groupId is in skipgroup.\n" );
356 private function getLanguageExportActions(
358 array $requestedLanguages,
359 array $alwaysExportLanguages,
360 int $exportThreshold = 0,
361 int $removalThreshold = 0
363 $stats = MessageGroupStats::forGroup( $groupId );
367 foreach ( $requestedLanguages as $code ) {
369 if ( !isset( $stats[$code] ) ) {
373 $total = $stats[$code][MessageGroupStats::TOTAL];
374 $translated = $stats[$code][MessageGroupStats::TRANSLATED];
375 $percentage = $total ? $translated / $total * 100 : 0;
377 if ( $percentage === 0 || $percentage < $removalThreshold ) {
378 $languages[$code] = self::ACTION_DELETE;
379 } elseif ( $percentage > $exportThreshold ) {
380 $languages[$code] = self::ACTION_CREATE;
382 $languages[$code] = self::ACTION_UPDATE;
386 foreach ( $alwaysExportLanguages as $code ) {
387 $languages[$code] = self::ACTION_CREATE;
389 if ( ( $stats[$code][MessageGroupStats::TRANSLATED] ??
null ) === 0 ) {
390 $languages[$code] = self::ACTION_DELETE;
399 $this->error(
"Group $groupId is currently being synchronized; skipping exports\n" );
404 $this->error(
"Skipping $groupId due to synchronization error\n" );
408 if ( $groupSyncCache->isGroupInReview( $groupId ) ) {
409 $this->error(
"Group $groupId is currently in review. Review changes on Special:ManageMessageGroups\n" );
416 private function csv2array(
string $input ): array {
418 array_map(
'trim', explode(
',', $input ) ),
419 static function ( $v ) {
426 private function parseLanguageCodes(
string $input ): array {
427 if ( $input ===
'*' ) {
428 $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
429 $languages = $languageNameUtils->getLanguageNames();
431 return array_keys( $languages );
434 return $this->csv2array( $input );