[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( '&quot;', '&#34;', '&#39;' ),
+				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( '"', '&quot;', $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