diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -83,6 +83,7 @@ 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', + 'PhutilAllCapsEnglishLocale' => 'internationalization/locales/PhutilAllCapsEnglishLocale.php', 'PhutilAmazonAuthAdapter' => 'auth/PhutilAmazonAuthAdapter.php', 'PhutilArgumentParser' => 'parser/argument/PhutilArgumentParser.php', 'PhutilArgumentParserException' => 'parser/argument/exception/PhutilArgumentParserException.php', @@ -106,6 +107,7 @@ 'PhutilBitbucketAuthAdapter' => 'auth/PhutilBitbucketAuthAdapter.php', 'PhutilBootloader' => 'moduleutils/PhutilBootloader.php', 'PhutilBootloaderException' => 'moduleutils/PhutilBootloaderException.php', + 'PhutilBritishEnglishLocale' => 'internationalization/locales/PhutilBritishEnglishLocale.php', 'PhutilBufferedIterator' => 'utils/PhutilBufferedIterator.php', 'PhutilBufferedIteratorTestCase' => 'utils/__tests__/PhutilBufferedIteratorTestCase.php', 'PhutilBugtraqParser' => 'parser/PhutilBugtraqParser.php', @@ -133,6 +135,7 @@ 'PhutilConsoleWrapTestCase' => 'console/__tests__/PhutilConsoleWrapTestCase.php', 'PhutilContextFreeGrammar' => 'grammar/PhutilContextFreeGrammar.php', 'PhutilCsprintfTestCase' => 'xsprintf/__tests__/PhutilCsprintfTestCase.php', + 'PhutilCzechLocale' => 'internationalization/locales/PhutilCzechLocale.php', 'PhutilDaemon' => 'daemon/PhutilDaemon.php', 'PhutilDaemonOverseer' => 'daemon/PhutilDaemonOverseer.php', 'PhutilDefaultSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilDefaultSyntaxHighlighter.php', @@ -211,6 +214,7 @@ 'PhutilLibraryMapBuilder' => 'moduleutils/PhutilLibraryMapBuilder.php', 'PhutilLibraryTestCase' => '__tests__/PhutilLibraryTestCase.php', 'PhutilLipsumContextFreeGrammar' => 'grammar/PhutilLipsumContextFreeGrammar.php', + 'PhutilLocale' => 'internationalization/PhutilLocale.php', 'PhutilLock' => 'filesystem/PhutilLock.php', 'PhutilLockException' => 'filesystem/PhutilLockException.php', 'PhutilLogFileChannel' => 'channel/PhutilLogFileChannel.php', @@ -258,6 +262,7 @@ 'PhutilQueryStringParser' => 'parser/PhutilQueryStringParser.php', 'PhutilQueryStringParserTestCase' => 'parser/__tests__/PhutilQueryStringParserTestCase.php', 'PhutilRainbowSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilRainbowSyntaxHighlighter.php', + 'PhutilRawEnglishLocale' => 'internationalization/locales/PhutilRawEnglishLocale.php', 'PhutilReadableSerializer' => 'readableserializer/PhutilReadableSerializer.php', 'PhutilReadableSerializerTestCase' => 'readableserializer/__tests__/PhutilReadableSerializerTestCase.php', 'PhutilRealNameContextFreeGrammar' => 'grammar/PhutilRealNameContextFreeGrammar.php', @@ -313,6 +318,7 @@ 'PhutilTestCase' => 'infrastructure/testing/PhutilTestCase.php', 'PhutilTestPhobject' => 'object/__tests__/PhutilTestPhobject.php', 'PhutilTortureTestDaemon' => 'daemon/torture/PhutilTortureTestDaemon.php', + 'PhutilTranslation' => 'internationalization/PhutilTranslation.php', 'PhutilTranslator' => 'internationalization/PhutilTranslator.php', 'PhutilTranslatorTestCase' => 'internationalization/__tests__/PhutilTranslatorTestCase.php', 'PhutilTwitchAuthAdapter' => 'auth/PhutilTwitchAuthAdapter.php', @@ -326,6 +332,7 @@ 'PhutilTypeSpecTestCase' => 'parser/__tests__/PhutilTypeSpecTestCase.php', 'PhutilURI' => 'parser/PhutilURI.php', 'PhutilURITestCase' => 'parser/__tests__/PhutilURITestCase.php', + 'PhutilUSEnglishLocale' => 'internationalization/locales/PhutilUSEnglishLocale.php', 'PhutilUTF8StringTruncator' => 'utils/PhutilUTF8StringTruncator.php', 'PhutilUTF8TestCase' => 'utils/__tests__/PhutilUTF8TestCase.php', 'PhutilUnknownSymbolParserGeneratorException' => 'parser/generator/exception/PhutilUnknownSymbolParserGeneratorException.php', @@ -333,6 +340,7 @@ 'PhutilUnreachableTerminalParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableTerminalParserGeneratorException.php', 'PhutilUrisprintfTestCase' => 'xsprintf/__tests__/PhutilUrisprintfTestCase.php', 'PhutilUtilsTestCase' => 'utils/__tests__/PhutilUtilsTestCase.php', + 'PhutilVeryWowEnglishLocale' => 'internationalization/locales/PhutilVeryWowEnglishLocale.php', 'PhutilWordPressAuthAdapter' => 'auth/PhutilWordPressAuthAdapter.php', 'PhutilWordPressFuture' => 'future/wordpress/PhutilWordPressFuture.php', 'PhutilXHPASTBinary' => 'parser/xhpast/bin/PhutilXHPASTBinary.php', @@ -532,6 +540,7 @@ 'PhutilAWSFuture' => 'FutureProxy', 'PhutilAWSS3Future' => 'PhutilAWSFuture', 'PhutilAggregateException' => 'Exception', + 'PhutilAllCapsEnglishLocale' => 'PhutilLocale', 'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilArgumentParserException' => 'Exception', 'PhutilArgumentParserTestCase' => 'PhutilTestCase', @@ -554,6 +563,7 @@ 'PhutilAuthUserAbortedException' => 'PhutilAuthException', 'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilBootloaderException' => 'Exception', + 'PhutilBritishEnglishLocale' => 'PhutilLocale', 'PhutilBufferedIterator' => 'Iterator', 'PhutilBufferedIteratorTestCase' => 'PhutilTestCase', 'PhutilBugtraqParserTestCase' => 'PhutilTestCase', @@ -573,6 +583,7 @@ 'PhutilConsoleTable' => 'Phobject', 'PhutilConsoleWrapTestCase' => 'PhutilTestCase', 'PhutilCsprintfTestCase' => 'PhutilTestCase', + 'PhutilCzechLocale' => 'PhutilLocale', 'PhutilDefaultSyntaxHighlighterEngine' => 'PhutilSyntaxHighlighterEngine', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'FutureProxy', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'PhutilTestCase', @@ -626,6 +637,7 @@ 'PhutilLibraryConflictException' => 'Exception', 'PhutilLibraryTestCase' => 'PhutilTestCase', 'PhutilLipsumContextFreeGrammar' => 'PhutilContextFreeGrammar', + 'PhutilLocale' => 'Phobject', 'PhutilLockException' => 'Exception', 'PhutilLogFileChannel' => 'PhutilChannelChannel', 'PhutilLunarPhaseTestCase' => 'PhutilTestCase', @@ -661,6 +673,7 @@ 'PhutilProxyException' => 'Exception', 'PhutilPythonFragmentLexer' => 'PhutilLexer', 'PhutilQueryStringParserTestCase' => 'PhutilTestCase', + 'PhutilRawEnglishLocale' => 'PhutilLocale', 'PhutilReadableSerializerTestCase' => 'PhutilTestCase', 'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar', 'PhutilRemarkupBoldRule' => 'PhutilRemarkupRule', @@ -702,6 +715,7 @@ 'PhutilTestCase' => 'ArcanistPhutilTestCase', 'PhutilTestPhobject' => 'Phobject', 'PhutilTortureTestDaemon' => 'PhutilDaemon', + 'PhutilTranslation' => 'Phobject', 'PhutilTranslatorTestCase' => 'PhutilTestCase', 'PhutilTwitchAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilTwitchFuture' => 'FutureProxy', @@ -712,6 +726,7 @@ 'PhutilTypeMissingParametersException' => 'Exception', 'PhutilTypeSpecTestCase' => 'PhutilTestCase', 'PhutilURITestCase' => 'PhutilTestCase', + 'PhutilUSEnglishLocale' => 'PhutilLocale', 'PhutilUTF8StringTruncator' => 'Phobject', 'PhutilUTF8TestCase' => 'PhutilTestCase', 'PhutilUnknownSymbolParserGeneratorException' => 'PhutilParserGeneratorException', @@ -719,6 +734,7 @@ 'PhutilUnreachableTerminalParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUrisprintfTestCase' => 'PhutilTestCase', 'PhutilUtilsTestCase' => 'PhutilTestCase', + 'PhutilVeryWowEnglishLocale' => 'PhutilLocale', 'PhutilWordPressAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilWordPressFuture' => 'FutureProxy', 'PhutilXHPASTSyntaxHighlighterFuture' => 'FutureProxy', diff --git a/src/internationalization/PhutilLocale.php b/src/internationalization/PhutilLocale.php new file mode 100644 --- /dev/null +++ b/src/internationalization/PhutilLocale.php @@ -0,0 +1,211 @@ + The raw input arguments. + * @param string The translated string. + * @return string Post-processed translation string. + */ + public function didTranslateString( + $raw_pattern, + $translated_pattern, + array $args, + $result_text) { + return $result_text; + } + + + /** + * Load all available locales. + * + * @return map Map from codes to locale objects. + */ + public static function loadAllLocales() { + static $locales; + if ($locales === null) { + $objects = id(new PhutilSymbolLoader()) + ->setAncestorClass(__CLASS__) + ->loadObjects(); + + $locale_map = array(); + foreach ($objects as $object) { + $locale_code = $object->getLocaleCode(); + if (empty($locale_map[$locale_code])) { + $locale_map[$locale_code] = $object; + } else { + throw new Exception( + pht( + 'Two subclasses of "PhutilLocale" ("%s" and "%s") define '. + 'locales with the same locale code ("%s"). Each locale must '. + 'have a unique locale code.', + get_class($object), + get_class($locale_map[$locale_code]), + $locale_code)); + } + } + + foreach ($locale_map as $locale_code => $locale) { + $fallback_code = $locale->getFallbackLocaleCode(); + if ($fallback_code !== null) { + if (empty($locale_map[$fallback_code])) { + throw new Exception( + pht( + 'The locale "%s" has an invalid fallback locale code ("%s"). '. + 'No locale class exists which defines this locale.', + get_class($locale), + $fallback_code)); + } + } + } + + foreach ($locale_map as $locale_code => $locale) { + $seen = array($locale_code => get_class($locale)); + self::checkLocaleFallback($locale_map, $locale, $seen); + } + + $locales = $locale_map; + } + return $locales; + } + + + /** + * Load a specific locale using a locale code. + * + * @param string Locale code. + * @return PhutilLocale Locale object. + */ + public static function loadLocale($locale_code) { + $all_locales = self::loadAllLocales(); + $locale = idx($all_locales, $locale_code); + + if (!$locale) { + throw new Exception( + pht( + 'There is no locale with the locale code "%s".', + $locale_code)); + } + + return $locale; + } + + + /** + * Recursively check locale fallbacks for cycles. + * + * @param map Map of locales. + * @param PhutilLocale Current locale. + * @param map Map of visited locales. + * @return void + */ + private static function checkLocaleFallback( + array $map, + PhutilLocale $locale, + array $seen) { + + $fallback_code = $locale->getFallbackLocaleCode(); + if ($fallback_code === null) { + return; + } + + if (isset($seen[$fallback_code])) { + $seen[] = get_class($locale); + $seen[] = pht('...'); + throw new Exception( + pht( + 'Locale "%s" is part of a cycle of locales which fall back on '. + 'one another in a loop (%s). Locales which fall back on other '. + 'locales must not loop.', + get_class($locale), + implode(' -> ', $seen))); + } + + $seen[$fallback_code] = get_class($locale); + self::checkLocaleFallback($map, $map[$fallback_code], $seen); + } + +} diff --git a/src/internationalization/PhutilTranslation.php b/src/internationalization/PhutilTranslation.php new file mode 100644 --- /dev/null +++ b/src/internationalization/PhutilTranslation.php @@ -0,0 +1,89 @@ + Map of raw strings to translations. + */ + abstract protected function getTranslations(); + + + /** + * Return a filtered map of all strings in this translation. + * + * Filters out empty/placeholder translations. + * + * @return map Map of raw strings to translations. + */ + final public function getFilteredTranslations() { + $translations = $this->getTranslations(); + + foreach ($translations as $key => $translation) { + if ($translation === null) { + unset($translations[$key]); + } + } + + return $translations; + } + + + /** + * Load all available translation objects. + * + * @return list List of available translation sources. + */ + public static function loadAllTranslations() { + static $translations; + if ($translations === null) { + $translations = id(new PhutilSymbolLoader()) + ->setAncestorClass(__CLASS__) + ->loadObjects(); + } + return $translations; + } + + + /** + * Load the complete translation map for a locale. + * + * This will compile primary and fallback translations into a single + * translation map. + * + * @param string Locale code, like "en_US". + * @return map Map of all avialable translations. + */ + public static function getTranslationMapForLocale($locale_code) { + $locale = PhutilLocale::loadLocale($locale_code); + + $translations = self::loadAllTranslations(); + + $results = array(); + foreach ($translations as $translation) { + if ($translation->getLocaleCode() == $locale_code) { + $results += $translation->getFilteredTranslations(); + } + } + + $fallback_code = $locale->getFallbackLocaleCode(); + if ($fallback_code !== null) { + $results += self::getTranslationMapForLocale($fallback_code); + } + + return $results; + } + +} diff --git a/src/internationalization/PhutilTranslator.php b/src/internationalization/PhutilTranslator.php --- a/src/internationalization/PhutilTranslator.php +++ b/src/internationalization/PhutilTranslator.php @@ -4,7 +4,9 @@ static private $instance; - private $language = 'en'; + private $locale; + private $localeCode; + private $shouldPostProcess; private $translations = array(); public static function getInstance() { @@ -18,8 +20,10 @@ self::$instance = $instance; } - public function setLanguage($language) { - $this->language = $language; + public function setLocale(PhutilLocale $locale) { + $this->locale = $locale; + $this->localeCode = $locale->getLocaleCode(); + $this->shouldPostProcess = $locale->shouldPostProcessTranslations(); return $this; } @@ -53,8 +57,8 @@ * @param array Identifier in key, translation in value. * @return PhutilTranslator Provides fluent interface. */ - public function addTranslations(array $translations) { - $this->translations = array_merge($this->translations, $translations); + public function setTranslations(array $translations) { + $this->translations = $translations; return $this; } @@ -97,8 +101,12 @@ $result = '[Invalid Translation!] '.$translation; } - if ($this->language == 'en-ac') { - $result = strtoupper($result); + if ($this->shouldPostProcess) { + $result = $this->locale->didTranslateString( + $text, + $translation, + $args, + $result); } if ($is_html) { @@ -118,17 +126,23 @@ $variant = $variant->getNumber(); } - switch ($this->language) { + // TODO: Move these into PhutilLocale if benchmarks show we aren't + // eating too much of a performance cost. - case 'en': - case 'en-ac': + switch ($this->localeCode) { + + case 'en_US': + case 'en_GB': + case 'en_W*': + case 'en_R*': + case 'en_A*': list($singular, $plural) = $translations; if ($variant == 1) { return $singular; } return $plural; - case 'cs': + case 'cs_CZ': if ($variant instanceof PhutilPerson) { list($male, $female) = $translations; if ($variant->getSex() == PhutilPerson::SEX_FEMALE) { diff --git a/src/internationalization/__tests__/PhutilPhtTestCase.php b/src/internationalization/__tests__/PhutilPhtTestCase.php --- a/src/internationalization/__tests__/PhutilPhtTestCase.php +++ b/src/internationalization/__tests__/PhutilPhtTestCase.php @@ -11,15 +11,18 @@ $this->assertEqual('beer', pht('beer')); $this->assertEqual('1 beer(s)', pht('%d beer(s)', 1)); - PhutilTranslator::getInstance()->addTranslations( + $english_locale = PhutilLocale::loadLocale('en_US'); + PhutilTranslator::getInstance()->setLocale($english_locale); + PhutilTranslator::getInstance()->setTranslations( array( '%d beer(s)' => array('%d beer', '%d beers'), )); $this->assertEqual('1 beer', pht('%d beer(s)', 1)); - PhutilTranslator::getInstance()->setLanguage('cs'); - PhutilTranslator::getInstance()->addTranslations( + $czech_locale = PhutilLocale::loadLocale('cs_CZ'); + PhutilTranslator::getInstance()->setLocale($czech_locale); + PhutilTranslator::getInstance()->setTranslations( array( '%d beer(s)' => array('%d pivo', '%d piva', '%d piv'), )); diff --git a/src/internationalization/__tests__/PhutilTranslatorTestCase.php b/src/internationalization/__tests__/PhutilTranslatorTestCase.php --- a/src/internationalization/__tests__/PhutilTranslatorTestCase.php +++ b/src/internationalization/__tests__/PhutilTranslatorTestCase.php @@ -3,8 +3,8 @@ final class PhutilTranslatorTestCase extends PhutilTestCase { public function testEnglish() { - $translator = new PhutilTranslator(); - $translator->addTranslations( + $translator = $this->newTranslator('en_US'); + $translator->setTranslations( array( '%d line(s)' => array('%d line', '%d lines'), '%d char(s) on %d row(s)' => array( @@ -31,11 +31,10 @@ } public function testSingleVariant() { - $translator = new PhutilTranslator(); - $translator->setLanguage('en'); + $translator = $this->newTranslator('en_US'); // In this translation, we have no alternatives for the first conversion. - $translator->addTranslations( + $translator->setTranslations( array( 'Run the command %s %d time(s).' => array( array( @@ -54,9 +53,8 @@ } public function testCzech() { - $translator = new PhutilTranslator(); - $translator->setLanguage('cs'); - $translator->addTranslations( + $translator = $this->newTranslator('cs_CZ'); + $translator->setTranslations( array( '%d beer(s)' => array('%d pivo', '%d piva', '%d piv'), )); @@ -70,9 +68,8 @@ } public function testPerson() { - $translator = new PhutilTranslator(); - $translator->setLanguage('cs'); - $translator->addTranslations( + $translator = $this->newTranslator('cs_CZ'); + $translator->setTranslations( array( '%s wrote.' => array('%s napsal.', '%s napsala.'), )); @@ -95,13 +92,13 @@ public function testTranslateDate() { $date = new DateTime('2012-06-21'); + $translator = $this->newTranslator('en_US'); - $translator = new PhutilTranslator(); $this->assertEqual('June', $translator->translateDate('F', $date)); $this->assertEqual('June 21', $translator->translateDate('F d', $date)); $this->assertEqual('F', $translator->translateDate('\F', $date)); - $translator->addTranslations( + $translator->setTranslations( array( 'June' => 'correct', '21' => 'wrong', @@ -113,12 +110,17 @@ } public function testSetInstance() { - PhutilTranslator::setInstance(new PhutilTranslator()); + $english_translator = $this->newTranslator('en_US'); + + PhutilTranslator::setInstance($english_translator); $original = PhutilTranslator::getInstance(); $this->assertEqual('color', pht('color')); + $british_locale = PhutilLocale::loadLocale('en_GB'); + $british = new PhutilTranslator(); - $british->addTranslations( + $british->setLocale($british_locale); + $british->setTranslations( array( 'color' => 'colour', )); @@ -130,12 +132,13 @@ } public function testFormatNumber() { - $translator = new PhutilTranslator(); + $translator = $this->newTranslator('en_US'); + $this->assertEqual('1,234', $translator->formatNumber(1234)); $this->assertEqual('1,234.5', $translator->formatNumber(1234.5, 1)); $this->assertEqual('1,234.5678', $translator->formatNumber(1234.5678, 4)); - $translator->addTranslations( + $translator->setTranslations( array( ',' => ' ', '.' => ',', @@ -146,8 +149,9 @@ } public function testNumberTranslations() { - $translator = new PhutilTranslator(); - $translator->addTranslations( + $translator = $this->newTranslator('en_US'); + + $translator->setTranslations( array( '%s line(s)' => array('%s line', '%s lines'), )); @@ -192,7 +196,8 @@ ), ); - $translator = new PhutilTranslator(); + $translator = $this->newTranslator('en_US'); + foreach ($tests as $original => $translations) { foreach ($translations as $translation => $expect) { $valid = ($expect ? 'valid' : 'invalid'); @@ -208,7 +213,7 @@ $string = '%s awoke suddenly at %s.'; $when = '<4 AM>'; - $translator = new PhutilTranslator(); + $translator = $this->newTranslator('en_US'); // When no components are HTML, everything is treated as a string. $who = 'Abraham'; @@ -243,4 +248,10 @@ $translation->getHTMLContent()); } + private function newTranslator($locale_code) { + $locale = PhutilLocale::loadLocale($locale_code); + return id(new PhutilTranslator()) + ->setLocale($locale); + } + } diff --git a/src/internationalization/locales/PhutilAllCapsEnglishLocale.php b/src/internationalization/locales/PhutilAllCapsEnglishLocale.php new file mode 100644 --- /dev/null +++ b/src/internationalization/locales/PhutilAllCapsEnglishLocale.php @@ -0,0 +1,38 @@ +addTranslations()` and language rules set - * by `PhutilTranslator::getInstance()->setLanguage()`. + * `PhutilTranslator::getInstance()->setTranslations()` and language rules set + * by `PhutilTranslator::getInstance()->setLocale()`. * * @param string Translation identifier with `sprintf()` placeholders. * @param mixed Value to select the variant from (e.g. singular or plural).