00001 <?php
00014
00015 if ( getenv( 'MW_INSTALL_PATH' ) !== false ) {
00016 $IP = getenv( 'MW_INSTALL_PATH' );
00017 } else {
00018 $dir = __DIR__;
00019 $IP = "$dir/../../..";
00020 }
00021 require_once "$IP/maintenance/Maintenance.php";
00022
00023 # Override the memory limit for wfShellExec, 100 MB seems to be too little for svn
00024 $wgMaxShellMemory = 1024 * 200;
00025
00026 class SyncGroup extends Maintenance {
00027 public function __construct() {
00028 parent::__construct();
00029 $this->mDescription = 'Import or update source messages and translations into ' .
00030 'the wiki database.';
00031 $this->addOption(
00032 'git',
00033 '(optional) Use git to retrieve last modified date of i18n files. Will use subversion ' .
00034 'by default and fallback on filesystem timestamp',
00035 false,
00036 false
00037 );
00038 $this->addOption(
00039 'group',
00040 'Comma separated list of group IDs (can use * as wildcard).',
00041 true,
00042 true
00043 );
00044 $this->addOption(
00045 'lang',
00046 '(optional) Comma separated list of language codes or *',
00047 false,
00048 true
00049 );
00050 $this->addOption(
00051 'norc',
00052 '(optional) Do not add entries to recent changes table',
00053 false,
00054 false
00055 );
00056 $this->addOption(
00057 'noask',
00058 '(optional) Skip all conflicts',
00059 false,
00060 false
00061 );
00062 $this->addOption(
00063 'start',
00064 '(optional) Start of the last export (changes in wiki after will conflict)',
00065 false,
00066 true
00067 );
00068 $this->addOption(
00069 'end',
00070 '(optional) End of the last export (changes in source after will conflict)',
00071 false,
00072 true
00073 );
00074 $this->addOption(
00075 'nocolor',
00076 '(optional) Without colors',
00077 false,
00078 false
00079 );
00080 $this->addOption(
00081 'core-meta',
00082 '(optional) Allow export of specific MediaWiki core meta groups ' .
00083 '(translatewiki.net specific)',
00084 false,
00085 false
00086 );
00087 }
00088
00089 public function execute() {
00090 $groupIds = explode( ',', trim( $this->getOption( 'group' ) ) );
00091 $groupIds = MessageGroups::expandWildcards( $groupIds );
00092 $groups = MessageGroups::getGroupsById( $groupIds );
00093
00094 if ( !count( $groups ) ) {
00095 $this->error( "ESG2: No valid message groups identified.", 1 );
00096 }
00097
00098 $start = $this->getOption( 'start' ) ? strtotime( $this->getOption( 'start' ) ) : false;
00099 $end = $this->getOption( 'end' ) ? strtotime( $this->getOption( 'end' ) ) : false;
00100
00101 $this->output( "Conflict times: " . wfTimestamp( TS_ISO_8601, $start ) . " - " .
00102 wfTimestamp( TS_ISO_8601, $end ) . "\n" );
00103
00104 $codes = array_filter( array_map( 'trim', explode( ',', $this->getOption( 'lang' ) ) ) );
00105
00106 $supportedCodes = array_keys( TranslateUtils::getLanguageNames( 'en' ) );
00107 ksort( $supportedCodes );
00108
00109 if ( $codes[0] === '*' ) {
00110 $codes = $supportedCodes;
00111 }
00112
00113 $coreMeta = $this->hasOption( 'core-meta' );
00114
00116 foreach ( $groups as $groupId => &$group ) {
00117 if ( $group->isMeta() ) {
00118 if ( !$coreMeta ) {
00119 $this->output( "Skipping meta message group $groupId.\n" );
00120 continue;
00121 }
00122
00123
00124 if ( strstr( $group->getId(), 'core-1', true ) !== '' ) {
00125 $this->output( "Skipping meta message group $groupId.\n" );
00126 continue;
00127 }
00128 }
00129
00130 $this->output( "{$group->getLabel()} ", $group );
00131
00132 foreach ( $codes as $code ) {
00133
00134 if ( !in_array( $code, $supportedCodes ) ) {
00135 $this->output( "Unsupported code " . $code . ": skipping.\n" );
00136 continue;
00137 }
00138
00139 if ( $group instanceof FileBasedMessageGroup ) {
00141 $file = $group->getSourceFilePath( $code );
00142 } else {
00144 $file = $group->getMessageFileWithPath( $code );
00145 }
00146
00147 if ( !$file ) {
00148 continue;
00149 }
00150
00151 if ( !file_exists( $file ) ) {
00152 continue;
00153 }
00154
00155 $cs = new ChangeSyncer( $group, $this );
00156 $cs->setProgressCallback( array( $this, 'myOutput' ) );
00157 $cs->interactive = !$this->hasOption( 'noask' );
00158 $cs->nocolor = $this->hasOption( 'nocolor' );
00159 $cs->norc = $this->hasOption( 'norc' );
00160
00161 # @todo FIXME: Make this auto detect.
00162 # Guess last modified date of the file from either git, svn or filesystem
00163 if ( $this->hasOption( 'git' ) ) {
00164 $ts = $cs->getTimestampsFromGit( $file );
00165 } else {
00166 $ts = $cs->getTimestampsFromSvn( $file );
00167 }
00168 if ( !$ts ) {
00169 $ts = $cs->getTimestampsFromFs( $file );
00170 }
00171
00172 $this->output( "Modify time for $code: " . wfTimestamp( TS_ISO_8601, $ts ) . "\n" );
00173
00174 $cs->checkConflicts( $code, $start, $end, $ts );
00175 }
00176
00177 unset( $group );
00178 }
00179
00180 $this->output( wfTimestamp( TS_RFC2822 ) . "\n" );
00181 }
00182
00190 public function myOutput( $text, $channel = null, $error = false ) {
00191 if ( $error ) {
00192 $this->error( $text, $channel );
00193 } else {
00194 $this->output( $text, $channel );
00195 }
00196 }
00197 }
00198
00202 class ChangeSyncer {
00204 protected $progressCallback;
00205
00207 public $norc = false;
00208
00210 public $interactive = true;
00211
00213 public $nocolor = false;
00214
00216 protected $group;
00217
00222 public function __construct( MessageGroup $group ) {
00223 $this->group = $group;
00224 }
00225
00226 public function setProgressCallback( $callback ) {
00227 $this->progressCallback = $callback;
00228 }
00229
00231 protected function reportProgress( $text, $channel, $severity = 'status' ) {
00232 if ( is_callable( $this->progressCallback ) ) {
00233 $useErrorOutput = $severity === 'error';
00234 call_user_func( $this->progressCallback, $text, $channel, $useErrorOutput );
00235 }
00236 }
00237
00238
00244 public function getTimestampsFromSvn( $file ) {
00245 $file = escapeshellarg( $file );
00246 $retval = 0;
00247 $output = wfShellExec( "svn info $file 2>/dev/null", $retval );
00248
00249 if ( $retval ) {
00250 return false;
00251 }
00252
00253 $matches = array();
00254
00255
00256
00257
00258 $regex = '^Last Changed Date: (.*) \(';
00259 $ok = preg_match( "~$regex~m", $output, $matches );
00260 if ( $ok ) {
00261 return strtotime( $matches[1] );
00262 }
00263
00264 return false;
00265 }
00266
00272 public function getTimestampsFromGit( $file ) {
00273 $file = escapeshellarg( $file );
00274 $retval = 0;
00275 $output = wfShellExec( "git log -n 1 --format=%cd $file", $retval );
00276
00277 if ( $retval ) {
00278 return false;
00279 }
00280
00281 return strtotime( $output );
00282 }
00283
00289 public function getTimestampsFromFs( $file ) {
00290 if ( !file_exists( $file ) ) {
00291 return false;
00292 }
00293
00294 $stat = stat( $file );
00295
00296 return $stat['mtime'];
00297 }
00298
00308 public function checkConflicts( $code, $startTs = false, $endTs = false, $changeTs = false ) {
00309 $messages = $this->group->load( $code );
00310
00311 if ( !count( $messages ) ) {
00312 return;
00313 }
00314
00315 $collection = $this->group->initCollection( $code );
00316 $collection->filter( 'ignored' );
00317 $collection->loadTranslations();
00318
00319 foreach ( $messages as $key => $translation ) {
00320 if ( !isset( $collection[$key] ) ) {
00321 continue;
00322 }
00323
00324
00325 if ( $translation == '{{PLURAL:GETTEXT|}}' ) {
00326 return;
00327 }
00328
00329 $title = Title::makeTitleSafe( $this->group->getNamespace(), "$key/$code" );
00330
00331 $page = $title->getPrefixedText();
00332
00333 if ( $collection[$key]->translation() === null ) {
00334 $this->reportProgress( "Importing $page as a new translation\n", 'importing' );
00335 $this->import( $title, $translation, 'Importing a new translation' );
00336 continue;
00337 }
00338
00339 $current = str_replace( TRANSLATE_FUZZY, '', $collection[$key]->translation() );
00340 $translation = str_replace( TRANSLATE_FUZZY, '', $translation );
00341 if ( $translation === $current ) {
00342 continue;
00343 }
00344
00345 $this->reportProgress( "Conflict in " . $this->color( 'bold', $page ) . "!", $page );
00346
00347 $iso = 'xnY-xnm-xnd"T"xnH:xni:xns';
00348 $lang = RequestContext::getMain()->getLanguage();
00349
00350
00351
00352 $wikiTs = $this->getLastGoodChange( $title, $startTs );
00353 if ( $wikiTs ) {
00354 $wikiTs = wfTimestamp( TS_UNIX, $wikiTs );
00355 $wikiDate = $lang->sprintfDate( $iso, wfTimestamp( TS_MW, $wikiTs ) );
00356 } else {
00357 $wikiDate = 'Unknown';
00358 }
00359
00360 if ( $startTs ) {
00361 $startTs = wfTimestamp( TS_UNIX, $startTs );
00362 }
00363
00364 if ( $endTs ) {
00365 $endTs = wfTimestamp( TS_UNIX, $endTs );
00366 }
00367 if ( $changeTs ) {
00368 $changeTs = wfTimestamp( TS_UNIX, $changeTs );
00369 $changeDate = $lang->sprintfDate( $iso, wfTimestamp( TS_MW, $changeTs ) );
00370 } else {
00371 $changeDate = 'Unknown';
00372 }
00373
00374 if ( $changeTs ) {
00375 if ( $wikiTs > $startTs && $changeTs <= $endTs ) {
00376 $this->reportProgress( " →Changed in wiki after export: IGNORE", $page );
00377 continue;
00378 } elseif ( !$wikiTs || ( $changeTs > $endTs && $wikiTs < $startTs ) ) {
00379 $this->reportProgress( " →Changed in source after export: IMPORT", $page );
00380 $this->import(
00381 $title,
00382 $translation,
00383 'Updating translation from external source'
00384 );
00385 continue;
00386 }
00387 }
00388
00389 if ( !$this->interactive ) {
00390 continue;
00391 }
00392
00393 $this->reportProgress( " →Needs manual resolution", $page );
00394 $this->reportProgress( "Source translation at $changeDate:", 'source' );
00395 $this->reportProgress( $this->color( 'blue', $translation ), 'source' );
00396 $this->reportProgress( "Wiki translation at $wikiDate:", 'translation' );
00397 $this->reportProgress( $this->color( 'green', $current ), 'translation' );
00398
00399 do {
00400 $this->reportProgress( "Resolution: [S]kip [I]mport [C]onflict: ", 'foo' );
00401
00402 $action = fgets( STDIN );
00403 $action = strtoupper( trim( $action ) );
00404
00405 if ( $action === 'S' ) {
00406 break;
00407 }
00408
00409 if ( $action === 'I' ) {
00410 $this->import(
00411 $title,
00412 $translation,
00413 'Updating translation from external source'
00414 );
00415 break;
00416 }
00417
00418 if ( $action === 'C' ) {
00419 $this->import(
00420 $title,
00421 TRANSLATE_FUZZY . $translation,
00422 'Edit conflict between wiki and source'
00423 );
00424 break;
00425 }
00426 } while ( true );
00427 }
00428 }
00429
00436 public function color( $color, $text ) {
00437 switch ( $color ) {
00438 case 'blue':
00439 return "\033[1;34m$text\033[0m";
00440 case 'green':
00441 return "\033[1;32m$text\033[0m";
00442 case 'bold':
00443 return "\033[1m$text\033[0m";
00444 default:
00445 return $text;
00446 }
00447 }
00448
00455 public function getLastGoodChange( $title, $startTs = false ) {
00456 global $wgTranslateFuzzyBotName;
00457
00458 $wikiTs = false;
00459 $revision = Revision::newFromTitle( $title );
00460 while ( $revision ) {
00461
00462 if ( $startTs && $wikiTs && ( $wikiTs < $startTs ) ) {
00463 break;
00464 }
00465
00466 if ( $revision->getRawUserText() === $wgTranslateFuzzyBotName ) {
00467 $revision = $revision->getPrevious();
00468 continue;
00469 }
00470
00471 $wikiTs = wfTimestamp( TS_UNIX, $revision->getTimestamp() );
00472 break;
00473 }
00474
00475 return $wikiTs;
00476 }
00477
00484 public function import( $title, $translation, $comment ) {
00485 $flags = EDIT_FORCE_BOT;
00486 if ( $this->norc ) {
00487 $flags |= EDIT_SUPPRESS_RC;
00488 }
00489
00490 $wikipage = new WikiPage( $title );
00491 $this->reportProgress( "Importing {$title->getPrefixedText()}: ", $title );
00492 $status = $wikipage->doEdit(
00493 $translation, $comment, $flags, false, FuzzyBot::getUser()
00494 );
00495 $success = $status === true || ( is_object( $status ) && $status->isOK() );
00496 $this->reportProgress( $success ? 'OK' : 'FAILED', $title );
00497 }
00498 }
00499
00500 $maintClass = 'SyncGroup';
00501 require_once RUN_MAINTENANCE_IF_MAIN;