[prev in list] [next in list] [prev in thread] [next in thread]
List: kde-commits
Subject: [websites/wiki-kde-org/develop] extensions: gitignore gets on my nerves, another set of missing file
From: Ingo Malchow <imalchow () kde ! org>
Date: 2012-07-24 20:08:39
Message-ID: 20120724200839.43D56A6042 () git ! kde ! org
[Download RAW message or body]
Git commit 087f68036ff0d0661c48246080253f85ca3ec352 by Ingo Malchow.
Committed on 24/07/2012 at 22:04.
Pushed by imalchow into branch 'develop'.
gitignore gets on my nerves, another set of missing files
A +22 -0 extensions/LiquidThreads/schema-changes/thread_history_table.pg.sql
A +8 -0 extensions/LiquidThreads/schema-changes/thread_pending_relationship.pg.sql
A +11 -0 extensions/LiquidThreads/schema-changes/thread_reactions.pg.sql
A +11 -0 extensions/LiquidThreads/schema-changes/ums_conversation.pg.sql
A +105 -0 extensions/Translate/ffs/DtdFFS.php
A +347 -0 extensions/Translate/ffs/FFS.php
A +120 -0 extensions/Translate/ffs/FlatPhpFFS.php
A +594 -0 extensions/Translate/ffs/GettextFFS.php
A +234 -0 extensions/Translate/ffs/JavaFFS.php
A +272 -0 extensions/Translate/ffs/JavaScriptFFS.php
A +162 -0 extensions/Translate/ffs/PythonSingleFFS.php
A +158 -0 extensions/Translate/ffs/RubyYamlFFS.php
A +243 -0 extensions/Translate/ffs/YamlFFS.php
A +12 -0 extensions/Translate/resources/ext.translate.special.managegroups.css
A +36 -0 extensions/Translate/resources/ext.translate.special.translationstats.js
A +199 -0 extensions/Translate/scripts/processMessageChanges.php
A +52 -0 extensions/Translate/tests/ApiTokensTest.php
A +93 -0 extensions/Translate/tests/BlackListTest.php
A +77 -0 extensions/Translate/tests/JavaFFSTest.php
A +54 -0 extensions/Translate/tests/MessageIndexRebuildJobTest.php
A +93 -0 extensions/Translate/tests/MessageIndexTest.php
A +65 -0 extensions/Translate/tests/PageTranslationTaggingTest.php
A +72 -0 extensions/Translate/tests/SpecialPagesTest.php
A +1 -0 extensions/Translate/tests/messageindexdata.ser
A +80 -0 extensions/Translate/utils/MessageUpdateJob.php
http://commits.kde.org/websites/wiki-kde-org/087f68036ff0d0661c48246080253f85ca3ec352
diff --git a/extensions/LiquidThreads/schema-changes/thread_history_table.pg.sql \
b/extensions/LiquidThreads/schema-changes/thread_history_table.pg.sql new file mode 100644
index 0000000..11556e5
--- /dev/null
+++ b/extensions/LiquidThreads/schema-changes/thread_history_table.pg.sql
@@ -0,0 +1,22 @@
+-- "New" storage location for history data.
+CREATE TABLE /*_*/thread_history (
+ th_id int NOT NULL auto_increment,
+ th_thread int NOT NULL,
+
+ th_timestamp varchar(14) NOT NULL,
+
+ th_user int NOT NULL,
+ th_user_text varchar(255) NOT NULL,
+
+ th_change_type int NOT NULL,
+ th_change_object int NOT NULL,
+ th_change_comment text NOT NULL,
+
+ -- Actual content, stored as a serialised thread row.
+ th_content bytea NOT NULL,
+
+ PRIMARY KEY (th_id),
+ KEY (th_thread,th_timestamp),
+ KEY (th_timestamp),
+ KEY (th_user,th_user_text)
+) /*$wgDBTableOptions*/;
diff --git a/extensions/LiquidThreads/schema-changes/thread_pending_relationship.pg.sql \
b/extensions/LiquidThreads/schema-changes/thread_pending_relationship.pg.sql new file mode \
100644 index 0000000..3b02684
--- /dev/null
+++ b/extensions/LiquidThreads/schema-changes/thread_pending_relationship.pg.sql
@@ -0,0 +1,8 @@
+-- Storage for "pending" relationships from import
+CREATE TABLE /*_*/thread_pending_relationship (
+ tpr_thread int NOT NULL,
+ tpr_relationship varchar(64) NOT NULL,
+ tpr_title varchar(255) NOT NULL,
+ tpr_type varchar(32) NOT NULL,
+ PRIMARY KEY (tpr_thread,tpr_relationship)
+) /*$wgDBTableOptions*/;
diff --git a/extensions/LiquidThreads/schema-changes/thread_reactions.pg.sql \
b/extensions/LiquidThreads/schema-changes/thread_reactions.pg.sql new file mode 100644
index 0000000..237deba
--- /dev/null
+++ b/extensions/LiquidThreads/schema-changes/thread_reactions.pg.sql
@@ -0,0 +1,11 @@
+-- Storage for reactions
+CREATE TABLE /*_*/thread_reaction (
+ tr_thread int NOT NULL,
+ tr_user int NOT NULL,
+ tr_user_text varchar(255) NOT NULL,
+ tr_type varchar(64) NOT NULL,
+ tr_value int NOT NULL,
+
+ PRIMARY KEY (tr_thread,tr_user,tr_user_text,tr_type,tr_value)
+) /*$wgDBTableOptions*/;
+CREATE INDEX thread_reaction_user_text_value ON thread_reaction \
(tr_user,tr_user_text,tr_type,tr_value);
diff --git a/extensions/LiquidThreads/schema-changes/ums_conversation.pg.sql \
b/extensions/LiquidThreads/schema-changes/ums_conversation.pg.sql new file mode 100644
index 0000000..0d153cf
--- /dev/null
+++ b/extensions/LiquidThreads/schema-changes/ums_conversation.pg.sql
@@ -0,0 +1,11 @@
+-- ums_conversation
+-- Adds and populates the ums_conversation field, along with relevant indices.
+ALTER TABLE /*_*/user_message_state ADD COLUMN ums_conversation int NOT NULL DEFAULT 0;
+
+CREATE INDEX /*i*/ums_user_conversation ON /*_*/user_message_state \
(ums_user,ums_conversation); +DROP INDEX IF EXISTS /*i*/ums_user_read;
+
+UPDATE /*_*/user_message_state
+SET /*_*/ums_conversation = coalesce(/*_*/thread.thread_ancestor, /*_*/thread.thread_id)
+FROM /*_*/thread
+WHERE ums_conversation = 0 AND /*_*/thread.thread_id = /*_*/user_message_state.ums_thread;
diff --git a/extensions/Translate/ffs/DtdFFS.php b/extensions/Translate/ffs/DtdFFS.php
new file mode 100644
index 0000000..676beb9
--- /dev/null
+++ b/extensions/Translate/ffs/DtdFFS.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Implements FFS for DTD file format.
+ *
+ * @file
+ * @author Guillaume Duhamel
+ * @author Niklas Laxström
+ * @author Siebrand Mazeland
+ * @copyright Copyright © 2009-2010, Guillaume Duhamel, Niklas Laxström, Siebrand Mazeland
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * File format support for DTD.
+ *
+ * @ingroup FFS
+ */
+class DtdFFS extends SimpleFFS {
+
+ /**
+ * @param $data string
+ * @return array
+ */
+ public function readFromVariable( $data ) {
+ preg_match_all( ',AUTHOR: ([^\n]+)\n,', $data, $matches );
+ $authors = array();
+
+ for ( $i = 0; $i < count( $matches[1] ); $i++ ) {
+ $authors[] = $matches[1][$i];
+ }
+
+ preg_match_all( ',<!ENTITY[ ]+([^ ]+)[ ]+"([^"]+)"[^>]*>,', $data, $matches );
+
+ $keys = $matches[1];
+ $values = $matches[2];
+
+ $messages = array();
+
+ for ( $i = 0; $i < count( $matches[1] ); $i++ ) {
+ $messages[$keys[$i]] = str_replace(
+ array( '"', '"', ''' ),
+ array( '"', '"', "'" ),
+ $values[$i] );
+ }
+
+ $messages = $this->group->getMangler()->mangle( $messages );
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages,
+ );
+ }
+
+ protected function writeReal( MessageCollection $collection ) {
+ $collection->loadTranslations();
+
+ $header = "<!--\n";
+ $header .= $this->doHeader( $collection );
+ $header .= $this->doAuthors( $collection );
+ $header .= "-->\n";
+
+ $output = '';
+ $mangler = $this->group->getMangler();
+
+ foreach ( $collection as $key => $m ) {
+ $key = $mangler->unmangle( $key );
+ $trans = $m->translation();
+ $trans = str_replace( TRANSLATE_FUZZY, '', $trans );
+
+ if ( $trans === '' ) {
+ continue;
+ }
+
+ $trans = str_replace( '"', '"', $trans );
+ $output .= "<!ENTITY $key \"$trans\">\n";
+ }
+
+ return $output ? $header . $output : false;
+ }
+
+ protected function doHeader( MessageCollection $collection ) {
+ global $wgSitename;
+
+ $code = $collection->code;
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+
+ $output = "# Messages for $name ($native)\n";
+ $output .= "# Exported from $wgSitename\n\n";
+
+ return $output;
+ }
+
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+
+ foreach ( $authors as $author ) {
+ $output .= "# Author: $author\n";
+ }
+
+ return $output;
+ }
+}
diff --git a/extensions/Translate/ffs/FFS.php b/extensions/Translate/ffs/FFS.php
new file mode 100644
index 0000000..4bb948e
--- /dev/null
+++ b/extensions/Translate/ffs/FFS.php
@@ -0,0 +1,347 @@
+<?php
+/**
+ * File format support classes.
+ *
+ * These classes handle parsing and generating various different
+ * file formats where translation messages are stored.
+ *
+ * @file
+ * @defgroup FFS File format support
+ * @author Niklas Laxström
+ * @copyright Copyright © 2008-2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Interface for file system support classes.
+ * @ingroup FFS
+ */
+interface FFS {
+ public function __construct( FileBasedMessageGroup $group );
+
+ /**
+ * Set the file system location
+ * @param $target \string Filesystem path for exported files.
+ */
+ public function setWritePath( $target );
+
+ /**
+ * Get the file system location
+ * @return \string
+ */
+ public function getWritePath();
+
+ /**
+ * Will parse messages, authors, and any custom data from the file
+ * and return it in associative array with keys like \c AUTHORS and
+ * \c MESSAGES.
+ * @param $code \string Languge code.
+ * @return \arrayof{String,Mixed} Parsed data.
+ */
+ public function read( $code );
+
+ /**
+ * Same as read(), but takes the data as a parameters. The caller
+ * is supposed to know in what language the translations are in.
+ * @param $data \string Formatted messages.
+ * @return \arrayof{String,Mixed} Parsed data.
+ */
+ public function readFromVariable( $data );
+
+ /**
+ * Writes to the location provided with setWritePath and group specific
+ * directory structure. Exports translations included in the given
+ * collection with any special handling needed.
+ * @param $collection MessageCollection
+ */
+ public function write( MessageCollection $collection );
+
+ /**
+ * Quick shortcut for getting the plain exported data.
+ * Same as write(), but returns the output instead of writing it into
+ * a file.
+ * @param $collection MessageCollection
+ * @return \string
+ */
+ public function writeIntoVariable( MessageCollection $collection );
+}
+
+/**
+ * Very basic FFS module that implements some basic functionality and
+ * simple binary based file format.
+ * Other FFS classes can extend SimpleFFS and override suitable methods.
+ * @ingroup FFS
+ */
+class SimpleFFS implements FFS {
+
+ /**
+ * @var FileBasedMessageGroup
+ */
+ protected $group;
+
+ protected $writePath;
+ protected $extra;
+
+ public function __construct( FileBasedMessageGroup $group ) {
+ $this->setGroup( $group );
+ $conf = $group->getConfiguration();
+ $this->extra = $conf['FILES'];
+ }
+
+ /**
+ * @param $group FileBasedMessageGroup
+ */
+ public function setGroup( FileBasedMessageGroup $group ) { $this->group = $group; }
+
+ /**
+ * @return FileBasedMessageGroup
+ */
+ public function getGroup() { return $this->group; }
+
+ /**
+ * @param $writePath string
+ */
+ public function setWritePath( $writePath ) { $this->writePath = $writePath; }
+
+ /**
+ * @return string
+ */
+ public function getWritePath() { return $this->writePath; }
+
+ /**
+ * @param $code string|bool
+ * @return bool
+ */
+ public function exists( $code = false ) {
+ if ( $code === false ) {
+ $code = $this->group->getSourceLanguage();
+ }
+
+ $filename = $this->group->getSourceFilePath( $code );
+ if ( $filename === null ) {
+ return false;
+ }
+
+ if ( !file_exists( $filename ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param $code string
+ * @return array|bool
+ * @throws MWException
+ */
+ public function read( $code ) {
+ if ( !$this->exists( $code ) ) {
+ return false;
+ }
+
+ $filename = $this->group->getSourceFilePath( $code );
+ $input = file_get_contents( $filename );
+ if ( $input === false ) {
+ throw new MWException( "Unable to read file $filename." );
+ }
+
+ return $this->readFromVariable( $input );
+ }
+
+ /**
+ * @param $data array
+ * @return array
+ * @throws MWException
+ */
+ public function readFromVariable( $data ) {
+ $parts = explode( "\0\0\0\0", $data );
+
+ if ( count( $parts ) !== 2 ) {
+ throw new MWException( 'Wrong number of parts.' );
+ }
+
+ list( $authorsPart, $messagesPart ) = $parts;
+ $authors = explode( "\0", $authorsPart );
+ $messages = array();
+
+ foreach ( explode( "\0", $messagesPart ) as $line ) {
+ if ( $line === '' ) {
+ continue;
+ }
+
+ $lineParts = explode( '=', $line, 2 );
+
+ if ( count( $lineParts ) !== 2 ) {
+ throw new MWException( "Wrong number of parts in line $line." );
+ }
+
+ list( $key, $message ) = $lineParts;
+ $key = trim( $key );
+ $messages[$key] = $message;
+ }
+
+ $messages = $this->group->getMangler()->mangle( $messages );
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages,
+ );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ */
+ public function write( MessageCollection $collection ) {
+ $writePath = $this->writePath;
+
+ if ( $writePath === null ) {
+ throw new MWException( "Write path is not set." );
+ }
+
+ if ( !file_exists( $writePath ) ) {
+ throw new MWException( "Write path '$writePath' does not exist." );
+ }
+
+ if ( !is_writable( $writePath ) ) {
+ throw new MWException( "Write path '$writePath' is not writable." );
+ }
+
+ $targetFile = $writePath . '/' . $this->group->getTargetFilename( $collection->code );
+
+ if ( file_exists( $targetFile ) ) {
+ $this->tryReadSource( $targetFile, $collection );
+ } else {
+ $sourceFile = $this->group->getSourceFilePath( $collection->code );
+ $this->tryReadSource( $sourceFile, $collection );
+ }
+
+ $output = $this->writeReal( $collection );
+ if ( $output ) {
+ wfMkdirParents( dirname( $targetFile ), null, __METHOD__ );
+ file_put_contents( $targetFile, $output );
+ }
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ public function writeIntoVariable( MessageCollection $collection ) {
+ $sourceFile = $this->group->getSourceFilePath( $collection->code );
+ $this->tryReadSource( $sourceFile, $collection );
+
+ return $this->writeReal( $collection );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function writeReal( MessageCollection $collection ) {
+ $output = '';
+
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+ $output .= implode( "\0", $authors );
+ $output .= "\0\0\0\0";
+
+ $mangler = $this->group->getMangler();
+
+ foreach ( $collection as $key => $m ) {
+ $key = $mangler->unmangle( $key );
+ $trans = $m->translation();
+ $output .= "$key=$trans\0";
+ }
+
+ return $output;
+ }
+
+ /**
+ * @param $filename string
+ * @param $collection MessageCollection
+ */
+ protected function tryReadSource( $filename, $collection ) {
+ $sourceText = $this->tryReadFile( $filename );
+
+ if ( $sourceText !== false ) {
+ $sourceData = $this->readFromVariable( $sourceText );
+
+ if ( isset( $sourceData['AUTHORS'] ) ) {
+ $collection->addCollectionAuthors( $sourceData['AUTHORS'] );
+ }
+ }
+ }
+
+ /**
+ * @param $filename string
+ * @return bool|string
+ * @throws MWException
+ */
+ protected function tryReadFile( $filename ) {
+ if ( !$filename ) {
+ return false;
+ }
+
+ if ( !file_exists( $filename ) ) {
+ return false;
+ }
+
+ if ( !is_readable( $filename ) ) {
+ throw new MWException( "File $filename is not readable." );
+ }
+
+ $data = file_get_contents( $filename );
+ if ( $data == false ) {
+ throw new MWException( "Unable to read file $filename." );
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param $authors array
+ * @param $code string
+ * @return array
+ */
+ protected function filterAuthors( array $authors, $code ) {
+ global $wgTranslateAuthorBlacklist;
+ $groupId = $this->group->getId();
+
+ foreach ( $authors as $i => $v ) {
+ $hash = "$groupId;$code;$v";
+
+ $blacklisted = false;
+ foreach ( $wgTranslateAuthorBlacklist as $rule ) {
+ list( $type, $regex ) = $rule;
+
+ if ( preg_match( $regex, $hash ) ) {
+ if ( $type === 'white' ) {
+ $blacklisted = false;
+ break;
+ } else {
+ $blacklisted = true;
+ }
+ }
+ }
+
+ if ( $blacklisted ) {
+ unset( $authors[$i] );
+ }
+ }
+
+ return $authors;
+ }
+
+ /**
+ * @param $data string
+ * @return string
+ */
+ public static function fixNewLines( $data ) {
+ $data = str_replace( "\r\n", "\n", $data );
+ $data = str_replace( "\r", "\n", $data );
+
+ return $data;
+ }
+}
+
+
diff --git a/extensions/Translate/ffs/FlatPhpFFS.php b/extensions/Translate/ffs/FlatPhpFFS.php
new file mode 100644
index 0000000..4dd9ec9
--- /dev/null
+++ b/extensions/Translate/ffs/FlatPhpFFS.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * PHP variables file format handler.
+ *
+ * @file
+ * @author Niklas Laxström
+ * @author Siebrand Mazeland
+ * @copyright Copyright © 2008-2010, Niklas Laxström, Siebrand Mazeland
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Implements file format support for PHP files which consist of multiple
+ * variable assignments.
+ */
+class FlatPhpFFS extends SimpleFFS {
+ //
+ // READ
+ //
+ public function readFromVariable( $data ) {
+ # Authors first
+ $matches = array();
+ preg_match_all( '/^ \* @author\s+(.+)$/m', $data, $matches );
+ $authors = $matches[1];
+
+ # Then messages
+ $matches = array();
+ $regex = '/^\$(.*?)\s*=\s*[\'"](.*?)[\'"];.*?$/mus';
+ preg_match_all( $regex, $data, $matches, PREG_SET_ORDER );
+ $messages = array();
+
+ foreach ( $matches as $_ ) {
+ $legal = Title::legalChars();
+ $key = preg_replace( "/([^$legal]|\\\\)/ue", '\'\x\'.' . "dechex(ord('\\0'))", $_[1] );
+ $value = str_replace( array( "\'", "\\\\" ), array( "'", "\\" ), $_[2] );
+ $messages[$key] = $value;
+ }
+
+ $messages = $this->group->getMangler()->mangle( $messages );
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages,
+ );
+ }
+
+ //
+ // WRITE
+ //
+ protected function writeReal( MessageCollection $collection ) {
+ if ( isset( $this->extra['header'] ) ) {
+ $output = $this->extra['header'];
+ } else {
+ $output = "<?php\n";
+ }
+
+ $output .= $this->doHeader( $collection );
+
+ $mangler = $this->group->getMangler();
+
+ foreach ( $collection as $item ) {
+ $key = $mangler->unmangle( $item->key() );
+ $key = stripcslashes( $key );
+
+ $value = $item->translation();
+ if ( $value === null ) {
+ continue;
+ }
+
+ $value = str_replace( TRANSLATE_FUZZY, '', $value );
+ $value = addcslashes( $value, "'" );
+
+ $output .= "\$$key = '$value';\n";
+ }
+
+ return $output;
+ }
+
+ protected function doHeader( MessageCollection $collection ) {
+ global $wgSitename, $wgTranslateDocumentationLanguageCode;
+
+ $code = $collection->code;
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+
+ if ( $wgTranslateDocumentationLanguageCode ) {
+ $docu = "\n * See the $wgTranslateDocumentationLanguageCode 'language' for message \
documentation incl. usage of parameters"; + } else {
+ $docu = '';
+ }
+
+ $authors = $this->doAuthors( $collection );
+
+ $output = <<<PHP
+/** $name ($native)
+ * $docu
+ * To improve a translation please visit http://$wgSitename
+ *
+ * @ingroup Language
+ * @file
+ *
+$authors */
+
+
+PHP;
+ return $output;
+ }
+
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+
+ foreach ( $authors as $author ) {
+ $output .= " * @author $author\n";
+ }
+
+ return $output;
+ }
+}
diff --git a/extensions/Translate/ffs/GettextFFS.php b/extensions/Translate/ffs/GettextFFS.php
new file mode 100644
index 0000000..f6163fc
--- /dev/null
+++ b/extensions/Translate/ffs/GettextFFS.php
@@ -0,0 +1,594 @@
+<?php
+/**
+ * Gettext file format handler for both old and new style message groups.
+ *
+ * @author Niklas Laxström
+ * @author Siebrand Mazeland
+ * @copyright Copyright © 2008-2010, Niklas Laxström, Siebrand Mazeland
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @file
+ */
+
+/**
+ * Identifies Gettext plural exceptions.
+ */
+class GettextPluralException extends MwException {}
+
+/**
+ * New-style FFS class that implements support for gettext file format.
+ * @ingroup FFS
+ */
+class GettextFFS extends SimpleFFS {
+ protected $offlineMode = false;
+
+ /**
+ * @param $value bool
+ */
+ public function setOfflineMode( $value ) {
+ $this->offlineMode = $value;
+ }
+
+ public function readFromVariable( $data ) {
+ # Authors first
+ $matches = array();
+ preg_match_all( '/^#\s*Author:\s*(.*)$/m', $data, $matches );
+ $authors = $matches[1];
+
+ # Then messages and everything else
+ $parsedData = $this->parseGettext( $data );
+ $parsedData['AUTHORS'] = $authors;
+
+ foreach ( $parsedData['MESSAGES'] as $key => $value ) {
+ if ( $value === '' ) unset( $parsedData['MESSAGES'][$key] );
+ }
+
+ return $parsedData;
+ }
+
+ public function parseGettext( $data ) {
+ $mangler = $this->group->getMangler();
+ $useCtxtAsKey = isset( $this->extra['CtxtAsKey'] ) && $this->extra['CtxtAsKey'];
+ return self::parseGettextData( $data, $useCtxtAsKey, $mangler );
+ }
+
+ /**
+ * Parses gettext data into internal representation.
+ * @param $data \string
+ * @param $useCtxtAsKey \bool Whether to create message keys from the context
+ * or use msgctxt (non-standard po-files)
+ * @param $mangler StringMangler
+ * @return \array
+ */
+ public static function parseGettextData( $data, $useCtxtAsKey = false, $mangler ) {
+ $potmode = false;
+
+ // Normalise newlines, to make processing easier
+ $data = str_replace( "\r\n", "\n", $data );
+
+ /* Delimit the file into sections, which are separated by two newlines.
+ * We are permissive and accept more than two. This parsing method isn't
+ * efficient wrt memory, but was easy to implement */
+ $sections = preg_split( '/\n{2,}/', $data );
+
+ /* First one isn't an actual message. We'll handle it specially below */
+ $headerSection = array_shift( $sections );
+ /* Since this is the header section, we are only interested in the tags
+ * and msgid is empty. Somewhere we should extract the header comments
+ * too */
+ $match = self::expectKeyword( 'msgstr', $headerSection );
+ if ( $match !== null ) {
+ $headerBlock = self::formatForWiki( $match, 'trim' );
+ $headers = self::parseHeaderTags( $headerBlock );
+
+ // Check for pot-mode by checking if the header is fuzzy
+ $flags = self::parseFlags( $headerSection );
+ if ( in_array( 'fuzzy', $flags, true ) ) {
+ $potmode = true;
+ }
+ } else {
+ throw new MWException( "Gettext file header was not found:\n\n$data" );
+ }
+
+ $template = array();
+ $messages = array();
+
+ // Extract some metadata from headers for easier use
+ $metadata = array();
+ if ( isset( $headers['X-Language-Code'] ) ) {
+ $metadata['code'] = $headers['X-Language-Code'];
+ }
+
+ if ( isset( $headers['X-Message-Group'] ) ) {
+ $metadata['group'] = $headers['X-Message-Group'];
+ }
+
+ /* At this stage we are only interested how many plurals forms we should
+ * be expecting when parsing the rest of this file. */
+ $pluralCount = false;
+ if ( isset( $headers['Plural-Forms'] ) ) {
+ if ( preg_match( '/nplurals=([0-9]+).*;/', $headers['Plural-Forms'], $matches ) ) {
+ $pluralCount = $metadata['plural'] = $matches[1];
+ }
+ }
+
+ // Then parse the messages
+ foreach ( $sections as $section ) {
+ self::parseGettextSection(
+ $section,
+ $useCtxtAsKey,
+ $pluralCount,
+ $mangler,
+ $potmode,
+ $messages,
+ $template,
+ $metadata
+ );
+ }
+
+ return array(
+ 'MESSAGES' => $messages,
+ 'TEMPLATE' => $template,
+ 'METADATA' => $metadata,
+ 'HEADERS' => $headers
+ );
+ }
+
+ public static function parseGettextSection( $section, $useCtxtAsKey, $pluralCount, $mangler, \
$potmode, &$messages, &$template, &$metadata ) { + if ( trim( $section ) === '' ) {
+ return false;
+ }
+
+ /* These inactive sections are of no interest to us. Multiline mode
+ * is needed because there may be flags or other annoying stuff
+ * before the commented out sections.
+ */
+ if ( preg_match( '/^#~/m', $section ) ) {
+ return false;
+ }
+
+ $item = array(
+ 'ctxt' => '',
+ 'id' => '',
+ 'str' => '',
+ 'flags' => array(),
+ 'comments' => array(),
+ );
+
+ $match = self::expectKeyword( 'msgid', $section );
+ if ( $match !== null ) {
+ $item['id'] = self::formatForWiki( $match );
+ } else {
+ throw new MWException( "Unable to parse msgid:\n\n$section" );
+ }
+
+ $match = self::expectKeyword( 'msgctxt', $section );
+ if ( $match !== null ) {
+ $item['ctxt'] = self::formatForWiki( $match );
+ } elseif ( $useCtxtAsKey ) { // Invalid message
+ $metadata['warnings'][] = "Ctxt missing for {$item['id']}";
+ error_log( "Ctxt missing for {$item['id']}" );
+ }
+
+ $pluralMessage = false;
+ $match = self::expectKeyword( 'msgid_plural', $section );
+ if ( $match !== null ) {
+ $pluralMessage = true;
+ $plural = self::formatForWiki( $match );
+ $item['id'] = "{{PLURAL:GETTEXT|{$item['id']}|$plural}}";
+ }
+
+ if ( $pluralMessage ) {
+ $pluralMessageText = self::processGettextPluralMessage( $pluralCount, $section );
+
+ // Keep the translation empty if no form has translation
+ if( $pluralMessageText !== '' ) {
+ $item['str'] = $pluralMessageText;
+ }
+ } else {
+ $match = self::expectKeyword( 'msgstr', $section );
+ if ( $match !== null ) {
+ $item['str'] = self::formatForWiki( $match );
+ } else {
+ throw new MWException( "Unable to parse msgstr:\n\n$section" );
+ }
+ }
+
+ // Parse flags
+ $flags = self::parseFlags( $section );
+ foreach ( $flags as $key => $flag ) {
+ if ( $flag === 'fuzzy' ) {
+ $item['str'] = TRANSLATE_FUZZY . $item['str'];
+ unset( $flags[$key] );
+ }
+ }
+ $item['flags'] = $flags;
+
+ // Rest of the comments
+ $matches = array();
+ if ( preg_match_all( '/^#(.?) (.*)$/m', $section, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ if ( $match[1] !== ',' && strpos( $match[1], '[Wiki]' ) !== 0 ) {
+ $item['comments'][$match[1]][] = $match[2];
+ }
+ }
+ }
+
+ if ( $useCtxtAsKey ) {
+ $key = $item['ctxt'];
+ } else {
+ $key = self::generateKeyFromItem( $item );
+ }
+
+ $key = $mangler->mangle( $key );
+
+ $messages[$key] = $potmode ? $item['id'] : $item['str'];
+ $template[$key] = $item;
+
+ return true;
+ }
+
+ public static function processGettextPluralMessage( $pluralCount, $section ) {
+ $actualForms = array();
+
+ for ( $i = 0; $i < $pluralCount; $i++ ) {
+ $match = self::expectKeyword( "msgstr\\[$i\\]", $section );
+
+ if ( $match !== null ) {
+ $actualForms[] = self::formatForWiki( $match );
+ } else {
+ $actualForms[] = '';
+ error_log( "Plural $i not found, expecting total of $pluralCount for {$item['id']}" );
+ }
+ }
+
+ if ( array_sum( array_map( 'strlen', $actualForms ) ) > 0 ) {
+ return '{{PLURAL:GETTEXT|' . implode( '|', $actualForms ) . '}}';
+ } else {
+ return '';
+ }
+ }
+
+ public static function parseFlags( $section ) {
+ $matches = array();
+ if ( preg_match( '/^#,(.*)$/mu', $section, $matches ) ) {
+ return array_map( 'trim', explode( ',', $matches[1] ) );
+ } else {
+ return array();
+ }
+ }
+
+ public static function expectKeyword( $name, $section ) {
+ /* Catches the multiline textblock that comes after keywords msgid,
+ * msgstr, msgid_plural, msgctxt.
+ */
+ $poformat = '".*"\n?(^".*"$\n?)*';
+
+ $matches = array();
+ if ( preg_match( "/^$name\s($poformat)/mx", $section, $matches ) ) {
+ return $matches[1];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Generates unique key for each message. Changing this WILL BREAK ALL
+ * existing pages!
+ * @param $item
+ * @return string
+ */
+ public static function generateKeyFromItem( $item ) {
+ $lang = Language::factory( 'en' );
+
+ global $wgLegalTitleChars;
+
+ $hash = sha1( $item['ctxt'] . $item['id'] );
+ $snippet = $item['id'];
+ $snippet = preg_replace( "/[^$wgLegalTitleChars]/", ' ', $snippet );
+ $snippet = preg_replace( "/[:&%\/_]/", ' ', $snippet );
+ $snippet = preg_replace( "/ {2,}/", ' ', $snippet );
+ $snippet = $lang->truncate( $snippet, 30, '' );
+ $snippet = str_replace( ' ', '_', trim( $snippet ) );
+
+ return "$hash-$snippet";
+ }
+
+ /**
+ * This parses the Gettext text block format. Since trailing whitespace is
+ * not allowed in MediaWiki pages, the default action is to append
+ * \-character at the end of the message. You can also choose to ignore it
+ * and use the trim action instead.
+ * @param $data
+ * @param $whitespace string
+ * @return string
+ */
+ public static function formatForWiki( $data, $whitespace = 'mark' ) {
+ $quotePattern = '/(^"|"$\n?)/m';
+ $data = preg_replace( $quotePattern, '', $data );
+ $data = stripcslashes( $data );
+
+ if ( preg_match( '/\s$/', $data ) ) {
+ if ( $whitespace === 'mark' )
+ $data .= '\\';
+ elseif ( $whitespace === 'trim' )
+ $data = rtrim( $data );
+ else
+ // @todo Only triggered if there is trailing whitespace
+ throw new MWException( 'Unknown action for whitespace' );
+ }
+
+ return $data;
+ }
+
+ public static function parseHeaderTags( $headers ) {
+ $tags = array();
+ foreach ( explode( "\n", $headers ) as $line ) {
+ if ( strpos( $line, ':' ) === false ) {
+ error_log( __METHOD__ . ": $line" );
+ }
+ list( $key, $value ) = explode( ':', $line, 2 );
+ $tags[trim( $key )] = trim( $value );
+ }
+
+ return $tags;
+ }
+
+ protected function writeReal( MessageCollection $collection ) {
+ $pot = $this->read( 'en' );
+ $template = $this->read( $collection->code );
+ $pluralCount = false;
+ $output = $this->doGettextHeader( $collection, $template, $pluralCount );
+
+ foreach ( $collection as $key => $m ) {
+ $transTemplate = isset( $template['TEMPLATE'][$key] ) ?
+ $template['TEMPLATE'][$key] : array();
+ $potTemplate = isset( $pot['TEMPLATE'][$key] ) ?
+ $pot['TEMPLATE'][$key] : array();
+
+ $output .= $this->formatMessageBlock( $key, $m, $transTemplate, $potTemplate, $pluralCount \
); + }
+
+ return $output;
+ }
+
+ protected function doGettextHeader( MessageCollection $collection, $template, &$pluralCount ) \
{ + global $wgSitename;
+
+ $code = $collection->code;
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+ $authors = $this->doAuthors( $collection );
+ if ( isset( $this->extra['header'] ) ) {
+ $extra = "# --\n" . $this->extra['header'];
+ } else {
+ $extra = '';
+ }
+
+ $output = <<<PHP
+# Translation of {$this->group->getLabel()} to $name ($native)
+# Exported from $wgSitename
+#
+$authors$extra
+PHP;
+
+ // Make sure there is no empty line before msgid
+ $output = trim( $output ) . "\n";
+
+ $specs = isset( $template['HEADERS'] ) ? $template['HEADERS'] : array();
+
+ $timestamp = wfTimestampNow();
+ $specs['PO-Revision-Date'] = self::formatTime( $timestamp );
+ if ( $this->offlineMode ) {
+ $specs['POT-Creation-Date'] = self::formatTime( $timestamp );
+ } elseif ( $this->group instanceof MessageGroupBase ) {
+ $specs['X-POT-Import-Date'] = self::formatTime( wfTimestamp( TS_MW, $this->getPotTime() ) \
); + }
+ $specs['Content-Type'] = 'text/plain; charset=UTF-8';
+ $specs['Content-Transfer-Encoding'] = '8bit';
+ wfRunHooks( 'Translate:GettextFFS:headerFields', array( &$specs, $this->group, $code ) );
+ $specs['X-Generator'] = $this->getGenerator();
+
+ if ( $this->offlineMode ) {
+ $specs['X-Language-Code'] = $code;
+ $specs['X-Message-Group'] = $this->group->getId();
+ }
+
+ $plural = self::getPluralRule( $code );
+ if ( $plural ) {
+ $specs['Plural-Forms'] = $plural;
+ } elseif ( !isset( $specs['Plural-Forms'] ) ) {
+ $specs['Plural-Forms'] = 'nplurals=2; plural=(n != 1);';
+ }
+
+ $match = array();
+ preg_match( '/nplurals=(\d+);/', $specs['Plural-Forms'], $match );
+ $pluralCount = $match[1];
+
+ $output .= 'msgid ""' . "\n";
+ $output .= 'msgstr ""' . "\n";
+ $output .= '""' . "\n";
+
+ foreach ( $specs as $k => $v ) {
+ $output .= self::escape( "$k: $v\n" ) . "\n";
+ }
+
+ $output .= "\n";
+
+ return $output;
+ }
+
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+
+ foreach ( $authors as $author ) {
+ $output .= "# Author: $author\n";
+ }
+
+ return $output;
+ }
+
+ protected function formatMessageBlock( $key, $m, $trans, $pot, $pluralCount ) {
+ $header = $this->formatDocumentation( $key );
+ $content = '';
+
+ $comments = self::chainGetter( 'comments', $pot, $trans, array() );
+ foreach ( $comments as $type => $typecomments ) {
+ foreach ( $typecomments as $comment ) {
+ $header .= "#$type $comment\n";
+ }
+ }
+
+ $flags = self::chainGetter( 'flags', $pot, $trans, array() );
+ $flags = array_merge( $m->getTags(), $flags );
+
+ if ( $this->offlineMode ) {
+ $content .= 'msgctxt ' . self::escape( $key ) . "\n";
+ } else {
+ $ctxt = self::chainGetter( 'ctxt', $pot, $trans, false );
+ if ( $ctxt ) {
+ $content .= 'msgctxt ' . self::escape( $ctxt ) . "\n";
+ }
+ }
+
+ $msgid = $m->definition();
+ $msgstr = $m->translation();
+ if ( strpos( $msgstr, TRANSLATE_FUZZY ) !== false ) {
+ $msgstr = str_replace( TRANSLATE_FUZZY, '', $msgstr );
+ // Might by fuzzy infile
+ $flags[] = 'fuzzy';
+ }
+
+ if ( preg_match( '/{{PLURAL:GETTEXT/i', $msgid ) ) {
+ $forms = $this->splitPlural( $msgid, 2 );
+ $content .= 'msgid ' . $this->escape( $forms[0] ) . "\n";
+ $content .= 'msgid_plural ' . $this->escape( $forms[1] ) . "\n";
+
+ try {
+ $forms = $this->splitPlural( $msgstr, $pluralCount );
+ foreach ( $forms as $index => $form ) {
+ $content .= "msgstr[$index] " . $this->escape( $form ) . "\n";
+ }
+ } catch ( GettextPluralException $e ) {
+ $flags[] = 'invalid-plural';
+ for ( $i = 0; $i < $pluralCount; $i++ ) {
+ $content .= "msgstr[$i] \"\"\n";
+ }
+ }
+
+ } else {
+ $content .= 'msgid ' . self::escape( $msgid ) . "\n";
+ $content .= 'msgstr ' . self::escape( $msgstr ) . "\n";
+ }
+
+ if ( $flags ) {
+ sort( $flags );
+ $header .= "#, " . implode( ', ', array_unique( $flags ) ) . "\n";
+ }
+
+ $output = $header ? $header : "#\n";
+ $output .= $content . "\n";
+ return $output;
+ }
+
+ protected static function chainGetter( $key, $a, $b, $default ) {
+ if ( isset( $a[$key] ) ) {
+ return $a[$key];
+ } elseif ( isset( $b[$key] ) ) {
+ return $b[$key];
+ } else {
+ return $default;
+ }
+ }
+
+ protected static function formatTime( $time ) {
+ $lang = Language::factory( 'en' );
+ return $lang->sprintfDate( 'xnY-xnm-xnd xnH:xni:xns+0000', $time );
+ }
+
+ protected function getPotTime() {
+ $defs = new MessageGroupCache( $this->group );
+ return $defs->exists() ? $defs->getTimestamp() : wfTimestampNow();
+ }
+
+ protected function getGenerator() {
+ return 'MediaWiki ' . SpecialVersion::getVersion() .
+ "; Translate " . TRANSLATE_VERSION;
+ }
+
+ protected function formatDocumentation( $key ) {
+ global $wgTranslateDocumentationLanguageCode;
+
+ if ( !$this->offlineMode ) return '';
+
+ $code = $wgTranslateDocumentationLanguageCode;
+ if ( !$code ) return '';
+
+ $documentation = TranslateUtils::getMessageContent( $key, $code, \
$this->group->getNamespace() ); + if ( !is_string( $documentation ) ) return '';
+
+ $lines = explode( "\n", $documentation );
+ $out = '';
+ foreach ( $lines as $line ) {
+ $out .= "#. [Wiki] $line\n";
+ }
+ return $out;
+ }
+
+ protected static function escape( $line ) {
+ // There may be \ as a last character, for keeping trailing whitespace
+ $line = preg_replace( '/\\\\$/', '', $line );
+ $line = addcslashes( $line, '\\"' );
+ $line = str_replace( "\n", '\n', $line );
+ $line = '"' . $line . '"';
+
+ return $line;
+ }
+
+ /**
+ * Returns plural rule for Gettext.
+ * @param $code \string Language code.
+ * @return \string
+ */
+ public static function getPluralRule( $code ) {
+ $rulefile = dirname( __FILE__ ) . '/../data/plural-gettext.txt';
+ $rules = file_get_contents( $rulefile );
+ foreach ( explode( "\n", $rules ) as $line ) {
+ if ( trim( $line ) === '' ) continue;
+ list( $rulecode, $rule ) = explode( "\t", $line );
+ if ( $rulecode === $code ) return $rule;
+ }
+ return '';
+ }
+
+ protected function splitPlural( $text, $forms ) {
+ if ( $forms === 1 ) {
+ return $text;
+ }
+
+ $splitPlurals = array();
+ for ( $i = 0; $i < $forms; $i++ ) {
+ $plurals = array();
+ $match = preg_match_all( '/{{PLURAL:GETTEXT\|(.*)}}/iUs', $text, $plurals );
+
+ if ( !$match ) {
+ throw new GettextPluralException( "Failed to find plural in: $text" );
+ }
+
+ $pluralForm = $text;
+ foreach ( $plurals[0] as $index => $definition ) {
+ $parsedFormsArray = explode( '|', $plurals[1][$index] );
+ if ( !isset( $parsedFormsArray[$i] ) ) {
+ error_log( "Too few plural forms in: $text" );
+ $pluralForm = '';
+ } else {
+ $pluralForm = str_replace( $pluralForm, $definition, $parsedFormsArray[$i] );
+ }
+ }
+ $splitPlurals[$i] = $pluralForm;
+ }
+
+ return $splitPlurals;
+ }
+}
diff --git a/extensions/Translate/ffs/JavaFFS.php b/extensions/Translate/ffs/JavaFFS.php
new file mode 100644
index 0000000..9b6ae1e
--- /dev/null
+++ b/extensions/Translate/ffs/JavaFFS.php
@@ -0,0 +1,234 @@
+<?php
+
+/**
+ * JavaFFS class implements support for Java properties files.
+ * This class reads and writes only utf-8 files. Java projects
+ * need to run native2ascii on them before using them.
+ *
+ * This class adds a new item into FILES section of group configuration:
+ * \c keySeparator which defaults to '='.
+ * @ingroup FFS
+ */
+class JavaFFS extends SimpleFFS {
+ protected $keySeparator = '=';
+
+ /**
+ * @param $group FileBasedMessageGroup
+ */
+ public function __construct( FileBasedMessageGroup $group ) {
+ parent::__construct( $group );
+
+ if ( isset( $this->extra['keySeparator'] ) ) {
+ $this->keySeparator = $this->extra['keySeparator'];
+ }
+ }
+
+ // READ
+
+ /**
+ * @param $data array
+ * @return array
+ * @throws MWException
+ */
+ public function readFromVariable( $data ) {
+ $data = self::fixNewLines( $data );
+ $lines = array_map( 'ltrim', explode( "\n", $data ) );
+ $authors = $messages = array();
+ $linecontinuation = false;
+
+ $value = '';
+ foreach ( $lines as $line ) {
+ if ( $linecontinuation ) {
+ $linecontinuation = false;
+ $valuecont = $line;
+ $valuecont = str_replace( '\n', "\n", $valuecont );
+ $value .= $valuecont;
+ } else {
+ if ( $line === '' ) {
+ continue;
+ }
+
+ if ( $line[0] === '#' || $line[0] === '!' ) {
+ $match = array();
+ $ok = preg_match( '/#\s*Author:\s*(.*)/', $line, $match );
+
+ if ( $ok ) {
+ $authors[] = $match[1];
+ }
+
+ continue;
+ }
+
+ if ( strpos( $line, $this->keySeparator ) === false ) {
+ throw new MWException( "Line without separator '{$this->keySeparator}': $line." );
+ }
+
+ list( $key, $value ) = self::readRow( $line, $this->keySeparator );
+ if ( $key === '' ) {
+ throw new MWException( "Empty key in line $line." );
+ }
+ }
+
+ /* TODO: this doesn't handle the pathological case of
+ * even number of trailing \ */
+ if ( strlen( $value ) && $value[strlen( $value ) - 1] === "\\" ) {
+ $value = substr( $value, 0, strlen( $value ) - 1 );
+ $linecontinuation = true;
+ } else {
+ $messages[$key] = ltrim( $value );
+ }
+ }
+
+ $messages = $this->group->getMangler()->mangle( $messages );
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages,
+ );
+ }
+
+ // Write
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function writeReal( MessageCollection $collection ) {
+ $header = $this->doHeader( $collection );
+ $header .= $this->doAuthors( $collection );
+ $header .= "\n";
+
+ $output = '';
+ $mangler = $this->group->getMangler();
+
+ foreach ( $collection as $key => $m ) {
+ $value = $m->translation();
+ $value = str_replace( TRANSLATE_FUZZY, '', $value );
+
+ if ( $value === '' ) {
+ continue;
+ }
+
+ // Just to give an overview of translation quality.
+ if ( $m->hasTag( 'fuzzy' ) ) {
+ $output .= "# Fuzzy\n";
+ }
+
+ $key = $mangler->unmangle( $key );
+ $output .= self::writeRow( $key, $this->keySeparator, $value );
+ }
+
+ if ( $output ) {
+ return $header . $output;
+ }
+ return '';
+ }
+
+
+ /**
+ * Writes well-formed properties file row with key and value.
+ * @return string
+ * @since 2012-03-28
+ */
+ public static function writeRow( /*string*/$key, /*string*/$sep, /*string*/$value ) {
+ /* Keys containing the separator need escaping. Also escape comment
+ * characters, though strictly they would only need escaping when
+ * they are the first character. Plus the escape character itself. */
+ $key = addcslashes( $key, "#!$sep\\" );
+ // Make sure we do not slip newlines trough... it would be fatal.
+ $value = str_replace( "\n", '\\n', $value );
+ return "$key$sep$value\n";
+ }
+
+ /**
+ * Parses non-empty properties file row to key and value.
+ * @return string
+ * @since 2012-03-28
+ */
+ public static function readRow( /*string*/$line, /*string*/$sep ) {
+ if ( strpos( $line, '\\' ) === false ) {
+ list( $key, $value ) = explode( $sep, $line, 2 );
+ } else {
+ /* There might be escaped separators in the key, using
+ * slower method to find the separator. */
+
+ /* Make the key default to empty instead of value, because
+ * empty key causes error on callers, while empty value
+ * wouldn't. */
+ $key = '';
+ $value = $line;
+
+ /* Find the first unescaped separator. Example:
+ * First line is the string being read, second line is the
+ * value of $escaped after having read the above character.
+ *
+ * ki\ts\\s\=a = koira
+ * 0010010010000
+ * ^ Not separator because $escaped was true
+ * ^ Split the string into key and value here
+ */
+
+ $len = strlen( $line );
+ $escaped = false;
+ for ( $i = 0; $i < $len; $i++ ) {
+ $char = $line[$i];
+ if ( $char === '\\' ) {
+ $escaped = !$escaped;
+ } elseif ( $escaped ) {
+ $escaped = false;
+ } elseif ( $char === $sep ) {
+ $key = substr( $line, 0, $i );
+ // Excluding the separator character from the value
+ $value = substr( $line, $i + 1 );
+ break;
+ }
+ }
+ }
+
+ /* We usually don't want to expand things like \t in values since
+ * translators cannot easily input those. But in keys we do.
+ * \n is exception we do handle in values. */
+ $key = trim( $key );
+ $key = stripcslashes( $key );
+ $value = ltrim( $value );
+ $value = str_replace( '\n', "\n", $value );
+ return array( $key, $value );
+ }
+
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function doHeader( MessageCollection $collection ) {
+ if ( isset( $this->extra['header'] ) ) {
+ $output = $this->extra['header'];
+ } else {
+ global $wgSitename;
+
+ $code = $collection->code;
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+ $output = "# Messages for $name ($native)\n";
+ $output .= "# Exported from $wgSitename\n";
+ }
+
+ return $output;
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+
+ foreach ( $authors as $author ) {
+ $output .= "# Author: $author\n";
+ }
+
+ return $output;
+ }
+}
diff --git a/extensions/Translate/ffs/JavaScriptFFS.php \
b/extensions/Translate/ffs/JavaScriptFFS.php new file mode 100644
index 0000000..5dc335f
--- /dev/null
+++ b/extensions/Translate/ffs/JavaScriptFFS.php
@@ -0,0 +1,272 @@
+<?php
+
+/**
+ * Generic file format support for JavaScript formatted files.
+ * @ingroup FFS
+ */
+abstract class JavaScriptFFS extends SimpleFFS {
+ /**
+ * Message keys format.
+ *
+ * @param $key string
+ *
+ * @return string
+ */
+ abstract protected function transformKey( $key );
+
+ /**
+ * Header of message file.
+ *
+ * @param $code string
+ * @param $authors array
+ */
+ abstract protected function header( $code, $authors );
+
+ /**
+ * Footer of message file.
+ */
+ abstract protected function footer();
+
+ /**
+ * @param $data array
+ * @return array
+ */
+ public function readFromVariable( $data ) {
+ /* Parse authors list */
+ $authors = preg_replace( "#/\* Translators\:\n(.*?)\n \*/(.*)#s", '$1', $data );
+ if ( $authors === $data ) {
+ $authors = array();
+ } else {
+ $authors = explode( "\n", $authors );
+ for ( $i = 0; $i < count( $authors ); $i++ ) {
+ // Each line should look like " * - Translatorname"
+ $authors[$i] = substr( $authors[$i], 6 );
+ }
+ }
+
+ /* Pre-processing of messages */
+
+ /**
+ * Find the start and end of the data section (enclosed in curly braces).
+ */
+ $dataStart = strpos( $data, '{' );
+ $dataEnd = strrpos( $data, '}' );
+
+ /**
+ * Strip everything outside of the data section.
+ */
+ $data = substr( $data, $dataStart + 1, $dataEnd - $dataStart - 1 );
+
+ /**
+ * Strip comments.
+ */
+ $data = preg_replace( '#^(\s*?)//(.*?)$#m', '', $data );
+
+ /**
+ * Replace message endings with double quotes.
+ */
+ $data = preg_replace( "#\'\,\n#", "\",\n", $data );
+
+ /**
+ * Strip excess whitespace.
+ */
+ $data = trim( $data );
+
+ /**
+ * Per-key message processing.
+ */
+
+ /**
+ * Break in to segments.
+ */
+ $data = explode( "\",\n", $data );
+
+ $messages = array();
+ foreach ( $data as $segment ) {
+ /**
+ * Add back trailing quote, removed by explosion.
+ */
+ $segment .= '"';
+
+ /**
+ * Concatenate separated strings.
+ */
+ $segment = str_replace( '"+', '" +', $segment );
+ $segment = explode( '" +', $segment );
+ for ( $i = 0; $i < count( $segment ); $i++ ) {
+ $segment[$i] = ltrim( ltrim( $segment[$i] ), '"' );
+ }
+ $segment = implode( $segment );
+
+ /**
+ * Remove line breaks between message keys and messages.
+ */
+ $segment = preg_replace( "#\:(\s+)[\\\"\']#", ': "', $segment );
+
+ /**
+ * Break in to key and message.
+ */
+ $segments = explode( ': "', $segment );
+
+ /**
+ * Strip excess whitespace from key and value, then quotation marks.
+ */
+ $key = trim( trim( $segments[0] ), "'\"" );
+ $value = trim( trim( $segments[1] ), "'\"" );
+
+ /**
+ * Unescape any JavaScript string syntax and append to message array.
+ */
+ $messages[$key] = self::unescapeJsString( $value );
+ }
+
+ $messages = $this->group->getMangler()->mangle( $messages );
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages
+ );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ public function writeReal( MessageCollection $collection ) {
+ $header = $this->header( $collection->code, $collection->getAuthors() );
+
+ $mangler = $this->group->getMangler();
+
+ /**
+ * Get and write messages.
+ */
+ $body = '';
+ foreach ( $collection as $message ) {
+ if ( strlen( $message->translation() ) === 0 ) {
+ continue;
+ }
+
+ $key = $mangler->unmangle( $message->key() );
+ $key = $this->transformKey( Xml::escapeJsString( $key ) );
+
+ $translation = Xml::escapeJsString( $message->translation() );
+
+ $body .= "\t{$key}: \"{$translation}\",\n";
+ }
+
+ if ( strlen( $body ) === 0 ) {
+ return false;
+ }
+
+ /**
+ * Strip last comma, re-add trailing newlines.
+ */
+ $body = substr( $body, 0, -2 );
+ $body .= "\n";
+
+ return $header . $body . $this->footer();
+ }
+
+ /**
+ * @param $authors array
+ * @return string
+ */
+ protected function authorsList( $authors ) {
+ if ( count( $authors ) === 0 ) {
+ return '';
+ }
+
+ $authorsList = '';
+ foreach ( $authors as $author ) {
+ $authorsList .= " * - $author\n";
+ }
+
+ /**
+ * Remove trailing newline, and return.
+ */
+ return substr( " * Translators:\n$authorsList", 0, -1 );
+ }
+
+ /**
+ * @param $string string
+ * @return string
+ */
+ protected static function unescapeJsString( $string ) {
+ // See ECMA 262 section 7.8.4 for string literal format
+ $pairs = array(
+ "\\" => "\\\\",
+ "\"" => "\\\"",
+ "'" => "\\'",
+ "\n" => "\\n",
+ "\r" => "\\r",
+
+ // To avoid closing the element or CDATA section.
+ "<" => "\\x3c",
+ ">" => "\\x3e",
+
+ // To avoid any complaints about bad entity refs.
+ "&" => "\\x26",
+
+ /*
+ * Work around https://bugzilla.mozilla.org/show_bug.cgi?id=274152
+ * Encode certain Unicode formatting chars so affected
+ * versions of Gecko do not misinterpret our strings;
+ * this is a common problem with Farsi text.
+ */
+ "\xe2\x80\x8c" => "\\u200c", // ZERO WIDTH NON-JOINER
+ "\xe2\x80\x8d" => "\\u200d", // ZERO WIDTH JOINER
+ );
+ $pairs = array_flip( $pairs );
+
+ return strtr( $string, $pairs );
+ }
+}
+
+/**
+ * File format support for Shapado, which uses JavaScript based format.
+ * @ingroup FFS
+ */
+class ShapadoJsFFS extends JavaScriptFFS {
+
+ /**
+ * @param $key string
+ *
+ * @return string
+ */
+ protected function transformKey( $key ) {
+ return $key;
+ }
+
+ /**
+ * @param $code string
+ * @param $authors array
+ * @return string
+ */
+ protected function header( $code, $authors ) {
+ global $wgSitename;
+
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+ $authorsList = $this->authorsList( $authors );
+
+ /** @cond doxygen_bug */
+ return <<<EOT
+/** Messages for $name ($native)
+ * Exported from $wgSitename
+ *
+{$authorsList}
+ */
+
+var I18n = {
+
+EOT;
+ /** @endcond */
+ }
+
+ /**
+ * @return string
+ */
+ protected function footer() {
+ return "};\n\n";
+ }
+}
diff --git a/extensions/Translate/ffs/PythonSingleFFS.php \
b/extensions/Translate/ffs/PythonSingleFFS.php new file mode 100644
index 0000000..982fa0c
--- /dev/null
+++ b/extensions/Translate/ffs/PythonSingleFFS.php
@@ -0,0 +1,162 @@
+<?php
+
+/**
+ * Generic file format support for Phython single dictionary formatted files.
+ * @ingroup FFS
+ */
+class PythonSingleFFS extends SimpleFFS {
+ private $fw = null;
+ static $data = null;
+
+ /**
+ * @param $code
+ * @return array
+ */
+ public function read( $code ) {
+ // Map codes
+ $code = $this->group->mapCode( $code );
+
+ // TODO: Improve this code to not use static variables.
+ if ( !isset( self::$data[$this->group->getId()] ) ) {
+ /* N levels of escaping
+ * - for PHP string
+ * - for Python string
+ * - for shell command
+ * - and wfShellExec will wrap the whole command once more
+ */
+ $filename = $this->group->getSourceFilePath( $code );
+ $filename = addcslashes( $filename, '\\"' );
+ $command = wfEscapeShellArg( "import simplejson as json; execfile(\"$filename\"); print \
json.dumps(msg)" ); + $json = wfShellExec( "python -c $command" );
+ self::$data[$this->group->getId()] = FormatJson::decode( $json, true );
+ }
+
+ if ( !isset( self::$data[$this->group->getId()][$code] ) ) {
+ self::$data[$this->group->getId()][$code] = array();
+ }
+
+ return array( 'MESSAGES' => self::$data[$this->group->getId()][$code] );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ */
+ public function write( MessageCollection $collection ) {
+ if ( $this->fw === null ) {
+ $sourceLanguage = $this->group->getSourceLanguage();
+ $outputFile = $this->writePath . '/' . $this->group->getTargetFilename( $sourceLanguage );
+ wfMkdirParents( dirname( $outputFile ), null, __METHOD__ );
+ $this->fw = fopen( $outputFile, 'w' );
+ $this->fw = fopen( $this->writePath . '/' . $this->group->getTargetFilename( \
$sourceLanguage ), 'w' ); + fwrite( $this->fw, "# -*- coding: utf-8 -*-\nmsg = {\n" );
+ }
+
+ // Not sure why this is needed, only continue if there are translations.
+ $collection->loadTranslations();
+ $ok = false;
+ foreach ( $collection as $messages ) {
+ if ( $messages->translation() != '' ) {
+ $ok = true;
+ }
+ }
+
+ if ( !$ok ) {
+ return;
+ }
+
+ $authors = $this->doAuthors( $collection );
+ if ( $authors != '' ) {
+ fwrite( $this->fw, "$authors" );
+ }
+
+ $code = $this->group->mapCode( $collection->code );
+ fwrite( $this->fw, "\t'{$code}': {\n" );
+ fwrite( $this->fw, $this->writeBlock( $collection ) );
+ fwrite( $this->fw, "\t},\n" );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ public function writeIntoVariable( MessageCollection $collection ) {
+ return <<<PHP
+# -*- coding: utf-8 -*-
+msg = {
+{$this->doAuthors($collection)}\t'{$collection->code}': {
+{$this->writeBlock( $collection )}\t}
+}
+PHP;
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function writeBlock( MessageCollection $collection ) {
+ $block = '';
+ $messages = array();
+
+ foreach ( $collection as $message ) {
+ if ( $message->translation() == '' ) {
+ continue;
+ }
+
+ $translation = str_replace( '\\', '\\\\', $message->translation() );
+ $translation = str_replace( '\'', '\\\'', $translation );
+ $translation = str_replace( "\n", '\n', $translation );
+ $translation = str_replace( TRANSLATE_FUZZY, '', $translation );
+
+ $messages[$message->key()] = $translation;
+ }
+
+ ksort( $messages );
+
+ foreach ( $messages as $key => $translation ) {
+ $block .= "\t\t'{$key}': u'{$translation}',\n";
+ }
+
+ return $block;
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+
+ // Read authors.
+ $fr = fopen( $this->group->getSourceFilePath( $collection->code ), 'r' );
+ $authors = array();
+
+ while ( !feof( $fr ) ) {
+ $line = fgets( $fr );
+
+ if ( strpos( $line, "\t# Author:" ) === 0 ) {
+ $authors[] = trim( substr( $line, strlen( "\t# Author: " ) ) );
+ } elseif ( $line === "\t'{$collection->code}': {\n" ) {
+ break;
+ } else {
+ $authors = array();
+ }
+ }
+
+ $authors2 = $collection->getAuthors();
+ $authors2 = $this->filterAuthors( $authors2, $collection->code );
+ $authors = array_unique( array_merge( $authors, $authors2 ) );
+
+ foreach ( $authors as $author ) {
+ $output .= "\t# Author: $author\n";
+ }
+
+ return $output;
+ }
+
+ public function __destruct() {
+ if ( $this->fw !== null ) {
+ fwrite( $this->fw, "}" );
+ fclose( $this->fw );
+ }
+ }
+}
diff --git a/extensions/Translate/ffs/RubyYamlFFS.php \
b/extensions/Translate/ffs/RubyYamlFFS.php new file mode 100644
index 0000000..f36645c
--- /dev/null
+++ b/extensions/Translate/ffs/RubyYamlFFS.php
@@ -0,0 +1,158 @@
+<?php
+
+/**
+ * Extends YamlFFS with Ruby (on Rails) style plural support. Supports subkeys
+ * zero, one, many, few, other and two for each message using plural with
+ * {{count}} variable.
+ * @ingroup FFS
+ */
+class RubyYamlFFS extends YamlFFS {
+ static $pluralWords = array(
+ 'zero' => 1,
+ 'one' => 1,
+ 'many' => 1,
+ 'few' => 1,
+ 'other' => 1,
+ 'two' => 1
+ );
+
+ /**
+ * Flattens ruby plural arrays into special plural syntax.
+ *
+ * @param $messages array
+ *
+ * @return bool|string
+ */
+ public function flattenPlural( $messages ) {
+
+ $plurals = false;
+ foreach ( array_keys( $messages ) as $key ) {
+ if ( isset( self::$pluralWords[$key] ) ) {
+ $plurals = true;
+ } elseif ( $plurals ) {
+ throw new MWException( "Reserved plural keywords mixed with other keys: $key." );
+ }
+ }
+
+ if ( !$plurals ) {
+ return false;
+ }
+
+ $pls = '{{PLURAL';
+ foreach ( $messages as $key => $value ) {
+ if ( $key === 'other' ) {
+ continue;
+ }
+
+ $pls .= "|$key=$value";
+ }
+
+ // Put the "other" alternative last, without other= prefix.
+ $other = isset( $messages['other'] ) ? '|' . $messages['other'] : '';
+ $pls .= "$other}}";
+ return $pls;
+ }
+
+ /**
+ * Converts the special plural syntax to array or ruby style plurals
+ *
+ * @param $key string
+ * @param $message string
+ *
+ * @return bool|array
+ */
+ public function unflattenPlural( $key, $message ) {
+ // Quick escape.
+ if ( strpos( $message, '{{PLURAL' ) === false ) {
+ return array( $key => $message );
+ }
+
+ /*
+ * Replace all variables with placeholders. Possible source of bugs
+ * if other characters that given below are used.
+ */
+ $regex = '~\{[a-zA-Z_-]+}~';
+ $placeholders = array();
+ $match = null;
+
+ while ( preg_match( $regex, $message, $match ) ) {
+ $uniqkey = $this->placeholder();
+ $placeholders[$uniqkey] = $match[0];
+ $search = preg_quote( $match[0], '~' );
+ $message = preg_replace( "~$search~", $uniqkey, $message );
+ }
+
+ // Then replace (possible multiple) plural instances into placeholders.
+ $regex = '~\{\{PLURAL\|(.*?)}}~s';
+ $matches = array();
+ $match = null;
+
+ while ( preg_match( $regex, $message, $match ) ) {
+ $uniqkey = $this->placeholder();
+ $matches[$uniqkey] = $match;
+ $message = preg_replace( $regex, $uniqkey, $message );
+ }
+
+ // No plurals, should not happen.
+ if ( !count( $matches ) ) {
+ return false;
+ }
+
+ // The final array of alternative plurals forms.
+ $alts = array();
+
+ /*
+ * Then loop trough each plural block and replacing the placeholders
+ * to construct the alternatives. Produces invalid output if there is
+ * multiple plural bocks which don't have the same set of keys.
+ */
+ $pluralChoice = implode( '|', array_keys( self::$pluralWords ) );
+ $regex = "~($pluralChoice)\s*=\s*(.+)~s";
+ foreach ( $matches as $ph => $plu ) {
+ $forms = explode( '|', $plu[1] );
+
+ foreach ( $forms as $form ) {
+ if ( $form === '' ) {
+ continue;
+ }
+
+ $match = array();
+ if ( preg_match( $regex, $form, $match ) ) {
+ $formWord = "$key.{$match[1]}";
+ $value = $match[2];
+ } else {
+ $formWord = "$key.other";
+ $value = $form;
+ }
+
+ if ( !isset( $alts[$formWord] ) ) {
+ $alts[$formWord] = $message;
+ }
+
+ $string = $alts[$formWord];
+ $alts[$formWord] = str_replace( $ph, $value, $string );
+ }
+ }
+
+ // Replace other variables.
+ foreach ( $alts as &$value ) {
+ $value = str_replace( array_keys( $placeholders ), array_values( $placeholders ), $value );
+ }
+
+ if ( !isset( $alts["$key.other"] ) ) {
+ wfWarn( "Other not set for key $key" );
+ return false;
+ }
+
+ return $alts;
+ }
+
+ /**
+ * @return string
+ */
+ protected function placeholder() {
+ static $i = 0;
+
+ return "\x7fUNIQ" . dechex( mt_rand( 0, 0x7fffffff ) ) . dechex( mt_rand( 0, 0x7fffffff ) ) \
. $i++; + }
+}
diff --git a/extensions/Translate/ffs/YamlFFS.php b/extensions/Translate/ffs/YamlFFS.php
new file mode 100644
index 0000000..45bb154
--- /dev/null
+++ b/extensions/Translate/ffs/YamlFFS.php
@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * Implements support for message storage in YAML format.
+ *
+ * This class adds new key into FILES section: \c codeAsRoot.
+ * If it is set to true, all messages will under language code.
+ * @ingroup FFS
+ */
+class YamlFFS extends SimpleFFS {
+ /**
+ * @param $data
+ * @return array
+ */
+ public function readFromVariable( $data ) {
+ // Authors first.
+ $matches = array();
+ preg_match_all( '/^#\s*Author:\s*(.*)$/m', $data, $matches );
+ $authors = $matches[1];
+
+ // Then messages.
+ $messages = TranslateYaml::loadString( $data );
+
+ // Some groups have messages under language code
+ if ( isset( $this->extra['codeAsRoot'] ) ) {
+ $messages = array_shift( $messages );
+ }
+
+ $messages = $this->flatten( $messages );
+ $messages = $this->group->getMangler()->mangle( $messages );
+ foreach ( $messages as &$value ) {
+ $value = rtrim( $value, "\n" );
+ }
+
+ return array(
+ 'AUTHORS' => $authors,
+ 'MESSAGES' => $messages,
+ );
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function writeReal( MessageCollection $collection ) {
+ $output = $this->doHeader( $collection );
+ $output .= $this->doAuthors( $collection );
+
+ $mangler = $this->group->getMangler();
+
+ $messages = array();
+ foreach ( $collection as $key => $m ) {
+ $key = $mangler->unmangle( $key );
+ $value = $m->translation();
+ $value = str_replace( TRANSLATE_FUZZY, '', $value );
+
+ if ( $value === '' ) {
+ continue;
+ }
+
+ $messages[$key] = $value;
+ }
+
+ if ( !count( $messages ) ) {
+ return false;
+ }
+
+ $messages = $this->unflatten( $messages );
+
+ // Some groups have messages under language code.
+ if ( isset( $this->extra['codeAsRoot'] ) ) {
+ $code = $this->group->mapCode( $collection->code );
+ $messages = array( $code => $messages );
+ }
+
+ $output .= TranslateYaml::dump( $messages );
+ return $output;
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function doHeader( MessageCollection $collection ) {
+ global $wgSitename;
+ global $wgTranslateYamlLibrary;
+
+ $code = $collection->code;
+ $name = TranslateUtils::getLanguageName( $code );
+ $native = TranslateUtils::getLanguageName( $code, true );
+ $output = "# Messages for $name ($native)\n";
+ $output .= "# Exported from $wgSitename\n";
+
+ if ( isset( $wgTranslateYamlLibrary ) ) {
+ $output .= "# Export driver: $wgTranslateYamlLibrary\n";
+ }
+
+ return $output;
+ }
+
+ /**
+ * @param $collection MessageCollection
+ * @return string
+ */
+ protected function doAuthors( MessageCollection $collection ) {
+ $output = '';
+ $authors = $collection->getAuthors();
+ $authors = $this->filterAuthors( $authors, $collection->code );
+
+ foreach ( $authors as $author ) {
+ $output .= "# Author: $author\n";
+ }
+
+ return $output;
+ }
+
+ /**
+ * Flattens multidimensional array by using the path to the value as key
+ * with each individual key separated by a dot.
+ *
+ * @param $messages array
+ *
+ * @return array
+ */
+ protected function flatten( $messages ) {
+ $flat = true;
+
+ foreach ( $messages as $v ) {
+ if ( !is_array( $v ) ) {
+ continue;
+ }
+
+ $flat = false;
+ break;
+ }
+
+ if ( $flat ) {
+ return $messages;
+ }
+
+ $array = array();
+ foreach ( $messages as $key => $value ) {
+ if ( !is_array( $value ) ) {
+ $array[$key] = $value;
+ } else {
+ $plural = $this->flattenPlural( $value );
+ if ( $plural ) {
+ $array[$key] = $plural;
+ } else {
+ $newArray = array();
+ foreach ( $value as $newKey => $newValue ) {
+ $newArray["$key.$newKey"] = $newValue;
+ }
+ $array += $this->flatten( $newArray );
+ }
+ }
+
+ /**
+ * Can as well keep only one copy around.
+ */
+ unset( $messages[$key] );
+ }
+
+ return $array;
+ }
+
+ /**
+ * Performs the reverse operation of flatten. Each dot in the key starts a
+ * new subarray in the final array.
+ *
+ * @param $messages array
+ *
+ * @return array
+ */
+ protected function unflatten( $messages ) {
+ $array = array();
+ foreach ( $messages as $key => $value ) {
+ $plurals = $this->unflattenPlural( $key, $value );
+
+ if ( $plurals === false ) {
+ continue;
+ }
+
+ foreach ( $plurals as $key => $value ) {
+
+ $path = explode( '.', $key );
+ if ( count( $path ) == 1 ) {
+ $array[$key] = $value;
+ continue;
+ }
+
+ $pointer = &$array;
+ do {
+ /**
+ * Extract the level and make sure it exists.
+ */
+ $level = array_shift( $path );
+ if ( !isset( $pointer[$level] ) ) {
+ $pointer[$level] = array();
+ }
+
+ /**
+ * Update the pointer to the new reference.
+ */
+ $tmpPointer = &$pointer[$level];
+ unset( $pointer );
+ $pointer = &$tmpPointer;
+ unset( $tmpPointer );
+
+ /**
+ * If next level is the last, add it into the array.
+ */
+ if ( count( $path ) === 1 ) {
+ $lastKey = array_shift( $path );
+ $pointer[$lastKey] = $value;
+ }
+ } while ( count( $path ) );
+ }
+ }
+
+ return $array;
+ }
+
+ /**
+ * @param $value
+ * @return bool
+ */
+ public function flattenPlural( $value ) {
+ return false;
+ }
+
+ /**
+ * Override this. Return false to skip processing this value. Otherwise
+ *
+ * @param $key string
+ * @param $value string
+ *
+ * @return array with keys and values.
+ */
+ public function unflattenPlural( $key, $value ) {
+ return array( $key => $value );
+ }
+}
diff --git a/extensions/Translate/resources/ext.translate.special.managegroups.css \
b/extensions/Translate/resources/ext.translate.special.managegroups.css new file mode 100644
index 0000000..312b534
--- /dev/null
+++ b/extensions/Translate/resources/ext.translate.special.managegroups.css
@@ -0,0 +1,12 @@
+.mw-translate-smg-change {
+ padding-bottom: 1em;
+ border-bottom: 1px solid #AAA;
+ margin-bottom: 2em;
+}
+
+.mw-translate-smg-submit {
+ font-size: 5em;
+ margin: auto;
+ width: 80%;
+ display: block;
+}
\ No newline at end of file
diff --git a/extensions/Translate/resources/ext.translate.special.translationstats.js \
b/extensions/Translate/resources/ext.translate.special.translationstats.js new file mode 100644
index 0000000..6be61ad
--- /dev/null
+++ b/extensions/Translate/resources/ext.translate.special.translationstats.js
@@ -0,0 +1,36 @@
+/**
+ * JavaScript functions for embedding jQuery controls
+ * into translation notification form.
+ *
+ * @author Amir E. Aharoni
+ * @author Siebrand Mazeland
+ * @copyright Copyright © 2012 Amir E. Aharoni
+ * @copyright Copyright © 2012 Siebrand Mazeland
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+jQuery( document ).ready( function( $ ) {
+ "use strict";
+
+ // Based on UploadWizard, TranslationNotifications
+ $( '#start' ).datepicker( {
+ dateFormat: 'yymmdd',
+ constrainInput: false,
+ showOn: 'focus',
+ changeMonth: true,
+ changeYear: true,
+ showAnim: false,
+ showButtonPanel: true,
+ maxDate: new Date(),
+ onClose: function( dateText, inst ) {
+ // TranslationStats works with the yyyymmddhhmmss format,
+ // so zeros that represents generic hh:mm:ss must be added.
+ // The zeros are added only if a date was actually selected
+ // and is not currently displayed.
+ if ( dateText !== '' && inst.input.val().length < 14 ) {
+ inst.input.val( dateText + '000000' );
+ }
+ }
+ } )
+ .attr( 'autocomplete', 'off' );
+} );
diff --git a/extensions/Translate/scripts/processMessageChanges.php \
b/extensions/Translate/scripts/processMessageChanges.php new file mode 100644
index 0000000..e6518a7
--- /dev/null
+++ b/extensions/Translate/scripts/processMessageChanges.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * Script for processing message changes in file based message groups.
+ *
+ * @author Niklas Laxstrom
+ *
+ * @copyright Copyright © 2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @file
+ */
+
+// Standard boilerplate to define $IP
+if ( getenv( 'MW_INSTALL_PATH' ) !== false ) {
+ $IP = getenv( 'MW_INSTALL_PATH' );
+} else {
+ $dir = dirname( __FILE__ ); $IP = "$dir/../../..";
+}
+require_once( "$IP/maintenance/Maintenance.php" );
+
+/**
+ * Script for processing message changes in file based message groups.
+ *
+ * We used to process changes during web request, but that was too slow. With
+ * this command line script we can do all the work needed even if it takes
+ * some time.
+ *
+ * @since 2012-04-23
+ */
+class ProcessMessageChanges extends Maintenance {
+ protected $changes = array();
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = 'Script for processing message changes in file based message groups';
+ }
+
+ public function execute() {
+ $groups = MessageGroups::getGroupsByType( 'FileBasedMessageGroup' );
+ foreach ( $groups as $id => $group ) {
+ $this->output( "Processing $id\n" );
+ $this->processMessageGroup( $group );
+ }
+ if ( count( $this->changes ) ) {
+ $this->writeChanges();
+ } else {
+ $this->output( "No changes found\n" );
+ }
+ }
+
+ protected function writeChanges() {
+ // This method is almost identical with MessageIndex::store
+ wfProfileIn( __METHOD__ );
+ $array = $this->changes;
+ /* This will overwrite the previous cache file if any. Once the cache
+ * file is processed with Special:ManageMessageGroups, it is
+ * renamed so that it wont be processed again. */
+ $file = TranslateUtils::cacheFile( SpecialManageGroups::CHANGEFILE );
+ $cache = CdbWriter::open( $file );
+ $keys = array_keys( $array );
+ $cache->set( '#keys', serialize( $keys ) );
+
+ foreach ( $array as $key => $value ) {
+ $value = serialize( $value );
+ $cache->set( $key, $value );
+ }
+ $cache->close();
+ wfProfileOut( __METHOD__ );
+ }
+
+ protected function processMessageGroup( FileBasedMessageGroup $group ) {
+ $languages = Language::getLanguageNames( false );
+
+ // Process the source language before others
+ $sourceLanguage = $group->getSourceLanguage();
+ unset( $languages[$sourceLanguage] );
+ $languages = array_keys( $languages );
+ $this->processLanguage( $group, $sourceLanguage );
+
+ foreach ( $languages as $code ) {
+ $this->processLanguage( $group, $code );
+ }
+ }
+
+ protected function processLanguage( FileBasedMessageGroup $group, $code ) {
+ wfProfileIn( __METHOD__ );
+ $cache = new MessageGroupCache( $group, $code );
+ $reason = 0;
+ if ( !$cache->isValid( $reason ) ) {
+ $this->addMessageUpdateChanges( $group, $code, $reason, $cache );
+
+ if ( !isset( $this->changes[$group->getId()][$code] ) ) {
+ /* Update the cache immediately if file and wiki state match.
+ * Otherwise the cache will get outdated compared to file state
+ * and will give false positive conflicts later. */
+ $cache->create();
+ }
+ }
+ wfProfileOut( __METHOD__ );
+ }
+
+ /**
+ * This is the detective roman. We have three sources of information:
+ * - current message state in the file
+ * - current message state in the wiki
+ * - cached message state since cache was last build
+ * (usually after export from wiki)
+ * Now we must try to guess what in earth has driven
+ * the file state and wiki state out of sync. Then we
+ * must compile list of events that would bring those
+ * to sync. Types of events are addition, deletion,
+ * (content) change and possible rename in the future.
+ * After that the list of events are stored for later
+ * processing of a translation administrator, who can
+ * decide what actions to take on those events to bring
+ * the state more or less in sync.
+ */
+ protected function addMessageUpdateChanges( FileBasedMessageGroup $group, $code, $reason, \
$cache ) { + wfProfileIn( __METHOD__ );
+ /* This throws a warning if message definitions are not yet
+ * cached and will read the file for definitions. */
+ $wiki = $group->initCollection( $code );
+ $wiki->filter( 'hastranslation', false );
+ $wiki->loadTranslations();
+ $wikiKeys = $wiki->getMessageKeys();
+
+ // By-pass cached message definitions
+ $file = $group->getFFS()->read( $code );
+ if ( !isset( $file['MESSAGES'] ) ) {
+ error_log( "{$group->getId()} has an FFS - the FFS didn't return cake for $code" );
+ }
+ $fileKeys = array_keys( $file['MESSAGES'] );
+
+ $common = array_intersect( $fileKeys, $wikiKeys );
+
+ foreach ( $common as $key ) {
+ $sourceContent = $file['MESSAGES'][$key];
+ $wikiContent = $wiki[$key]->translation();
+
+ if ( !self::compareContent( $sourceContent, $wikiContent ) ) {
+ if ( $reason !== MessageGroupCache::NO_CACHE ) {
+ $cacheContent = $cache->get( $key );
+ if ( self::compareContent( $sourceContent, $cacheContent ) ) {
+ /* This message has only changed in the wiki, which means
+ * we can ignore the difference and have it exported on
+ * next export. */
+ continue;
+ }
+ }
+ $this->addChange( 'change', $group, $code, $key, $sourceContent );
+ }
+ }
+
+ $added = array_diff( $fileKeys, $wikiKeys );
+ foreach ( $added as $key ) {
+ $sourceContent = $file['MESSAGES'][$key];
+ if ( trim( $sourceContent ) === '' ) continue;
+ $this->addChange( 'addition', $group, $code, $key, $sourceContent );
+ }
+
+ /* Should the cache not exist, don't consider the messages
+ * missing from the file as deleted - they probably aren't
+ * yet exported. For example new language translations are
+ * exported the first time. */
+ if ( $reason !== MessageGroupCache::NO_CACHE ) {
+ $deleted = array_diff( $wikiKeys, $fileKeys );
+ foreach ( $deleted as $key ) {
+ if ( $cache->get( $key ) === false ) {
+ /* This message has never existed in the cache, so it
+ * must be a newly made in the wiki. */
+ continue;
+ }
+ $this->addChange( 'deletion', $group, $code, $key, null );
+ }
+ }
+
+ wfProfileOut( __METHOD__ );
+ }
+
+ protected function addChange( $type, $group, $language, $key, $content ) {
+ $this->changes[$group->getId()][$language][$type][] = array(
+ 'key' => $key,
+ 'content' => $content,
+ );
+ }
+
+ /**
+ * Compares two strings ignoring fuzzy markers.
+ * @since 2012-05-08
+ * @return bool
+ */
+ protected static function compareContent( $a, $b ) {
+ $a = str_replace( TRANSLATE_FUZZY, '', $a );
+ $b = str_replace( TRANSLATE_FUZZY, '', $b );
+ return $a === $b;
+ }
+}
+
+$maintClass = 'ProcessMessageChanges';
+require_once( RUN_MAINTENANCE_IF_MAIN );
diff --git a/extensions/Translate/tests/ApiTokensTest.php \
b/extensions/Translate/tests/ApiTokensTest.php new file mode 100644
index 0000000..09dec02
--- /dev/null
+++ b/extensions/Translate/tests/ApiTokensTest.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Unit tests.
+ *
+ * @file
+ * @author Niklas Laxström
+ * @copyright Copyright © 2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Unit tests for api token retrieval.
+ */
+class ApiTokensTest extends MediaWikiTestCase {
+
+ /** @dataProvider getTokenClasses */
+ public function testTokenRetrieval( $id, $class ) {
+ // Make sure we have the right to get the token
+ global $wgGroupPermissions, $wgUser;
+ $wgGroupPermissions['*'][$class::getRight()] = true;
+ $wgUser->clearInstanceCache(); // Reread above global
+
+ // We should be getting anonymous user token
+ $expected = $class::getToken();
+ $this->assertNotSame( false, $expected, 'We did not get a valid token' );
+
+ $actionString = TranslateUtils::getTokenAction( $id );
+ $params = wfCgiToArray( $actionString );
+
+ $req = new FauxRequest( $params );
+ $api = new ApiMain( $req );
+ $api->execute();
+
+ $data = $api->getResultData();
+ if ( isset( $data['query'] ) ) {
+ foreach ( $data['query']['pages'] as $page ) {
+ $this->assertSame( $expected, $page[$id . 'token'] );
+ }
+ } else {
+ $this->assertArrayHasKey( 'tokens', $data, 'Result has tokens' );
+ $this->assertSame( $expected, $data['tokens'][$id . 'token'] );
+ }
+ }
+
+ public function getTokenClasses() {
+ return array(
+ array( 'groupreview', 'ApiGroupReview' ),
+ array( 'translationreview', 'ApiTranslationReview' ),
+ array( 'aggregategroups', 'ApiAggregateGroups' ),
+ );
+ }
+}
diff --git a/extensions/Translate/tests/BlackListTest.php \
b/extensions/Translate/tests/BlackListTest.php new file mode 100644
index 0000000..4c7c01d
--- /dev/null
+++ b/extensions/Translate/tests/BlackListTest.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Unit tests.
+ *
+ * @file
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Unit tests for blacklisting/whitelisting languages for a message group
+ */
+class BlackListTest extends MediaWikiTestCase {
+
+ /**
+ * @var MessageGroup
+ */
+ protected $group;
+ protected $codes;
+ protected $groupConfiguration = array(
+ 'BASIC' => array(
+ 'class' => 'FileBasedMessageGroup',
+ 'id' => 'test-id',
+ 'label' => 'Test Label',
+ 'namespace' => 'NS_MEDIAWIKI',
+ 'description' => 'Test description',
+ ),
+ 'FILES' => array(
+ 'class' => 'TestFFS',
+ ),
+ );
+
+ protected function setUp() {
+ parent::setUp();
+ $this->group = MessageGroupBase::factory( $this->groupConfiguration );
+ $this->codes = array_flip( array_keys( TranslateUtils::getLanguageNames( 'en' ) ) );
+ }
+
+ protected function tearDown() {
+ unset( $this->group );
+ parent::tearDown();
+ }
+
+ public function testNoLanguageConf() {
+ $translatableLanguages = $this->group->getTranslatableLanguages();
+ $this->assertNull( $translatableLanguages );
+ }
+
+ public function testAllBlackList() {
+ $conf = $this->groupConfiguration;
+ $conf['LANGUAGES'] = array(
+ 'blacklist' => '*',
+ );
+ $group = MessageGroupBase::factory( $conf );
+ $translatableLanguages = $group->getTranslatableLanguages();
+ $this->assertEquals( count( $translatableLanguages ), 0 );
+ }
+
+
+ public function testAllWhiteList() {
+ $conf = $this->groupConfiguration;
+ $conf['LANGUAGES'] = array(
+ 'whitelist' => '*',
+ );
+ $group = MessageGroupBase::factory( $conf );
+ $translatableLanguages = $group->getTranslatableLanguages();
+ $this->assertNull( $translatableLanguages );
+ }
+
+ public function testWhiteListOverrideBlackList() {
+ $conf = $this->groupConfiguration;
+ $conf['LANGUAGES'] = array(
+ 'whitelist' => array( 'en', 'hi', 'ta' ),
+ 'blacklist' => array( 'ta' ),
+ );
+ $group = MessageGroupBase::factory( $conf );
+ $translatableLanguages = $group->getTranslatableLanguages();
+ $this->assertTrue( isset( $translatableLanguages['ta'] ) );
+ $this->assertTrue( isset( $translatableLanguages['hi'] ) );
+ }
+
+ public function testSomeBlackList() {
+ $conf = $this->groupConfiguration;
+ $conf['LANGUAGES'] = array(
+ 'blacklist' => array( 'or', 'hi' ),
+ );
+ $group = MessageGroupBase::factory( $conf );
+ $translatableLanguages = $group->getTranslatableLanguages();
+ $this->assertTrue( !isset( $translatableLanguages['hi'] ) );
+ $this->assertTrue( isset( $translatableLanguages['he'] ) );
+ }
+}
diff --git a/extensions/Translate/tests/JavaFFSTest.php \
b/extensions/Translate/tests/JavaFFSTest.php new file mode 100644
index 0000000..cf42340
--- /dev/null
+++ b/extensions/Translate/tests/JavaFFSTest.php
@@ -0,0 +1,77 @@
+<?php
+
+class JavaFFSTest extends MediaWikiTestCase {
+
+ protected $groupConfiguration = array(
+ 'BASIC' => array(
+ 'class' => 'FileBasedMessageGroup',
+ 'id' => 'test-id',
+ 'label' => 'Test Label',
+ 'namespace' => 'NS_MEDIAWIKI',
+ 'description' => 'Test description',
+ ),
+ 'FILES' => array(
+ 'class' => 'JavaFFS',
+ ),
+ );
+
+
+ public function testParsing() {
+ $file =
+<<<PROPERTIES
+# You are reading the ".properties" entry.
+! The exclamation mark can also mark text as comments.
+website = <nowiki>http://en.wikipedia.org/</nowiki>
+language = English
+# The backslash below tells the application to continue reading
+# the value onto the next line.
+message = Welcome to \
+ Wikipedia!
+# Add spaces to the key
+key\ with\ spaces = This is the value that could be looked up with the key "key with spaces".
+PROPERTIES;
+
+ $group = MessageGroupBase::factory( $this->groupConfiguration );
+ $ffs = new JavaFFS( $group );
+ $parsed = $ffs->readFromVariable( $file );
+ $expected = array(
+ 'website' => '<nowiki>http://en.wikipedia.org/</nowiki>',
+ 'language' => 'English',
+ 'message' => 'Welcome to Wikipedia!',
+ 'key with spaces' => 'This is the value that could be looked up with the key "key with \
spaces".', + );
+ $expected = array( 'MESSAGES' => $expected, 'AUTHORS' => array() );
+ $this->assertEquals( $expected, $parsed );
+ }
+
+ /**
+ * @dataProvider rowValuesProvider
+ */
+ public function testRowRoundtrip( $key, $sep, $value, $comment ) {
+ $write = JavaFFS::writeRow( $key, $sep, $value );
+ // Trim the trailing newline
+ $write = rtrim( $write );
+ list( $newkey, $newvalue ) = JavaFFS::readRow( $write, $sep );
+
+ $this->assertSame( $key, $newkey, "Key survives roundtrip in testdata: $comment" );
+ $this->assertSame( $value, $newvalue, "Value survives roundtrip in testdata: $comment" );
+ }
+
+ public function rowValuesProvider() {
+ return array(
+ array( 'key', '=', 'value', 'simple row' ),
+ array( 'key', ':', 'value', 'row with different sep' ),
+ array( 'key', '=', 'val=ue', 'row with sep inside value' ),
+ array( 'k=ey', '=', 'value', 'row with sep inside key' ),
+ array( '!key', '=', 'value', 'row with ! at the beginning of key' ),
+ array( 'k!ey', '=', 'value', 'row with ! inside key' ),
+ array( '#key', '=', 'value', 'row with # at the beginning of key' ),
+ array( 'k#ey', '=', 'value', 'row with # inside key' ),
+ array( 'k\\tey', '=', 'value\\', 'row with escapes' ),
+ array( '01234', '=', '13.34', 'row with numbers' ),
+ array( '\\n\\tкаÑ', '=', 'каÑ', 'row with annoying characteres' ),
+ array( '=', '=', '', 'row with empty value' ),
+ array( '#k e\\=y#', '=', '=v!\\=alue\\ \\\\', 'complex row' ),
+ );
+ }
+}
diff --git a/extensions/Translate/tests/MessageIndexRebuildJobTest.php \
b/extensions/Translate/tests/MessageIndexRebuildJobTest.php new file mode 100644
index 0000000..ba77b53
--- /dev/null
+++ b/extensions/Translate/tests/MessageIndexRebuildJobTest.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Unit tests.
+ *
+ * @file
+ * @author Niklas Laxström
+ * @copyright Copyright © 2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Unit tests for MessageIndexRebuildJob class.
+ * @group Database
+ */
+class MessageIndexRebuildJobTest extends MediaWikiTestCase {
+ protected $config = array();
+
+ public function setUp() {
+ parent::setUp();
+ global $wgTranslateMessageIndex, $wgTranslateDelayedMessageIndexRebuild;
+ $this->config['class'] = $wgTranslateMessageIndex;
+ $this->config['delay'] = $wgTranslateDelayedMessageIndexRebuild;
+ $wgTranslateMessageIndex = array( 'DatabaseMessageIndex' );
+ }
+
+ public function tearDown() {
+ global $wgTranslateMessageIndex, $wgTranslateDelayedMessageIndexRebuild;
+ $wgTranslateMessageIndex = $this->config['class'];
+ $wgTranslateDelayedMessageIndexRebuild = $this->config['delay'];
+ }
+
+ public function testNewJob() {
+ $job = MessageIndexRebuildJob::newJob();
+ $this->assertInstanceOf( 'MessageIndexRebuildJob', $job, 'Job of correct type is created' );
+ }
+
+ public function testInsertImmediate() {
+ global $wgTranslateDelayedMessageIndexRebuild;
+ $wgTranslateDelayedMessageIndexRebuild = false;
+ $job = MessageIndexRebuildJob::newJob();
+ $this->assertTrue( $job->insert(), 'Job is executed succesfully' );
+ $this->assertFalse( Job::pop_type( 'MessageIndexRebuildJob' ), 'There is no job in the \
JobQueue' ); + }
+
+ public function testInsertDelayed() {
+ global $wgTranslateDelayedMessageIndexRebuild;
+ $wgTranslateDelayedMessageIndexRebuild = true;
+ $job = MessageIndexRebuildJob::newJob();
+ $this->assertTrue( $job->insert(), 'Job is inserted succesfully' );
+ $popJob = Job::pop_type( 'MessageIndexRebuildJob' );
+ $this->assertInstanceOf( 'MessageIndexRebuildJob', $popJob, 'There is a job in the JobQueue' \
); + $this->assertNull( $popJob->run(), 'Job is executed succesfully' );
+ }
+}
\ No newline at end of file
diff --git a/extensions/Translate/tests/MessageIndexTest.php \
b/extensions/Translate/tests/MessageIndexTest.php new file mode 100644
index 0000000..0ebd73d
--- /dev/null
+++ b/extensions/Translate/tests/MessageIndexTest.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Tests for different MessageIndex backends.
+ *
+ * @file
+ * @author Niklas Laxström
+ * @copyright Copyright © 2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * @group Database
+ */
+class MessageIndexTest extends MediaWikiTestCase {
+ protected $config;
+
+ public function setUp() {
+ parent::setUp();
+ $this->testdata = unserialize( file_get_contents( 'messageindexdata.ser' ) );
+ global $wgTranslateCacheDirectory;
+ $this->config = $wgTranslateCacheDirectory;
+ // Only in 1.20, but who runs tests again older versions anyway?
+ $wgTranslateCacheDirectory = $this->getNewTempDirectory();
+ }
+
+ public function tearDown() {
+ global $wgTranslateCacheDirectory;
+ $wgTranslateCacheDirectory = $this->config;
+ }
+
+ /**
+ * @dataProvider MessageIndexImplementationProvider
+ */
+ public function testMessageIndexImplementation( $mi ) {
+ $data = $this->testdata;
+ $mi->store( $data );
+
+ $tests = array_rand( $data, 10 );
+ foreach ( $tests as $key ) {
+ $this->assertSame( $data[$key], $mi->get( $key ), "Values are preserved for random key \
$key" ); + }
+
+ $cached = $mi->retrieve();
+
+ $tests = array_rand( $data, 10 );
+ foreach ( $tests as $key ) {
+ $this->assertSame( $data[$key], $mi->get( $key ), "Values are preserved after retrieve for \
random key $key" ); + }
+
+ $this->assertEquals( count( $data ), count( $cached ), 'Cache has same number of elements' \
); + $this->assertEquals( $data, $cached, 'Cache is preserved' );
+ }
+
+ public function MessageIndexImplementationProvider() {
+ return array(
+ array( new TestableDatabaseMessageIndex() ),
+ array( new TestableCDBMessageIndex() ),
+ array( new TestableSerializedMessageIndex() ),
+ // Not testing CachedMessageIndex because there is no easy way to mockup those.
+ );
+ }
+
+}
+
+class TestableDatabaseMessageIndex extends DatabaseMessageIndex {
+ public function store( array $a ) {
+ parent::store( $a );
+ }
+
+ public function get( $a ) {
+ return parent::get( $a );
+ }
+}
+
+class TestableCDBMessageIndex extends CDBMessageIndex {
+ public function store( array $a ) {
+ parent::store( $a );
+ }
+
+ public function get( $a ) {
+ return parent::get( $a );
+ }
+}
+
+class TestableSerializedMessageIndex extends SerializedMessageIndex {
+ public function store( array $a ) {
+ parent::store( $a );
+ }
+
+ public function get( $a ) {
+ return parent::get( $a );
+ }
+}
\ No newline at end of file
diff --git a/extensions/Translate/tests/PageTranslationTaggingTest.php \
b/extensions/Translate/tests/PageTranslationTaggingTest.php new file mode 100644
index 0000000..254c877
--- /dev/null
+++ b/extensions/Translate/tests/PageTranslationTaggingTest.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @group Database
+ */
+class PageTranslationTaggingText extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ global $wgEnablePageTranslation;
+ $wgEnablePageTranslation = true;
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ }
+
+ public function testNormalPage() {
+ $title = Title::newFromText( 'Fréttinga' );
+ $this->assertNotNull( $title, 'Title is valid' );
+ $page = WikiPage::factory( $title );
+ $this->assertNotNull( $page, 'WikiPage is valid' );
+ $translatablePage = TranslatablePage::newFromTitle( $title );
+
+ $page->doEdit( 'kissa', 'Test case' );
+
+ $this->assertFalse( $translatablePage->getReadyTag( DB_MASTER ), 'No ready tag was added' );
+ $this->assertFalse( $translatablePage->getMarkedTag( DB_MASTER ), 'No marked tag was added' \
); + }
+
+ public function testTranslatablePage() {
+ $title = Title::newFromText( 'Fréttinga' );
+ $this->assertNotNull( $title, 'Title is valid' );
+ $page = WikiPage::factory( $title );
+ $this->assertNotNull( $page, 'WikiPage is valid' );
+ $translatablePage = TranslatablePage::newFromTitle( $title );
+
+ $status = $page->doEdit( '<translate>kissa</translate>', 'Test case' );
+ $latest = $status->value['revision']->getId();
+
+ $this->assertSame( $latest, $translatablePage->getReadyTag( DB_MASTER ), 'Ready tag was \
added' ); + $this->assertFalse( $translatablePage->getMarkedTag( DB_MASTER ), 'No marked tag \
was added' ); + }
+
+ public function testTranslatablePageWithMarked() {
+ $title = Title::newFromText( 'Fréttinga' );
+ $this->assertNotNull( $title, 'Title is valid' );
+ $page = WikiPage::factory( $title );
+ $this->assertNotNull( $page, 'WikiPage is valid' );
+ $translatablePage = TranslatablePage::newFromTitle( $title );
+
+ $status = $page->doEdit( '<translate>koira</translate>', 'Test case' );
+ $latest = $status->value['revision']->getId();
+
+ $translatablePage->addMarkedTag( $latest, array( 'foo' ) );
+ $this->assertSame( $latest, $translatablePage->getReadyTag( DB_MASTER ), 'Ready tag was \
added' ); + $this->assertSame( $latest, $translatablePage->getMarkedTag( DB_MASTER ), 'Marked \
tag was added' ); + $page->updateRestrictions( array( 'edit' => 'sysop' ), 'Test case' );
+
+ $newLatest = $latest+1;
+ $this->assertSame( $newLatest, $translatablePage->getReadyTag( DB_MASTER ), 'Ready tag was \
updated after protection' ); + $this->assertSame( $latest, $translatablePage->getMarkedTag( \
DB_MASTER ), 'Marked tag was not updated after protection' ); + }
+
+}
diff --git a/extensions/Translate/tests/SpecialPagesTest.php \
b/extensions/Translate/tests/SpecialPagesTest.php new file mode 100644
index 0000000..7617842
--- /dev/null
+++ b/extensions/Translate/tests/SpecialPagesTest.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * General unit tests for special pages.
+ *
+ * @file
+ * @author Niklas Laxström
+ * @copyright Copyright © 2012, Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * Unit tests for making sure special pages execute
+ * @group Database
+ */
+class SpecialPagesTest extends MediaWikiTestCase {
+
+ public function specialPages() {
+ global $IP;
+ require( '../_autoload.php' );
+ global $wgSpecialPages;
+
+ $pages = array();
+ foreach ( $wgSpecialPages as $name => $class ) {
+ if ( isset( $wgAutoloadClasses[$class] )) {
+ $pages[] = array( $name );
+ }
+ }
+ return $pages;
+ }
+
+ /**
+ * @dataProvider specialPages
+ */
+ public function testSpecialPage( $name ) {
+ $page = SpecialPageFactory::getPage( $name );
+ $title = $page->getTitle();
+
+ $context = RequestContext::newExtraneousContext( $title );
+ $page->setContext( $context );
+
+ try {
+ $page->run( null );
+ } catch ( PermissionsError $e ) {
+ // This is okay
+ }
+ $this->assertTrue( true, "Special page $name was executed succesfully with anon user" );
+
+ $user = new SuperUser();
+ $context->setUser( $user );
+ $page->setContext( $context );
+
+ // This should not throw permission errors
+ $page->run( null );
+ $this->assertTrue( true, "Special page $name was executed succesfully with super user" );
+
+ }
+}
+
+
+class SuperUser extends User {
+ public function getId() {
+ return 666;
+ }
+
+ public function getName() {
+ return 'SuperUser';
+ }
+
+ public function isAllowed( $right = '' ) {
+ return true;
+ }
+}
diff --git a/extensions/Translate/tests/messageindexdata.ser \
b/extensions/Translate/tests/messageindexdata.ser new file mode 100644
index 0000000..a28d879
--- /dev/null
+++ b/extensions/Translate/tests/messageindexdata.ser
@@ -0,0 +1 @@
+a:49286:{s:19:"8:filereadonlyerror";s:4:"core";s:20:"8:userlogout-summary";R:2;s:15:"8:emaildis \
abled";R:2;s:21:"8:changeemail-summary";R:2;s:10:"8:creating";R:2;s:20:"8:defaultmessagetext";R: \
2;s:24:"8:changepassword-summary";R:2;s:35:"8:upload-copy-upload-invalid-domain";R:2;s:22:"8:bac \
kend-fail-maxsize";R:2;s:28:"8:filejournal-fail-dbconnect";R:2;s:26:"8:filejournal-fail-dbquery" \
;R:2;s:24:"8:sharedupload-desc-edit";R:2;s:26:"8:sharedupload-desc-create";R:2;s:26:"8:unusedcat \
egories-summary";R:2;s:22:"8:unusedimages-summary";R:2;s:25:"8:allpages-hide-redirects";R:2;s:30 \
:"8:deletedcontributions-summary";R:2;s:20:"8:linksearch-summary";R:2;s:19:"8:emailuser-summary" \
;R:2;s:19:"8:watchlist-summary";R:2;s:18:"8:undelete-summary";R:2;s:23:"8:contributions-summary" \
;R:2;s:33:"8:sp-contributions-footer-newbies";R:2;s:17:"8:unblock-summary";R:2;s:18:"8:movepage- \
summary";R:2;s:16:"8:export-summary";R:2;s:16:"8:import-summary";R:2;s:23:"8:editwatchlist-summa \
ry";R:2;s:17:"8:version-summary";R:2;s:14:"8:tags-summary";R:2;s:22:"8:comparepages-summary";R:2 \
;s:49:"1242:website-activity_logs.community_activity_log";s:12:"out-eol-site";s:47:"1242:website \
-activity_logs.curator_activity_log";R:3;s:37:"1202:freecol.desktopentry.genericname";s:11:"out- \
freecol";s:33:"1202:freecol.desktopentry.comment";R:4;s:7:"1202:ok";R:4;s:11:"1202:cancel";R:4;s \
:10:"1202:reset";R:4;s:9:"1202:save";R:4;s:12:"1202:display";R:4;s:8:"1202:yes";R:4;s:7:"1202:no \
";R:4;s:10:"1202:close";R:4;s:12:"1202:options";R:4;s:8:"1202:and";R:4;s:9:"1202:load";R:4;s:11: \
"1202:unload";R:4;s:9:"1202:fill";R:4;s:11:"1202:rename";R:4;s:14:"1202:abilities";R:4;s:14:"120 \
2:modifiers";R:4;s:9:"1202:true";R:4;s:10:"1202:false";R:4;s:9:"1202:more";R:4;s:9:"1202:none";R \
:4;s:12:"1202:nothing";R:4;s:8:"1202:all";R:4;s:24:"1202:notapplicable.short";R:4;s:10:"1202:rul \
es";R:4;s:15:"1202:difficulty";R:4;s:9:"1202:test";R:4;s:10:"1202:music";R:4;s:12:"1202:current" \
;R:4;s:9:"1202:help";R:4;s:13:"1202:list.add";R:4;s:16:"1202:list.remove";R:4;s:12:"1202:list.up \
";R:4;s:14:"1202:list.down";R:4;s:14:"1202:list.edit";R:4;s:16:"1202:file.browse";R:4;s:18:"1202 \
:option.remove";R:4;s:10:"1202:width";R:4;s:11:"1202:height";R:4;s:21:"1202:integerabovezero";R: \
4;s:24:"1202:newlanguageselected";R:4;s:31:"1202:autodetectlanguageselected";R:4;s:21:"1202:sing \
leplayergame";R:4;s:24:"1202:joinmultiplayergame";R:4;s:25:"1202:startmultiplayergame";R:4;s:9:" \
1202:name";R:4;s:9:"1202:host";R:4;s:9:"1202:port";R:4;s:12:"1202:players";R:4;s:17:"1202:player \
nation";R:4;s:14:"1202:gamestate";R:4;s:22:"1202:startserveronport";R:4;s:17:"1202:publicserver" \
;R:4;s:22:"1202:defaultplayername";R:4;s:18:"1202:getserverlist";R:4;s:12:"1202:connect";R:4;s:1 \
7:"1202:availability";R:4;s:26:"1202:nationstate.available";R:4;s:24:"1202:nationstate.ai_only"; \
R:4;s:30:"1202:nationstate.not_available";R:4;s:14:"1202:verysmall";R:4;s:10:"1202:small";R:4;s: \
11:"1202:medium";R:4;s:10:"1202:large";R:4;s:14:"1202:verylarge";R:4;s:12:"1202:verydry";R:4;s:8 \
:"1202:dry";R:4;s:11:"1202:normal";R:4;s:8:"1202:wet";R:4;s:12:"1202:verywet";R:4;s:9:"1202:cold \
";R:4;s:11:"1202:chilly";R:4;s:14:"1202:temperate";R:4;s:9:"1202:warm";R:4;s:8:"1202:hot";R:4;s: \
14:"1202:startgame";R:4;s:16:"1202:gameoptions";R:4;s:24:"1202:mapgeneratoroptions";R:4;s:13:"12 \
02:iamready";R:4;s:32:"1202:playeroptions.selectplayers";R:4;s:34:"1202:playeroptions.europeanpl \
ayers";R:4;s:32:"1202:playeroptions.nativeplayers";R:4;s:23:"1202:playeroptions.none";R:4;s:24:" \
1202:playeroptions.fixed";R:4;s:29:"1202:playeroptions.selectable";R:4;s:31:"1202:playeroptions. \
selectcolors";R:4;s:37:"1202:playeroptions.nationaladvantages";R:4;s:11:"1202:player";R:4;s:11:" \
1202:nation";R:4;s:10:"1202:color";R:4;s:14:"1202:advantage";R:4;s:10:"1202:moves";R:4;s:18:"120 \
2:sonsofliberty";R:4;s:13:"1202:building";R:4;s:10:"1202:goods";R:4;s:15:"1202:goldamount";R:4;s \
:19:"1202:cargooncarrier";R:4;s:23:"1202:cargooncarrierlong";R:4;s:18:"1202:outsidecolony";R:4;s \
:28:"1202:colonypanel.buybuilding";R:4;s:27:"1202:colonypanel.rebellabel";R:4;s:30:"1202:colonyp \
anel.royalistlabel";R:4;s:27:"1202:colonypanel.bonuslabel";R:4;s:32:"1202:colonypanel.population \
label";R:4;s:34:"1202:colonypanel.minimumcolonysize";R:4;s:34:"1202:colonypanel.currentlybuildin \
g";R:4;s:27:"1202:colonypanel.buildqueue";R:4;s:29:"1202:colonypanel.clicktobuild";R:4;s:28:"120 \
2:colonypanel.compactview";R:4;s:24:"1202:colonypanel.showall";R:4;s:28:"1202:colonypanel.unbuil \
dable";R:4;s:28:"1202:colonypanel.notbesttile";R:4;s:33:"1202:colonypanel.reducepopulation";R:4; \
s:25:"1202:colonypanel.requires";R:4;s:35:"1202:colonypanel.populationtoosmall";R:4;s:22:"1202:c \
olonypanel.units";R:4;s:26:"1202:colonypanel.buildings";R:4;s:10:"1202:turns";R:4;s:26:"1202:tur \
nstocomplete.short";R:4;s:25:"1202:turnstocomplete.long";R:4;s:15:"1202:waitingfor";R:4;s:20:"12 \
02:chooseimmigrant";R:4;s:17:"1202:abstractunit";R:4;s:13:"1202:colonist";R:4;s:14:"1202:colonis \
ts";R:4;s:11:"1202:tories";R:4;s:12:"1202:crosses";R:4;s:12:"1202:mission";R:4;s:11:"1202:spring \
";R:4;s:11:"1202:autumn";R:4;s:14:"1202:year.year";R:4;s:16:"1202:year.spring";R:4;s:16:"1202:ye \
ar.autumn";R:4;s:43:"1202:warofindependence.independencedeclared";R:4;s:27:"1202:purchasedialog. \
clickon";R:4;s:26:"1202:recruitdialog.clickon";R:4;s:24:"1202:traindialog.clickon";R:4;s:21:"120 \
2:traindialog.done";R:4;s:31:"1202:quitdialog.areyousure.text";R:4;s:33:"1202:retiredialog.areyo \
usure.text";R:4;s:34:"1202:foundingfatherdialog.nominate";R:4;s:29:"1202:errormessage.showlogfil \
e";R:4;s:21:"1202:tradeitem.colony";R:4;s:26:"1202:tradeitem.colony.long";R:4;s:19:"1202:tradeit \
em.gold";R:4;s:24:"1202:tradeitem.gold.long";R:4;s:20:"1202:tradeitem.goods";R:4;s:21:"1202:trad \
eitem.stance";R:4;s:19:"1202:tradeitem.unit";R:4;s:28:"1202:negotiationdialog.offer";R:4;s:29:"1 \
202:negotiationdialog.demand";R:4;s:31:"1202:negotiationdialog.exchange";R:4;s:30:"1202:negotiat \
iondialog.summary";R:4;s:29:"1202:negotiationdialog.accept";R:4;s:29:"1202:negotiationdialog.can \
cel";R:4;s:27:"1202:negotiationdialog.send";R:4;s:26:"1202:negotiationdialog.add";R:4;s:30:"1202 \
:negotiationdialog.nothing";R:4;s:36:"1202:negotiationdialog.offeraccepted";R:4;s:36:"1202:negot \
iationdialog.offerrejected";R:4;s:25:"1202:transaction.purchase";R:4;s:22:"1202:transaction.pric \
e";R:4;s:21:"1202:transaction.sale";R:4;s:20:"1202:transaction.tax";R:4;s:20:"1202:transaction.n \
et";R:4;s:17:"1202:tension.wary";R:4;s:18:"1202:tension.happy";R:4;s:20:"1202:tension.content";R \
:4;s:23:"1202:tension.displeased";R:4;s:18:"1202:tension.angry";R:4;s:20:"1202:tension.hateful"; \
R:4;s:12:"1202:tension";R:4;s:15:"1202:nosuchfile";R:4;s:17:"1202:gotothistile";R:4;s:15:"1202:g \
otoeurope";R:4;s:19:"1202:attacktileodds";R:4;s:14:"1202:dumpcargo";R:4;s:9:"1202:tile";R:4;s:22 \
:"1202:filter.savedgames";R:4;s:23:"1202:filter.gameoptions";R:4;s:36:"1202:filter.gameoptionsan \
dsavedgames";R:4;s:15:"1202:filter.xml";R:4;s:16:"1202:underrepair";R:4;s:33:"1202:eventpanel.me \
eting_europeans";R:4;s:31:"1202:eventpanel.meeting_natives";R:4;s:29:"1202:eventpanel.meeting_az \
tec";R:4;s:28:"1202:eventpanel.meeting_inca";R:4;s:23:"1202:tutorial.startgame";R:4;s:25:"1202:t \
utorial.buildcolony";R:4;s:11:"1202:colony";R:4;s:15:"1202:settlement";R:4;s:13:"1202:newworld"; \
R:4;s:26:"1202:loadingsavegame.title";R:4;s:33:"1202:loadingsavegame.singleplayer";R:4;s:39:"120 \
2:loadingsavegame.privatemultiplayer";R:4;s:38:"1202:loadingsavegame.publicmultiplayer";R:4;s:31 \
:"1202:loadingsavegame.servername";R:4;s:25:"1202:loadingsavegame.port";R:4;s:20:"1202:editor.re \
source";R:4;s:28:"1202:editor.removesettlement";R:4;s:33:"1202:editor.removesettlement.text";R:4 \
;s:19:"1202:editor.mapsize";R:4;s:29:"1202:buildingtooltip.breeding";R:4;s:17:"1202:menubar.game \
";R:4;s:17:"1202:menubar.view";R:4;s:18:"1202:menubar.tools";R:4;s:19:"1202:menubar.orders";R:4; \
s:22:"1202:menubar.colopedia";R:4;s:20:"1202:menubar.teacher";R:4;s:18:"1202:menubar.debug";R:4; \
s:34:"1202:menubar.debug.showcoordinates";R:4;s:34:"1202:menubar.debug.showcolonyvalue";R:4;s:41 \
:"1202:menubar.debug.showcommonoutpostvalue";R:4;s:28:"1202:menubar.debug.skipturns";R:4;s:36:"1 \
202:menubar.debug.stopskippingturns";R:4;s:30:"1202:menubar.debug.addbuilding";R:4;s:36:"1202:me \
nubar.debug.addfoundingfather";R:4;s:29:"1202:menubar.debug.runmonarch";R:4;s:26:"1202:menubar.d \
ebug.addgold";R:4;s:33:"1202:menubar.debug.addimmigration";R:4;s:29:"1202:menubar.debug.addliber \
ty";R:4;s:44:"1202:menubar.debug.steprandomnumbergenerator";R:4;s:30:"1202:menubar.debug.randomv \
alue";R:4;s:32:"1202:menubar.debug.displaypanels";R:4;s:38:"1202:menubar.debug.displaymonarchpan \
el";R:4;s:38:"1202:menubar.debug.displayvictorypanel";R:4;s:38:"1202:menubar.debug.displayeurope \
status";R:4;s:38:"1202:menubar.debug.displayerrormessage";R:4;s:24:"1202:menubar.debug.useai";R: \
4;s:34:"1202:menubar.debug.revealentiremap";R:4;s:30:"1202:menubar.debug.comparemaps";R:4;s:44:" \
1202:menubar.debug.comparemaps.checkcomplete";R:4;s:38:"1202:menubar.debug.comparemaps.problem"; \
R:4;s:35:"1202:menubar.debug.showresourcekeys";R:4;s:29:"1202:menubar.debug.statistics";R:4;s:32 \
:"1202:menubar.debug.memorymanager";R:4;s:43:"1202:menubar.debug.memorymanager.freememory";R:4;s \
:44:"1202:menubar.debug.memorymanager.totalmemory";R:4;s:42:"1202:menubar.debug.memorymanager.ma \
xmemory";R:4;s:35:"1202:menubar.debug.memorymanager.gc";R:4;s:19:"1202:menubar.report";R:4;s:23: \
"1202:menubar.statusline";R:4;s:30:"1202:findsettlementdialog.name";R:4;s:31:"1202:metaserver.co \
uldnotconnect";R:4;s:34:"1202:metaserver.communicationerror";R:4;s:32:"1202:infopanel.endturnpan \
el.text";R:4;s:23:"1202:endturndialog.name";R:4;s:29:"1202:endturndialog.areyousure";R:4;s:59:"1 \
202:menubar.tools.determinehighseas.disttolandfromhighseas";R:4;s:54:"1202:menubar.tools.determi \
nehighseas.maxdistancetoedge";R:4;s:25:"1202:stopcurrentgame.text";R:4;s:24:"1202:stopcurrentgam \
e.yes";R:4;s:23:"1202:stopcurrentgame.no";R:4;s:20:"1202:stopserver.text";R:4;s:19:"1202:stopser \
ver.yes";R:4;s:18:"1202:stopserver.no";R:4;s:35:"1202:connectcontroller.choiceplayer";R:4;s:19:" \
1202:reconnect.text";R:4;s:18:"1202:reconnect.yes";R:4;s:17:"1202:reconnect.no";R:4;s:17:"1202:f \
ailedtosave";R:4;s:21:"1202:couldnotsavegame";R:4;s:21:"1202:couldnotloadgame";R:4;s:17:"1202:fi \
lenotfound";R:4;s:25:"1202:incompatibleversions";R:4;s:27:"1202:opengame.unimplemented";R:4;s:16 \
:"1202:direction.n";R:4;s:17:"1202:direction.ne";R:4;s:16:"1202:direction.e";R:4;s:17:"1202:dire \
ction.se";R:4;s:16:"1202:direction.s";R:4;s:17:"1202:direction.sw";R:4;s:16:"1202:direction.w";R \
:4;s:17:"1202:direction.nw";R:4;s:18:"1202:server.reject";R:4;s:25:"1202:server.trade.nogoods";R \
:4;s:23:"1202:cli.arg.debuglevel";R:4;s:21:"1202:cli.arg.debugrun";R:4;s:23:"1202:cli.arg.dimens \
ions";R:4;s:22:"1202:cli.arg.directory";R:4;s:17:"1202:cli.arg.file";R:4;s:17:"1202:cli.arg.font \
";R:4;s:19:"1202:cli.arg.locale";R:4;s:21:"1202:cli.arg.loglevel";R:4;s:17:"1202:cli.arg.name";R \
:4;s:17:"1202:cli.arg.port";R:4;s:17:"1202:cli.arg.seed";R:4;s:20:"1202:cli.arg.timeout";R:4;s:2 \
3:"1202:cli.check-savegame";R:4;s:31:"1202:cli.check-savegame.success";R:4;s:31:"1202:cli.check- \
savegame.failure";R:4;s:14:"1202:cli.debug";R:4;s:18:"1202:cli.debug-run";R:4;s:23:"1202:cli.def \
ault-locale";R:4;s:19:"1202:cli.error.port";R:4;s:29:"1202:cli.error.home.notexists";R:4;s:26:"1 \
202:cli.error.home.noread";R:4;s:27:"1202:cli.error.home.nowrite";R:4;s:21:"1202:cli.freecol-dat \
a";R:4;s:13:"1202:cli.font";R:4;s:13:"1202:cli.help";R:4;s:23:"1202:cli.home-directory";R:4;s:22 \
:"1202:cli.load-savegame";R:4;s:20:"1202:cli.log-console";R:4;s:17:"1202:cli.log-file";R:4;s:18: \
"1202:cli.log-level";R:4;s:22:"1202:cli.no-java-check";R:4;s:24:"1202:cli.no-memory-check";R:4;s \
:17:"1202:cli.no-intro";R:4;s:17:"1202:cli.no-sound";R:4;s:16:"1202:cli.private";R:4;s:13:"1202: \
cli.seed";R:4;s:20:"1202:cli.server-name";R:4;s:15:"1202:cli.server";R:4;s:15:"1202:cli.splash"; \
R:4;s:11:"1202:cli.tc";R:4;s:16:"1202:cli.timeout";R:4;s:16:"1202:cli.version";R:4;s:17:"1202:cl \
i.windowed";R:4;s:21:"1202:gameoptions.name";R:4;s:33:"1202:gameoptions.shortdescription";R:4;s: \
25:"1202:gameoptions.map.name";R:4;s:37:"1202:gameoptions.map.shortdescription";R:4;s:31:"1202:m \
odel.option.fogofwar.name";R:4;s:43:"1202:model.option.fogofwar.shortdescription";R:4;s:40:"1202 \
:model.option.explorationpoints.name";R:4;s:52:"1202:model.option.explorationpoints.shortdescrip \
tion";R:4;s:34:"1202:model.option.turnstosail.name";R:4;s:46:"1202:model.option.turnstosail.shor \
tdescription";R:4;s:38:"1202:model.option.amphibiousmoves.name";R:4;s:50:"1202:model.option.amph \
ibiousmoves.shortdescription";R:4;s:52:"1202:model.option.settlementactionscontactchief.name";R: \
4;s:64:"1202:model.option.settlementactionscontactchief.shortdescription";R:4;s:43:"1202:model.o \
ption.enhancedmissionaries.name";R:4;s:55:"1202:model.option.enhancedmissionaries.shortdescripti \
on";R:4;s:56:"1202:model.option.continuefoundingfatherrecruitment.name";R:4;s:68:"1202:model.opt \
ion.continuefoundingfatherrecruitment.shortdescription";R:4;s:46:"1202:model.option.settlementli \
mitmodifier.name";R:4;s:58:"1202:model.option.settlementlimitmodifier.shortdescription";R:4;s:40 \
:"1202:model.option.startingpositions.name";R:4;s:52:"1202:model.option.startingpositions.shortd \
escription";R:4;s:43:"1202:model.option.startingpositions.classic";R:4;s:42:"1202:model.option.s \
tartingpositions.random";R:4;s:46:"1202:model.option.startingpositions.historical";R:4;s:28:"120 \
2:gameoptions.colony.name";R:4;s:40:"1202:gameoptions.colony.shortdescription";R:4;s:42:"1202:mo \
del.option.customignoreboycott.name";R:4;s:54:"1202:model.option.customignoreboycott.shortdescri \
ption";R:4;s:45:"1202:model.option.expertshaveconnections.name";R:4;s:57:"1202:model.option.expe \
rtshaveconnections.shortdescription";R:4;s:45:"1202:model.option.saveproductionoverflow.name";R: \
4;s:57:"1202:model.option.saveproductionoverflow.shortdescription";R:4;s:44:"1202:model.option.a \
llowstudentselection.name";R:4;s:56:"1202:model.option.allowstudentselection.shortdescription";R \
:4;s:39:"1202:gameoptions.victoryconditions.name";R:4;s:51:"1202:gameoptions.victoryconditions.s \
hortdescription";R:4;s:39:"1202:model.option.victorydefeatref.name";R:4;s:51:"1202:model.option. \
victorydefeatref.shortdescription";R:4;s:45:"1202:model.option.victorydefeateuropeans.name";R:4; \
s:57:"1202:model.option.victorydefeateuropeans.shortdescription";R:4;s:42:"1202:model.option.vic \
torydefeathumans.name";R:4;s:54:"1202:model.option.victorydefeathumans.shortdescription";R:4;s:2 \
7:"1202:gameoptions.years.name";R:4;s:39:"1202:gameoptions.years.shortdescription";R:4;s:35:"120 \
2:model.option.startingyear.name";R:4;s:47:"1202:model.option.startingyear.shortdescription";R:4 \
;s:33:"1202:model.option.seasonyear.name";R:4;s:45:"1202:model.option.seasonyear.shortdescriptio \
n";R:4;s:31:"1202:model.option.lastyear.name";R:4;s:42:"1202:model.option.mandatorycolonyyear.na \
me";R:4;s:54:"1202:model.option.mandatorycolonyyear.shortdescription";R:4;s:43:"1202:model.optio \
n.lastyear.shortdescription";R:4;s:39:"1202:model.option.lastcolonialyear.name";R:4;s:51:"1202:m \
odel.option.lastcolonialyear.shortdescription";R:4;s:36:"1202:model.option.startingmoney.name";R \
:4;s:48:"1202:model.option.startingmoney.shortdescription";R:4;s:39:"1202:model.option.crossesin \
crement.name";R:4;s:51:"1202:model.option.crossesincrement.shortdescription";R:4;s:41:"1202:mode \
l.option.badgovernmentlimit.name";R:4;s:53:"1202:model.option.badgovernmentlimit.shortdescriptio \
n";R:4;s:45:"1202:model.option.verybadgovernmentlimit.name";R:4;s:57:"1202:model.option.verybadg \
overnmentlimit.shortdescription";R:4;s:38:"1202:model.option.landpricefactor.name";R:4;s:50:"120 \
2:model.option.landpricefactor.shortdescription";R:4;s:43:"1202:model.option.foundingfatherfacto \
r.name";R:4;s:55:"1202:model.option.foundingfatherfactor.shortdescription";R:4;s:36:"1202:model. \
option.arrearsfactor.name";R:4;s:48:"1202:model.option.arrearsfactor.shortdescription";R:4;s:47: \
"1202:model.option.nativeconvertprobability.name";R:4;s:59:"1202:model.option.nativeconvertproba \
bility.shortdescription";R:4;s:38:"1202:model.option.burnprobability.name";R:4;s:50:"1202:model. \
option.burnprobability.shortdescription";R:4;s:43:"1202:model.option.recruitpriceincrease.name"; \
R:4;s:55:"1202:model.option.recruitpriceincrease.shortdescription";R:4;s:39:"1202:model.option.l \
owercapincrease.name";R:4;s:51:"1202:model.option.lowercapincrease.shortdescription";R:4;s:43:"1 \
202:model.option.priceincreasepertype.name";R:4;s:55:"1202:model.option.priceincreasepertype.sho \
rtdescription";R:4;s:46:"1202:model.option.priceincrease.artillery.name";R:4;s:58:"1202:model.op \
tion.priceincrease.artillery.shortdescription";R:4;s:34:"1202:model.option.refstrength.name";R:4 \
;s:46:"1202:model.option.refstrength.shortdescription";R:4;s:38:"1202:model.option.monarchmeddli \
ng.name";R:4;s:50:"1202:model.option.monarchmeddling.shortdescription";R:4;s:36:"1202:model.opti \
on.taxadjustment.name";R:4;s:48:"1202:model.option.taxadjustment.shortdescription";R:4;s:37:"120 \
2:model.option.mercenaryprice.name";R:4;s:49:"1202:model.option.mercenaryprice.shortdescription" \
;R:4;s:33:"1202:model.option.maximumtax.name";R:4;s:45:"1202:model.option.maximumtax.shortdescri \
ption";R:4;s:36:"1202:model.option.nativedemands.name";R:4;s:48:"1202:model.option.nativedemands \
.shortdescription";R:4;s:40:"1202:model.option.recruitable.slot0.name";R:4;s:52:"1202:model.opti \
on.recruitable.slot0.shortdescription";R:4;s:40:"1202:model.option.recruitable.slot1.name";R:4;s \
:52:"1202:model.option.recruitable.slot1.shortdescription";R:4;s:40:"1202:model.option.recruitab \
le.slot2.name";R:4;s:52:"1202:model.option.recruitable.slot2.shortdescription";R:4;s:37:"1202:mo \
del.option.tileproduction.name";R:4;s:49:"1202:model.option.tileproduction.shortdescription";R:4 \
;s:40:"1202:model.option.buildonnativeland.name";R:4;s:52:"1202:model.option.buildonnativeland.s \
hortdescription";R:4;s:47:"1202:model.option.buildonnativeland.always.name";R:4;s:59:"1202:model \
.option.buildonnativeland.always.shortdescription";R:4;s:46:"1202:model.option.buildonnativeland \
.first.name";R:4;s:58:"1202:model.option.buildonnativeland.first.shortdescription";R:4;s:60:"120 \
2:model.option.buildonnativeland.firstanduncontacted.name";R:4;s:72:"1202:model.option.buildonna \
tiveland.firstanduncontacted.shortdescription";R:4;s:46:"1202:model.option.buildonnativeland.nev \
er.name";R:4;s:58:"1202:model.option.buildonnativeland.never.shortdescription";R:4;s:42:"1202:mo \
del.option.expertstartingunits.name";R:4;s:54:"1202:model.option.expertstartingunits.shortdescri \
ption";R:4;s:42:"1202:model.option.unitsthatusenobells.name";R:4;s:54:"1202:model.option.unitsth \
atusenobells.shortdescription";R:4;s:37:"1202:model.option.monarchsupport.name";R:4;s:49:"1202:m \
odel.option.monarchsupport.shortdescription";R:4;s:39:"1202:model.option.rumourdifficulty.name"; \
R:4;s:51:"1202:model.option.rumourdifficulty.shortdescription";R:4;s:43:"1202:model.option.treas \
uretransportfee.name";R:4;s:55:"1202:model.option.treasuretransportfee.shortdescription";R:4;s:3 \
4:"1202:model.option.teleportref.name";R:4;s:46:"1202:model.option.teleportref.shortdescription" \
;R:4;s:39:"1202:model.option.shiptradepenalty.name";R:4;s:51:"1202:model.option.shiptradepenalty \
.shortdescription";R:4;s:30:"1202:model.option.refsize.name";R:4;s:42:"1202:model.option.refsize \
.shortdescription";R:4;s:39:"1202:model.option.refsize.soldiers.name";R:4;s:51:"1202:model.optio \
n.refsize.soldiers.shortdescription";R:4;s:39:"1202:model.option.refsize.dragoons.name";R:4;s:51 \
[prev in list] [next in list] [prev in thread] [next in thread]
Configure |
About |
News |
Add a list |
Sponsored by KoreLogic