From 8a07c56bdf28a4d74ebd22c17b1e258f91a273b2 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 18 Jan 2017 16:58:48 +1300 Subject: [PATCH 1/2] API Replace i18n message localisation with symfony/translation API Implement enhanced pluralisation Remove Zend_Translate and all Zend dependencies from i18n Deprecated $context from i18n::_t() Warn on missing default string for i18n::_t() --- .upgrade.yml | 9 +- _config/i18n.yml | 42 +- admin/code/CMSMenu.php | 5 +- admin/code/ModelAdmin.php | 2 +- composer.json | 4 +- .../05_Extending/05_Injector.md | 15 + docs/en/02_Developer_Guides/13_i18n/index.md | 112 +- docs/en/04_Changelogs/4.0.0.md | 127 ++- lang/en.yml | 54 +- src/Core/Core.php | 9 +- src/Core/Injector/Injector.php | 7 + src/Dev/Backtrace.php | 1 - src/Dev/Tasks/i18nTextCollectorTask.php | 2 +- src/Forms/ConfirmedPasswordField.php | 2 +- src/Forms/GridField/GridFieldFilterHeader.php | 2 +- src/Forms/HTMLEditor/HTMLEditorField.php | 2 +- .../HTMLEditor/HTMLEditorField_Toolbar.php | 7 +- src/ORM/DataObject.php | 84 +- src/ORM/Versioning/ChangeSet.php | 8 +- src/Security/MemberAuthenticator.php | 5 +- src/View/SSViewer_DataPresenter.php | 16 +- src/i18n/Messages/MessageProvider.php | 30 + src/i18n/Messages/Reader.php | 18 + .../Symfony/FlushInvalidatedResource.php | 76 ++ .../Messages/Symfony/ModuleYamlLoader.php | 143 +++ .../Symfony/SymfonyMessageProvider.php | 188 ++++ .../Writer.php} | 11 +- src/i18n/Messages/YamlReader.php | 72 ++ src/i18n/Messages/YamlWriter.php | 111 ++ src/i18n/TextCollection/Parser.php | 85 ++ .../i18nTextCollector.php | 179 ++-- src/i18n/i18n.php | 580 ++++------ src/i18n/i18nEntityProvider.php | 74 +- src/i18n/i18nRailsYamlAdapter.php | 21 - src/i18n/i18nTextCollector_Parser.php | 69 -- .../i18nTextCollector_Writer_RailsYaml.php | 62 -- src/i18n/i18nTranslateAdapterInterface.php | 22 - tests/php/Core/Injector/InjectorTest.php | 29 + tests/php/i18n/YamlReaderTest.php | 38 + tests/php/i18n/YamlWriterTest.php | 42 + tests/php/i18n/i18nTest.php | 580 +++------- .../i18n/i18nTest/CustomTranslatorAdapter.php | 30 - tests/php/i18n/i18nTest/MyObject.php | 12 + .../i18nTest/OtherCustomTranslatorAdapter.php | 29 - tests/php/i18n/i18nTest/TestDataObject.php | 1 + tests/php/i18n/i18nTest/TestObject.php | 11 +- .../code/i18nProviderClass.php | 21 + .../_fakewebroot/i18ntestmodule/lang/en.yml | 3 + .../i18ntestmodule/lang/en_GB.yml | 15 + .../i18ntestmodule/lang/es_AR.yml | 15 + .../i18ntestmodule/lang/es_ES.yml | 15 + .../templates/Layout/i18nTestModule.ss | 3 +- .../templates/i18nTestModule.ss | 3 + tests/php/i18n/i18nTestManifest.php | 145 +++ tests/php/i18n/i18nTextCollectorTest.php | 443 ++++---- .../i18n/i18nTextCollectorTest/Collector.php | 2 +- thirdparty/Zend/Translate.php | 220 ---- thirdparty/Zend/Translate/Adapter.php | 998 ------------------ thirdparty/Zend/Translate/Adapter/Array.php | 81 -- thirdparty/Zend/Translate/Adapter/Csv.php | 121 --- thirdparty/Zend/Translate/Adapter/Gettext.php | 169 --- thirdparty/Zend/Translate/Adapter/Ini.php | 74 -- thirdparty/Zend/Translate/Adapter/Qt.php | 160 --- thirdparty/Zend/Translate/Adapter/Tbx.php | 165 --- thirdparty/Zend/Translate/Adapter/Tmx.php | 233 ---- thirdparty/Zend/Translate/Adapter/Xliff.php | 229 ---- thirdparty/Zend/Translate/Adapter/XmlTm.php | 139 --- thirdparty/Zend/Translate/Exception.php | 37 - thirdparty/Zend/Translate/Plural.php | 224 ---- thirdparty/php-peg/Parser.php | 22 +- 70 files changed, 2147 insertions(+), 4418 deletions(-) create mode 100644 src/i18n/Messages/MessageProvider.php create mode 100644 src/i18n/Messages/Reader.php create mode 100644 src/i18n/Messages/Symfony/FlushInvalidatedResource.php create mode 100644 src/i18n/Messages/Symfony/ModuleYamlLoader.php create mode 100644 src/i18n/Messages/Symfony/SymfonyMessageProvider.php rename src/i18n/{i18nTextCollector_Writer.php => Messages/Writer.php} (57%) create mode 100644 src/i18n/Messages/YamlReader.php create mode 100644 src/i18n/Messages/YamlWriter.php create mode 100644 src/i18n/TextCollection/Parser.php rename src/i18n/{ => TextCollection}/i18nTextCollector.php (82%) delete mode 100644 src/i18n/i18nRailsYamlAdapter.php delete mode 100644 src/i18n/i18nTextCollector_Parser.php delete mode 100644 src/i18n/i18nTextCollector_Writer_RailsYaml.php delete mode 100644 src/i18n/i18nTranslateAdapterInterface.php create mode 100644 tests/php/i18n/YamlReaderTest.php create mode 100644 tests/php/i18n/YamlWriterTest.php delete mode 100644 tests/php/i18n/i18nTest/CustomTranslatorAdapter.php delete mode 100644 tests/php/i18n/i18nTest/OtherCustomTranslatorAdapter.php create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en_GB.yml create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_AR.yml create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_ES.yml create mode 100644 tests/php/i18n/i18nTestManifest.php delete mode 100644 thirdparty/Zend/Translate.php delete mode 100644 thirdparty/Zend/Translate/Adapter.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Array.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Csv.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Gettext.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Ini.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Qt.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Tbx.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Tmx.php delete mode 100644 thirdparty/Zend/Translate/Adapter/Xliff.php delete mode 100644 thirdparty/Zend/Translate/Adapter/XmlTm.php delete mode 100644 thirdparty/Zend/Translate/Exception.php delete mode 100644 thirdparty/Zend/Translate/Plural.php diff --git a/.upgrade.yml b/.upgrade.yml index f2452214a..d9d6a5a63 100644 --- a/.upgrade.yml +++ b/.upgrade.yml @@ -454,11 +454,10 @@ mappings: i18nRailsYamlAdapter: SilverStripe\i18n\i18nRailsYamlAdapter i18nSSLegacyAdapter: SilverStripe\i18n\i18nSSLegacyAdapter i18nSSLegacyAdapter_Iterator: SilverStripe\i18n\i18nSSLegacyAdapter_Iterator - i18nTextCollector: SilverStripe\i18n\i18nTextCollector - i18nTextCollector_Writer: SilverStripe\i18n\i18nTextCollector_Writer - i18nTextCollector_Writer_Php: SilverStripe\i18n\i18nTextCollector_Writer_Php - i18nTextCollector_Writer_RailsYaml: SilverStripe\i18n\i18nTextCollector_Writer_RailsYaml - i18nTextCollector_Parser: SilverStripe\i18n\i18nTextCollector_Parser + i18nTextCollector: SilverStripe\i18n\TextCollection\i18nTextCollector + i18nTextCollector_Writer: SilverStripe\i18n\Messages\Writer + i18nTextCollector_Writer_RailsYaml: SilverStripe\i18n\Messages\YamlWriter + i18nTextCollector_Parser: SilverStripe\i18n\TextCollection\Parser i18nTranslateAdapterInterface: SilverStripe\i18n\i18nTranslateAdapterInterface SilverStripe\Framework\Logging\DebugViewFriendlyErrorFormatter: SilverStripe\Logging\DebugViewFriendlyErrorFormatter SilverStripe\Framework\Logging\DetailedErrorFormatter: SilverStripe\Logging\DetailedErrorFormatter diff --git a/_config/i18n.yml b/_config/i18n.yml index 5af9bd4f7..a5a0bfe08 100644 --- a/_config/i18n.yml +++ b/_config/i18n.yml @@ -14,7 +14,47 @@ SilverStripe\i18n\i18n: module_priority: - other_modules --- +name: i18nMessages +--- +SilverStripe\Core\Injector\Injector: + # Custom yml loader for localisation messages + SilverStripe\i18n\Messages\Reader: + class: SilverStripe\i18n\Messages\YamlReader + SilverStripe\i18n\Messages\Writer: + class: SilverStripe\i18n\Messages\YamlWriter + Symfony\Component\Translation\Loader\LoaderInterface: + class: SilverStripe\i18n\Messages\Symfony\ModuleYamlLoader + properties: + Reader: %$SilverStripe\i18n\Messages\Reader + # Ensure our cache respects ModuleYamlLoader's self-invalidation + # @see DirectoryListResource::isFresh() + # Note: This could be replaced with a more aggressive cache if necessary on a live environment + Symfony\Component\Config\ConfigCacheFactoryInterface: + class: Symfony\Component\Config\ResourceCheckerConfigCacheFactory + constructor: + 0: [ %$Symfony\Component\Config\Resource\SelfCheckingResourceChecker ] + # Create default translator with standard cache path and our custom loader + Symfony\Component\Translation\TranslatorInterface: + class: Symfony\Component\Translation\Translator + constructor: + 0: 'en' + 1: null + 2: `TEMP_FOLDER` + properties: + ConfigCacheFactory: %$Symfony\Component\Config\ConfigCacheFactoryInterface + calls: + FallbackLocales: [ setFallbackLocales, [['en']]] + Loader: [ addLoader, ['ss', %$Symfony\Component\Translation\Loader\LoaderInterface ]] + # Set this translator as our message provider for silverstripe's i18n + SilverStripe\i18n\Messages\MessageProvider: + class: SilverStripe\i18n\Messages\Symfony\SymfonyMessageProvider + properties: + Translator: %$Symfony\Component\Translation\TranslatorInterface +--- Name: textcollector --- SilverStripe\Core\Injector\Injector: - SilverStripe\i18n\i18nTextCollector_Writer: SilverStripe\i18n\i18nTextCollector_Writer_RailsYaml + SilverStripe\i18n\TextCollection\i18nTextCollector: + properties: + Reader: %$SilverStripe\i18n\Messages\Reader + Writer: %$SilverStripe\i18n\Messages\Writer diff --git a/admin/code/CMSMenu.php b/admin/code/CMSMenu.php index e237d561e..94e783f3b 100644 --- a/admin/code/CMSMenu.php +++ b/admin/code/CMSMenu.php @@ -428,7 +428,10 @@ class CMSMenu extends Object implements IteratorAggregate, i18nEntityProvider foreach ($cmsClasses as $cmsClass) { $defaultTitle = LeftAndMain::menu_title($cmsClass, false); $ownerModule = i18n::get_owner_module($cmsClass); - $entities["{$cmsClass}.MENUTITLE"] = array($defaultTitle, 'Menu title', $ownerModule); + $entities["{$cmsClass}.MENUTITLE"] = [ + 'default' => $defaultTitle, + 'module' => $ownerModule + ]; } return $entities; } diff --git a/admin/code/ModelAdmin.php b/admin/code/ModelAdmin.php index 75b5dbdaa..6809656cc 100644 --- a/admin/code/ModelAdmin.php +++ b/admin/code/ModelAdmin.php @@ -464,7 +464,7 @@ abstract class ModelAdmin extends LeftAndMain } $fields = new FieldList( - new HiddenField('ClassName', _t('ModelAdmin.CLASSTYPE'), $this->modelClass), + new HiddenField('ClassName', false, $this->modelClass), new FileField('_CsvFile', false) ); diff --git a/composer.json b/composer.json index a68f48448..053a9094f 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "league/flysystem": "~1.0.12", "symfony/yaml": "~2.7", "embed/embed": "^2.6", - "swiftmailer/swiftmailer": "~5.4" + "swiftmailer/swiftmailer": "~5.4", + "symfony/config": "^2.8|^3", + "symfony/translation": "^2.8|^3" }, "require-dev": { "phpunit/PHPUnit": "~4.8", diff --git a/docs/en/02_Developer_Guides/05_Extending/05_Injector.md b/docs/en/02_Developer_Guides/05_Extending/05_Injector.md index b3a414ed8..aa3785fbf 100644 --- a/docs/en/02_Developer_Guides/05_Extending/05_Injector.md +++ b/docs/en/02_Developer_Guides/05_Extending/05_Injector.md @@ -121,6 +121,21 @@ As well as properties, method calls can also be specified: - [ pushHandler, [ %$DefaultHandler ] ] +## Using constants as variables + +Any of the core constants can be used as a service argument by quoting with back ticks "`". + + + :::yaml + CachingService: + class: SilverStripe\Cache\CacheProvider + properties: + CacheDir: `TEMP_DIR` + + +Note: undefined variables will be replaced with null + + ## Factories Some services require non-trivial construction which means they must be created by a factory class. To do this, create diff --git a/docs/en/02_Developer_Guides/13_i18n/index.md b/docs/en/02_Developer_Guides/13_i18n/index.md index 81cc1fd85..a51b86605 100644 --- a/docs/en/02_Developer_Guides/13_i18n/index.md +++ b/docs/en/02_Developer_Guides/13_i18n/index.md @@ -166,12 +166,61 @@ All strings passed through the `_t()` function will be collected in a separate l The `_t()` function is the main gateway to localized text, and takes four parameters, all but the first being optional. It can be used to translate strings in both PHP files and template files. The usage for each case is described below. - * **$entity:** Unique identifier, composed by a namespace and an entity name, with a dot separating them. Both are arbitrary names, although by convention we use the name of the containing class or template. Use this identifier to reference the same translation elsewhere in your code. - * **$string:** (optional) The original language string to be translated. Only needs to be declared once, and gets picked up the [text collector](#collecting-text). - * **$string:** (optional) Natural language comment (particularly short phrases and individual words) -are very context dependent. This parameter allows the developer to convey this information -to the translator. - * **$array::** (optional) An array of injecting variables into the second parameter +* **$entity:** Unique identifier, composed by a namespace and an entity name, with a dot + separating them. Both are arbitrary names, although by convention we use the name of + the containing class or template. Use this identifier to reference the same translation + elsewhere in your code. +* **$default:** The original language string to be translated. This should be declared + whenever used, and will get picked up the [text collector](#collecting-text). +* **$injection::** (optional) An array of injecting variables into the second parameter + +## Pluralisation + +i18n also supports locale-respective pluralisation rules. Many languages have more than two plural forms, +unlike English which has two only; One for the singular, and another for any other number. + +More information on what forms these plurals can take for various locales can be found on the +[CLDR documentation](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) + +The ability to pluralise strings is provided through the `i18n::pluaralise` method, which is similar to the +`i18n::_t` method, other than that it takes an additional `$count` argument. + +For instance, this is an example of how to correctly declare pluralisations for an object + + + :::php + class MyObject extends DataObject, implements i18nEntityProvider + { + public function provideI18nEntities() + { + return [ + 'MyObject.SINGULAR_NAME' => 'object', + 'MyObject.PLURAL_NAME' => 'objects', + 'MyObject.PLURALS' => [ + 'one' => 'An object', + 'other' => '{count} objects', + ], + ]; + } + } + + +In YML format this will be expressed as the below. This follows the +[ruby i18n convention](guides.rubyonrails.org/i18n.html#pluralization) for plural forms. + + + :::yaml + en: + MyObject: + SINGULAR_NAME: 'object' + PLURAL_NAME: 'objects' + PLURALS: + one: 'An object', + other: '{count} objects' + + +Note: i18nTextCollector support for pluralisation is not yet available. +Please ensure that any required plurals are exposed via provideI18nEntities. #### Usage in PHP Files @@ -180,16 +229,24 @@ to the translator. // Simple string translation _t('LeftAndMain.FILESIMAGES','Files & Images'); - // Using the natural languate comment parameter to supply additional context information to translators - _t('LeftAndMain.HELLO','Site content','Menu title'); - // Using injection to add variables into the translated strings. _t('CMSMain.RESTORED', "Restored {value} successfully", - 'This is a message when restoring a broken part of the CMS', array('value' => $itemRestored) ); + +You can invoke plurals for any object using the new `i18n::pluralise` method. +In addition to array form, you can also pass in a pipe-delimited string as a default +argument for brevity. + + + :::php + public function pluralise($count) + { + return i18n::pluralise('MyObject.PLURALS', 'An object|{count} objects', $count); + } + #### Usage in Template Files
@@ -207,11 +264,15 @@ the PHP version of the function. // Simple string translation <%t Namespace.Entity "String to translate" %> - // Using the natural languate comment parameter to supply additional context information to translators - <%t SearchResults.NoResult "There are no results matching your query." is "A message displayed to users when the search produces no results." %> - // Using injection to add variables into the translated strings (note that $Name and $Greeting must be available in the current template scope). <%t Header.Greeting "Hello {name} {greeting}" name=$Name greeting=$Greeting %> + +Pluralisation in templates is available via the global `$pluralise` method. + + + :::ss + You have $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', $Count) in your cart + #### Caching in Template Files with locale switching @@ -279,13 +340,14 @@ Each module can have one language table per locale, stored by convention in the The translation is powered by [Zend_Translate](http://framework.zend.com/manual/current/en/modules/zend.i18n.translating.html), which supports different translation adapters, dealing with different storage formats. -By default, SilverStripe 3.x uses a YAML format (through the [Zend_Translate_RailsYAML adapter](https://github.com/chillu/zend_translate_railsyaml)). +By default, SilverStripe uses a YAML format which is loaded via the +[symfony/translate](http://symfony.com/doc/current/translation.html) library. Example: framework/lang/en.yml (extract) en: ImageUploader: - Attach: 'Attach %s' + Attach: 'Attach {title}' UploadField: NOTEADDFILES: 'You can add files once you have saved for the first time.' @@ -293,7 +355,7 @@ Translation table: framework/lang/de.yml (extract) de: ImageUploader: - ATTACH: '%s anhängen' + ATTACH: '{title} anhängen' UploadField: NOTEADDFILES: 'Sie können Dateien hinzufügen sobald Sie das erste mal gespeichert haben' @@ -301,24 +363,6 @@ Note that translations are cached across requests. The cache can be cleared through the `?flush=1` query parameter, or explicitly through `Zend_Translate::getCache()->clean(Zend_Cache::CLEANING_MODE_ALL)`. -
-The format of language definitions has changed significantly in since version 2.x. -
- -In order to enable usage of [version 2.x style language definitions](http://doc.silverstripe.org/framework/en/2.4/topics/i18n#language-tables-in-php) in 3.x, you need to register a legacy adapter -in your `mysite/_config.php`: - - :::php - i18n::register_translator( - new Zend_Translate(array( - 'adapter' => 'i18nSSLegacyAdapter', - 'locale' => i18n::default_locale(), - 'disableNotices' => true, - )), - 'legacy', - 9 // priority lower than standard translator - ); - ## Javascript Usage The i18n system in JavaScript is similar to its PHP equivalent. diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index 8a3b71a99..7bb5648f3 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -15,6 +15,7 @@ guide developers in preparing existing 3.x code for compatibility with 4.0 * [ORM API](#overview-orm) * [Filesystem API](#overview-filesystem) * [Template and Form API](#overview-template) + * [i18n](#overview-i18n) * [Email and Mailer](#overview-mailer) * [Commit History](#commit-history) @@ -47,6 +48,7 @@ guide developers in preparing existing 3.x code for compatibility with 4.0 unless explicitly opted out. * Themes are now configured to cascade, where you can specify a list of themes, and have the template engine search programatically through a prioritised list when resolving template and CSS file paths. +* i18n Updated to use symfony/translation over zend Framework 1. Zend_Translate has been removed. ## Upgrading @@ -212,6 +214,104 @@ instead, or if used in an actual XML file use `.CDATA` (see [template casting](/ Where your code once used SQLQuery you should now use SQLSelect in all cases, as this has been removed (check the [3.2.0](3.2.0) upgrading notes). +#### Upgrade code that uses i18n + +In many cases, localisation strings which worked in 3.x will continue to work in 4.0, however certain patterns +have been deprecated and will be removed in 5.0. These include: + + - _t calls with a $context parameter, which is ignored. + - _t calls with sprintf-style placeholders (`%s`). Replace with named placeholders instead. + - _t calls with non-associative injection arguments. Please use an associative array for all arguments. + - _t calls which do not include a default value. + +Note: If you attempt to use non-associative injection arguments with named placeholders, the result will +now trigger an exception. + +Implementors of i18nEntityProvider should note that the return type for provideI18nEntities() has changed as well. +The non-associative array return type is deprecated. If returning a default string for a module +other than itself, it should return an array with the `default` and `module` keys respectively. + +Full locale-rule respecting localisation for plural forms is now supported. The default +key for an object plural form is `.PLURALS`, and follows CLDR array form for each +pluralisation. See [the CLDR chart](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) +for reference. + +The below demonstrates how you can provide new localisation strings for an object, +including both plurals and cross-module localisations. + + + :::php + class MyObject extends DataObject, implements i18nEntityProvider + { + public function provideI18nEntities() + { + return [ + 'MyObject.SINGULAR_NAME' => 'object', + 'MyObject.PLURAL_NAME' => 'objects', + 'MyObject.PLURALS' => [ + 'one' => 'An object', + 'other' => '{count} objects', + ], + 'AnotherSection.DESCRIPTION' => [ + 'default' => 'This is the description for this section', + 'module' => 'extendedmodule', + ], + ]; + } + } + + +In YML format this will be expressed as the below: + +`mymodule/lang/en.yml`: + + + :::yaml + en: + MyObject: + SINGULAR_NAME: 'object' + PLURAL_NAME: 'objects' + PLURALS: + one: 'An object', + other: '{count} objects' + + +`extendedmodule/lang/en.yml`: + + + :::yaml + en: + AnotherSection: + DESCRIPTION: 'This is the description for this section' + + +You can invoke plurals for any object using the new `i18n::pluralise` method. +In addition to array form, you can also pass in a pipe-delimited string as a default +argument for brevity. + + + :::php + public function pluralise($count) + { + return i18n::pluralise('MyObject.PLURALS', 'An object|{count} objects', $count); + } + + +Which is equivalent to the below: + + + :::php + public function pluralise($count) + { + return i18n::pluralise('MyObject.PLURALS', [ + 'one' => 'An object', + 'other' => '{count} objects', + ], $count); + } + + +Note: Template syntax for pluralisation is not yet available. + #### New asset storage mechanism File system has been abstracted into an abstract interface. By default, the out of the box filesystem @@ -1216,15 +1316,34 @@ handle field-level and form-level messages. This has the following properties: * `Requirements::delete_combined_files()` and `Requirements::delete_combined_files()` methods have been removed as they are obsolete. +### i18n API + +#### i18n API Additions / Changes + +* Upgrade of i18n to symfony/translation +* Localisation based on language-only (without any specific locale) is now supported +* i18nEntityProvider::provideI18nEntities() Now is expected to return only a single array + map of key to default values. +* i18n keys for '.PLURAL_NAME' and '.SINGULAR_NAME' have been changed back to FQN class names + for all DataObject subclasses. +* i18n key for locale-respective pluralisation rules added as '.PLURALS'. These can be configured + within yaml in array format as per [ruby i18n pluralization rules](http://guides.rubyonrails.org/i18n.html#pluralization). + +#### i18n API Removed API + +* Zend_Translate removed +* i18n::_t $context parameter deprecated +* i18n::_t Support for sprintf-style `%s` arguments deprecated +* i18n::_t Using non-associative injection with named parameters is now an error +* i18nEntityProvider no longer can collect strings for other modules. + ### Email and Mailer +#### Email Additions / Changes + * `Mailer` converted to an interface * `SwfitMailer` added as new default mailer * `Email` re-written to be powered by [SwiftMailer](https://github.com/swiftmailer/swiftmailer) - - -#### Email Additions / Changes - * Default template body variable renamed from `$Body` to `$EmailContent` * `$email->setTemplate()` renamed to `$email->setHTMLTemplate()` * Added `$email->setPlainTemplate` for rendering plain versions of email diff --git a/lang/en.yml b/lang/en.yml index 4fc6f584a..2ab21a75b 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -120,13 +120,15 @@ en: PASSWORD: Password ChangeSet: DESCRIPTION_AND: '{first} and {second}' - DESCRIPTION_ITEM: item - DESCRIPTION_ITEMS: items + DESCRIPTION_ITEM_PLURALS: + one: 'one item' + other: '{count} items' DESCRIPTION_LIST_FIRST: '{item}' DESCRIPTION_LIST_LAST: '{list}, and {item}' DESCRIPTION_LIST_MID: '{list}, {item}' - DESCRIPTION_OTHER_ITEM: 'other item' - DESCRIPTION_OTHER_ITEMS: 'other items' + DESCRIPTION_OTHER_ITEM_PLURALS: + one: 'one other item' + other: '{count} other items' NAME: Name PLURALNAME: Campaigns SINGULARNAME: Campaign @@ -328,7 +330,7 @@ en: CSSCLASSRIGHT: 'On the right, with text wrapping around.' DETAILS: Details EMAIL: 'Email address' - FILE: SilverStripe\\Assets\\File + FILE: File FOLDER: Folder IMAGEALT: 'Alternative text (alt)' IMAGEALTTEXT: 'Alternative text (alt) - shown if image can''t be displayed' @@ -665,45 +667,87 @@ en: MENUTITLE: Security SilverStripe\Assets\File: PLURALNAME: Files + PLURALS: + one: 'A File' + other: '{count} Files' SINGULARNAME: File SilverStripe\Assets\Folder: PLURALNAME: Folders + PLURALS: + one: 'A Folder' + other: '{count} Folders' SINGULARNAME: Folder SilverStripe\Assets\Image: PLURALNAME: Images + PLURALS: + one: 'An Image' + other: '{count} Images' SINGULARNAME: Image SilverStripe\ORM\DataObject: PLURALNAME: 'Data Objects' + PLURALS: + one: 'A Data Object' + other: '{count} Data Objects' SINGULARNAME: 'Data Object' SilverStripe\ORM\Versioning\ChangeSet: PLURALNAME: Campaigns + PLURALS: + one: 'A Campaign' + other: '{count} Campaigns' SINGULARNAME: Campaign SilverStripe\ORM\Versioning\ChangeSetItem: PLURALNAME: 'Change Set Items' + PLURALS: + one: 'A Change Set Item' + other: '{count} Change Set Items' SINGULARNAME: 'Change Set Item' SilverStripe\Security\Group: PLURALNAME: Groups + PLURALS: + one: 'A Group' + other: '{count} Groups' SINGULARNAME: Group SilverStripe\Security\LoginAttempt: PLURALNAME: 'Login Attempts' + PLURALS: + one: 'A Login Attempt' + other: '{count} Login Attempts' SINGULARNAME: 'Login Attempt' SilverStripe\Security\Member: PLURALNAME: Members + PLURALS: + one: 'A Member' + other: '{count} Members' SINGULARNAME: Member SilverStripe\Security\MemberPassword: PLURALNAME: 'Member Passwords' + PLURALS: + one: 'A Member Password' + other: '{count} Member Passwords' SINGULARNAME: 'Member Password' SilverStripe\Security\Permission: PLURALNAME: Permissions + PLURALS: + one: 'A Permission' + other: '{count} Permissions' SINGULARNAME: Permission SilverStripe\Security\PermissionRole: PLURALNAME: Roles + PLURALS: + one: 'A Role' + other: '{count} Roles' SINGULARNAME: Role SilverStripe\Security\PermissionRoleCode: PLURALNAME: 'Permission Role Codes' + PLURALS: + one: 'A Permission Role Code' + other: '{count} Permission Role Codes' SINGULARNAME: 'Permission Role Code' SilverStripe\Security\RememberLoginHash: PLURALNAME: 'Remember Login Hashs' + PLURALS: + one: 'A Remember Login Hash' + other: '{count} Remember Login Hashs' SINGULARNAME: 'Remember Login Hash' SiteTree: TABMAIN: Main diff --git a/src/Core/Core.php b/src/Core/Core.php index 853e64c69..225177ecf 100644 --- a/src/Core/Core.php +++ b/src/Core/Core.php @@ -7,6 +7,7 @@ use SilverStripe\Core\Manifest\ClassLoader; use SilverStripe\Core\Manifest\ConfigStaticManifest; use SilverStripe\Core\Manifest\ConfigManifest; use SilverStripe\Control\Director; +use SilverStripe\Dev\Deprecation; use SilverStripe\i18n\i18n; /** @@ -121,7 +122,7 @@ $errorHandler->start(); */ function singleton($className) { - if ($className === 'SilverStripe\\Core\\Config\\Config') { + if ($className === Config::class) { throw new InvalidArgumentException("Don't pass Config to singleton()"); } if (!isset($className)) { @@ -146,13 +147,13 @@ function project() * * @param string $entity * @param string $string - * @param string $context * @param array $injection * @return string */ -function _t($entity, $string = "", $context = "", $injection = null) +function _t($entity, $string = "", $injection = []) { - return i18n::_t($entity, $string, $context, $injection); + // Pass args directly to handle deprecation + return call_user_func_array([i18n::class, '_t'], func_get_args()); } /** diff --git a/src/Core/Injector/Injector.php b/src/Core/Injector/Injector.php index b86173530..60fcbbf57 100644 --- a/src/Core/Injector/Injector.php +++ b/src/Core/Injector/Injector.php @@ -519,10 +519,17 @@ class Injector return $newVal; } + // Evaluate service references if (is_string($value) && strpos($value, '%$') === 0) { $id = substr($value, 2); return $this->get($id); } + + // Evaluate constants surrounded by back ticks + if (preg_match('/^`(?[^`]+)`$/', $value, $matches)) { + $value = defined($matches['name']) ? constant($matches['name']) : null; + } + return $value; } diff --git a/src/Dev/Backtrace.php b/src/Dev/Backtrace.php index cabd8020f..d264fc1ad 100644 --- a/src/Dev/Backtrace.php +++ b/src/Dev/Backtrace.php @@ -2,7 +2,6 @@ namespace SilverStripe\Dev; -use SilverStripe\Core\Config\Config; use SilverStripe\Control\Director; use SilverStripe\Core\Config\Configurable; diff --git a/src/Dev/Tasks/i18nTextCollectorTask.php b/src/Dev/Tasks/i18nTextCollectorTask.php index e48429e94..fdfd766b0 100644 --- a/src/Dev/Tasks/i18nTextCollectorTask.php +++ b/src/Dev/Tasks/i18nTextCollectorTask.php @@ -6,7 +6,7 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Debug; use SilverStripe\Dev\BuildTask; -use SilverStripe\i18n\i18nTextCollector; +use SilverStripe\i18n\TextCollection\i18nTextCollector; /** * Collects i18n strings diff --git a/src/Forms/ConfirmedPasswordField.php b/src/Forms/ConfirmedPasswordField.php index 68c1a92a5..c6ca309c7 100644 --- a/src/Forms/ConfirmedPasswordField.php +++ b/src/Forms/ConfirmedPasswordField.php @@ -560,7 +560,7 @@ class ConfirmedPasswordField extends FormField { /** @var ReadonlyField $field */ $field = $this->castedCopy('SilverStripe\\Forms\\ReadonlyField') - ->setTitle($this->title ? $this->title : _t('Member.PASSWORD')) + ->setTitle($this->title ? $this->title : _t('Member.PASSWORD', 'Password')) ->setValue('*****'); return $field; diff --git a/src/Forms/GridField/GridFieldFilterHeader.php b/src/Forms/GridField/GridFieldFilterHeader.php index 597a440a4..56b384627 100755 --- a/src/Forms/GridField/GridFieldFilterHeader.php +++ b/src/Forms/GridField/GridFieldFilterHeader.php @@ -212,7 +212,7 @@ class GridFieldFilterHeader implements GridField_HTMLProvider, GridField_DataMan $fields->push( GridField_FormAction::create($gridField, 'filter', false, 'filter', null) ->addExtraClass('btn font-icon-search btn--no-text btn--icon-large grid-field__filter-submit ss-gridfield-button-filter') - ->setAttribute('title', _t('GridField.Filter', "Filter")) + ->setAttribute('title', _t('GridField.Filter', 'Filter')) ->setAttribute('id', 'action_filter_' . $gridField->getModelClass() . '_' . $columnField) ); $fields->push( diff --git a/src/Forms/HTMLEditor/HTMLEditorField.php b/src/Forms/HTMLEditor/HTMLEditorField.php index 96d2a3501..85322762a 100644 --- a/src/Forms/HTMLEditor/HTMLEditorField.php +++ b/src/Forms/HTMLEditor/HTMLEditorField.php @@ -154,7 +154,7 @@ class HTMLEditorField extends TextareaField */ public function performReadonlyTransformation() { - return $this->castedCopy('SilverStripe\\Forms\\HTMLEditor\\HTMLEditorField_Readonly'); + return $this->castedCopy(HTMLEditorField_Readonly::class); } public function performDisabledTransformation() diff --git a/src/Forms/HTMLEditor/HTMLEditorField_Toolbar.php b/src/Forms/HTMLEditor/HTMLEditorField_Toolbar.php index a5e0151af..3517c5c0f 100644 --- a/src/Forms/HTMLEditor/HTMLEditorField_Toolbar.php +++ b/src/Forms/HTMLEditor/HTMLEditorField_Toolbar.php @@ -104,6 +104,7 @@ class HTMLEditorField_Toolbar extends RequestHandler * Return a {@link Form} instance allowing a user to * add links in the TinyMCE content editor. * + * @skipUpgrade * @return Form */ public function LinkForm() @@ -111,7 +112,7 @@ class HTMLEditorField_Toolbar extends RequestHandler $siteTree = TreeDropdownField::create( 'internal', _t('HTMLEditorField.PAGE', "Page"), - 'SilverStripe\\CMS\\Model\\SiteTree', + SiteTree::class, 'ID', 'MenuTitle', true @@ -158,7 +159,7 @@ class HTMLEditorField_Toolbar extends RequestHandler $siteTree, TextField::create('external', _t('HTMLEditorField.URL', 'URL'), 'http://'), EmailField::create('email', _t('HTMLEditorField.EMAIL', 'Email address')), - $fileField = UploadField::create('file', _t('HTMLEditorField.FILE', 'SilverStripe\\Assets\\File')), + $fileField = UploadField::create('file', _t('HTMLEditorField.FILE', 'File')), TextField::create('Anchor', _t('HTMLEditorField.ANCHORVALUE', 'Anchor')), TextField::create('Subject', _t('HTMLEditorField.SUBJECT', 'Email subject')), TextField::create('Description', _t('HTMLEditorField.LINKDESCR', 'Link description')), @@ -229,7 +230,7 @@ class HTMLEditorField_Toolbar extends RequestHandler $columns = $fileField->getConfig()->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDataColumns'); $columns->setDisplayFields(array( 'StripThumbnail' => false, - 'Title' => _t('File.Title'), + 'Title' => _t('File.Title', 'Title'), 'Created' => File::singleton()->fieldLabel('Created'), )); $columns->setFieldCasting(array( diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 9b4fb6891..bb82bc364 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -674,16 +674,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * E.g. "0 Pages", "1 File", "3 Images" * * @param string $count - * @param bool $prependNumber Include number in result. Defaults to true. * @return string */ - public function i18n_pluralise($count, $prependNumber = true) + public function i18n_pluralise($count) { + $default = 'one ' . $this->i18n_singular_name() . '|{count} ' . $this->i18n_plural_name(); return i18n::pluralise( - $this->i18n_singular_name(), - $this->i18n_plural_name(), - $count, - $prependNumber + static::class.'.PLURALS', + $default, + $count ); } @@ -696,12 +695,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function singular_name() { - if (!$name = $this->stat('singular_name')) { - $reflection = new \ReflectionClass($this); - $name = ucwords(trim(strtolower(preg_replace('/_?([A-Z])/', ' $1', $reflection->getShortName())))); + $name = $this->stat('singular_name'); + if ($name) { + return $name; } - - return $name; + return ucwords(trim(strtolower(preg_replace( + '/_?([A-Z])/', + ' $1', + ClassInfo::shortName($this) + )))); } /** @@ -717,9 +719,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function i18n_singular_name() { - // @todo Update localisation to FQN for all classes - $reflection = new \ReflectionClass($this); - return _t($reflection->getShortName().'.SINGULARNAME', $this->singular_name()); + return _t(static::class.'.SINGULARNAME', $this->singular_name()); } /** @@ -733,14 +733,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity { if ($name = $this->stat('plural_name')) { return $name; - } else { - $name = $this->singular_name(); - //if the penultimate character is not a vowel, replace "y" with "ies" - if (preg_match('/[^aeiou]y$/i', $name)) { - $name = substr($name, 0, -1) . 'ie'; - } - return ucfirst($name . 's'); } + $name = $this->singular_name(); + //if the penultimate character is not a vowel, replace "y" with "ies" + if (preg_match('/[^aeiou]y$/i', $name)) { + $name = substr($name, 0, -1) . 'ie'; + } + return ucfirst($name . 's'); } /** @@ -755,10 +754,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function i18n_plural_name() { - // @todo Update localisation to FQN for all classes - $name = $this->plural_name(); - $reflection = new \ReflectionClass($this); - return _t($reflection->getShortName().'.PLURALNAME', $name); + return _t(static::class.'.PLURALNAME', $this->plural_name()); } /** @@ -3740,33 +3736,21 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ private static $summary_fields = null; - /** - * Collect all static properties on the object - * which contain natural language, and need to be translated. - * The full entity name is composed from the class name and a custom identifier. - * - * @return array A numerical array which contains one or more entities in array-form. - * Each numeric entity array contains the "arguments" for a _t() call as array values: - * $entity, $string, $priority, $context. - */ public function provideI18nEntities() { - $entities = array(); - - $entities["{$this->class}.SINGULARNAME"] = array( - $this->singular_name(), - - 'Singular name of the object, used in dropdowns and to generally identify a single object in the interface' - ); - - $entities["{$this->class}.PLURALNAME"] = array( - $this->plural_name(), - - 'Pural name of the object, used in dropdowns and to generally identify a collection of this object in the' - . ' interface' - ); - - return $entities; + // Note: see http://guides.rubyonrails.org/i18n.html#pluralization for rules + // Best guess for a/an rule. Better guesses require overriding in subclasses + $pluralName = $this->plural_name(); + $singularName = $this->singular_name(); + $conjunction = preg_match('/^[aeiou]/i', $singularName) ? 'An ' : 'A '; + return [ + static::class.'.SINGULARNAME' => $this->singular_name(), + static::class.'.PLURALNAME' => $pluralName, + static::class.'.PLURALS' => [ + 'one' => $conjunction . $singularName, + 'other' => '{count} ' . $pluralName + ] + ]; } /** diff --git a/src/ORM/Versioning/ChangeSet.php b/src/ORM/Versioning/ChangeSet.php index b9627893f..93f5af514 100644 --- a/src/ORM/Versioning/ChangeSet.php +++ b/src/ORM/Versioning/ChangeSet.php @@ -494,14 +494,14 @@ class ChangeSet extends DataObject if ($countedOther) { if ($counted) { $parts[] = i18n::pluralise( - _t('ChangeSet.DESCRIPTION_OTHER_ITEM', 'other item'), - _t('ChangeSet.DESCRIPTION_OTHER_ITEMS', 'other items'), + 'ChangeSet.DESCRIPTION_OTHER_ITEM_PLURALS', + 'one other item|{count} other items', $countedOther ); } else { $parts[] = i18n::pluralise( - _t('ChangeSet.DESCRIPTION_ITEM', 'item'), - _t('ChangeSet.DESCRIPTION_ITEMS', 'items'), + 'ChangeSet.DESCRIPTION_ITEM_PLURALS', + 'one item|{count} items', $countedOther ); } diff --git a/src/Security/MemberAuthenticator.php b/src/Security/MemberAuthenticator.php index bd7d2a66e..daa059ba5 100644 --- a/src/Security/MemberAuthenticator.php +++ b/src/Security/MemberAuthenticator.php @@ -83,7 +83,10 @@ class MemberAuthenticator extends Authenticator $result = $member->checkPassword($data['Password']); $success = $result->isValid(); } else { - $result = ValidationResult::create()->addError(_t('Member.ERRORWRONGCRED')); + $result = ValidationResult::create()->addError(_t( + 'Member.ERRORWRONGCRED', + 'The provided details don\'t seem to be correct. Please try again.' + )); } // Emit failure to member and form (if available) diff --git a/src/View/SSViewer_DataPresenter.php b/src/View/SSViewer_DataPresenter.php index 36e122229..97986f8c1 100644 --- a/src/View/SSViewer_DataPresenter.php +++ b/src/View/SSViewer_DataPresenter.php @@ -43,7 +43,7 @@ class SSViewer_DataPresenter extends SSViewer_Scope // Get all the exposed variables from all classes that implement the TemplateGlobalProvider interface $this->createCallableArray( self::$globalProperties, - "SilverStripe\\View\\TemplateGlobalProvider", + TemplateGlobalProvider::class, "get_template_global_variables" ); } @@ -55,7 +55,7 @@ class SSViewer_DataPresenter extends SSViewer_Scope // //call non-statically $this->createCallableArray( self::$iteratorProperties, - "SilverStripe\\View\\TemplateIteratorProvider", + TemplateIteratorProvider::class, "get_template_iterator_variables", true ); @@ -82,11 +82,7 @@ class SSViewer_DataPresenter extends SSViewer_Scope if (!is_array($details)) { $details = array( 'method' => $details, - 'casting' => Config::inst()->get( - 'SilverStripe\\View\\ViewableData', - 'default_cast', - Config::FIRST_SET - ) + 'casting' => ViewableData::config()->get('default_cast', Config::FIRST_SET) ); } @@ -189,11 +185,7 @@ class SSViewer_DataPresenter extends SSViewer_Scope // If not provided, use default if (!$casting) { - $casting = Config::inst()->get( - 'SilverStripe\\View\\ViewableData', - 'default_cast', - Config::FIRST_SET - ); + $casting = ViewableData::config()->get('default_cast', Config::FIRST_SET); } $obj = Injector::inst()->get($casting, false, array($property)); diff --git a/src/i18n/Messages/MessageProvider.php b/src/i18n/Messages/MessageProvider.php new file mode 100644 index 000000000..c18369a60 --- /dev/null +++ b/src/i18n/Messages/MessageProvider.php @@ -0,0 +1,30 @@ +loadMessages($path, $locale), + $messages + ); + } + ksort($messages); + $catalog = parent::load($messages, $locale, $domain); + + // Ensure this catalog is invalidated on flush + $catalog->addResource(new FlushInvalidatedResource()); + return $catalog; + } + + /** + * @return Reader + */ + public function getReader() + { + return $this->reader; + } + + /** + * @param Reader $reader + * @return $this + */ + public function setReader(Reader $reader) + { + $this->reader = $reader; + return $this; + } + + + /** + * Load messages + * + * @param string $path + * @param string $locale + * @return array + */ + protected function loadMessages($path, $locale) + { + $filePath = $path . $locale . '.yml'; + $messages = $this->getReader()->read($locale, $filePath); + return $this->normaliseMessages($messages, $locale); + } + + /** + * Normalises plurals in messages from rails-yaml format to symfony. + * + * @param array $messages List of messages + * @param string $locale + * @return array Normalised messages + */ + protected function normaliseMessages($messages, $locale) + { + foreach ($messages as $key => $value) { + if (is_array($value)) { + $messages[$key] = $this->normalisePlurals($key, $value, $locale); + } + } + return $messages; + } + + /** + * Normalise rails-yaml plurals into pipe-separated rules + * + * @link http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html + * @link http://guides.rubyonrails.org/i18n.html#pluralization + * @link http://symfony.com/doc/current/components/translation/usage.html#component-translation-pluralization + * + * @param string $key + * @param array $map + * @param string $locale + * @return string + */ + protected function normalisePlurals($key, $map, $locale) + { + $parts = []; + foreach ($this->config()->get('plurals') as $form) { + if (isset($map[$form])) { + $parts[] = $map[$form]; + } + } + // Non-associative plural, just keep in same order + if (empty($parts)) { + return $parts = $map; + } + + // Warn if mismatched plural forms + if (count($map) !== count($parts)) { + trigger_error("Plural form {$locale}.{$key} has invalid plural keys", E_USER_WARNING); + } + + return implode('|', $parts); + } +} diff --git a/src/i18n/Messages/Symfony/SymfonyMessageProvider.php b/src/i18n/Messages/Symfony/SymfonyMessageProvider.php new file mode 100644 index 000000000..25c044ace --- /dev/null +++ b/src/i18n/Messages/Symfony/SymfonyMessageProvider.php @@ -0,0 +1,188 @@ +translator; + } + + /** + * @param Translator $translator + * @return $this + */ + public function setTranslator($translator) + { + $this->translator = $translator; + foreach ($translator->getFallbackLocales() as $locale) { + $this->load($locale); + } + return $this; + } + + /** + * Load resources for the given locale + * + * @param string $locale + */ + protected function load($locale) + { + if (isset($this->loadedLocales[$locale])) { + return; + } + + // Add full locale file. E.g. 'en_NZ' + $this + ->getTranslator() + ->addResource('ss', $this->getSourceDirs(), $locale); + + // Add lang-only file. E.g. 'en' + $lang = i18n::get_lang_from_locale($locale); + if ($lang !== $locale) { + $this + ->getTranslator() + ->addResource('ss', $this->getSourceDirs(), $lang); + } + + + $this->loadedLocales[$locale] = true; + } + + public function translate($entity, $default, $injection) + { + // Ensure localisation is ready + $locale = i18n::get_locale(); + $this->load($locale); + + // Prepare arguments + $arguments = $this->templateInjection($injection); + + // Pass to symfony translator + $result = $this->getTranslator()->trans($entity, $arguments, 'messages', $locale); + + // Manually inject default if no translation found + if ($entity === $result) { + $result = $this->getTranslator()->trans($default, $arguments, 'messages', $locale); + } + + return $result; + } + + public function pluralise($entity, $default, $count, $injection) + { + if (is_array($default)) { + $default = $this->normalisePlurals($default); + } + + // Ensure localisation is ready + $locale = i18n::get_locale(); + $this->load($locale); + + // Prepare arguments + $arguments = $this->templateInjection(array_merge( + $injection, + [ 'count' => $count ] + )); + + // Pass to symfony translator + $result = $this->getTranslator()->transChoice($entity, $count, $arguments, 'messages', $locale); + + // Manually inject default if no translation found + if ($entity === $result) { + $result = $this->getTranslator()->transChoice($default, $count, $arguments, 'messages', $locale); + } + + return $result; + } + + /** + * Get the list of /lang dirs to load localisations from + * + * @return array + */ + public function getSourceDirs() + { + if (!$this->sourceDirs) { + $this->setSourceDirs(i18n::get_lang_dirs()); + } + return $this->sourceDirs; + } + + /** + * Set the list of /lang dirs to load localisations from + * + * @param array $sourceDirs + * @return $this + */ + public function setSourceDirs($sourceDirs) + { + $this->sourceDirs = $sourceDirs; + return $this; + } + + /** + * Generate template safe injection parameters + * + * @param array $injection + * @return array Injection array with all keys surrounded with {} placeholders + */ + protected function templateInjection($injection) + { + $injection = $injection ?: []; + // Rewrite injection to {} surrounded placeholders + $arguments = array_combine( + array_map(function ($val) { + return '{' . $val . '}'; + }, array_keys($injection)), + $injection + ); + return $arguments; + } + + /** + * Convert ruby i18n plural form to symfony pipe-delimited form. + * + * @param array $parts + * @return array|string + */ + protected function normalisePlurals($parts) + { + return implode('|', $parts); + } +} diff --git a/src/i18n/i18nTextCollector_Writer.php b/src/i18n/Messages/Writer.php similarity index 57% rename from src/i18n/i18nTextCollector_Writer.php rename to src/i18n/Messages/Writer.php index a46eb93d8..f917cae37 100644 --- a/src/i18n/i18nTextCollector_Writer.php +++ b/src/i18n/Messages/Writer.php @@ -1,21 +1,20 @@ parser) { + $this->parser = new Parser(); + } + return $this->parser; + } + + public function read($locale, $path) + { + try { + if (!file_exists($path)) { + return []; + } + // Load + $yaml = $this->getParser()->parse(file_get_contents($path)); + if (empty($yaml[$locale])) { + return []; + } + // Normalise messages + return $this->normaliseMessages($yaml[$locale]); + } catch (ParseException $exception) { + var_dump($exception); + throw new InvalidResourceException(sprintf('Error parsing YAML, invalid file "%s"', $path), 0, $exception); + } + } + + /** + * Flatten [class => [ key1 => value1, key2 => value2]] into [class.key1 => value1, class.key2 => value2] + * + * Inverse of YamlWriter::denormaliseMessages() + * + * @param array $entities + * @return mixed + */ + protected function normaliseMessages($entities) + { + $messages = []; + // Squash second and third levels together (class.key) + foreach ($entities as $class => $keys) { + // Check if namespace omits class + if (!is_array($keys)) { + $messages[$class] = $keys; + } else { + foreach ($keys as $key => $value) { + $fullKey = "{$class}.{$key}"; + $messages[$fullKey] = $value; + } + } + } + return $messages; + } +} diff --git a/src/i18n/Messages/YamlWriter.php b/src/i18n/Messages/YamlWriter.php new file mode 100644 index 000000000..322cf034a --- /dev/null +++ b/src/i18n/Messages/YamlWriter.php @@ -0,0 +1,111 @@ +dumper) { + $this->dumper = new Dumper(); + $this->dumper->setIndentation(2); + } + return $this->dumper; + } + + + public function write($messages, $locale, $path) + { + // Skip empty entities + if (empty($messages)) { + return; + } + + // Create folder for lang files + $langFolder = $path . '/lang'; + if (!file_exists($langFolder)) { + Filesystem::makeFolder($langFolder); + touch($langFolder . '/_manifest_exclude'); + } + + // De-normalise messages and convert to yml + $content = $this->getYaml($messages, $locale); + + // Open the English file and write the Master String Table + $langFile = $langFolder . '/' . $locale . '.yml'; + if ($fh = fopen($langFile, "w")) { + fwrite($fh, $content); + fclose($fh); + } else { + throw new LogicException("Cannot write language file! Please check permissions of $langFile"); + } + } + + /** + * Explodes [class.key1 => value1, class.key2 => value2] into [class => [ key1 => value1, key2 => value2]] + * + * Inverse of YamlReader::normaliseMessages() + * + * @param array $messages + * @return array + */ + protected function denormaliseMessages($messages) + { + $entities = []; + foreach ($messages as $entity => $value) { + // Skip un-namespaced keys + if (strstr($entity, '.') === false) { + $entities[$entity] = $value; + continue; + } + $parts = explode('.', $entity); + $class = array_shift($parts); + + // Ensure the `.ss` suffix gets added to the top level class rather than the key + if (count($parts) > 1 && reset($parts) === 'ss') { + $class .= '.ss'; + array_shift($parts); + } + $key = implode('.', $parts); + if (!isset($entities[$class])) { + $entities[$class] = []; + } + $entities[$class][$key] = $value; + } + return $entities; + } + + /** + * Convert messages to yml ready to write + * + * @param array $messages + * @param string $locale + * @return string + */ + public function getYaml($messages, $locale) + { + $entities = $this->denormaliseMessages($messages); + $content = $this->getDumper()->dump([ + $locale => $entities + ], 99); + return $content; + } +} diff --git a/src/i18n/TextCollection/Parser.php b/src/i18n/TextCollection/Parser.php new file mode 100644 index 000000000..d2febae90 --- /dev/null +++ b/src/i18n/TextCollection/Parser.php @@ -0,0 +1,85 @@ +string = $string; + $this->pos = 0; + $this->depth = 0; + $this->regexps = array(); + } + + public function Translate__construct(&$res) + { + $this->currentEntity = [null, null]; + } + + public function Translate_Entity(&$res, $sub) + { + $this->currentEntity[0] = $sub['text']; // key + } + + public function Translate_Default(&$res, $sub) + { + $this->currentEntity[1] = $sub['String']['text']; // default + } + + public function Translate__finalise(&$res) + { + // Capture entity if, and only if, a default vaule is provided + if ($this->currentEntity[1]) { + $this->entities[$this->currentEntity[0]] = $this->currentEntity[1]; + } + } + + /** + * Parses a template and returns any translatable entities + * + * @param string $template String to parse for translations + * @return array Map of keys -> values + */ + public static function getTranslatables($template) + { + // Run the parser and throw away the result + $parser = new Parser($template); + if (substr($template, 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) { + $parser->pos = 3; + } + $parser->match_TopTemplate(); + return $parser->getEntities(); + } + + /** + * @return array + */ + public function getEntities() + { + return $this->entities; + } +} diff --git a/src/i18n/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php similarity index 82% rename from src/i18n/i18nTextCollector.php rename to src/i18n/TextCollection/i18nTextCollector.php index e21942288..34a8d04e3 100644 --- a/src/i18n/i18nTextCollector.php +++ b/src/i18n/TextCollection/i18nTextCollector.php @@ -1,14 +1,18 @@ get('default_locale')); $this->basePath = Director::baseFolder(); $this->baseSavePath = Director::baseFolder(); - - parent::__construct(); } /** * Assign a writer * - * @param i18nTextCollector_Writer $writer + * @param Writer $writer + * @return $this */ public function setWriter($writer) { $this->writer = $writer; + return $this; } /** * Gets the currently assigned writer, or the default if none is specified. * - * @return i18nTextCollector_Writer + * @return Writer */ public function getWriter() { - if (!$this->writer) { - $this->setWriter(Injector::inst()->get('SilverStripe\\i18n\\i18nTextCollector_Writer')); - } return $this->writer; } + /** + * Get reader + * + * @return Reader + */ + public function getReader() + { + return $this->reader; + } + + /** + * Set reader + * + * @param Reader $reader + * @return $this + */ + public function setReader(Reader $reader) + { + $this->reader = $reader; + return $this; + } + /** * This is the main method to build the master string tables with the * original strings. It will search for existent modules that use the @@ -347,31 +378,19 @@ class i18nTextCollector extends Object */ protected function mergeWithExisting($entitiesByModule) { - // TODO Support all defined source formats through i18n::get_translators(). - // Currently not possible because adapter instances can't be fully reset through the Zend API, - // meaning master strings accumulate across modules - foreach ($entitiesByModule as $module => $entities) { - $adapter = Injector::inst()->create('SilverStripe\\i18n\\i18nRailsYamlAdapter'); - $fileName = $adapter->getFilenameForLocale($this->defaultLocale); - $masterFile = "{$this->basePath}/{$module}/lang/{$fileName}"; - if (!file_exists($masterFile)) { - continue; - } + // For each module do a simple merge of the default yml with these strings + foreach ($entitiesByModule as $module => $messages) { + // Load existing localisations + $masterFile = "{$this->basePath}/{$module}/lang/{$this->defaultLocale}.yml"; + $existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile); - $adapter->addTranslation(array( - 'content' => $masterFile, - 'locale' => $this->defaultLocale - )); - $entitiesByModule[$module] = array_merge( - array_map( - // Transform each master string from scalar value to array of strings - function ($v) { - return array($v); - }, - $adapter->getMessages($this->defaultLocale) - ), - $entities - ); + // Merge + if ($existingMessages) { + $entitiesByModule[$module] = array_merge( + $existingMessages, + $messages + ); + } } return $entitiesByModule; } @@ -395,18 +414,24 @@ class i18nTextCollector extends Object $entitiesByModule[$module] = $processedEntities; } - // extract all entities for "foreign" modules (fourth argument) + // Extract all entities for "foreign" modules ('module' key in array form) // @see CMSMenu::provideI18nEntities for an example usage foreach ($entitiesByModule[$module] as $fullName => $spec) { - if (!empty($spec[2]) && $spec[2] !== $module) { - $othermodule = $spec[2]; - if (!isset($entitiesByModule[$othermodule])) { - $entitiesByModule[$othermodule] = array(); - } - unset($spec[2]); - $entitiesByModule[$othermodule][$fullName] = $spec; + $specModule = $module; + $specDefault = $spec; + if (is_array($spec) && isset($spec['module'])) { + $specModule = $spec['module']; + $specDefault = $spec['default']; + } + // Remove from source module + if ($specModule !== $module) { unset($entitiesByModule[$module][$fullName]); } + // Write to target module + if (!isset($entitiesByModule[$specModule])) { + $entitiesByModule[$specModule] = []; + } + $entitiesByModule[$specModule][$fullName] = $specDefault; } } return $entitiesByModule; @@ -493,11 +518,12 @@ class i18nTextCollector extends Object /** * Extracts translatables from .php files. + * Note: Translations without default values are omitted. * * @param string $content The text content of a parsed template-file * @param string $module Module's name or 'themes'. Could also be a namespace * Generated by templates includes. E.g. 'UploadField.ss' - * @return array $entities An array of entities representing the extracted translation function calls in code + * @return array Map of localised keys to default values provided for this code */ public function collectFromCode($content, $module) { @@ -520,15 +546,19 @@ class i18nTextCollector extends Object if ($id == T_STRING && $text == '_t') { // start definition $inTransFn = true; - } elseif ($inTransFn && $id == T_VARIABLE) { - // Dynamic definition from provideEntities - skip + } elseif ($inTransFn && ( + in_array($id, [T_VARIABLE, T_STATIC, T_CLASS_C]) || + ($id === T_STRING && in_array($text, ['self', 'static', 'parent'])) + )) { + // Un-collectable strings such as _t(static::class.'.KEY'). + // Should be provided by i18nEntityProvider instead $inTransFn = false; $inConcat = false; $currentEntity = array(); } elseif ($inTransFn && $id == T_CONSTANT_ENCAPSED_STRING) { // Fixed quoting escapes, and remove leading/trailing quotes if (preg_match('/^\'/', $text)) { - $text = str_replace("\'", "'", $text); + $text = str_replace("\\'", "'", $text); $text = preg_replace('/^\'/', '', $text); $text = preg_replace('/\'$/', '', $text); } else { @@ -538,7 +568,12 @@ class i18nTextCollector extends Object } if ($inConcat) { - $currentEntity[count($currentEntity)-1] .= $text; + // Parser error + if (empty($currentEntity)) { + user_error('Error concatenating localisation key', E_USER_WARNING); + } else { + $currentEntity[count($currentEntity) - 1] .= $text; + } } else { $currentEntity[] = $text; } @@ -551,22 +586,22 @@ class i18nTextCollector extends Object // finalize definition $inTransFn = false; $inConcat = false; - $entity = array_shift($currentEntity); - $entities[$entity] = $currentEntity; + // Only collect translations with default values provided + if (!empty($currentEntity[1])) { + $entities[$currentEntity[0]] = $currentEntity[1]; + } elseif (!empty($currentEntity[0])) { + // Add minor notice + trigger_error("Missing localisation default for key ".$currentEntity[0], E_USER_NOTICE); + } $currentEntity = array(); $finalTokenDueToArray = false; } } - foreach ($entities as $entity => $spec) { - // call without master language definition - if (!$spec) { - unset($entities[$entity]); - continue; - } - - unset($entities[$entity]); - $entities[$this->normalizeEntity($entity, $module)] = $spec; + // Normalise all keys + foreach ($entities as $key => $default) { + unset($entities[$key]); + $entities[$this->normalizeEntity($key, $module)] = $default; } ksort($entities); @@ -585,7 +620,7 @@ class i18nTextCollector extends Object public function collectFromTemplate($content, $fileName, $module, &$parsedFiles = array()) { // use parser to extract <%t style translatable entities - $entities = i18nTextCollector_Parser::GetTranslatables($content); + $entities = Parser::getTranslatables($content); // use the old method of getting _t() style translatable entities // Collect in actual template @@ -622,7 +657,7 @@ class i18nTextCollector extends Object $classes = ClassInfo::classes_for_file($filePath); foreach ($classes as $class) { // Skip non-implementing classes - if (!class_exists($class) || !is_a($class, 'SilverStripe\\i18n\\i18nEntityProvider', true)) { + if (!class_exists($class) || !is_a($class, i18nEntityProvider::class, true)) { continue; } @@ -632,8 +667,26 @@ class i18nTextCollector extends Object continue; } + /** @var i18nEntityProvider $obj */ $obj = singleton($class); - $entities = array_merge($entities, (array)$obj->provideI18nEntities()); + $provided = $obj->provideI18nEntities(); + // Handle deprecated return syntax + foreach ($provided as $key => $value) { + // Detect non-associative result for any key + if (is_array($value) && $value === array_values($value)) { + Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated'); + if (!empty($value[2])) { + $provided[$key] = [ + 'default' => $value[0], + 'module' => $value[2], + ]; + } else { + $provided[$key] = $value[0]; + } + + } + } + $entities = array_merge($entities, $provided); } ksort($entities); diff --git a/src/i18n/i18n.php b/src/i18n/i18n.php index c36083093..aeaaa83cf 100644 --- a/src/i18n/i18n.php +++ b/src/i18n/i18n.php @@ -3,25 +3,17 @@ namespace SilverStripe\i18n; use SilverStripe\Control\Director; -use SilverStripe\Core\Cache; -use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Object; -use SilverStripe\Core\Flushable; +use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Manifest\ClassLoader; -use SilverStripe\ORM\ArrayLib; +use SilverStripe\Dev\Deprecation; +use SilverStripe\i18n\Messages\MessageProvider; +use SilverStripe\View\SSViewer; use SilverStripe\View\TemplateGlobalProvider; -use Zend_Cache_Backend_ExtendedInterface; -use Zend_Cache; -use Zend_Cache_Core; -use Zend_Translate_Adapter; -use Zend_Translate; -use Zend_Locale_Data; -use Zend_Locale_Exception; +use SilverStripe\View\ThemeResourceLoader; use InvalidArgumentException; -require_once 'Zend/Translate.php'; - /** * Base-class for storage and retrieval of translated entities. * @@ -79,11 +71,15 @@ require_once 'Zend/Translate.php'; * * @author Bernat Foj Capell */ -class i18n extends Object implements TemplateGlobalProvider, Flushable +class i18n implements TemplateGlobalProvider { + use Injectable; + use Configurable; /** * This static variable is used to store the current defined locale. + * + * @var string */ protected static $current_locale = ''; @@ -93,12 +89,6 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable */ private static $default_locale = 'en_US'; - /** - * @config - * @var boolean - */ - private static $js_i18n = true; - /** * @config * @var string @@ -112,38 +102,34 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable private static $time_format = 'H:mm'; /** - * @var array Array of priority keys to instances of Zend_Translate, mapped by name. - */ - protected static $translators; - - /** - * Triggered early in the request when someone requests a flush. - */ - public static function flush() - { - $cache = self::get_cache(); - $backend = $cache->getBackend(); - - if ($backend instanceof Zend_Cache_Backend_ExtendedInterface - && ($capabilities = $backend->getCapabilities()) - && $capabilities['tags'] - ) { - $cache->clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, $cache->getTags()); - } else { - $cache->clean(Zend_Cache::CLEANING_MODE_ALL); - } - } - - /** - * Return an instance of the cache used for i18n data. + * List of prioritised modules, in lowest to highest priority. * - * @skipUpgrade - * @return Zend_Cache_Core + * @config + * @var array */ - public static function get_cache() - { - return Cache::factory('i18n', 'Output', array('lifetime' => null, 'automatic_serialization' => true)); - } + private static $module_priority = []; + + /** + * Config for ltr/rtr of specific locales. + * Will default to ltr. + * + * @config + * @var array + */ + private static $text_direction = [ + 'ar' => 'rtl', + 'dv' => 'rtl', + 'fa' => 'rtl', + 'ha_Arab' => 'rtl', + 'he' => 'rtl', + 'ku' => 'rtl', + 'pa_Arab' => 'rtl', + 'ps' => 'rtl', + 'syr' => 'rtl', + 'ug' => 'rtl', + 'ur' => 'rtl', + 'uz_Arab' => 'rtl', + ]; /** * An exhaustive list of possible locales (code => language and country) @@ -1973,253 +1959,121 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable * This is the main translator function. Returns the string defined by $class and $entity according to the * currently set locale. * - * @param string $entity Entity that identifies the string. It must be in the form "Namespace.Entity" where - * Namespace will be usually the class name where this string is used and Entity identifies - * the string inside the namespace. - * @param string $string The original string itself. In a usual call this is a mandatory parameter, but if you are - * reusing a string which has already been "declared" (using another call to this function, - * with the same class and entity), you can omit it. - * @param string $context (optional) If the string can be difficult to translate by any reason, you can help - * translators with some more info using this param - * @param array $injection (optional) array of key value pairs that are used to replace corresponding - * expressions in {curly brackets} in the $string. The injection array can also be - * used as the their argument to the _t() function + * @param string $entity Entity that identifies the string. It must be in the form + * "Namespace.Entity" where Namespace will be usually the class name where this + * string is used and Entity identifies the string inside the namespace. + * @param string $default The original string itself. In a usual call this is a + * mandatory parameter, but if you are reusing a string which has already been + * "declared" (using another call to this function, with the same class and entity), + * you can omit it. + * @param array $injection (optional) array of key value pairs that are used + * to replace corresponding expressions in {curly brackets} in the $string. + * The injection array can also be used as the their argument to the _t() function * @return string The translated string, according to the currently set locale {@link i18n::set_locale()} */ - public static function _t($entity, $string = "", $context = "", $injection = null) + public static function _t($entity, $default = '', $injection = []) { - //fetch the injection array out of the parameters (if it is present) - $argList = func_get_args(); - $argNum = func_num_args(); - //_t($entity, $string = "", $context (optional), $injectionArray (optional)) - $injectionArray = null; - for ($i = 0; $i < $argNum; $i++) { - if (is_array($argList[$i])) { //we have reached the injectionArray - $injectionArray = $argList[$i]; //any array in the args will be the injection array - } + // Deprecate passing in injection as second param + if (is_array($default)) { + Deprecation::notice('5.0', 'Passing in $injection as second parameter is deprecated'); + $injection = $default; + $default = ''; } - // Find best translation - $locale = i18n::get_locale(); - $returnValue = static::with_translators(function (Zend_Translate_Adapter $adapter) use ($entity, $locale) { - // Return translation only if we found a match thats not the entity itself (Zend fallback) - $translation = $adapter->translate($entity, $locale); - if ($translation && $translation != $entity) { - return $translation; - } - return null; - }); - - // Fall back to default string argument - if ($returnValue === null) { - $returnValue = (is_string($string)) ? $string : ''; + // Encourage the provision of default values so that text collector can discover new strings + if (!$default) { + user_error( + "Localisation without a default is deprecated (key: $entity) and will be an exception in 5.0", + E_USER_WARNING + ); } - // inject the variables from injectionArray (if present) - if ($injectionArray) { - $regex = '/\{[\w\d]*\}/i'; - if (!preg_match($regex, $returnValue)) { - // Legacy mode: If no injection placeholders are found, - // replace sprintf placeholders in fixed order. - // Fail silently in case the translation is outdated - preg_match_all('/%[s,d]/', $returnValue, $returnValueArgs); - if ($returnValueArgs) { - foreach ($returnValueArgs[0] as $i => $returnValueArg) { - if ($i >= count($injectionArray)) { - $injectionArray[] = ''; - } - } - } - $replaced = vsprintf($returnValue, array_values($injectionArray)); - if ($replaced) { - $returnValue = $replaced; - } - } elseif (!ArrayLib::is_associative($injectionArray)) { - // Legacy mode: If injection placeholders are found, - // but parameters are passed without names, replace them in fixed order. - $returnValue = preg_replace_callback( - $regex, - function () use (&$injectionArray) { - return $injectionArray ? array_shift($injectionArray) : ''; - }, - $returnValue - ); + // Deprecate old $context param + if (!is_array($injection)) { + // Don't need to show warning if only mistake is passing in null instead of empty array + if ($injection || func_num_args() > 3) { + Deprecation::notice('5.0', '$context parameter is deprecated'); + } + // Find best injection array + if (func_num_args() > 3 && is_array(func_get_arg(3))) { + $injection = func_get_arg(3); } else { - // Standard placeholder replacement with named injections and variable order. - foreach ($injectionArray as $variable => $injection) { - $placeholder = '{'.$variable.'}'; - $returnValue = str_replace($placeholder, $injection, $returnValue, $count); - if (!$count) { - Injector::inst()->get('Logger')->log('notice', sprintf( - "Couldn't find placeholder '%s' in translation string '%s' (id: '%s')", - $placeholder, - $returnValue, - $entity - )); - } - } + $injection = []; } } - return $returnValue; + // Deprecate legacy injection format (`string %s, %d`) + // inject the variables from injectionArray (if present) + $sprintfArgs = []; + if ($default && $injection && !preg_match('/\{[\w\d]*\}/i', $default) && preg_match('/%[s,d]/', $default)) { + Deprecation::notice('5.0', 'sprintf style localisation variables are deprecated'); + $sprintfArgs = array_values($injection); + $injection = []; + } + + // If injection isn't associative, assume legacy injection format + $failUnlessSprintf = false; + if ($injection && array_values($injection) === $injection) { + $failUnlessSprintf = true; // Note: Will trigger either a deprecation error or exception below + $sprintfArgs = array_values($injection); + $injection = []; + } + + // Pass back to translation backend + $result = static::getMessageProvider()->translate($entity, $default, $injection); + + // Sometimes default is omitted, so we don't know we have %s injection format until after translation + if (!$default && !preg_match('/\{[\w\d]*\}/i', $result) && preg_match('/%[s,d]/', $result)) { + Deprecation::notice('5.0', 'sprintf style localisation is deprecated'); + if ($injection) { + $sprintfArgs = array_values($injection); + } + } elseif ($failUnlessSprintf) { + // Note: After removing deprecated code, you can move this error up into the is-associative check + // Neither default nor translated strings were %s substituted, and our array isn't associative + throw new InvalidArgumentException('Injection must be an associative array'); + } + + // @deprecated (see above) + if ($sprintfArgs) { + return vsprintf($result, $sprintfArgs); + } + + return $result; } /** * Pluralise an item or items. * - * @param string $singular Singular form - * @param string $plural Plural form - * @param int $number Number of items (natural number only) - * @param bool $prependNumber Include number in result - * @return string Result with the number and pluralised form appended. E.g. '1 page' - */ - public static function pluralise($singular, $plural, $number, $prependNumber = true) - { - $locale = static::get_locale(); - $form = static::with_translators( - function (Zend_Translate_Adapter $adapter) use ($singular, $plural, $number, $locale) { - // Return translation only if we found a match thats not the entity itself (Zend fallback) - $result = $adapter->plural($singular, $plural, $number, $locale); - if ($result) { - return $result; - } - return null; - } - ); - if ($prependNumber) { - return _t('i18n.PLURAL', '{number} {form}', [ - 'number' => $number, - 'form' => $form - ]); - } else { - return $form; - } - } - - /** - * Loop over all translators in order of precedence, and return the first non-null value - * returned via $callback + * Yaml form of these strings should be set via the rails i18n standard format + * http://guides.rubyonrails.org/i18n.html#pluralization. For example: * - * @param callable $callback Callback which is given the translator - * @return mixed First non-null result from $callback, or null if none matched + * + * en: + * ChangeSet: + * DESCRIPTION_ITEM_PLURALS: + * one: 'one item' + * other: '{count} items' + * + * + * Some languages support up to 6 plural forms: + * @link http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html + * + * @todo text collection support for pluralised strings + * + * @param string $entity Entity that identifies the string. It must be in the form + * "Namespace.Entity" where Namespace will be usually the class name where this + * string is used and Entity identifies the string inside the namespace. + * Standard convention is to have a `Class._PLURALS` key for a on a class + * @param string|array $default If passed as a string, treated as a symfony format specifier. + * If passed as an array, treated as a ruby i18n pluralised form. + * @param int $count Number to pluralise against + * @param array $injection Additional parameters + * @return string Localised number */ - protected static function with_translators($callback) + public static function pluralise($entity, $default = '', $count = 0, $injection = []) { - // get current locale (either default or user preference) - $locale = i18n::get_locale(); - $lang = i18n::get_lang_from_locale($locale); - - // Only call getter if static isn't already defined (for performance reasons) - $translatorsByPrio = self::$translators ?: self::get_translators(); - - foreach ($translatorsByPrio as $priority => $translators) { - /** @var Zend_Translate $translator */ - foreach ($translators as $name => $translator) { - $adapter = $translator->getAdapter(); - - // at this point, we need to ensure the language and locale are loaded - // as include_by_locale() doesn't load a fallback. - - // TODO Remove reliance on global state, by refactoring into an i18nTranslatorManager - // which is instanciated by core with a $clean instance variable. - - if (!$adapter->isAvailable($lang)) { - i18n::include_by_locale($lang); - } - - if (!$adapter->isAvailable($locale)) { - i18n::include_by_locale($locale); - } - - $result = call_user_func($callback, $adapter); - if ($result !== null) { - return $result; - } - } - } - - // Nothing matched - return null; - } - - - /** - * @return array Array of priority keys to instances of Zend_Translate, mapped by name. - */ - public static function get_translators() - { - if (!Zend_Translate::getCache()) { - Zend_Translate::setCache(self::get_cache()); - } - - if (!self::$translators) { - $defaultPriority = 10; - self::$translators[$defaultPriority] = array( - 'core' => new Zend_Translate(array( - 'adapter' => 'SilverStripe\\i18n\\i18nRailsYamlAdapter', - 'locale' => i18n::config()->get('default_locale'), - 'disableNotices' => true, - )) - ); - - i18n::include_by_locale('en'); - i18n::include_by_locale('en_US'); - } - - return self::$translators; - } - - /** - * @param String - * @return Zend_Translate - */ - public static function get_translator($name) - { - foreach (self::get_translators() as $priority => $translators) { - if (isset($translators[$name])) { - return $translators[$name]; - } - } - return null; - } - - /** - * @param Zend_Translate $translator Needs to implement {@link i18nTranslateAdapterInterface} - * @param string $name If left blank will override the default translator. - * @param int $priority - */ - public static function register_translator($translator, $name, $priority = 10) - { - if (!is_int($priority)) { - throw new InvalidArgumentException("register_translator expects an int priority"); - } - - // Ensure it's not there. If it is, we're replacing it. It may exist in a different priority. - self::unregister_translator($name); - - // Add our new translator - if (!isset(self::$translators[$priority])) { - self::$translators[$priority] = array(); - } - self::$translators[$priority][$name] = $translator; - - // Resort array, ensuring highest priority comes first - krsort(self::$translators); - - i18n::include_by_locale('en_US'); - i18n::include_by_locale('en'); - } - - /** - * @param String - */ - public static function unregister_translator($name) - { - foreach (self::get_translators() as $priority => $translators) { - if (isset($translators[$name])) { - unset(self::$translators[$priority][$name]); - } - } + return static::getMessageProvider()->pluralise($entity, $default, $count, $injection); } /** @@ -2285,25 +2139,14 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable public static function get_existing_translations() { $locales = array(); - - // TODO Inspect themes - $modules = ClassLoader::instance()->getManifest()->getModules(); - - foreach ($modules as $module) { - if (!file_exists("{$module}/lang/")) { - continue; - } - + foreach (static::get_lang_dirs() as $langPath) { $allLocales = i18n::config()->get('all_locales'); - $moduleLocales = scandir("{$module}/lang/"); - foreach ($moduleLocales as $moduleLocale) { - $locale = pathinfo($moduleLocale, PATHINFO_FILENAME); - $ext = pathinfo($moduleLocale, PATHINFO_EXTENSION); - if ($locale && in_array($ext, array('php','yml'))) { + $langFiles = scandir($langPath); + foreach ($langFiles as $langFile) { + $locale = pathinfo($langFile, PATHINFO_FILENAME); + $ext = pathinfo($langFile, PATHINFO_EXTENSION); + if ($locale && $ext === 'yml') { // Normalize locale to include likely region tag, avoid repetition in locale labels - // TODO Replace with CLDR list of actually available languages/regions - // Only allow explicitly registered locales, otherwise we'll get into trouble - // if the locale doesn't exist in Zend's CLDR data $fullLocale = self::get_locale_from_lang($locale); if (isset($allLocales[$fullLocale])) { $locales[$fullLocale] = $allLocales[$fullLocale]; @@ -2505,36 +2348,32 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable * Returns the script direction in format compatible with the HTML "dir" attribute. * * @see http://www.w3.org/International/tutorials/bidi-xhtml/ - * @param String $locale Optional locale incl. region (underscored) - * @return String "rtl" or "ltr" + * @param string $locale Optional locale incl. region (underscored) + * @return string "rtl" or "ltr" */ public static function get_script_direction($locale = null) { - require_once 'Zend/Locale/Data.php'; + $dirs = static::config()->get('text_direction'); if (!$locale) { $locale = i18n::get_locale(); } - try { - $dir = Zend_Locale_Data::getList($locale, 'layout'); - } catch (Zend_Locale_Exception $e) { - $dir = Zend_Locale_Data::getList(i18n::get_lang_from_locale($locale), 'layout'); + if (isset($dirs[$locale])) { + return $dirs[$locale]; } - - return ($dir && $dir['characters'] == 'right-to-left') ? 'rtl' : 'ltr'; + $lang = static::get_lang_from_locale($locale); + if (isset($dirs[$lang])) { + return $dirs[$lang]; + } + return 'ltr'; } /** - * Includes all available language files for a certain defined locale. + * Get sorted modules * - * @param string $locale All resources from any module in locale $locale will be loaded - * @param Boolean $clean Clean old caches? + * @return array Array of module names -> path */ - public static function include_by_locale($locale, $clean = false) + public static function get_sorted_modules() { - if ($clean) { - self::flush(); - } - // Get list of module => path pairs, and then just the names $modules = ClassLoader::instance()->getManifest()->getModules(); $moduleNames = array_keys($modules); @@ -2545,7 +2384,7 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable array_splice($moduleNames, $idx, 1); } - // Get the order from the config syste, + // Get the order from the config syste (lowest to highest) $order = i18n::config()->get('module_priority'); // Find all modules that don't have their order specified by the config system @@ -2559,9 +2398,9 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable array_splice($order, 0, 0, $unspecified); } - // Put the project module back in at the begining if it wasn't specified by the config system + // Put the project at end (highest priority) if (!in_array($project, $order)) { - array_unshift($order, $project); + $order[] = $project; } $sortedModules = array(); @@ -2571,90 +2410,40 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable } } $sortedModules = array_reverse($sortedModules, true); - - // Loop in reverse order, meaning the translator with the highest priority goes first - $translatorsByPrio = array_reverse(self::get_translators(), true); - foreach ($translatorsByPrio as $priority => $translators) { - /** @var Zend_Translate $translator */ - foreach ($translators as $name => $translator) { - /** @var i18nTranslateAdapterInterface|Zend_Translate_Adapter $adapter */ - $adapter = $translator->getAdapter(); - - // Load translations from modules - foreach ($sortedModules as $module) { - $filename = $adapter->getFilenameForLocale($locale); - $filepath = "{$module}/lang/" . $filename; - - if ($filename && !file_exists($filepath)) { - continue; - } - $adapter->addTranslation( - array('content' => $filepath, 'locale' => $locale) - ); - } - - // Load translations from themes - // TODO Replace with theme listing once implemented in TemplateManifest - $themesBase = Director::baseFolder() . '/themes'; - if (is_dir($themesBase)) { - foreach (scandir($themesBase) as $theme) { - if (strpos($theme, Config::inst()->get('SilverStripe\\View\\SSViewer', 'theme')) === 0 - && file_exists("{$themesBase}/{$theme}/lang/") - ) { - $filename = $adapter->getFilenameForLocale($locale); - $filepath = "{$themesBase}/{$theme}/lang/" . $filename; - if ($filename && !file_exists($filepath)) { - continue; - } - $adapter->addTranslation( - array('content' => $filepath, 'locale' => $locale) - ); - } - } - } - - // Add empty translations to ensure the locales are "registered" with isAvailable(), - // and the next invocation of include_by_locale() doesn't cause a new reparse. - $adapter->addTranslation( - array( - // Cached by content hash, so needs to be locale dependent - 'content' => array($locale => $locale), - 'locale' => $locale, - 'usetranslateadapter' => true - ) - ); - } - } + return $sortedModules; } /** - * Given a class name (a "locale namespace"), will search for its module and, if available, - * will load the resources for the currently defined locale. - * If not available, the original English resource will be loaded instead (to avoid blanks) + * Find the list of prioritised /lang folders in this application * - * @param string $class Resources for this class will be included, according to the set locale. + * @return array */ - public static function include_by_class($class) + public static function get_lang_dirs() { - $module = self::get_owner_module($class); + $paths = []; - $translatorsByPrior = array_reverse(self::get_translators(), true); - foreach ($translatorsByPrior as $priority => $translators) { - /** @var Zend_Translate $translator */ - foreach ($translators as $name => $translator) { - /** @var i18nTranslateAdapterInterface|Zend_Translate_Adapter $adapter */ - $adapter = $translator->getAdapter(); - $filename = $adapter->getFilenameForLocale(self::get_locale()); - $filepath = "{$module}/lang/" . $filename; - if ($filename && !file_exists($filepath)) { - continue; - } - $adapter->addTranslation(array( - 'content' => $filepath, - 'locale' => self::get_locale() - )); + // Search sorted modules + foreach (static::get_sorted_modules() as $module => $path) { + $langPath = "{$path}/lang/"; + if (is_dir($langPath)) { + $paths[] = $langPath; } } + + // Search theme dirs + $locator = ThemeResourceLoader::instance(); + foreach (SSViewer::get_themes() as $theme) { + if ($locator->getSet($theme)) { + continue; + } + $path = $locator->getPath($theme); + $langPath = "{$path}/lang/"; + if (is_dir($langPath)) { + $paths[] = $langPath; + } + } + + return $paths; } public static function get_template_global_variables() @@ -2663,6 +2452,15 @@ class i18n extends Object implements TemplateGlobalProvider, Flushable 'i18nLocale' => 'get_locale', 'get_locale', 'i18nScriptDirection' => 'get_script_direction', + 'pluralise', ); } + + /** + * @return MessageProvider + */ + public static function getMessageProvider() + { + return Injector::inst()->get(MessageProvider::class); + } } diff --git a/src/i18n/i18nEntityProvider.php b/src/i18n/i18nEntityProvider.php index 835739908..9fece7ca8 100644 --- a/src/i18n/i18nEntityProvider.php +++ b/src/i18n/i18nEntityProvider.php @@ -2,6 +2,8 @@ namespace SilverStripe\i18n; +use SilverStripe\i18n\TextCollection\i18nTextCollector; + /** * Dynamically provide translatable entites for the {@link i18n} logic. * This is particularly handy for natural language strings in static variables @@ -22,53 +24,53 @@ interface i18nEntityProvider { /** + * Returns the list of provided translations for this object. + * + * Note: Pluralised forms are always returned in array format. + * * Example usage: * - * class MyTestClass implements i18nEntityProvider { - * function provideI18nEntities() { - * $entities = array(); - * foreach($this->stat('my_static_array) as $key => $value) { - * $entities["MyTestClass.my_static_array_{$key}"] = array( - * $value, - * - * 'My context description' - * ); - * } - * return $entities; - * } - * - * public static function my_static_array() { - * $t_my_static_array = array(); - * foreach(self::$my_static_array as $k => $v) { - * $t_my_static_array[$k] = _t("MyTestClass.my_static_array_{$key}", $v); - * } - * return $t_my_static_array; - * } + * class MyTestClass implements i18nEntityProvider + * { + * public function provideI18nEntities() + * { + * $entities = []; + * foreach($this->stat('my_static_array) as $key => $value) { + * $entities["MyTestClass.my_static_array_{$key}"] = $value; + * } + * $entities["MyTestClass.PLURALS"] = [ + * 'one' => 'A test class', + * 'other' => '{count} test classes', + * ] + * return $entities; + * } * } * * * Example usage in {@link DataObject->provideI18nEntities()}. * - * You can ask textcollector to add the provided entity to a different module - * than the class is contained in by adding a 4th argument to the array: - * - * class MyTestClass implements i18nEntityProvider { - * function provideI18nEntities() { - * $entities = array(); - * $entities["MyOtherModuleClass.MYENTITY"] = array( - * $value, + * You can ask textcollector to add the provided entity to a different module. + * Simply wrap the returned value for any item in an array with the format: + * [ 'default' => $defaultValue, 'module' => $module ] * - * 'My context description', - * 'myothermodule' - * ); - * } - * return $entities; + * + * class MyTestClass implements i18nEntityProvider + * { + * public function provideI18nEntities() + * { + * $entities = [ + * 'MyOtherModuleClass.MYENTITY' => [ + * 'default' => $value, + * 'module' => 'myothermodule', + * ] + * ]; + * } + * return $entities; * } * * - * @return array All entites in an associative array, with - * entity name as the key, and a numerical array of pseudo-arguments - * for _t() as a value. + * @return array Map of keys to default values, which are strings in the default case, + * and array-form for pluralisations. */ public function provideI18nEntities(); } diff --git a/src/i18n/i18nRailsYamlAdapter.php b/src/i18n/i18nRailsYamlAdapter.php deleted file mode 100644 index 9bc96a7be..000000000 --- a/src/i18n/i18nRailsYamlAdapter.php +++ /dev/null @@ -1,21 +0,0 @@ -string = $string; - $this->pos = 0; - $this->depth = 0; - $this->regexps = array(); - } - - public function Translate__construct(&$res) - { - self::$currentEntity = array(null, null, null); //start with empty array - } - - public function Translate_Entity(&$res, $sub) - { - self::$currentEntity[0] = $sub['text']; //entity - } - - public function Translate_Default(&$res, $sub) - { - self::$currentEntity[1] = $sub['String']['text']; //value - } - - public function Translate_Context(&$res, $sub) - { - self::$currentEntity[2] = $sub['String']['text']; //comment - } - - public function Translate__finalise(&$res) - { - // set the entity name and the value (default), as well as the context (comment) - // priority is no longer used, so that is blank - self::$entities[self::$currentEntity[0]] = array(self::$currentEntity[1], null, self::$currentEntity[2]); - } - - /** - * Parses a template and returns any translatable entities - */ - public static function GetTranslatables($template) - { - self::$entities = array(); - - // Run the parser and throw away the result - $parser = new i18nTextCollector_Parser($template); - if (substr($template, 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) { - $parser->pos = 3; - } - $parser->match_TopTemplate(); - - return self::$entities; - } -} diff --git a/src/i18n/i18nTextCollector_Writer_RailsYaml.php b/src/i18n/i18nTextCollector_Writer_RailsYaml.php deleted file mode 100644 index 43b2a57a2..000000000 --- a/src/i18n/i18nTextCollector_Writer_RailsYaml.php +++ /dev/null @@ -1,62 +0,0 @@ -getYaml($entities, $locale)); - fclose($fh); - } else { - throw new LogicException("Cannot write language file! Please check permissions of $langFile"); - } - - return true; - } - - public function getYaml($entities, $locale) - { - // Unflatten array - $entitiesNested = array(); - foreach ($entities as $entity => $spec) { - // Legacy support: Don't count *.ss as namespace - $entity = preg_replace('/\.ss\./', '___ss.', $entity); - $parts = explode('.', $entity); - $currLevel = &$entitiesNested; - while ($part = array_shift($parts)) { - $part = str_replace('___ss', '.ss', $part); - if (!isset($currLevel[$part])) { - $currLevel[$part] = array(); - } - $currLevel = &$currLevel[$part]; - } - $currLevel = $spec[0]; - } - - // Write YAML - $dumper = new Dumper(); - $dumper->setIndentation(2); - // TODO Dumper can't handle YAML comments, so the context information is currently discarded - $result = $dumper->dump(array($locale => $entitiesNested), 99); - return $result; - } -} diff --git a/src/i18n/i18nTranslateAdapterInterface.php b/src/i18n/i18nTranslateAdapterInterface.php deleted file mode 100644 index 5068fac85..000000000 --- a/src/i18n/i18nTranslateAdapterInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -assertEquals('Three', $another->filters[2]); } + public function testConstantUsage() + { + $injector = new Injector(); + $services = array( + AnotherService::class => array( + 'properties' => array( + 'filters' => array( + '`BASE_PATH`', + '`TEMP_FOLDER`', + '`NOT_DEFINED`', + 'THIRDPARTY_DIR' // Not back-tick escaped + ) + ), + ) + ); + + $injector->load($services); + $another = $injector->get(AnotherService::class); + $this->assertEquals( + [ + BASE_PATH, + TEMP_FOLDER, + null, + 'THIRDPARTY_DIR', + ], + $another->filters + ); + } + public function testAutoSetInjector() { $injector = new Injector(); diff --git a/tests/php/i18n/YamlReaderTest.php b/tests/php/i18n/YamlReaderTest.php new file mode 100644 index 000000000..55c748ca0 --- /dev/null +++ b/tests/php/i18n/YamlReaderTest.php @@ -0,0 +1,38 @@ +read('en', $path); + $expected = [ + 'NONAMESPACE' => 'Include Entity without Namespace', + 'SPRINTFNONAMESPACE' => 'My replacement no namespace: %s', + 'SPRINTFINCLUDENONAMESPACE' => 'My include replacement no namespace: %s', + 'LAYOUTTEMPLATENONAMESPACE' => 'Layout Template no namespace', + 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes"', + 'i18nTestModule.ADDITION' => 'Addition', + 'i18nTestModule.MAINTEMPLATE' => 'Main Template', + 'i18nTestModule.WITHNAMESPACE' => 'Include Entity with Namespace', + 'i18nTestModule.LAYOUTTEMPLATE' => 'Layout Template', + 'i18nTestModule.SPRINTFNAMESPACE' => 'My replacement: %s', + 'i18nTestModule.PLURAL' => [ + 'one' => 'A test', + 'other' => '{count} tests', + ], + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'My include replacement: %s', + ]; + $this->assertEquals($expected, $output); + } +} diff --git a/tests/php/i18n/YamlWriterTest.php b/tests/php/i18n/YamlWriterTest.php new file mode 100644 index 000000000..7da982ebf --- /dev/null +++ b/tests/php/i18n/YamlWriterTest.php @@ -0,0 +1,42 @@ + 'Text', + 'Level1.OtherEntityName' => 'Other Text', + 'Level1.Plurals' => [ + 'one' => 'An item', + 'other' => '{count} items', + ], + 'Level1.BoolTest' => 'True', + 'Level1.FlagTest' => 'No', + 'Level1.TextTest' => 'Maybe', + 'TopLevel' => 'The Top', + ]; + $yaml = <<assertEquals($yaml, Convert::nl2os($writer->getYaml($entities, 'de'))); + } +} diff --git a/tests/php/i18n/i18nTest.php b/tests/php/i18n/i18nTest.php index cbd160520..08258065b 100644 --- a/tests/php/i18n/i18nTest.php +++ b/tests/php/i18n/i18nTest.php @@ -2,136 +2,30 @@ namespace SilverStripe\i18n\Tests; -use SilverStripe\Assets\Filesystem; +use InvalidArgumentException; use SilverStripe\Control\Director; use SilverStripe\Core\Convert; -use SilverStripe\Core\Manifest\ClassManifest; -use SilverStripe\Core\Manifest\ClassLoader; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\SapphireTest; use SilverStripe\i18n\i18n; -use SilverStripe\i18n\i18nRailsYamlAdapter; -use SilverStripe\i18n\Tests\i18nTest\CustomTranslatorAdapter; -use SilverStripe\i18n\Tests\i18nTest\MyObject; -use SilverStripe\i18n\Tests\i18nTest\MySubObject; -use SilverStripe\i18n\Tests\i18nTest\OtherCustomTranslatorAdapter; -use SilverStripe\i18n\Tests\i18nTest\TestDataObject; -use SilverStripe\i18n\Tests\i18nTest\TestObject; +use SilverStripe\i18n\Messages\MessageProvider; +use SilverStripe\i18n\Messages\Symfony\SymfonyMessageProvider; use SilverStripe\View\ArrayData; use SilverStripe\View\SSViewer; -use SilverStripe\View\ThemeResourceLoader; -use SilverStripe\View\ThemeManifest; -use Zend_Translate; - -require_once 'Zend/Translate.php'; class i18nTest extends SapphireTest { - - /** - * @var string $tmpBasePath Used to write language files. - * We don't want to store them inside framework (or in any web-accessible place) - * in case something goes wrong with the file parsing. - */ - protected $alternateBaseSavePath; - - /** - * @var string $alternateBasePath Fake webroot with a single module - * /i18ntestmodule which contains some files with _t() calls. - */ - protected $alternateBasePath; - - protected $extraDataObjects = [ - TestDataObject::class - ]; - - protected $preloadClasses = [ - OtherCustomTranslatorAdapter::class, - CustomTranslatorAdapter::class, - TestObject::class, - MySubObject::class, - MyObject::class - ]; - + use i18nTestManifest; public function setUp() { parent::setUp(); - - // Force loading of classes before manifests potentially break autoloading - foreach ($this->preloadClasses as $class) { - if (!class_exists($class)) { - throw new \LogicException("Could not load class $class"); - } - } - - $s = DIRECTORY_SEPARATOR; - $this->alternateBasePath = __DIR__ . $s . 'i18nTest' . $s . "_fakewebroot"; - $this->alternateBaseSavePath = TEMP_FOLDER . $s . 'i18nTextCollectorTest_webroot'; - Filesystem::makeFolder($this->alternateBaseSavePath); - Director::config()->update('alternate_base_folder', $this->alternateBasePath); - - // Replace old template loader with new one with alternate base path - $this->_oldLoader = ThemeResourceLoader::instance(); - ThemeResourceLoader::set_instance($loader = new ThemeResourceLoader($this->alternateBasePath)); - $loader->addSet( - '$default', - new ThemeManifest( - $this->alternateBasePath, - project(), - false, - true - ) - ); - - SSViewer::config()->update('theme', 'testtheme1'); - - $this->originalLocale = i18n::get_locale(); - - // Override default adapter to avoid cached translations between tests. - // Emulates behaviour in i18n::get_translators() - $this->origAdapter = i18n::get_translator('core'); - $adapter = new Zend_Translate( - array( - 'adapter' => i18nRailsYamlAdapter::class, - 'locale' => i18n::config()->get('default_locale'), - 'disableNotices' => true, - ) - ); - i18n::register_translator($adapter, 'core'); - $adapter->removeCache(); - i18n::include_by_locale('en'); - } - - /** - * Number of test manifests - * - * @var int - */ - protected $manifests = 0; - - /** - * Safely push a new class manifest. - * These will be cleaned up on tearDown() - * - * @param ClassManifest $manifest - */ - protected function pushManifest(ClassManifest $manifest) - { - $this->manifests++; - ClassLoader::instance()->pushManifest($manifest); + $this->setupManifest(); } public function tearDown() { - ThemeResourceLoader::set_instance($this->_oldLoader); - i18n::set_locale($this->originalLocale); - i18n::register_translator($this->origAdapter, 'core'); - - while ($this->manifests > 0) { - ClassLoader::instance()->popManifest(); - $this->manifests--; - } - + $this->tearDownManifest(); parent::tearDown(); } @@ -145,16 +39,22 @@ class i18nTest extends SapphireTest public function testGetClosestTranslation() { - // Validate necessary assumptions for this test + // As per set of locales loaded from _fakewebroot $translations = i18n::get_existing_translations(); - $this->assertTrue(isset($translations['en_US'])); - $this->assertTrue(isset($translations['en_GB'])); - $this->assertTrue(isset($translations['es_ES'])); - $this->assertTrue(isset($translations['es_AR'])); - $this->assertFalse(isset($translations['en_ZZ'])); - $this->assertFalse(isset($translations['es_ZZ'])); - $this->assertFalse(isset($translations['zz_ZZ'])); + $this->assertEquals( + [ + 'en_GB', + 'en_US', + 'fr_FR', + 'de_AT', + 'de_DE', + 'es_AR', + 'es_ES', + 'mi_NZ', + ], + array_keys($translations) + ); // Test indeterminate locales $this->assertEmpty(i18n::get_closest_translation('zz_ZZ')); @@ -172,57 +72,51 @@ class i18nTest extends SapphireTest public function testDataObjectFieldLabels() { - $oldLocale = i18n::get_locale(); i18n::set_locale('de_DE'); - $obj = new i18nTest\TestDataObject(); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTest_DataObject.MyProperty' => 'MyProperty' - ), + // Load into the translator as a literal array data source + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTest_DataObject.MyProperty' => 'MyProperty' ], 'en_US' ); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTest_DataObject.MyProperty' => 'Mein Attribut' - ), + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTest_DataObject.MyProperty' => 'Mein Attribut' ], 'de_DE' ); + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTest_DataObject.MyUntranslatedProperty' => 'Mein Attribut' ], + 'en_US' + ); + // Test field labels + $obj = new i18nTest\TestDataObject(); $this->assertEquals( $obj->fieldLabel('MyProperty'), 'Mein Attribut' ); - - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTest_DataObject.MyUntranslatedProperty' => 'Mein Attribut' - ), - 'en_US' - ); $this->assertEquals( $obj->fieldLabel('MyUntranslatedProperty'), 'My Untranslated Property' ); - - i18n::set_locale($oldLocale); } public function testProvideI18nEntities() { - $oldLocale = i18n::get_locale(); - i18n::set_locale('en_US'); - - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTest_Object.MyProperty' => 'Untranslated' - ), + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTest_Object.MyProperty' => 'Untranslated' ], 'en_US' ); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTest_Object.my_translatable_property' => 'Übersetzt' - ), + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTest_Object.my_translatable_property' => 'Übersetzt' ], 'de_DE' ); @@ -254,24 +148,28 @@ class i18nTest extends SapphireTest { $oldLocale = i18n::get_locale(); - i18n::set_locale('en_US'); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.MAINTEMPLATE' => 'Main Template', - 'i18nTestModule.ss.SPRINTFNONAMESPACE' => 'My replacement no namespace: %s', - 'i18nTestModule.LAYOUTTEMPLATE' => 'Layout Template', - 'i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE' => 'Layout Template no namespace', - 'i18nTestModule.SPRINTFNAMESPACE' => 'My replacement: %s', - 'i18nTestModule.WITHNAMESPACE' => 'Include Entity with Namespace', - 'i18nTestModuleInclude.ss.NONAMESPACE' => 'Include Entity without Namespace', - 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'My include replacement: %s', - 'i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE' => 'My include replacement no namespace: %s' - ), + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.MAINTEMPLATE' => 'Main Template', + 'i18nTestModule.ss.SPRINTFNONAMESPACE' => 'My replacement no namespace: %s', + 'i18nTestModule.LAYOUTTEMPLATE' => 'Layout Template', + 'i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE' => 'Layout Template no namespace', + 'i18nTestModule.SPRINTFNAMESPACE' => 'My replacement: %s', + 'i18nTestModule.WITHNAMESPACE' => 'Include Entity with Namespace', + 'i18nTestModuleInclude.ss.NONAMESPACE' => 'Include Entity without Namespace', + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'My include replacement: %s', + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE' => 'My include replacement no namespace: %s' + ], 'en_US' ); $viewer = new SSViewer('i18nTestModule'); - $parsedHtml = Convert::nl2os($viewer->process(new ArrayData(array('TestProperty' => 'TestPropertyValue')))); + $parsedHtml = Convert::nl2os($viewer->process(new ArrayData([ + 'TestProperty' => 'TestPropertyValue' + ]))); $this->assertContains( Convert::nl2os("Layout Template\n"), $parsedHtml @@ -281,22 +179,24 @@ class i18nTest extends SapphireTest $parsedHtml ); - i18n::set_locale('de_DE'); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.MAINTEMPLATE' => 'TRANS Main Template', - 'i18nTestModule.ss.SPRINTFNONAMESPACE' => 'TRANS My replacement no namespace: %s', - 'i18nTestModule.LAYOUTTEMPLATE' => 'TRANS Layout Template', - 'i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE' => 'TRANS Layout Template no namespace', - 'i18nTestModule.SPRINTFNAMESPACE' => 'TRANS My replacement: %s', - 'i18nTestModule.WITHNAMESPACE' => 'TRANS Include Entity with Namespace', - 'i18nTestModuleInclude.ss.NONAMESPACE' => 'TRANS Include Entity without Namespace', - 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'TRANS My include replacement: %s', - 'i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE' => 'TRANS My include replacement no namespace: %s' - ), + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.MAINTEMPLATE' => 'TRANS Main Template', + 'i18nTestModule.ss.SPRINTFNONAMESPACE' => 'TRANS My replacement no namespace: %s', + 'i18nTestModule.LAYOUTTEMPLATE' => 'TRANS Layout Template', + 'i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE' => 'TRANS Layout Template no namespace', + 'i18nTestModule.SPRINTFNAMESPACE' => 'TRANS My replacement: %s', + 'i18nTestModule.WITHNAMESPACE' => 'TRANS Include Entity with Namespace', + 'i18nTestModuleInclude.ss.NONAMESPACE' => 'TRANS Include Entity without Namespace', + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'TRANS My include replacement: %s', + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE' => 'TRANS My include replacement no namespace: %s', + 'i18nTestModule.PLURALS' => 'An item|{count} items', + ], 'de_DE' ); + i18n::set_locale('de_DE'); $viewer = new SSViewer('i18nTestModule'); $parsedHtml = Convert::nl2os($viewer->process(new ArrayData(array('TestProperty' => 'TestPropertyValue')))); $this->assertContains( @@ -331,35 +231,34 @@ class i18nTest extends SapphireTest Convert::nl2os("TRANS My include replacement no namespace: TestPropertyValue\n"), $parsedHtml ); + // Check plurals + $this->assertContains('Single: An item', $parsedHtml); + $this->assertContains('Multiple: 4 items', $parsedHtml); + $this->assertContains('None: 0 items', $parsedHtml); i18n::set_locale($oldLocale); } public function testNewTMethodSignature() { - global $lang; - $oldLocale = i18n::get_locale(); - - i18n::set_locale('en_US'); - - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.NEWMETHODSIG' => 'TRANS New _t method signature test', - 'i18nTestModule.INJECTIONS' => 'TRANS Hello {name} {greeting}. But it is late, {goodbye}', - 'i18nTestModule.INJECTIONSLEGACY' => 'TRANS Hello %s %s. But it is late, %s', - ), + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.NEWMETHODSIG' => 'TRANS New _t method signature test', + 'i18nTestModule.INJECTIONS' => 'TRANS Hello {name} {greeting}. But it is late, {goodbye}', + 'i18nTestModule.INJECTIONSLEGACY' => 'TRANS Hello %s %s. But it is late, %s', + ], 'en_US' ); $entity = "i18nTestModule.INJECTIONS"; $default = "Hello {name} {greeting}. But it is late, {goodbye}"; + $entityLegacy = 'i18nTestModule.INJECTIONSLEGACY'; + $defaultLegacy = 'TRANS Hello %s %s. But it is late, %s'; - $translated = i18n::_t('i18nTestModule.NEWMETHODSIG', "New _t method signature test"); - $this->assertContains( - "TRANS New _t method signature test", - $translated - ); - + // Test missing entity key $translated = i18n::_t( $entity.'_DOES_NOT_EXIST', $default, @@ -371,10 +270,11 @@ class i18nTest extends SapphireTest "Testing fallback to the translation default (but using the injection array)" ); + // Test standard injection $translated = i18n::_t( $entity, $default, - array("name"=>"Paul", "greeting"=>"good you are here", "goodbye"=>"see you") + ["name"=>"Paul", "greeting"=>"good you are here", "goodbye"=>"see you"] ); $this->assertContains( "TRANS Hello Paul good you are here. But it is late, see you", @@ -382,11 +282,12 @@ class i18nTest extends SapphireTest "Testing entity, default string and injection array" ); + // @deprecated 5.0 Passing in context $translated = i18n::_t( $entity, $default, "New context (this should be ignored)", - array("name"=>"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen") + ["name"=>"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen"] ); $this->assertContains( "TRANS Hello Steffen willkommen. But it is late, wiedersehen", @@ -394,16 +295,12 @@ class i18nTest extends SapphireTest "Full test of translation, using default, context and injection array" ); - $translated = i18n::_t($entity, array("name"=>"Cat", "greeting"=>"meow", "goodbye"=>"meow")); - $this->assertContains( - "TRANS Hello Cat meow. But it is late, meow", - $translated, - "Testing a translation with just entity and injection array" - ); - + // @deprecated 5.0 Passing in % placeholders (detected in default value) + // Note: Missing-placeholder substitution no longer functions $translated = i18n::_t( - 'i18nTestModule.INJECTIONSLEGACY', // has %s placeholders - array("name"=>"Cat", "greeting2"=>"meow", "goodbye"=>"meow") + $entityLegacy, // has %s placeholders + $defaultLegacy, + ["name"=>"Cat", "greeting2"=>"meow", "goodbye"=>"meow"] ); $this->assertContains( "TRANS Hello Cat meow. But it is late, meow", @@ -411,27 +308,13 @@ class i18nTest extends SapphireTest "Testing sprintf placeholders with named injections" ); - $translated = i18n::_t( - 'i18nTestModule.INJECTIONSLEGACY', // has %s placeholders - array("Cat", "meow"/*, "meow" */) // remove third arg + // Passing in non-associative arrays for placeholders is now an error + $this->setExpectedException(InvalidArgumentException::class, 'Injection must be an associative array'); + i18n::_t( + $entity, // has {name} placeholders + $default, + ["Cat", "meow", "meow"] ); - $this->assertContains( - "TRANS Hello Cat meow. But it is late, ", - $translated, - "Testing sprintf placeholders with unnamed injections and too few args" - ); - - $translated = i18n::_t( - 'i18nTestModule.INJECTIONS', // has {name} placeholders - array("Cat", "meow", "meow") - ); - $this->assertContains( - "TRANS Hello Cat meow. But it is late, meow", - $translated, - "Testing named injection placeholders with unnamed injections" - ); - - i18n::set_locale($oldLocale); } /** @@ -439,20 +322,19 @@ class i18nTest extends SapphireTest * */ public function testNewTemplateTranslation() { - global $lang; - $oldLocale = i18n::get_locale(); - - i18n::set_locale('en_US'); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.NEWMETHODSIG' => 'TRANS New _t method signature test', - 'i18nTestModule.INJECTIONS' => 'TRANS Hello {name} {greeting}. But it is late, {goodbye}' - ), + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.NEWMETHODSIG' => 'TRANS New _t method signature test', + 'i18nTestModule.INJECTIONS' => 'TRANS Hello {name} {greeting}. But it is late, {goodbye}' + ], 'en_US' ); $viewer = new SSViewer('i18nTestModule'); - $parsedHtml = Convert::nl2os($viewer->process(new ArrayData(array('TestProperty' => 'TestPropertyValue')))); + $parsedHtml = Convert::nl2os($viewer->process(new ArrayData(['TestProperty' => 'TestPropertyValue']))); $this->assertContains( Convert::nl2os("Hello Mark welcome. But it is late, bye\n"), $parsedHtml, @@ -465,12 +347,6 @@ class i18nTest extends SapphireTest "Testing entity, default string and injection array" ); - $this->assertContains( - Convert::nl2os("TRANS Hello Cat meow. But it is late, meow\n"), - $parsedHtml, - "Testing a translation with just entity and injection array" - ); - //test injected calls $this->assertContains( Convert::nl2os( @@ -479,8 +355,6 @@ class i18nTest extends SapphireTest $parsedHtml, "Testing a translation with just entity and injection array, but with global variables injected in" ); - - i18n::set_locale($oldLocale); } public function testGetLocaleFromLang() @@ -501,233 +375,61 @@ class i18nTest extends SapphireTest public function testTranslate() { - $oldLocale = i18n::get_locale(); - - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes"', - ), + /** @var SymfonyMessageProvider $provider */ + $provider = Injector::inst()->get(MessageProvider::class); + $provider->getTranslator()->addResource( + 'array', + [ 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes"' ], 'en_US' ); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes" (de)', - 'i18nTestModule.ADDITION' => 'Addition (de)', - ), + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes" (de)', + 'i18nTestModule.ADDITION' => 'Addition (de)', + ], 'de' ); - i18n::get_translator('core')->getAdapter()->addTranslation( - array( - 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes" (de_AT)', - ), + $provider->getTranslator()->addResource( + 'array', + [ + 'i18nTestModule.ENTITY' => 'Entity with "Double Quotes" (de_AT)', + ], 'de_AT' ); $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), 'Entity with "Double Quotes"', + i18n::_t('i18nTestModule.ENTITY', 'Ignored default'), 'Returns translation in default language' ); i18n::set_locale('de'); $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), 'Entity with "Double Quotes" (de)', + i18n::_t('i18nTestModule.ENTITY', 'Entity with "Double Quotes"'), 'Returns translation according to current locale' ); i18n::set_locale('de_AT'); $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), 'Entity with "Double Quotes" (de_AT)', + i18n::_t('i18nTestModule.ENTITY', 'Entity with "Double Quotes"'), 'Returns specific regional translation if available' ); $this->assertEquals( - i18n::_t('i18nTestModule.ADDITION'), 'Addition (de)', + i18n::_t('i18nTestModule.ADDITION', 'Addition'), 'Returns fallback non-regional translation if regional is not available' ); i18n::set_locale('fr'); $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), - '', - 'Returns empty translation without default string if locale is not found' + 'Entity with "Double Quotes" (fr)', + i18n::_t('i18nTestModule.ENTITY', 'Entity with "Double Quotes"'), + 'Non-specific locales fall back to language-only localisations' ); - $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY', 'default'), - 'default', - 'Returns default string if locale is not found' - ); - - i18n::set_locale($oldLocale); - } - - public function testIncludeByLocale() - { - // Looping through modules, so we can test the translation autoloading - // Load non-exclusive to retain core class autoloading - $classManifest = new ClassManifest($this->alternateBasePath, true, true, false); - $this->pushManifest($classManifest); - - $adapter = i18n::get_translator('core')->getAdapter(); - $this->assertTrue($adapter->isAvailable('en')); - $this->assertFalse($adapter->isAvailable('de')); - $this->assertFalse( - $adapter->isTranslated('i18nTestModule.ENTITY', 'de'), - 'Existing unloaded entity not available before call' - ); - $this->assertFalse( - $adapter->isTranslated('i18nTestModule.ENTITY', 'af'), - 'Non-existing unloaded entity not available before call' - ); - - // set _fakewebroot module priority - i18n::config()->update('module_priority', array('subfolder','i18ntestmodule')); - - i18n::include_by_locale('de'); - - $this->assertTrue($adapter->isAvailable('en')); - $this->assertTrue($adapter->isAvailable('de')); - $this->assertTrue($adapter->isTranslated('i18nTestModule.ENTITY', null, 'de'), 'Includes module files'); - $this->assertTrue($adapter->isTranslated('i18nTestTheme1.LAYOUTTEMPLATE', null, 'de'), 'Includes theme files'); - $this->assertTrue($adapter->isTranslated('i18nTestModule.OTHERENTITY', null, 'de'), 'Includes submodule files'); - - // check module priority - $this->assertEquals( - $adapter->translate('i18nTestModule.PRIORITYNOTICE', 'de'), - 'High Module Priority (de)' - ); - } - - public function testIncludeByLocaleWithoutFallbackLanguage() - { - $classManifest = new ClassManifest($this->alternateBasePath, true, true, false); - $this->pushManifest($classManifest); - - $adapter = i18n::get_translator('core')->getAdapter(); - $this->assertTrue($adapter->isAvailable('en')); - $this->assertFalse($adapter->isAvailable('mi')); // not defined at all - $this->assertFalse($adapter->isAvailable('mi_NZ')); // defined, but not loaded yet - $this->assertFalse( - $adapter->isTranslated('i18nTestModule.ENTITY', 'mi'), - 'Existing unloaded entity not available before call' - ); - $this->assertFalse( - $adapter->isTranslated('i18nTestModule.ENTITY', 'mi_NZ'), - 'Non-existing unloaded entity not available before call' - ); - - i18n::include_by_locale('mi_NZ'); - - $this->assertFalse($adapter->isAvailable('mi')); - $this->assertTrue($adapter->isAvailable('mi_NZ')); - $this->assertTrue($adapter->isTranslated('i18nTestModule.ENTITY', null, 'mi_NZ'), 'Includes module files'); - } - - public function testRegisterTranslator() - { - $translator = new Zend_Translate( - array( - 'adapter' => CustomTranslatorAdapter::class, - 'disableNotices' => true, - ) - ); - - i18n::register_translator($translator, 'custom', 10); - $translators = i18n::get_translators(); - $this->assertArrayHasKey('custom', $translators[10]); - $this->assertInstanceOf('Zend_Translate', $translators[10]['custom']); - $this->assertInstanceOf(CustomTranslatorAdapter::class, $translators[10]['custom']->getAdapter()); - - i18n::unregister_translator('custom'); - $translators = i18n::get_translators(); - $this->assertArrayNotHasKey('custom', $translators[10]); - } - - public function testMultipleTranslators() - { - // Looping through modules, so we can test the translation autoloading - // Load non-exclusive to retain core class autoloading - $classManifest = new ClassManifest($this->alternateBasePath, true, true, false); - $this->pushManifest($classManifest); - - // Changed manifest, so we also need to unset all previously collected messages. - // The easiest way to do this it to register a new adapter. - $adapter = new Zend_Translate( - array( - 'adapter' => i18nRailsYamlAdapter::class, - 'locale' => i18n::config()->get('default_locale'), - 'disableNotices' => true, - ) - ); - i18n::register_translator($adapter, 'core'); - - i18n::set_locale('en_US'); - - $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), - 'Entity with "Double Quotes"' - ); - $this->assertEquals( - i18n::_t('AdapterEntity1', 'AdapterEntity1'), - 'AdapterEntity1', - 'Falls back to default string if not found' - ); - - // Add a new translator - $translator = new Zend_Translate( - array( - 'adapter' => CustomTranslatorAdapter::class, - 'disableNotices' => true, - ) - ); - i18n::register_translator($translator, 'custom', 11); - $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), - 'i18nTestModule.ENTITY CustomAdapter (en_US)', - 'Existing entities overruled by adapter with higher priority' - ); - $this->assertEquals( - i18n::_t('AdapterEntity1', 'AdapterEntity1'), - 'AdapterEntity1 CustomAdapter (en_US)', - 'New entities only defined in new adapter are detected' - ); - - // Add a second new translator to test priorities - $translator = new Zend_Translate( - array( - 'adapter' => OtherCustomTranslatorAdapter::class, - 'disableNotices' => true, - ) - ); - i18n::register_translator($translator, 'othercustom_lower_prio', 5); - $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), - 'i18nTestModule.ENTITY CustomAdapter (en_US)', - 'Adapter with lower priority loses' - ); - - // Add a third new translator to test priorities - $translator = new Zend_Translate( - array( - 'adapter' => OtherCustomTranslatorAdapter::class, - 'disableNotices' => true, - ) - ); - - i18n::register_translator($translator, 'othercustom_higher_prio', 15); - - $this->assertEquals( - i18n::_t('i18nTestModule.ENTITY'), - 'i18nTestModule.ENTITY OtherCustomAdapter (en_US)', - 'Adapter with higher priority wins' - ); - - i18n::unregister_translator('custom'); - i18n::unregister_translator('othercustom_lower_prio'); - i18n::unregister_translator('othercustom_higher_prio'); } public function testGetLanguageName() diff --git a/tests/php/i18n/i18nTest/CustomTranslatorAdapter.php b/tests/php/i18n/i18nTest/CustomTranslatorAdapter.php deleted file mode 100644 index 7960403fa..000000000 --- a/tests/php/i18n/i18nTest/CustomTranslatorAdapter.php +++ /dev/null @@ -1,30 +0,0 @@ - array( - 'AdapterEntity1' => 'AdapterEntity1 CustomAdapter (' . $locale . ')', - 'i18nTestModule.ENTITY' => 'i18nTestModule.ENTITY CustomAdapter (' . $locale . ')', - ) - ); - } - - public function toString() - { - return 'i18nTest_CustomTranslatorAdapter'; - } - - public function getFilenameForLocale($locale) - { - return false; // not file based - } -} diff --git a/tests/php/i18n/i18nTest/MyObject.php b/tests/php/i18n/i18nTest/MyObject.php index 790bf92ff..2ccb995c5 100644 --- a/tests/php/i18n/i18nTest/MyObject.php +++ b/tests/php/i18n/i18nTest/MyObject.php @@ -2,6 +2,7 @@ namespace SilverStripe\i18n\Tests\i18nTest; +use SilverStripe\Admin\LeftAndMain; use SilverStripe\ORM\DataObject; use SilverStripe\Dev\TestOnly; use SilverStripe\Security\Group; @@ -22,4 +23,15 @@ class MyObject extends DataObject implements TestOnly private static $singular_name = "My Object"; private static $plural_name = "My Objects"; + + public function provideI18nEntities() + { + $entities = parent::provideI18nEntities(); + return array_merge($entities, [ + LeftAndMain::class.'.OTHER_TITLE' => [ + 'default' => 'Other title', + 'module' => 'admin', + ], + ]); + } } diff --git a/tests/php/i18n/i18nTest/OtherCustomTranslatorAdapter.php b/tests/php/i18n/i18nTest/OtherCustomTranslatorAdapter.php deleted file mode 100644 index 68370d665..000000000 --- a/tests/php/i18n/i18nTest/OtherCustomTranslatorAdapter.php +++ /dev/null @@ -1,29 +0,0 @@ - array( - 'i18nTestModule.ENTITY' => 'i18nTestModule.ENTITY OtherCustomAdapter (' . $locale . ')', - ) - ); - } - - public function toString() - { - return 'i18nTest_OtherCustomTranslatorAdapter'; - } - - public function getFilenameForLocale($locale) - { - return false; // not file based - } -} diff --git a/tests/php/i18n/i18nTest/TestDataObject.php b/tests/php/i18n/i18nTest/TestDataObject.php index d6c0c1073..41ceab171 100644 --- a/tests/php/i18n/i18nTest/TestDataObject.php +++ b/tests/php/i18n/i18nTest/TestDataObject.php @@ -8,6 +8,7 @@ use SilverStripe\Security\Member; class TestDataObject extends DataObject implements TestOnly { + private static $table_name = 'i18nTest_TestDataObject'; private static $db = array( 'MyProperty' => 'Varchar', diff --git a/tests/php/i18n/i18nTest/TestObject.php b/tests/php/i18n/i18nTest/TestObject.php index 42412cf31..51bc384ca 100644 --- a/tests/php/i18n/i18nTest/TestObject.php +++ b/tests/php/i18n/i18nTest/TestObject.php @@ -2,11 +2,10 @@ namespace SilverStripe\i18n\Tests\i18nTest; -use SilverStripe\Core\Object; use SilverStripe\Dev\TestOnly; use SilverStripe\i18n\i18nEntityProvider; -class TestObject extends Object implements TestOnly, i18nEntityProvider +class TestObject implements TestOnly, i18nEntityProvider { static $my_translatable_property = "Untranslated"; @@ -17,10 +16,8 @@ class TestObject extends Object implements TestOnly, i18nEntityProvider public function provideI18nEntities() { - return array( - "i18nTest_Object.my_translatable_property" => array( - self::$my_translatable_property - ) - ); + return [ + "i18nTest_Object.my_translatable_property" => self::$my_translatable_property, + ]; } } diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php b/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php new file mode 100644 index 000000000..a978f91da --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php @@ -0,0 +1,21 @@ + 'My Provider Class', + 'i18nProviderClass.PLURALS' => [ + 'one' => 'A class', + 'other' => '{count} classes', + ], + 'i18nProviderClass.OTHER_MODULE' => [ + 'default' => 'i18ntestmodule string defined in i18nothermodule', + 'module' => 'i18ntestmodule' + ], + ]; + } +} diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml index b58eee65e..66c49b332 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml @@ -11,5 +11,8 @@ en: WITHNAMESPACE: Include Entity with Namespace LAYOUTTEMPLATE: Layout Template SPRINTFNAMESPACE: My replacement: %s + PLURAL: + one: 'A test' + other: '{count} tests' i18nTestModuleInclude.ss: SPRINTFINCLUDENAMESPACE: My include replacement: %s diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en_GB.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en_GB.yml new file mode 100644 index 000000000..b58eee65e --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en_GB.yml @@ -0,0 +1,15 @@ +en: + NONAMESPACE: Include Entity without Namespace + SPRINTFNONAMESPACE: My replacement no namespace: %s + SPRINTFINCLUDENONAMESPACE: My include replacement no namespace: %s + LAYOUTTEMPLATENONAMESPACE: Layout Template no namespace + i18nTestModule: + # Comment for entity + ENTITY: Entity with "Double Quotes" + ADDITION: Addition + MAINTEMPLATE: Main Template + WITHNAMESPACE: Include Entity with Namespace + LAYOUTTEMPLATE: Layout Template + SPRINTFNAMESPACE: My replacement: %s + i18nTestModuleInclude.ss: + SPRINTFINCLUDENAMESPACE: My include replacement: %s diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_AR.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_AR.yml new file mode 100644 index 000000000..b58eee65e --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_AR.yml @@ -0,0 +1,15 @@ +en: + NONAMESPACE: Include Entity without Namespace + SPRINTFNONAMESPACE: My replacement no namespace: %s + SPRINTFINCLUDENONAMESPACE: My include replacement no namespace: %s + LAYOUTTEMPLATENONAMESPACE: Layout Template no namespace + i18nTestModule: + # Comment for entity + ENTITY: Entity with "Double Quotes" + ADDITION: Addition + MAINTEMPLATE: Main Template + WITHNAMESPACE: Include Entity with Namespace + LAYOUTTEMPLATE: Layout Template + SPRINTFNAMESPACE: My replacement: %s + i18nTestModuleInclude.ss: + SPRINTFINCLUDENAMESPACE: My include replacement: %s diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_ES.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_ES.yml new file mode 100644 index 000000000..b58eee65e --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/es_ES.yml @@ -0,0 +1,15 @@ +en: + NONAMESPACE: Include Entity without Namespace + SPRINTFNONAMESPACE: My replacement no namespace: %s + SPRINTFINCLUDENONAMESPACE: My include replacement no namespace: %s + LAYOUTTEMPLATENONAMESPACE: Layout Template no namespace + i18nTestModule: + # Comment for entity + ENTITY: Entity with "Double Quotes" + ADDITION: Addition + MAINTEMPLATE: Main Template + WITHNAMESPACE: Include Entity with Namespace + LAYOUTTEMPLATE: Layout Template + SPRINTFNAMESPACE: My replacement: %s + i18nTestModuleInclude.ss: + SPRINTFINCLUDENAMESPACE: My include replacement: %s diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss index 5e46088ce..aec117959 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss @@ -8,5 +8,4 @@ <%t i18nTestModule.INJECTIONS_DOES_NOT_EXIST "Hello {name} {greeting}. But it is late, {goodbye}" name="Mark" greeting="welcome" goodbye="bye" %> <%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" name="Paul" greeting="good you are here" goodbye="see you" %> <%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" is "New context (this should be ignored)" name="Steffen" greeting="willkommen" goodbye="wiedersehen" %> -<%t i18nTestModule.INJECTIONS name="Cat" greeting='meow' goodbye="meow" %> -<%t i18nTestModule.INJECTIONS name=$absoluteBaseURL greeting=$get_locale goodbye="global calls" %> +<%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" name=$absoluteBaseURL greeting=$get_locale goodbye="global calls" %> diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss index 57f99e453..27ef419f2 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss @@ -2,3 +2,6 @@ $Layout lonely _t() call that should be ignored <% _t('i18nTestModule.NEWENTITY',"Not stored in master file yet") %> +Single: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 1) +Multiple: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 4) +None: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 0) diff --git a/tests/php/i18n/i18nTestManifest.php b/tests/php/i18n/i18nTestManifest.php new file mode 100644 index 000000000..5c3756cb9 --- /dev/null +++ b/tests/php/i18n/i18nTestManifest.php @@ -0,0 +1,145 @@ +alternateBasePath = __DIR__ . $s . 'i18nTest' . $s . "_fakewebroot"; + Director::config()->update('alternate_base_folder', $this->alternateBasePath); + + // Replace old template loader with new one with alternate base path + $this->oldThemeResourceLoader = ThemeResourceLoader::instance(); + ThemeResourceLoader::set_instance($loader = new ThemeResourceLoader($this->alternateBasePath)); + $loader->addSet( + '$default', + new ThemeManifest( + $this->alternateBasePath, + project(), + false, + true + ) + ); + + SSViewer::set_themes([ + 'testtheme1', + '$default', + ]); + + $this->originalLocale = i18n::get_locale(); + i18n::set_locale('en_US'); + + // Set new manifest against the root + $classManifest = new ClassManifest($this->alternateBasePath, true, true, false); + $this->pushManifest($classManifest); + + // Setup uncached translator + // This should pull the module list from the above manifest + $translator = new Translator('en'); + $translator->setFallbackLocales(['en']); + $loader = new ModuleYamlLoader(); + $loader->setReader(new YamlReader()); + $translator->addLoader('ss', $loader); // Standard ss module loader + $translator->addLoader('array', new ArrayLoader()); // Note: array loader isn't added by default + $provider = new SymfonyMessageProvider(); + $provider->setTranslator($translator); + Injector::inst()->registerService($provider, MessageProvider::class); + } + + public function tearDownManifest() + { + ThemeResourceLoader::set_instance($this->oldThemeResourceLoader); + i18n::set_locale($this->originalLocale); + + // Reset any manifests pushed during this test + $this->popManifests(); + } + + /** + * Safely push a new class manifest. + * These will be cleaned up on tearDown() + * + * @param ClassManifest $manifest + */ + protected function pushManifest(ClassManifest $manifest) + { + $this->manifests++; + ClassLoader::instance()->pushManifest($manifest); + } + + /** + * Pop off all extra manifests + */ + protected function popManifests() + { + // Reset any manifests pushed during this test + while ($this->manifests > 0) { + ClassLoader::instance()->popManifest(); + $this->manifests--; + } + } +} diff --git a/tests/php/i18n/i18nTextCollectorTest.php b/tests/php/i18n/i18nTextCollectorTest.php index 903ab5960..3d84f79bd 100644 --- a/tests/php/i18n/i18nTextCollectorTest.php +++ b/tests/php/i18n/i18nTextCollectorTest.php @@ -2,74 +2,46 @@ namespace SilverStripe\i18n\Tests; +use PHPUnit_Framework_Error_Notice; use SilverStripe\Assets\Filesystem; -use SilverStripe\Control\Director; -use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Convert; -use SilverStripe\Core\Manifest\ClassManifest; -use SilverStripe\Core\Manifest\ClassLoader; -use SilverStripe\Dev\Debug; use SilverStripe\Dev\SapphireTest; use SilverStripe\i18n\i18n; -use SilverStripe\i18n\i18nTextCollector; -use SilverStripe\i18n\i18nTextCollector_Writer_RailsYaml; +use SilverStripe\i18n\TextCollection\i18nTextCollector; +use SilverStripe\i18n\Messages\YamlWriter; use SilverStripe\i18n\Tests\i18nTextCollectorTest\Collector; -use SilverStripe\View\ThemeResourceLoader; +use SilverStripe\View\SSViewer; class i18nTextCollectorTest extends SapphireTest { + use i18nTestManifest; /** - * @var string $tmpBasePath Used to write language files. - * We don't want to store them inside framework (or in any web-accessible place) - * in case something goes wrong with the file parsing. + * @var string */ - protected $alternateBaseSavePath; - - /** - * @var string $alternateBasePath Fake webroot with a single module - * /i18ntestmodule which contains some files with _t() calls. - */ - protected $alternateBasePath; - - protected $manifest; + protected $alternateBaseSavePath = null; public function setUp() { parent::setUp(); + $this->setupManifest(); - $this->alternateBasePath = __DIR__ . "/i18nTest/_fakewebroot"; - Config::inst()->update(Director::class, 'alternate_base_folder', $this->alternateBasePath); - $this->alternateBaseSavePath = TEMP_FOLDER . '/i18nTextCollectorTest_webroot'; + $this->alternateBaseSavePath = TEMP_FOLDER . DIRECTORY_SEPARATOR . 'i18nTextCollectorTest_webroot'; Filesystem::makeFolder($this->alternateBaseSavePath); - - // Push a class and template loader running from the fake webroot onto - // the stack. - $this->manifest = new ClassManifest( - $this->alternateBasePath, - false, - true, - false - ); - - // Replace old template loader with new one with alternate base path - $this->_oldLoader = ThemeResourceLoader::instance(); - ThemeResourceLoader::set_instance(new ThemeResourceLoader($this->alternateBasePath)); } public function tearDown() { - ThemeResourceLoader::set_instance($this->_oldLoader); - // Pop if added during testing - if (ClassLoader::instance()->getManifest() === $this->manifest) { - ClassLoader::instance()->popManifest(); + if (is_dir($this->alternateBaseSavePath)) { + Filesystem::removeFolder($this->alternateBaseSavePath); } + + $this->tearDownManifest(); parent::tearDown(); } public function testConcatenationInEntityValues() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), array( - 'Test.CONCATENATED' => array("Line 1 and Line '2' and Line \"3\"",'Comment'), - 'Test.CONCATENATED2' => array("Line \"4\" and Line 5") - ) + 'Test.CONCATENATED' => "Line 1 and Line '2' and Line \"3\"", + 'Test.CONCATENATED2' => "Line \"4\" and Line 5" + ), + $c->collectFromCode($php, 'mymodule') ); } public function testCollectFromNewTemplateSyntaxUsingParserSubclass() { - $c = new i18nTextCollector(); - - $html = << + $c = i18nTextCollector::create(); + $html = << <%t i18nTestModule.NEWMETHODSIG "New _t method signature test" %> <%t i18nTestModule.INJECTIONS_0 "Hello {name} {greeting}, and {goodbye}" name="Mark" greeting="welcome" goodbye="bye" %> <%t i18nTestModule.INJECTIONS_1 "Hello {name} {greeting}, and {goodbye}" name="Paul" greeting="welcome" goodbye="cya" %> @@ -111,57 +82,49 @@ SS; $c->collectFromTemplate($html, 'mymodule', 'Test'); $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.SINGLEQUOTE' => array('Single Quote'), - 'i18nTestModule.NEWMETHODSIG' => array("New _t method signature test",null,null), - 'i18nTestModule.INJECTIONS_0' => array("Hello {name} {greeting}, and {goodbye}", null, null), - 'i18nTestModule.INJECTIONS_1' => array("Hello {name} {greeting}, and {goodbye}", null, null), - 'i18nTestModule.INJECTIONS_2' => array("Hello {name} {greeting}", null, "context (ignored)"), - 'i18nTestModule.INJECTIONS_3' => array(null, null, null), - 'i18nTestModule.INJECTIONS_4' => array(null, null, null), - ) + [ + 'Test.SINGLEQUOTE' => 'Single Quote', + 'i18nTestModule.NEWMETHODSIG' => "New _t method signature test", + 'i18nTestModule.INJECTIONS_0' => "Hello {name} {greeting}, and {goodbye}", + 'i18nTestModule.INJECTIONS_1' => "Hello {name} {greeting}, and {goodbye}", + 'i18nTestModule.INJECTIONS_2' => "Hello {name} {greeting}", + ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); } public function testCollectFromTemplateSimple() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.SINGLEQUOTE' => array('Single Quote') - ) + [ 'Test.SINGLEQUOTE' => 'Single Quote' ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.DOUBLEQUOTE' => array("Double Quote and Spaces") - ) + [ 'Test.DOUBLEQUOTE' => "Double Quote and Spaces" ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.NOSEMICOLON' => array("No Semicolon") - ) + [ 'Test.NOSEMICOLON' => "No Semicolon" ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); } public function testCollectFromTemplateAdvanced() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.NEWLINES' => array("New Lines") - ) + [ 'Test.NEWLINES' => "New Lines" ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.PRIOANDCOMMENT' => array(' Prio and Value with "Double Quotes"','Comment with "Double Quotes"') - ) + [ 'Test.PRIOANDCOMMENT' => ' Prio and Value with "Double Quotes"' ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); $html = << SS; $this->assertEquals( - $c->collectFromTemplate($html, 'mymodule', 'Test'), - array( - 'Test.PRIOANDCOMMENT' => array(" Prio and Value with 'Single Quotes'","Comment with 'Single Quotes'") - ) + [ 'Test.PRIOANDCOMMENT' => " Prio and Value with 'Single Quotes'" ], + $c->collectFromTemplate($html, 'mymodule', 'Test') ); } public function testCollectFromCodeSimple() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.SINGLEQUOTE' => array('Single Quote') - ) + [ 'Test.SINGLEQUOTE' => 'Single Quote' ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.DOUBLEQUOTE' => array("Double Quote and Spaces") - ) + [ 'Test.DOUBLEQUOTE' => "Double Quote and Spaces" ], + $c->collectFromCode($php, 'mymodule') ); } public function testCollectFromCodeAdvanced() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.NEWLINES' => array("New Lines") - ) + [ 'Test.NEWLINES' => "New Lines" ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.PRIOANDCOMMENT' => array(' Value with "Double Quotes"','Comment with "Double Quotes"') - ) + [ 'Test.PRIOANDCOMMENT' => ' Value with "Double Quotes"' ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.PRIOANDCOMMENT' => array(" Value with 'Single Quotes'","Comment with 'Single Quotes'") - ) + [ 'Test.PRIOANDCOMMENT' => " Value with 'Single Quotes'" ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.PRIOANDCOMMENT' => array("Value with 'Escaped Single Quotes'") - ) + [ 'Test.PRIOANDCOMMENT' => "Value with 'Escaped Single Quotes'" ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.PRIOANDCOMMENT' => array("Doublequoted Value with 'Unescaped Single Quotes'") - ) + [ 'Test.PRIOANDCOMMENT' => "Doublequoted Value with 'Unescaped Single Quotes'"], + $c->collectFromCode($php, 'mymodule') ); } public function testNewlinesInEntityValues() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.NEWLINESINGLEQUOTE' => array("Line 1{$eol}Line 2") - ) + [ 'Test.NEWLINESINGLEQUOTE' => "Line 1{$eol}Line 2" ], + $c->collectFromCode($php, 'mymodule') ); $php = <<assertEquals( - $c->collectFromCode($php, 'mymodule'), - array( - 'Test.NEWLINEDOUBLEQUOTE' => array("Line 1{$eol}Line 2") - ) + [ 'Test.NEWLINEDOUBLEQUOTE' => "Line 1{$eol}Line 2" ], + $c->collectFromCode($php, 'mymodule') ); } @@ -347,79 +286,65 @@ PHP; */ public function testCollectFromCodeNewSignature() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $php = <<"Mark", "greeting"=>"welcome", "goodbye"=>"bye")); _t('i18nTestModule.INJECTIONS2', "Hello {name} {greeting}. But it is late, {goodbye}", array("name"=>"Paul", "greeting"=>"good you are here", "goodbye"=>"see you")); _t("i18nTestModule.INJECTIONS3", "Hello {name} {greeting}. But it is late, {goodbye}", "New context (this should be ignored)", array("name"=>"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen")); -_t('i18nTestModule.INJECTIONS4', array("name"=>"Cat", "greeting"=>"meow", "goodbye"=>"meow")); -_t('i18nTestModule.INJECTIONS5','_DOES_NOT_EXIST', "Hello {name} {greeting}. But it is late, {goodbye}", - ["name"=>"Mark", "greeting"=>"welcome", "goodbye"=>"bye"]); _t('i18nTestModule.INJECTIONS6', "Hello {name} {greeting}. But it is late, {goodbye}", ["name"=>"Paul", "greeting"=>"good you are here", "goodbye"=>"see you"]); _t("i18nTestModule.INJECTIONS7", "Hello {name} {greeting}. But it is late, {goodbye}", "New context (this should be ignored)", ["name"=>"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen"]); -_t('i18nTestModule.INJECTIONS8', ["name"=>"Cat", "greeting"=>"meow", "goodbye"=>"meow"]); PHP; $collectedTranslatables = $c->collectFromCode($php, 'mymodule'); - $expectedArray = (array( - 'i18nTestModule.NEWMETHODSIG' => array("New _t method signature test"), - 'i18nTestModule.INJECTIONS1' => array("_DOES_NOT_EXIST", - "Hello {name} {greeting}. But it is late, {goodbye}"), - 'i18nTestModule.INJECTIONS2' => array("Hello {name} {greeting}. But it is late, {goodbye}"), - 'i18nTestModule.INJECTIONS3' => array("Hello {name} {greeting}. But it is late, {goodbye}", - "New context (this should be ignored)"), - 'i18nTestModule.INJECTIONS5' => array("_DOES_NOT_EXIST", - "Hello {name} {greeting}. But it is late, {goodbye}"), - 'i18nTestModule.INJECTIONS6' => array("Hello {name} {greeting}. But it is late, {goodbye}"), - 'i18nTestModule.INJECTIONS7' => array("Hello {name} {greeting}. But it is late, {goodbye}", - "New context (this should be ignored)"), - )); + $expectedArray = [ + 'i18nTestModule.INJECTIONS2' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.INJECTIONS3' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.INJECTIONS6' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.INJECTIONS7' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.NEWMETHODSIG' => "New _t method signature test", + ]; + $this->assertEquals($expectedArray, $collectedTranslatables); - ksort($expectedArray); - - $this->assertEquals($collectedTranslatables, $expectedArray); + // Test warning is raised + $this->setExpectedException( + PHPUnit_Framework_Error_Notice::class, + 'Missing localisation default for key i18nTestModule.INJECTIONS4' + ); + $php = <<"Cat", "greeting"=>"meow", "goodbye"=>"meow")); +PHP; + $c->collectFromCode($php, 'mymodule'); } - /** - * @todo Should be in a separate test suite, but don't want to duplicate setup logic - */ - public function testYamlWriter() + public function testUncollectableCode() { - $writer = new i18nTextCollector_Writer_RailsYaml(); - $entities = array( - 'Level1.Level2.EntityName' => array('Text', 'Context'), - 'Level1.OtherEntityName' => array('Other Text', 'Other Context'), - 'Level1.BoolTest' => array('True'), - 'Level1.FlagTest' => array('No'), - 'Level1.TextTest' => array('Maybe') - ); - $yaml = <<assertEquals($yaml, Convert::nl2os($writer->getYaml($entities, 'de'))); + $php = <<collectFromCode($php, 'mymodule'); + + // Only one item is collectable + $expectedArray = [ 'Collectable.KEY4' => 'Default' ]; + $this->assertEquals($expectedArray, $collectedTranslatables); } public function testCollectFromIncludedTemplates() { - $c = new i18nTextCollector(); + $c = i18nTextCollector::create(); $templateFilePath = $this->alternateBasePath . '/i18ntestmodule/templates/Layout/i18nTestModule.ss'; $html = file_get_contents($templateFilePath); @@ -427,23 +352,23 @@ YAML; $this->assertArrayHasKey('RandomNamespace.LAYOUTTEMPLATENONAMESPACE', $matches); $this->assertEquals( - $matches['RandomNamespace.LAYOUTTEMPLATENONAMESPACE'], - array('Layout Template no namespace') + 'Layout Template no namespace', + $matches['RandomNamespace.LAYOUTTEMPLATENONAMESPACE'] ); $this->assertArrayHasKey('RandomNamespace.SPRINTFNONAMESPACE', $matches); $this->assertEquals( - $matches['RandomNamespace.SPRINTFNONAMESPACE'], - array('My replacement no namespace: %s') + 'My replacement no namespace: %s', + $matches['RandomNamespace.SPRINTFNONAMESPACE'] ); $this->assertArrayHasKey('i18nTestModule.LAYOUTTEMPLATE', $matches); $this->assertEquals( - $matches['i18nTestModule.LAYOUTTEMPLATE'], - array('Layout Template') + 'Layout Template', + $matches['i18nTestModule.LAYOUTTEMPLATE'] ); $this->assertArrayHasKey('i18nTestModule.SPRINTFNAMESPACE', $matches); $this->assertEquals( - $matches['i18nTestModule.SPRINTFNAMESPACE'], - array('My replacement: %s') + 'My replacement: %s', + $matches['i18nTestModule.SPRINTFNAMESPACE'] ); // Includes should not automatically inject translations into parent templates @@ -455,8 +380,8 @@ YAML; public function testCollectFromThemesTemplates() { - $c = new i18nTextCollector(); - Config::inst()->update('SilverStripe\\View\\SSViewer', 'theme', 'testtheme1'); + $c = i18nTextCollector::create(); + SSViewer::set_themes([ 'testtheme1' ]); // Collect from layout $layoutFilePath = $this->alternateBasePath . '/themes/testtheme1/templates/Layout/i18nTestTheme1.ss'; @@ -465,16 +390,12 @@ YAML; // all entities from i18nTestTheme1.ss $this->assertEquals( - array( - 'i18nTestTheme1.LAYOUTTEMPLATE' - => array('Theme1 Layout Template'), - 'i18nTestTheme1.SPRINTFNAMESPACE' - => array('Theme1 My replacement: %s'), - 'i18nTestTheme1.ss.LAYOUTTEMPLATENONAMESPACE' - => array('Theme1 Layout Template no namespace'), - 'i18nTestTheme1.ss.SPRINTFNONAMESPACE' - => array('Theme1 My replacement no namespace: %s'), - ), + [ + 'i18nTestTheme1.LAYOUTTEMPLATE' => 'Theme1 Layout Template', + 'i18nTestTheme1.SPRINTFNAMESPACE' => 'Theme1 My replacement: %s', + 'i18nTestTheme1.ss.LAYOUTTEMPLATENONAMESPACE' => 'Theme1 Layout Template no namespace', + 'i18nTestTheme1.ss.SPRINTFNONAMESPACE' => 'Theme1 My replacement no namespace: %s', + ], $layoutMatches ); @@ -485,29 +406,20 @@ YAML; // all entities from i18nTestTheme1Include.ss $this->assertEquals( - array( - 'i18nTestTheme1Include.SPRINTFINCLUDENAMESPACE' - => array('Theme1 My include replacement: %s'), - 'i18nTestTheme1Include.WITHNAMESPACE' - => array('Theme1 Include Entity with Namespace'), - 'i18nTestTheme1Include.ss.NONAMESPACE' - => array('Theme1 Include Entity without Namespace'), - 'i18nTestTheme1Include.ss.SPRINTFINCLUDENONAMESPACE' - => array('Theme1 My include replacement no namespace: %s') - ), + [ + 'i18nTestTheme1Include.SPRINTFINCLUDENAMESPACE' => 'Theme1 My include replacement: %s', + 'i18nTestTheme1Include.WITHNAMESPACE' => 'Theme1 Include Entity with Namespace', + 'i18nTestTheme1Include.ss.NONAMESPACE' => 'Theme1 Include Entity without Namespace', + 'i18nTestTheme1Include.ss.SPRINTFINCLUDENONAMESPACE' => 'Theme1 My include replacement no namespace: %s' + ], $includeMatches ); } public function testCollectMergesWithExisting() { - i18n::set_locale('en_US'); - i18n::config()->update('default_locale', 'en_US'); - i18n::include_by_locale('en'); - i18n::include_by_locale('en_US'); - - $c = new i18nTextCollector(); - $c->setWriter(new i18nTextCollector_Writer_RailsYaml()); + $c = i18nTextCollector::create(); + $c->setWriter(new YamlWriter()); $c->basePath = $this->alternateBasePath; $c->baseSavePath = $this->alternateBaseSavePath; @@ -522,6 +434,16 @@ YAML; $entitiesByModule['i18ntestmodule'], 'Adds new entities' ); + + // Test cross-module strings are set correctly + $this->assertArrayHasKey( + 'i18nProviderClass.OTHER_MODULE', + $entitiesByModule['i18ntestmodule'] + ); + $this->assertEquals( + 'i18ntestmodule string defined in i18nothermodule', + $entitiesByModule['i18ntestmodule']['i18nProviderClass.OTHER_MODULE'] + ); } public function testCollectFromFilesystemAndWriteMasterTables() @@ -530,8 +452,8 @@ YAML; i18n::set_locale('en_US'); //set the locale to the US locale expected in the asserts i18n::config()->update('default_locale', 'en_US'); - $c = new i18nTextCollector(); - $c->setWriter(new i18nTextCollector_Writer_RailsYaml()); + $c = i18nTextCollector::create(); + $c->setWriter(new YamlWriter()); $c->basePath = $this->alternateBasePath; $c->baseSavePath = $this->alternateBaseSavePath; @@ -648,20 +570,26 @@ YAML; public function testCollectFromEntityProvidersInCustomObject() { - $c = new i18nTextCollector(); + // note: Disable _fakewebroot manifest for this test + $this->popManifests(); + $c = i18nTextCollector::create(); $filePath = __DIR__ . '/i18nTest/MyObject.php'; $matches = $c->collectFromEntityProviders($filePath); $this->assertEquals( - array( - 'SilverStripe\i18n\Tests\i18nTest\MyObject.PLURALNAME', - 'SilverStripe\i18n\Tests\i18nTest\MyObject.SINGULARNAME', - ), - array_keys($matches) - ); - $this->assertEquals( - 'My Object', - $matches['SilverStripe\i18n\Tests\i18nTest\MyObject.SINGULARNAME'][0] + [ + 'SilverStripe\Admin\LeftAndMain.OTHER_TITLE' => [ + 'default' => 'Other title', + 'module' => 'admin', + ], + 'SilverStripe\i18n\Tests\i18nTest\MyObject.PLURALNAME' => 'My Objects', + 'SilverStripe\i18n\Tests\i18nTest\MyObject.PLURALS' => [ + 'one' => 'A My Object', + 'other' => '{count} My Objects', + ], + 'SilverStripe\i18n\Tests\i18nTest\MyObject.SINGULARNAME' => 'My Object', + ], + $matches ); } @@ -671,48 +599,47 @@ YAML; public function testResolveDuplicates() { $collector = new Collector(); - ClassLoader::instance()->pushManifest($this->manifest); // Dummy data as collected - $data1 = array( - 'i18ntestmodule' => array( - 'i18nTestModule.PLURALNAME' => array('Data Objects'), - 'i18nTestModule.SINGULARNAME' => array('Data Object') - ), - 'mymodule' => array( - 'i18nTestModule.PLURALNAME' => array('Ignored String'), - 'i18nTestModule.STREETNAME' => array('Shortland Street') - ) - ); - $expected = array( - 'i18ntestmodule' => array( - 'i18nTestModule.PLURALNAME' => array('Data Objects'), - 'i18nTestModule.SINGULARNAME' => array('Data Object') - ), - 'mymodule' => array( - // Because this key doesn't exist in i18ntestmodule strings - 'i18nTestModule.STREETNAME' => array('Shortland Street') - ) - ); + $data1 = [ + 'i18ntestmodule' => [ + 'i18nTestModule.PLURALNAME' => 'Data Objects', + 'i18nTestModule.SINGULARNAME' => 'Data Object', + ], + 'mymodule' => [ + 'i18nTestModule.PLURALNAME' => 'Ignored String', + 'i18nTestModule.STREETNAME' => 'Shortland Street', + ], + ]; + $expected = [ + 'i18ntestmodule' => [ + 'i18nTestModule.PLURALNAME' => 'Data Objects', + 'i18nTestModule.SINGULARNAME' => 'Data Object', + ], + 'mymodule' => [ + // Removed PLURALNAME because this key doesn't exist in i18ntestmodule strings + 'i18nTestModule.STREETNAME' => 'Shortland Street' + ] + ]; $resolved = $collector->resolveDuplicateConflicts_Test($data1); $this->assertEquals($expected, $resolved); // Test getConflicts - $data2 = array( - 'module1' => array( - 'i18ntestmodule.ONE' => array('One'), - 'i18ntestmodule.TWO' => array('Two'), - 'i18ntestmodule.THREE' => array('Three'), - ), - 'module2' => array( - 'i18ntestmodule.THREE' => array('Three'), - ), - 'module3' => array( - 'i18ntestmodule.TWO' => array('Two'), - 'i18ntestmodule.THREE' => array('Three'), - ) - ); + $data2 = [ + 'module1' => [ + 'i18ntestmodule.ONE' => 'One', + 'i18ntestmodule.TWO' => 'Two', + 'i18ntestmodule.THREE' => 'Three', + ], + 'module2' => [ + 'i18ntestmodule.THREE' => 'Three', + ], + 'module3' => [ + 'i18ntestmodule.TWO' => 'Two', + 'i18ntestmodule.THREE' => 'Three', + ], + ]; $conflictsA = $collector->getConflicts_Test($data2); sort($conflictsA); $this->assertEquals( @@ -735,7 +662,6 @@ YAML; public function testModuleDetection() { $collector = new Collector(); - ClassLoader::instance()->pushManifest($this->manifest); $modules = $collector->getModules_Test($this->alternateBasePath); $this->assertEquals( array( @@ -790,9 +716,10 @@ YAML; // Standard modules with code in odd places should only have code in those directories detected $otherFiles = $collector->getFileListForModule_Test('i18nothermodule'); $otherRoot = $this->alternateBasePath . '/i18nothermodule'; - $this->assertEquals(3, count($otherFiles)); + $this->assertEquals(4, count($otherFiles)); // Only detect well-behaved files $this->assertArrayHasKey("{$otherRoot}/code/i18nOtherModule.php", $otherFiles); + $this->assertArrayHasKey("{$otherRoot}/code/i18nProviderClass.php", $otherFiles); $this->assertArrayHasKey("{$otherRoot}/code/i18nTestModuleDecorator.php", $otherFiles); $this->assertArrayHasKey("{$otherRoot}/templates/i18nOtherModule.ss", $otherFiles); diff --git a/tests/php/i18n/i18nTextCollectorTest/Collector.php b/tests/php/i18n/i18nTextCollectorTest/Collector.php index 9c5c7629b..cd33be414 100644 --- a/tests/php/i18n/i18nTextCollectorTest/Collector.php +++ b/tests/php/i18n/i18nTextCollectorTest/Collector.php @@ -3,7 +3,7 @@ namespace SilverStripe\i18n\Tests\i18nTextCollectorTest; use SilverStripe\Dev\TestOnly; -use SilverStripe\i18n\i18nTextCollector; +use SilverStripe\i18n\TextCollection\i18nTextCollector; /** * Assist with testing of specific protected methods diff --git a/thirdparty/Zend/Translate.php b/thirdparty/Zend/Translate.php deleted file mode 100644 index 7a5d4c3c8..000000000 --- a/thirdparty/Zend/Translate.php +++ /dev/null @@ -1,220 +0,0 @@ -toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options = array(); - $options['adapter'] = array_shift($args); - if (!empty($args)) { - $options['content'] = array_shift($args); - } - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $opt = array_shift($args); - $options = array_merge($opt, $options); - } - } else if (!is_array($options)) { - $options = array('adapter' => $options); - } - - $this->setAdapter($options); - } - - /** - * Sets a new adapter - * - * @param array|Zend_Config $options Options to use - * @throws Zend_Translate_Exception - */ - public function setAdapter($options = array()) - { - if ($options instanceof Zend_Config) { - $options = $options->toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options = array(); - $options['adapter'] = array_shift($args); - if (!empty($args)) { - $options['content'] = array_shift($args); - } - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $opt = array_shift($args); - $options = array_merge($opt, $options); - } - } else if (!is_array($options)) { - $options = array('adapter' => $options); - } - - if (Zend_Loader::isReadable('Zend/Translate/Adapter/' . ucfirst($options['adapter']). '.php')) { - $options['adapter'] = 'Zend_Translate_Adapter_' . ucfirst($options['adapter']); - } - - if (!class_exists($options['adapter'])) { - Zend_Loader::loadClass($options['adapter']); - } - - if (array_key_exists('cache', $options)) { - Zend_Translate_Adapter::setCache($options['cache']); - } - - $adapter = $options['adapter']; - unset($options['adapter']); - $this->_adapter = new $adapter($options); - if (!$this->_adapter instanceof Zend_Translate_Adapter) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("Adapter " . $adapter . " does not extend Zend_Translate_Adapter"); - } - } - - /** - * Returns the adapters name and it's options - * - * @return Zend_Translate_Adapter - */ - public function getAdapter() - { - return $this->_adapter; - } - - /** - * Returns the set cache - * - * @return Zend_Cache_Core The set cache - */ - public static function getCache() - { - return Zend_Translate_Adapter::getCache(); - } - - /** - * Sets a cache for all instances of Zend_Translate - * - * @param Zend_Cache_Core $cache Cache to store to - * @return void - */ - public static function setCache(Zend_Cache_Core $cache) - { - Zend_Translate_Adapter::setCache($cache); - } - - /** - * Returns true when a cache is set - * - * @return boolean - */ - public static function hasCache() - { - return Zend_Translate_Adapter::hasCache(); - } - - /** - * Removes any set cache - * - * @return void - */ - public static function removeCache() - { - Zend_Translate_Adapter::removeCache(); - } - - /** - * Clears all set cache data - * - * @param string $tag Tag to clear when the default tag name is not used - * @return void - */ - public static function clearCache($tag = null) - { - Zend_Translate_Adapter::clearCache($tag); - } - - /** - * Calls all methods from the adapter - */ - public function __call($method, array $options) - { - if (method_exists($this->_adapter, $method)) { - return call_user_func_array(array($this->_adapter, $method), $options); - } - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("Unknown method '" . $method . "' called!"); - } -} diff --git a/thirdparty/Zend/Translate/Adapter.php b/thirdparty/Zend/Translate/Adapter.php deleted file mode 100644 index 38fb15f43..000000000 --- a/thirdparty/Zend/Translate/Adapter.php +++ /dev/null @@ -1,998 +0,0 @@ - when true, clears already loaded translations when adding new files - * 'content' => content to translate or file or directory with content - * 'disableNotices' => when true, omits notices from being displayed - * 'ignore' => a prefix for files and directories which are not being added - * 'locale' => the actual set locale to use - * 'log' => a instance of Zend_Log where logs are written to - * 'logMessage' => message to be logged - * 'logPriority' => priority which is used to write the log message - * 'logUntranslated' => when true, untranslated messages are not logged - * 'reload' => reloads the cache by reading the content again - * 'scan' => searches for translation files using the LOCALE constants - * 'tag' => tag to use for the cache - * - * @var array - */ - protected $_options = array( - 'clear' => false, - 'content' => null, - 'disableNotices' => false, - 'ignore' => '.', - 'locale' => 'auto', - 'log' => null, - 'logMessage' => "Untranslated message within '%locale%': %message%", - 'logPriority' => 5, - 'logUntranslated' => false, - 'reload' => false, - 'route' => null, - 'scan' => null, - 'tag' => 'Zend_Translate' - ); - - /** - * Translation table - * @var array - */ - protected $_translate = array(); - - /** - * Generates the adapter - * - * @param array|Zend_Config $options Translation options for this adapter - * @throws Zend_Translate_Exception - * @return void - */ - public function __construct($options = array()) - { - if ($options instanceof Zend_Config) { - $options = $options->toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options = array(); - $options['content'] = array_shift($args); - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $opt = array_shift($args); - $options = array_merge($opt, $options); - } - } else if (!is_array($options)) { - $options = array('content' => $options); - } - - if (array_key_exists('cache', $options)) { - self::setCache($options['cache']); - unset($options['cache']); - } - - if (isset(self::$_cache)) { - $id = 'Zend_Translate_' . $this->toString() . '_Options'; - $result = self::$_cache->load($id); - if ($result) { - $this->_options = $result; - } - } - - if (empty($options['locale']) || ($options['locale'] === "auto")) { - $this->_automatic = true; - } else { - $this->_automatic = false; - } - - $locale = null; - if (!empty($options['locale'])) { - $locale = $options['locale']; - unset($options['locale']); - } - - $this->setOptions($options); - $options['locale'] = $locale; - - if (!empty($options['content'])) { - $this->addTranslation($options); - } - - if ($this->getLocale() !== (string) $options['locale']) { - $this->setLocale($options['locale']); - } - } - - /** - * Add translations - * - * This may be a new language or additional content for an existing language - * If the key 'clear' is true, then translations for the specified - * language will be replaced and added otherwise - * - * @param array|Zend_Config $options Options and translations to be added - * @throws Zend_Translate_Exception - * @return Zend_Translate_Adapter Provides fluent interface - */ - public function addTranslation($options = array()) - { - if ($options instanceof Zend_Config) { - $options = $options->toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options = array(); - $options['content'] = array_shift($args); - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $opt = array_shift($args); - $options = array_merge($opt, $options); - } - } else if (!is_array($options)) { - $options = array('content' => $options); - } - - if (!isset($options['content']) || empty($options['content'])) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("Required option 'content' is missing"); - } - - $originate = null; - if (!empty($options['locale'])) { - $originate = (string) $options['locale']; - } - - if ((array_key_exists('log', $options)) && !($options['log'] instanceof Zend_Log)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Instance of Zend_Log expected for option log'); - } - - try { - if (!($options['content'] instanceof Zend_Translate) && !($options['content'] instanceof Zend_Translate_Adapter)) { - if (empty($options['locale'])) { - $options['locale'] = null; - } - - $options['locale'] = Zend_Locale::findLocale($options['locale']); - } - } catch (Zend_Locale_Exception $e) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("The given Language '{$options['locale']}' does not exist", 0, $e); - } - - $options = $options + $this->_options; - if (is_string($options['content']) and is_dir($options['content'])) { - $options['content'] = realpath($options['content']); - $prev = ''; - $iterator = new RecursiveIteratorIterator( - new RecursiveRegexIterator( - new RecursiveDirectoryIterator($options['content'], RecursiveDirectoryIterator::KEY_AS_PATHNAME), - '/^(?!.*(\.svn|\.cvs)).*$/', RecursiveRegexIterator::MATCH - ), - RecursiveIteratorIterator::SELF_FIRST - ); - - foreach ($iterator as $directory => $info) { - $file = $info->getFilename(); - if (is_array($options['ignore'])) { - foreach ($options['ignore'] as $key => $ignore) { - if (strpos($key, 'regex') !== false) { - if (preg_match($ignore, $directory)) { - // ignore files matching the given regex from option 'ignore' and all files below - continue 2; - } - } else if (strpos($directory, DIRECTORY_SEPARATOR . $ignore) !== false) { - // ignore files matching first characters from option 'ignore' and all files below - continue 2; - } - } - } else { - if (strpos($directory, DIRECTORY_SEPARATOR . $options['ignore']) !== false) { - // ignore files matching first characters from option 'ignore' and all files below - continue; - } - } - - if ($info->isDir()) { - // pathname as locale - if (($options['scan'] === self::LOCALE_DIRECTORY) and (Zend_Locale::isLocale($file, true, false))) { - $options['locale'] = $file; - $prev = (string) $options['locale']; - } - } else if ($info->isFile()) { - // filename as locale - if ($options['scan'] === self::LOCALE_FILENAME) { - $filename = explode('.', $file); - array_pop($filename); - $filename = implode('.', $filename); - if (Zend_Locale::isLocale((string) $filename, true, false)) { - $options['locale'] = (string) $filename; - } else { - $parts = explode('.', $file); - $parts2 = array(); - foreach($parts as $token) { - $parts2 += explode('_', $token); - } - $parts = array_merge($parts, $parts2); - $parts2 = array(); - foreach($parts as $token) { - $parts2 += explode('-', $token); - } - $parts = array_merge($parts, $parts2); - $parts = array_unique($parts); - $prev = ''; - foreach($parts as $token) { - if (Zend_Locale::isLocale($token, true, false)) { - if (strlen($prev) <= strlen($token)) { - $options['locale'] = $token; - $prev = $token; - } - } - } - } - } - - try { - $options['content'] = $info->getPathname(); - $this->_addTranslationData($options); - } catch (Zend_Translate_Exception $e) { - // ignore failed sources while scanning - } - } - } - - unset($iterator); - } else { - $this->_addTranslationData($options); - } - - if ((isset($this->_translate[$originate]) === true) and (count($this->_translate[$originate]) > 0)) { - $this->setLocale($originate); - } - - return $this; - } - - /** - * Sets new adapter options - * - * @param array $options Adapter options - * @throws Zend_Translate_Exception - * @return Zend_Translate_Adapter Provides fluent interface - */ - public function setOptions(array $options = array()) - { - $change = false; - $locale = null; - foreach ($options as $key => $option) { - if ($key == 'locale') { - $locale = $option; - } else if ((isset($this->_options[$key]) and ($this->_options[$key] != $option)) or - !isset($this->_options[$key])) { - if (($key == 'log') && !($option instanceof Zend_Log)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Instance of Zend_Log expected for option log'); - } - - if ($key == 'cache') { - self::setCache($option); - continue; - } - - $this->_options[$key] = $option; - $change = true; - } - } - - if ($locale !== null) { - $this->setLocale($locale); - } - - if (isset(self::$_cache) and ($change == true)) { - $id = 'Zend_Translate_' . $this->toString() . '_Options'; - if (self::$_cacheTags) { - self::$_cache->save($this->_options, $id, array($this->_options['tag'])); - } else { - self::$_cache->save($this->_options, $id); - } - } - - return $this; - } - - /** - * Returns the adapters name and it's options - * - * @param string|null $optionKey String returns this option - * null returns all options - * @return integer|string|array|null - */ - public function getOptions($optionKey = null) - { - if ($optionKey === null) { - return $this->_options; - } - - if (isset($this->_options[$optionKey]) === true) { - return $this->_options[$optionKey]; - } - - return null; - } - - /** - * Gets locale - * - * @return Zend_Locale|string|null - */ - public function getLocale() - { - return $this->_options['locale']; - } - - /** - * Sets locale - * - * @param string|Zend_Locale $locale Locale to set - * @throws Zend_Translate_Exception - * @return Zend_Translate_Adapter Provides fluent interface - */ - public function setLocale($locale) - { - if (($locale === "auto") or ($locale === null)) { - $this->_automatic = true; - } else { - $this->_automatic = false; - } - - try { - $locale = Zend_Locale::findLocale($locale); - } catch (Zend_Locale_Exception $e) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("The given Language ({$locale}) does not exist", 0, $e); - } - - if (!isset($this->_translate[$locale])) { - $temp = explode('_', $locale); - if (!isset($this->_translate[$temp[0]]) and !isset($this->_translate[$locale])) { - if (!$this->_options['disableNotices']) { - if ($this->_options['log']) { - $this->_options['log']->log("The language '{$locale}' has to be added before it can be used.", $this->_options['logPriority']); - } else { - trigger_error("The language '{$locale}' has to be added before it can be used.", E_USER_NOTICE); - } - } - } - - $locale = $temp[0]; - } - - if (empty($this->_translate[$locale])) { - if (!$this->_options['disableNotices']) { - if ($this->_options['log']) { - $this->_options['log']->log("No translation for the language '{$locale}' available.", $this->_options['logPriority']); - } else { - trigger_error("No translation for the language '{$locale}' available.", E_USER_NOTICE); - } - } - } - - if ($this->_options['locale'] != $locale) { - $this->_options['locale'] = $locale; - - if (isset(self::$_cache)) { - $id = 'Zend_Translate_' . $this->toString() . '_Options'; - if (self::$_cacheTags) { - self::$_cache->save($this->_options, $id, array($this->_options['tag'])); - } else { - self::$_cache->save($this->_options, $id); - } - } - } - - return $this; - } - - /** - * Returns the available languages from this adapter - * - * @return array|null - */ - public function getList() - { - $list = array_keys($this->_translate); - $result = null; - foreach($list as $value) { - if (!empty($this->_translate[$value])) { - $result[$value] = $value; - } - } - return $result; - } - - /** - * Returns the message id for a given translation - * If no locale is given, the actual language will be used - * - * @param string $message Message to get the key for - * @param string|Zend_Locale $locale (optional) Language to return the message ids from - * @return string|array|false - */ - public function getMessageId($message, $locale = null) - { - if (empty($locale) or !$this->isAvailable($locale)) { - $locale = $this->_options['locale']; - } - - return array_search($message, $this->_translate[(string) $locale]); - } - - /** - * Returns all available message ids from this adapter - * If no locale is given, the actual language will be used - * - * @param string|Zend_Locale $locale (optional) Language to return the message ids from - * @return array - */ - public function getMessageIds($locale = null) - { - if (empty($locale) or !$this->isAvailable($locale)) { - $locale = $this->_options['locale']; - } - - return array_keys($this->_translate[(string) $locale]); - } - - /** - * Returns all available translations from this adapter - * If no locale is given, the actual language will be used - * If 'all' is given the complete translation dictionary will be returned - * - * @param string|Zend_Locale $locale (optional) Language to return the messages from - * @return array - */ - public function getMessages($locale = null) - { - if ($locale === 'all') { - return $this->_translate; - } - - if ((empty($locale) === true) or ($this->isAvailable($locale) === false)) { - $locale = $this->_options['locale']; - } - - return $this->_translate[(string) $locale]; - } - - /** - * Is the wished language available ? - * - * @see Zend_Locale - * @param string|Zend_Locale $locale Language to search for, identical with locale identifier, - * @see Zend_Locale for more information - * @return boolean - */ - public function isAvailable($locale) - { - $return = isset($this->_translate[(string) $locale]); - return $return; - } - - /** - * Load translation data - * - * @param mixed $data - * @param string|Zend_Locale $locale - * @param array $options (optional) - * @return array - */ - abstract protected function _loadTranslationData($data, $locale, array $options = array()); - - /** - * Internal function for adding translation data - * - * This may be a new language or additional data for an existing language - * If the options 'clear' is true, then the translation data for the specified - * language is replaced and added otherwise - * - * @see Zend_Locale - * @param array|Zend_Config $content Translation data to add - * @throws Zend_Translate_Exception - * @return Zend_Translate_Adapter Provides fluent interface - */ - private function _addTranslationData($options = array()) - { - if ($options instanceof Zend_Config) { - $options = $options->toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options['content'] = array_shift($args); - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $options += array_shift($args); - } - } - - if (($options['content'] instanceof Zend_Translate) || ($options['content'] instanceof Zend_Translate_Adapter)) { - $options['usetranslateadapter'] = true; - if (!empty($options['locale']) && ($options['locale'] !== 'auto')) { - $options['content'] = $options['content']->getMessages($options['locale']); - } else { - $content = $options['content']; - $locales = $content->getList(); - foreach ($locales as $locale) { - $options['locale'] = $locale; - $options['content'] = $content->getMessages($locale); - $this->_addTranslationData($options); - } - - return $this; - } - } - - try { - $options['locale'] = Zend_Locale::findLocale($options['locale']); - } catch (Zend_Locale_Exception $e) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("The given Language '{$options['locale']}' does not exist", 0, $e); - } - - if ($options['clear'] || !isset($this->_translate[$options['locale']])) { - $this->_translate[$options['locale']] = array(); - } - - $read = true; - if (isset(self::$_cache)) { - $id = 'Zend_Translate_' . md5(serialize($options['content'])) . '_' . $this->toString(); - $temp = self::$_cache->load($id); - if ($temp) { - $read = false; - } - } - - if ($options['reload']) { - $read = true; - } - - if ($read) { - if (!empty($options['usetranslateadapter'])) { - $temp = array($options['locale'] => $options['content']); - } else { - $temp = $this->_loadTranslationData($options['content'], $options['locale'], $options); - } - } - - if (empty($temp)) { - $temp = array(); - } - - $keys = array_keys($temp); - foreach($keys as $key) { - if (!isset($this->_translate[$key])) { - $this->_translate[$key] = array(); - } - - if (array_key_exists($key, $temp) && is_array($temp[$key])) { - $this->_translate[$key] = $temp[$key] + $this->_translate[$key]; - } - } - - if ($this->_automatic === true) { - $find = new Zend_Locale($options['locale']); - $browser = $find->getEnvironment() + $find->getBrowser(); - arsort($browser); - foreach($browser as $language => $quality) { - if (isset($this->_translate[$language])) { - $this->_options['locale'] = $language; - break; - } - } - } - - if (($read) and (isset(self::$_cache))) { - $id = 'Zend_Translate_' . md5(serialize($options['content'])) . '_' . $this->toString(); - if (self::$_cacheTags) { - self::$_cache->save($temp, $id, array($this->_options['tag'])); - } else { - self::$_cache->save($temp, $id); - } - } - - return $this; - } - - /** - * Translates the given string - * returns the translation - * - * @see Zend_Locale - * @param string|array $messageId Translation string, or Array for plural translations - * @param string|Zend_Locale $locale (optional) Locale/Language to use, identical with - * locale identifier, @see Zend_Locale for more information - * @return string - */ - public function translate($messageId, $locale = null) - { - if ($locale === null) { - $locale = $this->_options['locale']; - } - - $plural = null; - if (is_array($messageId)) { - if (count($messageId) > 2) { - $number = array_pop($messageId); - if (!is_numeric($number)) { - $plocale = $number; - $number = array_pop($messageId); - } else { - $plocale = 'en'; - } - - $plural = $messageId; - $messageId = $messageId[0]; - } else { - $messageId = $messageId[0]; - } - } - - // CUSTOM ischommer: Skip locale checks, too computationally expensive. - // Assume correct locale value is passed in. - // if (!Zend_Locale::isLocale($locale, true, false)) { - // if (!Zend_Locale::isLocale($locale, false, false)) { - // // language does not exist, return original string - // $this->_log($messageId, $locale); - // // use rerouting when enabled - // if (!empty($this->_options['route'])) { - // if (array_key_exists($locale, $this->_options['route']) && - // !array_key_exists($locale, $this->_routed)) { - // $this->_routed[$locale] = true; - // return $this->translate($messageId, $this->_options['route'][$locale]); - // } - // } - - // $this->_routed = array(); - // if ($plural === null) { - // return $messageId; - // } - - // $rule = Zend_Translate_Plural::getPlural($number, $plocale); - // if (!isset($plural[$rule])) { - // $rule = 0; - // } - - // return $plural[$rule]; - // } - - // $locale = new Zend_Locale($locale); - // } - // CUSTOM END - - $locale = (string) $locale; - if ((is_string($messageId) || is_int($messageId)) && isset($this->_translate[$locale][$messageId])) { - // return original translation - if ($plural === null) { - $this->_routed = array(); - return $this->_translate[$locale][$messageId]; - } - - $rule = Zend_Translate_Plural::getPlural($number, $locale); - if (isset($this->_translate[$locale][$plural[0]][$rule])) { - $this->_routed = array(); - return $this->_translate[$locale][$plural[0]][$rule]; - } - } else if (strlen($locale) != 2) { - // faster than creating a new locale and separate the leading part - $locale = substr($locale, 0, -strlen(strrchr($locale, '_'))); - - if ((is_string($messageId) || is_int($messageId)) && isset($this->_translate[$locale][$messageId])) { - // return regionless translation (en_US -> en) - if ($plural === null) { - $this->_routed = array(); - return $this->_translate[$locale][$messageId]; - } - - $rule = Zend_Translate_Plural::getPlural($number, $locale); - if (isset($this->_translate[$locale][$plural[0]][$rule])) { - $this->_routed = array(); - return $this->_translate[$locale][$plural[0]][$rule]; - } - } - } - - $this->_log($messageId, $locale); - // use rerouting when enabled - if (!empty($this->_options['route'])) { - if (array_key_exists($locale, $this->_options['route']) && - !array_key_exists($locale, $this->_routed)) { - $this->_routed[$locale] = true; - return $this->translate($messageId, $this->_options['route'][$locale]); - } - } - - $this->_routed = array(); - if ($plural === null) { - return $messageId; - } - - $rule = Zend_Translate_Plural::getPlural($number, $plocale); - if (!isset($plural[$rule])) { - $rule = 0; - } - - return $plural[$rule]; - } - - /** - * Translates the given string using plural notations - * Returns the translated string - * - * @see Zend_Locale - * @param string $singular Singular translation string - * @param string $plural Plural translation string - * @param integer $number Number for detecting the correct plural - * @param string|Zend_Locale $locale (Optional) Locale/Language to use, identical with - * locale identifier, @see Zend_Locale for more information - * @return string - */ - public function plural($singular, $plural, $number, $locale = null) - { - return $this->translate(array($singular, $plural, $number), $locale); - } - - /** - * Logs a message when the log option is set - * - * @param string $message Message to log - * @param String $locale Locale to log - */ - protected function _log($message, $locale) { - if ($this->_options['logUntranslated']) { - $message = str_replace('%message%', $message, $this->_options['logMessage']); - $message = str_replace('%locale%', $locale, $message); - if ($this->_options['log']) { - $this->_options['log']->log($message, $this->_options['logPriority']); - } else { - trigger_error($message, E_USER_NOTICE); - } - } - } - - /** - * Translates the given string - * returns the translation - * - * @param string $messageId Translation string - * @param string|Zend_Locale $locale (optional) Locale/Language to use, identical with locale - * identifier, @see Zend_Locale for more information - * @return string - */ - public function _($messageId, $locale = null) - { - return $this->translate($messageId, $locale); - } - - /** - * Checks if a string is translated within the source or not - * returns boolean - * - * @param string $messageId Translation string - * @param boolean $original (optional) Allow translation only for original language - * when true, a translation for 'en_US' would give false when it can - * be translated with 'en' only - * @param string|Zend_Locale $locale (optional) Locale/Language to use, identical with locale identifier, - * see Zend_Locale for more information - * @return boolean - */ - public function isTranslated($messageId, $original = false, $locale = null) - { - if (($original !== false) and ($original !== true)) { - $locale = $original; - $original = false; - } - - if ($locale === null) { - $locale = $this->_options['locale']; - } - - if (!Zend_Locale::isLocale($locale, true, false)) { - if (!Zend_Locale::isLocale($locale, false, false)) { - // language does not exist, return original string - return false; - } - - $locale = new Zend_Locale($locale); - } - - $locale = (string) $locale; - if ((is_string($messageId) || is_int($messageId)) && isset($this->_translate[$locale][$messageId])) { - // return original translation - return true; - } else if ((strlen($locale) != 2) and ($original === false)) { - // faster than creating a new locale and separate the leading part - $locale = substr($locale, 0, -strlen(strrchr($locale, '_'))); - - if ((is_string($messageId) || is_int($messageId)) && isset($this->_translate[$locale][$messageId])) { - // return regionless translation (en_US -> en) - return true; - } - } - - // No translation found, return original - return false; - } - - /** - * Returns the set cache - * - * @return Zend_Cache_Core The set cache - */ - public static function getCache() - { - return self::$_cache; - } - - /** - * Sets a cache for all Zend_Translate_Adapters - * - * @param Zend_Cache_Core $cache Cache to store to - */ - public static function setCache(Zend_Cache_Core $cache) - { - self::$_cache = $cache; - self::_getTagSupportForCache(); - } - - /** - * Returns true when a cache is set - * - * @return boolean - */ - public static function hasCache() - { - if (self::$_cache !== null) { - return true; - } - - return false; - } - - /** - * Removes any set cache - * - * @return void - */ - public static function removeCache() - { - self::$_cache = null; - } - - /** - * Clears all set cache data - * - * @param string $tag Tag to clear when the default tag name is not used - * @return void - */ - public static function clearCache($tag = null) - { - require_once 'Zend/Cache.php'; - if (self::$_cacheTags) { - if ($tag == null) { - $tag = 'Zend_Translate'; - } - - self::$_cache->clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array($tag)); - } else { - self::$_cache->clean(Zend_Cache::CLEANING_MODE_ALL); - } - } - - /** - * Returns the adapter name - * - * @return string - */ - abstract public function toString(); - - /** - * Internal method to check if the given cache supports tags - * - * @param Zend_Cache $cache - */ - private static function _getTagSupportForCache() - { - $backend = self::$_cache->getBackend(); - if ($backend instanceof Zend_Cache_Backend_ExtendedInterface) { - $cacheOptions = $backend->getCapabilities(); - self::$_cacheTags = $cacheOptions['tags']; - } else { - self::$_cacheTags = false; - } - - return self::$_cacheTags; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Array.php b/thirdparty/Zend/Translate/Adapter/Array.php deleted file mode 100644 index cbecbb934..000000000 --- a/thirdparty/Zend/Translate/Adapter/Array.php +++ /dev/null @@ -1,81 +0,0 @@ -_data = array(); - if (!is_array($data)) { - if (file_exists($data)) { - ob_start(); - $data = include($data); - ob_end_clean(); - } - } - if (!is_array($data)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("Error including array or file '".$data."'"); - } - - if (!isset($this->_data[$locale])) { - $this->_data[$locale] = array(); - } - - $this->_data[$locale] = $data + $this->_data[$locale]; - return $this->_data; - } - - /** - * returns the adapters name - * - * @return string - */ - public function toString() - { - return "Array"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Csv.php b/thirdparty/Zend/Translate/Adapter/Csv.php deleted file mode 100644 index f24437dd9..000000000 --- a/thirdparty/Zend/Translate/Adapter/Csv.php +++ /dev/null @@ -1,121 +0,0 @@ -_options['delimiter'] = ";"; - $this->_options['length'] = 0; - $this->_options['enclosure'] = '"'; - - if ($options instanceof Zend_Config) { - $options = $options->toArray(); - } else if (func_num_args() > 1) { - $args = func_get_args(); - $options = array(); - $options['content'] = array_shift($args); - - if (!empty($args)) { - $options['locale'] = array_shift($args); - } - - if (!empty($args)) { - $opt = array_shift($args); - $options = array_merge($opt, $options); - } - } else if (!is_array($options)) { - $options = array('content' => $options); - } - - parent::__construct($options); - } - - /** - * Load translation data - * - * @param string|array $filename Filename and full path to the translation source - * @param string $locale Locale/Language to add data for, identical with locale identifier, - * see Zend_Locale for more information - * @param array $option OPTIONAL Options to use - * @return array - */ - protected function _loadTranslationData($filename, $locale, array $options = array()) - { - $this->_data = array(); - $options = $options + $this->_options; - $this->_file = @fopen($filename, 'rb'); - if (!$this->_file) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Error opening translation file \'' . $filename . '\'.'); - } - - while(($data = fgetcsv($this->_file, $options['length'], $options['delimiter'], $options['enclosure'])) !== false) { - if (substr($data[0], 0, 1) === '#') { - continue; - } - - if (!isset($data[1])) { - continue; - } - - if (count($data) == 2) { - $this->_data[$locale][$data[0]] = $data[1]; - } else { - $singular = array_shift($data); - $this->_data[$locale][$singular] = $data; - } - } - - return $this->_data; - } - - /** - * returns the adapters name - * - * @return string - */ - public function toString() - { - return "Csv"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Gettext.php b/thirdparty/Zend/Translate/Adapter/Gettext.php deleted file mode 100644 index de06c107a..000000000 --- a/thirdparty/Zend/Translate/Adapter/Gettext.php +++ /dev/null @@ -1,169 +0,0 @@ -_bigEndian === false) { - return unpack('V' . $bytes, fread($this->_file, 4 * $bytes)); - } else { - return unpack('N' . $bytes, fread($this->_file, 4 * $bytes)); - } - } - - /** - * Load translation data (MO file reader) - * - * @param string $filename MO file to add, full path must be given for access - * @param string $locale New Locale/Language to set, identical with locale identifier, - * see Zend_Locale for more information - * @param array $option OPTIONAL Options to use - * @throws Zend_Translation_Exception - * @return array - */ - protected function _loadTranslationData($filename, $locale, array $options = array()) - { - $this->_data = array(); - $this->_bigEndian = false; - $this->_file = @fopen($filename, 'rb'); - if (!$this->_file) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Error opening translation file \'' . $filename . '\'.'); - } - if (@filesize($filename) < 10) { - @fclose($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('\'' . $filename . '\' is not a gettext file'); - } - - // get Endian - $input = $this->_readMOData(1); - if (strtolower(substr(dechex($input[1]), -8)) == "950412de") { - $this->_bigEndian = false; - } else if (strtolower(substr(dechex($input[1]), -8)) == "de120495") { - $this->_bigEndian = true; - } else { - @fclose($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('\'' . $filename . '\' is not a gettext file'); - } - // read revision - not supported for now - $input = $this->_readMOData(1); - - // number of bytes - $input = $this->_readMOData(1); - $total = $input[1]; - - // number of original strings - $input = $this->_readMOData(1); - $OOffset = $input[1]; - - // number of translation strings - $input = $this->_readMOData(1); - $TOffset = $input[1]; - - // fill the original table - fseek($this->_file, $OOffset); - $origtemp = $this->_readMOData(2 * $total); - fseek($this->_file, $TOffset); - $transtemp = $this->_readMOData(2 * $total); - - for($count = 0; $count < $total; ++$count) { - if ($origtemp[$count * 2 + 1] != 0) { - fseek($this->_file, $origtemp[$count * 2 + 2]); - $original = @fread($this->_file, $origtemp[$count * 2 + 1]); - $original = explode("\0", $original); - } else { - $original[0] = ''; - } - - if ($transtemp[$count * 2 + 1] != 0) { - fseek($this->_file, $transtemp[$count * 2 + 2]); - $translate = fread($this->_file, $transtemp[$count * 2 + 1]); - $translate = explode("\0", $translate); - if ((count($original) > 1) && (count($translate) > 1)) { - $this->_data[$locale][$original[0]] = $translate; - array_shift($original); - foreach ($original as $orig) { - $this->_data[$locale][$orig] = ''; - } - } else { - $this->_data[$locale][$original[0]] = $translate[0]; - } - } - } - - @fclose($this->_file); - - $this->_data[$locale][''] = trim($this->_data[$locale]['']); - if (empty($this->_data[$locale][''])) { - $this->_adapterInfo[$filename] = 'No adapter information available'; - } else { - $this->_adapterInfo[$filename] = $this->_data[$locale]['']; - } - - unset($this->_data[$locale]['']); - return $this->_data; - } - - /** - * Returns the adapter informations - * - * @return array Each loaded adapter information as array value - */ - public function getAdapterInfo() - { - return $this->_adapterInfo; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "Gettext"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Ini.php b/thirdparty/Zend/Translate/Adapter/Ini.php deleted file mode 100644 index 4dfdb6492..000000000 --- a/thirdparty/Zend/Translate/Adapter/Ini.php +++ /dev/null @@ -1,74 +0,0 @@ -_data = array(); - if (!file_exists($data)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception("Ini file '".$data."' not found"); - } - - $inidata = parse_ini_file($data, false); - if (!isset($this->_data[$locale])) { - $this->_data[$locale] = array(); - } - - $this->_data[$locale] = array_merge($this->_data[$locale], $inidata); - return $this->_data; - } - - /** - * returns the adapters name - * - * @return string - */ - public function toString() - { - return "Ini"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Qt.php b/thirdparty/Zend/Translate/Adapter/Qt.php deleted file mode 100644 index 27ff71217..000000000 --- a/thirdparty/Zend/Translate/Adapter/Qt.php +++ /dev/null @@ -1,160 +0,0 @@ -_data = array(); - if (!is_readable($filename)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Translation file \'' . $filename . '\' is not readable.'); - } - - $this->_target = $locale; - - $encoding = $this->_findEncoding($filename); - $this->_file = xml_parser_create($encoding); - xml_set_object($this->_file, $this); - xml_parser_set_option($this->_file, XML_OPTION_CASE_FOLDING, 0); - xml_set_element_handler($this->_file, "_startElement", "_endElement"); - xml_set_character_data_handler($this->_file, "_contentElement"); - - if (!xml_parse($this->_file, file_get_contents($filename))) { - $ex = sprintf('XML error: %s at line %d', - xml_error_string(xml_get_error_code($this->_file)), - xml_get_current_line_number($this->_file)); - xml_parser_free($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception($ex); - } - - return $this->_data; - } - - private function _startElement($file, $name, $attrib) - { - switch(strtolower($name)) { - case 'message': - $this->_source = null; - $this->_stag = false; - $this->_ttag = false; - $this->_scontent = null; - $this->_tcontent = null; - break; - case 'source': - $this->_stag = true; - break; - case 'translation': - $this->_ttag = true; - break; - default: - break; - } - } - - private function _endElement($file, $name) - { - switch (strtolower($name)) { - case 'source': - $this->_stag = false; - break; - - case 'translation': - if (!empty($this->_scontent) and !empty($this->_tcontent) or - (isset($this->_data[$this->_target][$this->_scontent]) === false)) { - $this->_data[$this->_target][$this->_scontent] = $this->_tcontent; - } - $this->_ttag = false; - break; - - default: - break; - } - } - - private function _contentElement($file, $data) - { - if ($this->_stag === true) { - $this->_scontent .= $data; - } - - if ($this->_ttag === true) { - $this->_tcontent .= $data; - } - } - - private function _findEncoding($filename) - { - $file = file_get_contents($filename, null, null, 0, 100); - if (strpos($file, "encoding") !== false) { - $encoding = substr($file, strpos($file, "encoding") + 9); - $encoding = substr($encoding, 1, strpos($encoding, $encoding[0], 1) - 1); - return $encoding; - } - return 'UTF-8'; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "Qt"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Tbx.php b/thirdparty/Zend/Translate/Adapter/Tbx.php deleted file mode 100644 index 1436c5eb0..000000000 --- a/thirdparty/Zend/Translate/Adapter/Tbx.php +++ /dev/null @@ -1,165 +0,0 @@ -_data = array(); - if (!is_readable($filename)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Translation file \'' . $filename . '\' is not readable.'); - } - - $encoding = $this->_findEncoding($filename); - $this->_file = xml_parser_create($encoding); - xml_set_object($this->_file, $this); - xml_parser_set_option($this->_file, XML_OPTION_CASE_FOLDING, 0); - xml_set_element_handler($this->_file, "_startElement", "_endElement"); - xml_set_character_data_handler($this->_file, "_contentElement"); - - if (!xml_parse($this->_file, file_get_contents($filename))) { - $ex = sprintf('XML error: %s at line %d', - xml_error_string(xml_get_error_code($this->_file)), - xml_get_current_line_number($this->_file)); - xml_parser_free($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception($ex); - } - - return $this->_data; - } - - private function _startElement($file, $name, $attrib) - { - if ($this->_term !== null) { - $this->_content .= "<".$name; - foreach($attrib as $key => $value) { - $this->_content .= " $key=\"$value\""; - } - $this->_content .= ">"; - } else { - switch(strtolower($name)) { - case 'termentry': - $this->_termentry = null; - break; - case 'langset': - if (isset($attrib['xml:lang']) === true) { - $this->_langset = $attrib['xml:lang']; - if (isset($this->_data[$this->_langset]) === false) { - $this->_data[$this->_langset] = array(); - } - } - break; - case 'term': - $this->_term = true; - $this->_content = null; - break; - default: - break; - } - } - } - - private function _endElement($file, $name) - { - if (($this->_term !== null) and ($name != "term")) { - $this->_content .= ""; - } else { - switch (strtolower($name)) { - case 'langset': - $this->_langset = null; - break; - case 'term': - $this->_term = null; - if (empty($this->_termentry)) { - $this->_termentry = $this->_content; - } - if (!empty($this->_content) or (isset($this->_data[$this->_langset][$this->_termentry]) === false)) { - $this->_data[$this->_langset][$this->_termentry] = $this->_content; - } - break; - default: - break; - } - } - } - - private function _contentElement($file, $data) - { - if ($this->_term !== null) { - $this->_content .= $data; - } - } - - private function _findEncoding($filename) - { - $file = file_get_contents($filename, null, null, 0, 100); - if (strpos($file, "encoding") !== false) { - $encoding = substr($file, strpos($file, "encoding") + 9); - $encoding = substr($encoding, 1, strpos($encoding, $encoding[0], 1) - 1); - return $encoding; - } - return 'UTF-8'; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "Tbx"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Tmx.php b/thirdparty/Zend/Translate/Adapter/Tmx.php deleted file mode 100644 index 31c8f1bf4..000000000 --- a/thirdparty/Zend/Translate/Adapter/Tmx.php +++ /dev/null @@ -1,233 +0,0 @@ -_data = array(); - if (!is_readable($filename)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Translation file \'' . $filename . '\' is not readable.'); - } - - if (isset($options['useId'])) { - $this->_useId = (boolean) $options['useId']; - } - - $encoding = $this->_findEncoding($filename); - $this->_file = xml_parser_create($encoding); - xml_set_object($this->_file, $this); - xml_parser_set_option($this->_file, XML_OPTION_CASE_FOLDING, 0); - xml_set_element_handler($this->_file, "_startElement", "_endElement"); - xml_set_character_data_handler($this->_file, "_contentElement"); - - if (!xml_parse($this->_file, file_get_contents($filename))) { - $ex = sprintf('XML error: %s at line %d', - xml_error_string(xml_get_error_code($this->_file)), - xml_get_current_line_number($this->_file)); - xml_parser_free($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception($ex); - } - - return $this->_data; - } - - /** - * Internal method, called by xml element handler at start - * - * @param resource $file File handler - * @param string $name Elements name - * @param array $attrib Attributes for this element - */ - protected function _startElement($file, $name, $attrib) - { - if ($this->_seg !== null) { - $this->_content .= "<".$name; - foreach($attrib as $key => $value) { - $this->_content .= " $key=\"$value\""; - } - $this->_content .= ">"; - } else { - switch(strtolower($name)) { - case 'header': - if (empty($this->_useId) && isset($attrib['srclang'])) { - if (Zend_Locale::isLocale($attrib['srclang'])) { - $this->_srclang = Zend_Locale::findLocale($attrib['srclang']); - } else { - if (!$this->_options['disableNotices']) { - if ($this->_options['log']) { - $this->_options['log']->notice("The language '{$attrib['srclang']}' can not be set because it does not exist."); - } else { - trigger_error("The language '{$attrib['srclang']}' can not be set because it does not exist.", E_USER_NOTICE); - } - } - - $this->_srclang = $attrib['srclang']; - } - } - break; - case 'tu': - if (isset($attrib['tuid'])) { - $this->_tu = $attrib['tuid']; - } - break; - case 'tuv': - if (isset($attrib['xml:lang'])) { - if (Zend_Locale::isLocale($attrib['xml:lang'])) { - $this->_tuv = Zend_Locale::findLocale($attrib['xml:lang']); - } else { - if (!$this->_options['disableNotices']) { - if ($this->_options['log']) { - $this->_options['log']->notice("The language '{$attrib['xml:lang']}' can not be set because it does not exist."); - } else { - trigger_error("The language '{$attrib['xml:lang']}' can not be set because it does not exist.", E_USER_NOTICE); - } - } - - $this->_tuv = $attrib['xml:lang']; - } - - if (!isset($this->_data[$this->_tuv])) { - $this->_data[$this->_tuv] = array(); - } - } - break; - case 'seg': - $this->_seg = true; - $this->_content = null; - break; - default: - break; - } - } - } - - - /** - * Internal method, called by xml element handler at end - * - * @param resource $file File handler - * @param string $name Elements name - */ - protected function _endElement($file, $name) - { - if (($this->_seg !== null) and ($name !== 'seg')) { - $this->_content .= ""; - } else { - switch (strtolower($name)) { - case 'tu': - $this->_tu = null; - break; - case 'tuv': - $this->_tuv = null; - break; - case 'seg': - $this->_seg = null; - if (!empty($this->_srclang) && ($this->_srclang == $this->_tuv)) { - $this->_tu = $this->_content; - } - - if (!empty($this->_content) or (!isset($this->_data[$this->_tuv][$this->_tu]))) { - $this->_data[$this->_tuv][$this->_tu] = $this->_content; - } - break; - default: - break; - } - } - } - - /** - * Internal method, called by xml element handler for content - * - * @param resource $file File handler - * @param string $data Elements content - */ - protected function _contentElement($file, $data) - { - if (($this->_seg !== null) and ($this->_tu !== null) and ($this->_tuv !== null)) { - $this->_content .= $data; - } - } - - - /** - * Internal method, detects the encoding of the xml file - * - * @param string $name Filename - * @return string Encoding - */ - protected function _findEncoding($filename) - { - $file = file_get_contents($filename, null, null, 0, 100); - if (strpos($file, "encoding") !== false) { - $encoding = substr($file, strpos($file, "encoding") + 9); - $encoding = substr($encoding, 1, strpos($encoding, $encoding[0], 1) - 1); - return $encoding; - } - return 'UTF-8'; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "Tmx"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/Xliff.php b/thirdparty/Zend/Translate/Adapter/Xliff.php deleted file mode 100644 index 2fc12ff96..000000000 --- a/thirdparty/Zend/Translate/Adapter/Xliff.php +++ /dev/null @@ -1,229 +0,0 @@ -_data = array(); - if (!is_readable($filename)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Translation file \'' . $filename . '\' is not readable.'); - } - - if (empty($options['useId'])) { - $this->_useId = false; - } else { - $this->_useId = true; - } - - $encoding = $this->_findEncoding($filename); - $this->_target = $locale; - $this->_file = xml_parser_create($encoding); - xml_set_object($this->_file, $this); - xml_parser_set_option($this->_file, XML_OPTION_CASE_FOLDING, 0); - xml_set_element_handler($this->_file, "_startElement", "_endElement"); - xml_set_character_data_handler($this->_file, "_contentElement"); - - if (!xml_parse($this->_file, file_get_contents($filename))) { - $ex = sprintf('XML error: %s at line %d', - xml_error_string(xml_get_error_code($this->_file)), - xml_get_current_line_number($this->_file)); - xml_parser_free($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception($ex); - } - - return $this->_data; - } - - private function _startElement($file, $name, $attrib) - { - if ($this->_stag === true) { - $this->_scontent .= "<".$name; - foreach($attrib as $key => $value) { - $this->_scontent .= " $key=\"$value\""; - } - $this->_scontent .= ">"; - } else if ($this->_ttag === true) { - $this->_tcontent .= "<".$name; - foreach($attrib as $key => $value) { - $this->_tcontent .= " $key=\"$value\""; - } - $this->_tcontent .= ">"; - } else { - switch(strtolower($name)) { - case 'file': - $this->_source = $attrib['source-language']; - if (isset($attrib['target-language'])) { - $this->_target = $attrib['target-language']; - } - - if (!isset($this->_data[$this->_source])) { - $this->_data[$this->_source] = array(); - } - - if (!isset($this->_data[$this->_target])) { - $this->_data[$this->_target] = array(); - } - - break; - case 'trans-unit': - $this->_transunit = true; - $this->_langId = $attrib['id']; - break; - case 'source': - if ($this->_transunit === true) { - $this->_scontent = null; - $this->_stag = true; - $this->_ttag = false; - } - break; - case 'target': - if ($this->_transunit === true) { - $this->_tcontent = null; - $this->_ttag = true; - $this->_stag = false; - } - break; - default: - break; - } - } - } - - private function _endElement($file, $name) - { - if (($this->_stag === true) and ($name !== 'source')) { - $this->_scontent .= ""; - } else if (($this->_ttag === true) and ($name !== 'target')) { - $this->_tcontent .= ""; - } else { - switch (strtolower($name)) { - case 'trans-unit': - $this->_transunit = null; - $this->_langId = null; - $this->_scontent = null; - $this->_tcontent = null; - break; - case 'source': - if ($this->_useId) { - if (!empty($this->_scontent) && !empty($this->_langId) && - !isset($this->_data[$this->_source][$this->_langId])) { - $this->_data[$this->_source][$this->_langId] = $this->_scontent; - } - } else { - if (!empty($this->_scontent) && - !isset($this->_data[$this->_source][$this->_scontent])) { - $this->_data[$this->_source][$this->_scontent] = $this->_scontent; - } - } - $this->_stag = false; - break; - case 'target': - if ($this->_useId) { - if (!empty($this->_tcontent) && !empty($this->_langId) && - !isset($this->_data[$this->_target][$this->_langId])) { - $this->_data[$this->_target][$this->_langId] = $this->_tcontent; - } - } else { - if (!empty($this->_tcontent) && !empty($this->_scontent) && - !isset($this->_data[$this->_target][$this->_scontent])) { - $this->_data[$this->_target][$this->_scontent] = $this->_tcontent; - } - } - $this->_ttag = false; - break; - default: - break; - } - } - } - - private function _contentElement($file, $data) - { - if (($this->_transunit !== null) and ($this->_source !== null) and ($this->_stag === true)) { - $this->_scontent .= $data; - } - - if (($this->_transunit !== null) and ($this->_target !== null) and ($this->_ttag === true)) { - $this->_tcontent .= $data; - } - } - - private function _findEncoding($filename) - { - $file = file_get_contents($filename, null, null, 0, 100); - if (strpos($file, "encoding") !== false) { - $encoding = substr($file, strpos($file, "encoding") + 9); - $encoding = substr($encoding, 1, strpos($encoding, $encoding[0], 1) - 1); - return $encoding; - } - return 'UTF-8'; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "Xliff"; - } -} diff --git a/thirdparty/Zend/Translate/Adapter/XmlTm.php b/thirdparty/Zend/Translate/Adapter/XmlTm.php deleted file mode 100644 index a0e5f8839..000000000 --- a/thirdparty/Zend/Translate/Adapter/XmlTm.php +++ /dev/null @@ -1,139 +0,0 @@ -_data = array(); - $this->_lang = $locale; - if (!is_readable($filename)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('Translation file \'' . $filename . '\' is not readable.'); - } - - $encoding = $this->_findEncoding($filename); - $this->_file = xml_parser_create($encoding); - xml_set_object($this->_file, $this); - xml_parser_set_option($this->_file, XML_OPTION_CASE_FOLDING, 0); - xml_set_element_handler($this->_file, "_startElement", "_endElement"); - xml_set_character_data_handler($this->_file, "_contentElement"); - - if (!xml_parse($this->_file, file_get_contents($filename))) { - $ex = sprintf('XML error: %s at line %d', - xml_error_string(xml_get_error_code($this->_file)), - xml_get_current_line_number($this->_file)); - xml_parser_free($this->_file); - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception($ex); - } - - return $this->_data; - } - - private function _startElement($file, $name, $attrib) - { - switch(strtolower($name)) { - case 'tm:tu': - $this->_tag = $attrib['id']; - $this->_content = null; - break; - default: - break; - } - } - - private function _endElement($file, $name) - { - switch (strtolower($name)) { - case 'tm:tu': - if (!empty($this->_tag) and !empty($this->_content) or - (isset($this->_data[$this->_lang][$this->_tag]) === false)) { - $this->_data[$this->_lang][$this->_tag] = $this->_content; - } - $this->_tag = null; - $this->_content = null; - break; - - default: - break; - } - } - - private function _contentElement($file, $data) - { - if (($this->_tag !== null)) { - $this->_content .= $data; - } - } - - private function _findEncoding($filename) - { - $file = file_get_contents($filename, null, null, 0, 100); - if (strpos($file, "encoding") !== false) { - $encoding = substr($file, strpos($file, "encoding") + 9); - $encoding = substr($encoding, 1, strpos($encoding, $encoding[0], 1) - 1); - return $encoding; - } - return 'UTF-8'; - } - - /** - * Returns the adapter name - * - * @return string - */ - public function toString() - { - return "XmlTm"; - } -} diff --git a/thirdparty/Zend/Translate/Exception.php b/thirdparty/Zend/Translate/Exception.php deleted file mode 100644 index 66f910900..000000000 --- a/thirdparty/Zend/Translate/Exception.php +++ /dev/null @@ -1,37 +0,0 @@ - 3) { - $locale = substr($locale, 0, -strlen(strrchr($locale, '_'))); - } - - if (isset(self::$_plural[$locale])) { - $return = call_user_func(self::$_plural[$locale], $number); - - if (!is_int($return) || ($return < 0)) { - $return = 0; - } - - return $return; - } - - switch($locale) { - case 'bo': - case 'dz': - case 'id': - case 'ja': - case 'jv': - case 'ka': - case 'km': - case 'kn': - case 'ko': - case 'ms': - case 'th': - case 'tr': - case 'vi': - case 'zh': - return 0; - break; - - case 'af': - case 'az': - case 'bn': - case 'bg': - case 'ca': - case 'da': - case 'de': - case 'el': - case 'en': - case 'eo': - case 'es': - case 'et': - case 'eu': - case 'fa': - case 'fi': - case 'fo': - case 'fur': - case 'fy': - case 'gl': - case 'gu': - case 'ha': - case 'he': - case 'hu': - case 'is': - case 'it': - case 'ku': - case 'lb': - case 'ml': - case 'mn': - case 'mr': - case 'nah': - case 'nb': - case 'ne': - case 'nl': - case 'nn': - case 'no': - case 'om': - case 'or': - case 'pa': - case 'pap': - case 'ps': - case 'pt': - case 'so': - case 'sq': - case 'sv': - case 'sw': - case 'ta': - case 'te': - case 'tk': - case 'ur': - case 'zu': - return ($number == 1) ? 0 : 1; - - case 'am': - case 'bh': - case 'fil': - case 'fr': - case 'gun': - case 'hi': - case 'ln': - case 'mg': - case 'nso': - case 'xbr': - case 'ti': - case 'wa': - return (($number == 0) || ($number == 1)) ? 0 : 1; - - case 'be': - case 'bs': - case 'hr': - case 'ru': - case 'sr': - case 'uk': - return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2); - - case 'cs': - case 'sk': - return ($number == 1) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2); - - case 'ga': - return ($number == 1) ? 0 : (($number == 2) ? 1 : 2); - - case 'lt': - return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2); - - case 'sl': - return ($number % 100 == 1) ? 0 : (($number % 100 == 2) ? 1 : ((($number % 100 == 3) || ($number % 100 == 4)) ? 2 : 3)); - - case 'mk': - return ($number % 10 == 1) ? 0 : 1; - - case 'mt': - return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 1) && ($number % 100 < 11))) ? 1 : ((($number % 100 > 10) && ($number % 100 < 20)) ? 2 : 3)); - - case 'lv': - return ($number == 0) ? 0 : ((($number % 10 == 1) && ($number % 100 != 11)) ? 1 : 2); - - case 'pl': - return ($number == 1) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 12) || ($number % 100 > 14))) ? 1 : 2); - - case 'cy': - return ($number == 1) ? 0 : (($number == 2) ? 1 : ((($number == 8) || ($number == 11)) ? 2 : 3)); - - case 'ro': - return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 0) && ($number % 100 < 20))) ? 1 : 2); - - case 'ar': - return ($number == 0) ? 0 : (($number == 1) ? 1 : (($number == 2) ? 2 : ((($number >= 3) && ($number <= 10)) ? 3 : ((($number >= 11) && ($number <= 99)) ? 4 : 5)))); - - default: - return 0; - } - } - - /** - * Set's a new plural rule - * - * @param string $rule Callback which acts as rule - * @param string $locale Locale which is used for this callback - * @return null - */ - public static function setPlural($rule, $locale) - { - if ($locale == "pt_BR") { - // temporary set a locale for brasilian - $locale = "xbr"; - } - - if (strlen($locale) > 3) { - $locale = substr($locale, 0, -strlen(strrchr($locale, '_'))); - } - - if (!is_callable($rule)) { - require_once 'Zend/Translate/Exception.php'; - throw new Zend_Translate_Exception('The given rule can not be called'); - } - - self::$_plural[$locale] = $rule; - } -} diff --git a/thirdparty/php-peg/Parser.php b/thirdparty/php-peg/Parser.php index e41dedce5..ae6b4aefa 100644 --- a/thirdparty/php-peg/Parser.php +++ b/thirdparty/php-peg/Parser.php @@ -45,6 +45,26 @@ class ParserRegexp { * for result construction and building */ class Parser { + /** + * @var string + */ + public $string; + + /** + * @var int + */ + public $pos; + + /** + * @var int + */ + public $depth; + + /** + * @var array + */ + public $regexps; + function __construct( $string ) { $this->string = $string ; $this->pos = 0 ; @@ -105,7 +125,7 @@ class Parser { } function packread( $key, $pos ) { - throw 'PackRead after PackHas=>false in Parser.php' ; + throw new \Exception('PackRead after PackHas=>false in Parser.php'); } function packwrite( $key, $pos, $res ) { From de02a3f733952e59a6a7355e8cb23b9a7681320a Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 25 Jan 2017 16:35:13 +1300 Subject: [PATCH 2/2] =?UTF-8?q?Restored=20context=20parameter=20to=20i18n,?= =?UTF-8?q?=20and=20added=20to=20a=20=E2=80=9Ccomment=E2=80=9D=20key=20Rol?= =?UTF-8?q?led=20pluralisation=20functionality=20into=20the=20i18n::=5Ft()?= =?UTF-8?q?=20method=20Warnings=20on=20missing=20default=20can=20now=20be?= =?UTF-8?q?=20turned=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 4 +- docs/en/02_Developer_Guides/13_i18n/index.md | 32 ++-- docs/en/04_Changelogs/4.0.0.md | 42 ++--- lang/en.yml | 8 +- src/Core/Core.php | 26 ++- src/Security/RememberLoginHash.php | 5 +- src/i18n/Messages/MessageProvider.php | 4 +- .../Messages/Symfony/ModuleYamlLoader.php | 21 +-- .../Symfony/SymfonyMessageProvider.php | 2 +- src/i18n/Messages/YamlReader.php | 1 + src/i18n/Messages/YamlWriter.php | 88 ++++++++- src/i18n/TextCollection/Parser.php | 66 +++++-- src/i18n/TextCollection/i18nTextCollector.php | 173 ++++++++++++++---- src/i18n/i18n.php | 170 ++++++++++------- .../php/Security/MemberAuthenticatorTest.php | 5 +- tests/php/i18n/YamlReaderTest.php | 9 +- tests/php/i18n/YamlWriterTest.php | 26 ++- tests/php/i18n/i18nTest.php | 45 +++++ .../code/i18nProviderClass.php | 2 + .../_fakewebroot/i18ntestmodule/lang/en.yml | 6 +- .../_fakewebroot/i18ntestmodule/lang/ja.yml | 4 + .../_fakewebroot/i18ntestmodule/lang/pl.yml | 7 + .../templates/Layout/i18nTestModule.ss | 3 +- .../templates/i18nTestModule.ss | 6 +- tests/php/i18n/i18nTextCollectorTest.php | 134 ++++++++++++-- 25 files changed, 660 insertions(+), 229 deletions(-) create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/ja.yml create mode 100644 tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/pl.yml diff --git a/composer.json b/composer.json index 053a9094f..185e092cd 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ "symfony/yaml": "~2.7", "embed/embed": "^2.6", "swiftmailer/swiftmailer": "~5.4", - "symfony/config": "^2.8|^3", - "symfony/translation": "^2.8|^3" + "symfony/config": "^2.8|^3", + "symfony/translation": "^2.8|^3" }, "require-dev": { "phpunit/PHPUnit": "~4.8", diff --git a/docs/en/02_Developer_Guides/13_i18n/index.md b/docs/en/02_Developer_Guides/13_i18n/index.md index a51b86605..66e8c934c 100644 --- a/docs/en/02_Developer_Guides/13_i18n/index.md +++ b/docs/en/02_Developer_Guides/13_i18n/index.md @@ -172,8 +172,12 @@ It can be used to translate strings in both PHP files and template files. The us elsewhere in your code. * **$default:** The original language string to be translated. This should be declared whenever used, and will get picked up the [text collector](#collecting-text). +* **$string:** (optional) Natural language comment (particularly short phrases and individual words) + are very context dependent. This parameter allows the developer to convey this information + to the translator. * **$injection::** (optional) An array of injecting variables into the second parameter + ## Pluralisation i18n also supports locale-respective pluralisation rules. Many languages have more than two plural forms, @@ -182,8 +186,11 @@ unlike English which has two only; One for the singular, and another for any oth More information on what forms these plurals can take for various locales can be found on the [CLDR documentation](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) -The ability to pluralise strings is provided through the `i18n::pluaralise` method, which is similar to the -`i18n::_t` method, other than that it takes an additional `$count` argument. +The ability to pluralise strings is provided through the `i18n::_t` method when supplied with a +`{count}` argument and `|` pipe-delimiter provided with the default string. + +Plural forms can also be explicitly declared via the i18nEntityProvider interface in array-format +with both a 'one' and 'other' key (as per the CLDR for the default `en` language). For instance, this is an example of how to correctly declare pluralisations for an object @@ -234,19 +241,11 @@ Please ensure that any required plurals are exposed via provideI18nEntities. "Restored {value} successfully", array('value' => $itemRestored) ); + + // Plurals are invoked via a `|` pipe-delimeter with a {count} argument + _t('MyObject.PLURALS', 'An object|{count} objects', [ 'count' => '$count ]); -You can invoke plurals for any object using the new `i18n::pluralise` method. -In addition to array form, you can also pass in a pipe-delimited string as a default -argument for brevity. - - - :::php - public function pluralise($count) - { - return i18n::pluralise('MyObject.PLURALS', 'An object|{count} objects', $count); - } - #### Usage in Template Files
@@ -267,11 +266,8 @@ the PHP version of the function. // Using injection to add variables into the translated strings (note that $Name and $Greeting must be available in the current template scope). <%t Header.Greeting "Hello {name} {greeting}" name=$Name greeting=$Greeting %> -Pluralisation in templates is available via the global `$pluralise` method. - - - :::ss - You have $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', $Count) in your cart + // Plurals follow the same convention, required a `|` and `{count}` in the default string + <%t MyObject.PLURALS 'An item|{count} items' count=$Count %> #### Caching in Template Files with locale switching diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index 7bb5648f3..2e78b427c 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -219,10 +219,10 @@ Where your code once used SQLQuery you should now use SQLSelect in all cases, as In many cases, localisation strings which worked in 3.x will continue to work in 4.0, however certain patterns have been deprecated and will be removed in 5.0. These include: - - _t calls with a $context parameter, which is ignored. - _t calls with sprintf-style placeholders (`%s`). Replace with named placeholders instead. - _t calls with non-associative injection arguments. Please use an associative array for all arguments. - - _t calls which do not include a default value. + - _t calls which do not include a default value will now raise a warning. This can be disabled by setting + the `i18n.missing_default_warning` config to false. Note: If you attempt to use non-associative injection arguments with named placeholders, the result will now trigger an exception. @@ -232,7 +232,7 @@ The non-associative array return type is deprecated. If returning a default stri other than itself, it should return an array with the `default` and `module` keys respectively. Full locale-rule respecting localisation for plural forms is now supported. The default -key for an object plural form is `.PLURALS`, and follows CLDR array form for each +key for an object plural form is `.PLURALS`, and follows CLDR array form for each pluralisation. See [the CLDR chart](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) for reference. @@ -285,33 +285,23 @@ In YML format this will be expressed as the below: DESCRIPTION: 'This is the description for this section' -You can invoke plurals for any object using the new `i18n::pluralise` method. -In addition to array form, you can also pass in a pipe-delimited string as a default -argument for brevity. +Usage of these pluralised strings is through the existing _t() method, +and require a `|` pipe-delimeter with a {count} argument. :::php public function pluralise($count) { - return i18n::pluralise('MyObject.PLURALS', 'An object|{count} objects', $count); + return _t('MyObject.PLURALS', 'An object|{count} objects', [ 'count' => $count ]); } - -Which is equivalent to the below: +In templates this can also be invoked as below: - :::php - public function pluralise($count) - { - return i18n::pluralise('MyObject.PLURALS', [ - 'one' => 'An object', - 'other' => '{count} objects', - ], $count); - } + :::ss + <%t MyObject.PLURALS 'An item|{count} items' count=$Count %> -Note: Template syntax for pluralisation is not yet available. - #### New asset storage mechanism File system has been abstracted into an abstract interface. By default, the out of the box filesystem @@ -1322,20 +1312,18 @@ handle field-level and form-level messages. This has the following properties: * Upgrade of i18n to symfony/translation * Localisation based on language-only (without any specific locale) is now supported -* i18nEntityProvider::provideI18nEntities() Now is expected to return only a single array +* `i18nEntityProvider::provideI18nEntities()` Now is expected to return only a single array map of key to default values. -* i18n keys for '.PLURAL_NAME' and '.SINGULAR_NAME' have been changed back to FQN class names - for all DataObject subclasses. +* i18n keys for '.PLURAL_NAME' and '.SINGULAR_NAME' have been changed back to use the namespaced class names + for all DataObject subclasses, rather than just the basename without namespace. * i18n key for locale-respective pluralisation rules added as '.PLURALS'. These can be configured within yaml in array format as per [ruby i18n pluralization rules](http://guides.rubyonrails.org/i18n.html#pluralization). #### i18n API Removed API -* Zend_Translate removed -* i18n::_t $context parameter deprecated -* i18n::_t Support for sprintf-style `%s` arguments deprecated -* i18n::_t Using non-associative injection with named parameters is now an error -* i18nEntityProvider no longer can collect strings for other modules. +* `Zend_Translate` removed +* `i18n::_t` Support for sprintf-style `%s` arguments deprecated +* `i18n::_t` Using non-associative injection with named parameters is now an error ### Email and Mailer diff --git a/lang/en.yml b/lang/en.yml index 2ab21a75b..a03ecf877 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -744,11 +744,11 @@ en: other: '{count} Permission Role Codes' SINGULARNAME: 'Permission Role Code' SilverStripe\Security\RememberLoginHash: - PLURALNAME: 'Remember Login Hashs' + PLURALNAME: 'Login Hashes' PLURALS: - one: 'A Remember Login Hash' - other: '{count} Remember Login Hashs' - SINGULARNAME: 'Remember Login Hash' + one: 'A Login Hash' + other: '{count} Login Hashes' + SINGULARNAME: 'Login Hash' SiteTree: TABMAIN: Main TableListField: diff --git a/src/Core/Core.php b/src/Core/Core.php index 225177ecf..93105fc8e 100644 --- a/src/Core/Core.php +++ b/src/Core/Core.php @@ -143,14 +143,24 @@ function project() } /** - * @see i18n::_t() - * - * @param string $entity - * @param string $string - * @param array $injection - * @return string - */ -function _t($entity, $string = "", $injection = []) + * This is the main translator function. Returns the string defined by $entity according to the + * currently set locale. + * + * Also supports pluralisation of strings. Pass in a `count` argument, as well as a + * default value with `|` pipe-delimited options for each plural form. + * + * @param string $entity Entity that identifies the string. It must be in the form + * "Namespace.Entity" where Namespace will be usually the class name where this + * string is used and Entity identifies the string inside the namespace. + * @param mixed $arg,... Additional arguments are parsed as such: + * - Next string argument is a default. Pass in a `|` pipe-delimeted value with `{count}` + * to do pluralisation. + * - Any other string argument after default is context for i18nTextCollector + * - Any array argument in any order is an injection parameter list. Pass in a `count` + * injection parameter to pluralise. + * @return string + */ +function _t($entity, $arg = null) { // Pass args directly to handle deprecation return call_user_func_array([i18n::class, '_t'], func_get_args()); diff --git a/src/Security/RememberLoginHash.php b/src/Security/RememberLoginHash.php index 35fe2fe31..e98deb71c 100644 --- a/src/Security/RememberLoginHash.php +++ b/src/Security/RememberLoginHash.php @@ -21,6 +21,9 @@ use DateInterval; */ class RememberLoginHash extends DataObject { + private static $singular_name = 'Login Hash'; + + private static $plural_name = 'Login Hashes'; private static $db = array ( 'DeviceID' => 'Varchar(40)', @@ -29,7 +32,7 @@ class RememberLoginHash extends DataObject ); private static $has_one = array ( - 'Member' => 'SilverStripe\\Security\\Member', + 'Member' => Member::class, ); private static $indexes = array( diff --git a/src/i18n/Messages/MessageProvider.php b/src/i18n/Messages/MessageProvider.php index c18369a60..a32a1a213 100644 --- a/src/i18n/Messages/MessageProvider.php +++ b/src/i18n/Messages/MessageProvider.php @@ -22,9 +22,9 @@ interface MessageProvider * * @param string $entity Identifier for this message in Namespace.key format * @param array|string $default Default message with pipe-separated delimiters, or array - * @param int $count Number to pluralise against * @param array $injection List of injection variables + * @param int $count Number to pluralise against * @return string Localised string */ - public function pluralise($entity, $default, $count, $injection); + public function pluralise($entity, $default, $injection, $count); } diff --git a/src/i18n/Messages/Symfony/ModuleYamlLoader.php b/src/i18n/Messages/Symfony/ModuleYamlLoader.php index ecd1e7000..e16fd1d10 100644 --- a/src/i18n/Messages/Symfony/ModuleYamlLoader.php +++ b/src/i18n/Messages/Symfony/ModuleYamlLoader.php @@ -4,6 +4,7 @@ namespace SilverStripe\i18n\Messages\Symfony; use SilverStripe\Core\Config\Configurable; use SilverStripe\Dev\Debug; +use SilverStripe\i18n\i18n; use SilverStripe\i18n\Messages\Reader; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\PluralizationRules; @@ -15,24 +16,6 @@ use Symfony\Component\Translation\PluralizationRules; */ class ModuleYamlLoader extends ArrayLoader { - use Configurable; - - /** - * Map of rails plurals into symfony standard order - * - * @see PluralizationRules For symfony's implementation of this logic - * @config - * @var array - */ - private static $plurals = [ - 'zero', - 'one', - 'two', - 'few', - 'many', - 'other', - ]; - /** * Message reader * @@ -123,7 +106,7 @@ class ModuleYamlLoader extends ArrayLoader protected function normalisePlurals($key, $map, $locale) { $parts = []; - foreach ($this->config()->get('plurals') as $form) { + foreach (i18n::config()->get('plurals') as $form) { if (isset($map[$form])) { $parts[] = $map[$form]; } diff --git a/src/i18n/Messages/Symfony/SymfonyMessageProvider.php b/src/i18n/Messages/Symfony/SymfonyMessageProvider.php index 25c044ace..5a76841ae 100644 --- a/src/i18n/Messages/Symfony/SymfonyMessageProvider.php +++ b/src/i18n/Messages/Symfony/SymfonyMessageProvider.php @@ -104,7 +104,7 @@ class SymfonyMessageProvider implements MessageProvider return $result; } - public function pluralise($entity, $default, $count, $injection) + public function pluralise($entity, $default, $injection, $count) { if (is_array($default)) { $default = $this->normalisePlurals($default); diff --git a/src/i18n/Messages/YamlReader.php b/src/i18n/Messages/YamlReader.php index 2cbc952d2..c633aca53 100644 --- a/src/i18n/Messages/YamlReader.php +++ b/src/i18n/Messages/YamlReader.php @@ -67,6 +67,7 @@ class YamlReader implements Reader } } } + ksort($messages); return $messages; } } diff --git a/src/i18n/Messages/YamlWriter.php b/src/i18n/Messages/YamlWriter.php index 322cf034a..47648b7a4 100644 --- a/src/i18n/Messages/YamlWriter.php +++ b/src/i18n/Messages/YamlWriter.php @@ -3,6 +3,7 @@ namespace SilverStripe\i18n\Messages; use SilverStripe\Assets\Filesystem; +use SilverStripe\i18n\i18n; use Symfony\Component\Yaml\Dumper; use SilverStripe\i18n\Messages\Symfony\ModuleYamlLoader; use LogicException; @@ -10,6 +11,9 @@ use LogicException; /** * Write yml files compatible with ModuleYamlLoader * + * Note: YamlWriter may not correctly denormalise plural strings if writing outside of the + * default locale (en). + * * @see ModuleYamlLoader */ class YamlWriter implements Writer @@ -69,30 +73,78 @@ class YamlWriter implements Writer */ protected function denormaliseMessages($messages) { + // Sort prior to denormalisation + ksort($messages); $entities = []; foreach ($messages as $entity => $value) { // Skip un-namespaced keys + $value = $this->denormaliseValue($value); + + // Non-nested key if (strstr($entity, '.') === false) { $entities[$entity] = $value; continue; } - $parts = explode('.', $entity); - $class = array_shift($parts); - // Ensure the `.ss` suffix gets added to the top level class rather than the key - if (count($parts) > 1 && reset($parts) === 'ss') { - $class .= '.ss'; - array_shift($parts); - } - $key = implode('.', $parts); + // Get key nested within class + list($class, $key) = $this->getClassKey($entity); if (!isset($entities[$class])) { $entities[$class] = []; } + $entities[$class][$key] = $value; } return $entities; } + /** + * Convert entities array format into yml-ready string / array + * + * @param array|string $value Input value + * @return array|string denormalised value + */ + protected function denormaliseValue($value) + { + // Check plural form + $plurals = $this->getPluralForm($value); + if ($plurals) { + return $plurals; + } + + // Non-plural non-array is already denormalised + if (!is_array($value)) { + return $value; + } + + // Denormalise from default key + if (!empty($value['default'])) { + return $this->denormaliseValue($value['default']); + } + + // No value + return null; + } + + /** + * Get array-plural form for any value + * + * @param array|string $value + * @return array List of plural forms, or empty array if not plural + */ + protected function getPluralForm($value) + { + // Strip non-plural keys away + if (is_array($value)) { + $forms = i18n::config()->get('plurals'); + $forms = array_combine($forms, $forms); + return array_intersect_key($value, $forms); + } + + // Parse from string + // Note: Risky outside of en locale. + return i18n::parse_plurals($value); + } + /** * Convert messages to yml ready to write * @@ -108,4 +160,24 @@ class YamlWriter implements Writer ], 99); return $content; } + + /** + * Determine class and key for a localisation entity + * + * @param string $entity + * @return array Two-length array with class and key as elements + */ + protected function getClassKey($entity) + { + $parts = explode('.', $entity); + $class = array_shift($parts); + + // Ensure the `.ss` suffix gets added to the top level class rather than the key + if (count($parts) > 1 && reset($parts) === 'ss') { + $class .= '.ss'; + array_shift($parts); + } + $key = implode('.', $parts); + return array($class, $key); + } } diff --git a/src/i18n/TextCollection/Parser.php b/src/i18n/TextCollection/Parser.php index d2febae90..43b9df3a9 100644 --- a/src/i18n/TextCollection/Parser.php +++ b/src/i18n/TextCollection/Parser.php @@ -2,6 +2,7 @@ namespace SilverStripe\i18n\TextCollection; +use SilverStripe\i18n\i18n; use SilverStripe\View\SSTemplateParser; /** @@ -10,64 +11,105 @@ use SilverStripe\View\SSTemplateParser; class Parser extends SSTemplateParser { /** - * Current entity + * List of all entities * * @var array */ protected $entities = []; /** - * List of all entities + * Current entity * * @var array */ protected $currentEntity = []; /** - * @param string $string + * Key of current entity + * + * @var string */ - public function __construct($string) + protected $currentEntityKey = null; + + /* + * Show warning if default omitted + * + * @var bool + */ + protected $warnIfEmpty = true; + + /** + * @param string $string + * @param bool $warnIfEmpty + */ + public function __construct($string, $warnIfEmpty = true) { parent::__construct(); $this->string = $string; $this->pos = 0; $this->depth = 0; $this->regexps = array(); + $this->warnIfEmpty = $warnIfEmpty; } public function Translate__construct(&$res) { - $this->currentEntity = [null, null]; + $this->currentEntity = []; + $this->currentEntityKey = null; } public function Translate_Entity(&$res, $sub) { - $this->currentEntity[0] = $sub['text']; // key + $this->currentEntityKey = $sub['text']; // key } public function Translate_Default(&$res, $sub) { - $this->currentEntity[1] = $sub['String']['text']; // default + $this->currentEntity['default'] = $sub['String']['text']; // default + } + + public function Translate_Context(&$res, $sub) + { + $this->currentEntity['comment'] = $sub['String']['text']; //comment } public function Translate__finalise(&$res) { - // Capture entity if, and only if, a default vaule is provided - if ($this->currentEntity[1]) { - $this->entities[$this->currentEntity[0]] = $this->currentEntity[1]; + // Validate entity + $entity = $this->currentEntity; + if (empty($entity['default'])) { + if ($this->warnIfEmpty) { + trigger_error("Missing localisation default for key " . $this->currentEntityKey, E_USER_NOTICE); + } + return; } + + // Detect plural forms + $plurals = i18n::parse_plurals($entity['default']); + if ($plurals) { + unset($entity['default']); + $entity = array_merge($entity, $plurals); + } + + // If only default is set, simplify + if (count($entity) === 1 && !empty($entity['default'])) { + $entity = $entity['default']; + } + + $this->entities[$this->currentEntityKey] = $entity; } /** * Parses a template and returns any translatable entities * * @param string $template String to parse for translations + * @param bool $warnIfEmpty Show warnings if default omitted * @return array Map of keys -> values */ - public static function getTranslatables($template) + public static function getTranslatables($template, $warnIfEmpty = true) { // Run the parser and throw away the result - $parser = new Parser($template); + $parser = new Parser($template, $warnIfEmpty); if (substr($template, 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) { $parser->pos = 3; } diff --git a/src/i18n/TextCollection/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php index 34a8d04e3..b272501ea 100644 --- a/src/i18n/TextCollection/i18nTextCollector.php +++ b/src/i18n/TextCollection/i18nTextCollector.php @@ -49,6 +49,13 @@ class i18nTextCollector */ protected $defaultLocale; + /** + * Trigger if warnings should be shown if default is omitted + * + * @var bool + */ + protected $warnOnEmptyDefault = false; + /** * The directory base on which the collector should act. * Usually the webroot set through {@link Director::baseFolder()}. @@ -95,6 +102,7 @@ class i18nTextCollector : i18n::get_lang_from_locale(i18n::config()->get('default_locale')); $this->basePath = Director::baseFolder(); $this->baseSavePath = Director::baseFolder(); + $this->setWarnOnEmptyDefault(i18n::config()->get('missing_default_warning')); } /** @@ -418,20 +426,28 @@ class i18nTextCollector // @see CMSMenu::provideI18nEntities for an example usage foreach ($entitiesByModule[$module] as $fullName => $spec) { $specModule = $module; - $specDefault = $spec; + + // Rewrite spec if module is specified if (is_array($spec) && isset($spec['module'])) { $specModule = $spec['module']; - $specDefault = $spec['default']; + unset($spec['module']); + + // If only element is defalt, simplify + if (count($spec) === 1 && !empty($spec['default'])) { + $spec = $spec['default']; + } } + // Remove from source module if ($specModule !== $module) { unset($entitiesByModule[$module][$fullName]); } + // Write to target module if (!isset($entitiesByModule[$specModule])) { $entitiesByModule[$specModule] = []; } - $entitiesByModule[$specModule][$fullName] = $specDefault; + $entitiesByModule[$specModule][$fullName] = $spec; } } return $entitiesByModule; @@ -532,30 +548,43 @@ class i18nTextCollector $tokens = token_get_all(" $default]; + } else { + $entity = $default; + } + if ($comment) { + $entity['comment'] = $comment; + } + $entities[$key] = $entity; + } elseif ($this->getWarnOnEmptyDefault()) { + trigger_error("Missing localisation default for key " . $currentEntity[0], E_USER_NOTICE); + } + } + $currentEntity = array(); + $inArrayClosedBy = false; + break; } } // Normalise all keys - foreach ($entities as $key => $default) { + foreach ($entities as $key => $entity) { unset($entities[$key]); - $entities[$this->normalizeEntity($key, $module)] = $default; + $entities[$this->normalizeEntity($key, $module)] = $entity; } ksort($entities); @@ -620,7 +694,7 @@ class i18nTextCollector public function collectFromTemplate($content, $fileName, $module, &$parsedFiles = array()) { // use parser to extract <%t style translatable entities - $entities = Parser::getTranslatables($content); + $entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault()); // use the old method of getting _t() style translatable entities // Collect in actual template @@ -675,15 +749,18 @@ class i18nTextCollector // Detect non-associative result for any key if (is_array($value) && $value === array_values($value)) { Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated'); - if (!empty($value[2])) { - $provided[$key] = [ - 'default' => $value[0], - 'module' => $value[2], - ]; - } else { + $entity = array_filter([ + 'default' => $value[0], + 'comment' => isset($value[1]) ? $value[1] : null, + 'module' => isset($value[2]) ? $value[2] : null, + ]); + if (count($entity) === 1) { $provided[$key] = $value[0]; + } elseif ($entity) { + $provided[$key] = $entity; + } else { + unset($provided[$key]); } - } } $entities = array_merge($entities, $provided); @@ -777,4 +854,22 @@ class i18nTextCollector { $this->defaultLocale = $locale; } + + /** + * @return bool + */ + public function getWarnOnEmptyDefault() + { + return $this->warnOnEmptyDefault; + } + + /** + * @param bool $warnOnEmptyDefault + * @return $this + */ + public function setWarnOnEmptyDefault($warnOnEmptyDefault) + { + $this->warnOnEmptyDefault = $warnOnEmptyDefault; + return $this; + } } diff --git a/src/i18n/i18n.php b/src/i18n/i18n.php index aeaaa83cf..f2935ff59 100644 --- a/src/i18n/i18n.php +++ b/src/i18n/i18n.php @@ -1956,56 +1956,80 @@ class i18n implements TemplateGlobalProvider ); /** - * This is the main translator function. Returns the string defined by $class and $entity according to the + * Map of rails plurals into standard order (fewest to most) + * Note: Default locale only supplies one|other, but non-default locales + * can specify custom plurals. + * + * @config + * @var array + */ + private static $plurals = [ + 'zero', + 'one', + 'two', + 'few', + 'many', + 'other', + ]; + + /** + * Plural forms in default (en) locale + * + * @var array + */ + private static $default_plurals = [ + 'one', + 'other', + ]; + + /** + * Warn if _t() invoked without a default. + * + * @config + * @var bool + */ + private static $missing_default_warning = true; + + /** + * This is the main translator function. Returns the string defined by $entity according to the * currently set locale. * + * Also supports pluralisation of strings. Pass in a `count` argument, as well as a + * default value with `|` pipe-delimited options for each plural form. + * * @param string $entity Entity that identifies the string. It must be in the form * "Namespace.Entity" where Namespace will be usually the class name where this * string is used and Entity identifies the string inside the namespace. - * @param string $default The original string itself. In a usual call this is a - * mandatory parameter, but if you are reusing a string which has already been - * "declared" (using another call to this function, with the same class and entity), - * you can omit it. - * @param array $injection (optional) array of key value pairs that are used - * to replace corresponding expressions in {curly brackets} in the $string. - * The injection array can also be used as the their argument to the _t() function - * @return string The translated string, according to the currently set locale {@link i18n::set_locale()} + * @param mixed $arg,... Additional arguments are parsed as such: + * - Next string argument is a default. Pass in a `|` pipe-delimited value with `{count}` + * to do pluralisation. + * - Any other string argument after default is context for i18nTextCollector + * - Any array argument in any order is an injection parameter list. Pass in a `count` + * injection parameter to pluralise. + * @return string */ - public static function _t($entity, $default = '', $injection = []) + public static function _t($entity, $arg = null) { - // Deprecate passing in injection as second param - if (is_array($default)) { - Deprecation::notice('5.0', 'Passing in $injection as second parameter is deprecated'); - $injection = $default; - $default = ''; + // Detect args + $default = null; + $injection = []; + foreach (array_slice(func_get_args(), 1) as $arg) { + if (is_array($arg)) { + $injection = $arg; + } elseif (!isset($default)) { + $default = $arg ?: ''; + } } // Encourage the provision of default values so that text collector can discover new strings - if (!$default) { - user_error( - "Localisation without a default is deprecated (key: $entity) and will be an exception in 5.0", - E_USER_WARNING - ); - } - - // Deprecate old $context param - if (!is_array($injection)) { - // Don't need to show warning if only mistake is passing in null instead of empty array - if ($injection || func_num_args() > 3) { - Deprecation::notice('5.0', '$context parameter is deprecated'); - } - // Find best injection array - if (func_num_args() > 3 && is_array(func_get_arg(3))) { - $injection = func_get_arg(3); - } else { - $injection = []; - } + if (!$default && static::config()->get('missing_default_warning')) { + user_error("Missing default for localisation key $entity", E_USER_WARNING); } // Deprecate legacy injection format (`string %s, %d`) // inject the variables from injectionArray (if present) $sprintfArgs = []; - if ($default && $injection && !preg_match('/\{[\w\d]*\}/i', $default) && preg_match('/%[s,d]/', $default)) { + if ($default && !preg_match('/\{[\w\d]*\}/i', $default) && preg_match('/%[s,d]/', $default)) { Deprecation::notice('5.0', 'sprintf style localisation variables are deprecated'); $sprintfArgs = array_values($injection); $injection = []; @@ -2019,8 +2043,20 @@ class i18n implements TemplateGlobalProvider $injection = []; } + // Detect plurals: Has a {count} argument as well as a `|` pipe delimited string (if provided) + $isPlural = isset($injection['count']); + $count = $isPlural ? $injection['count'] : null; + // Refine check against default + if ($isPlural && $default && !static::parse_plurals($default)) { + $isPlural = false; + } + // Pass back to translation backend - $result = static::getMessageProvider()->translate($entity, $default, $injection); + if ($isPlural) { + $result = static::getMessageProvider()->pluralise($entity, $default, $injection, $count); + } else { + $result = static::getMessageProvider()->translate($entity, $default, $injection); + } // Sometimes default is omitted, so we don't know we have %s injection format until after translation if (!$default && !preg_match('/\{[\w\d]*\}/i', $result) && preg_match('/%[s,d]/', $result)) { @@ -2043,37 +2079,44 @@ class i18n implements TemplateGlobalProvider } /** - * Pluralise an item or items. + * Split plural string into standard CLDR array form. + * A string is considered a pluralised form if it has a {count} argument, and + * a single `|` pipe-delimiting character. * - * Yaml form of these strings should be set via the rails i18n standard format - * http://guides.rubyonrails.org/i18n.html#pluralization. For example: + * Note: Only splits in the default (en) locale as the string form contains limited metadata. * - * - * en: - * ChangeSet: - * DESCRIPTION_ITEM_PLURALS: - * one: 'one item' - * other: '{count} items' - * - * - * Some languages support up to 6 plural forms: - * @link http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html - * - * @todo text collection support for pluralised strings - * - * @param string $entity Entity that identifies the string. It must be in the form - * "Namespace.Entity" where Namespace will be usually the class name where this - * string is used and Entity identifies the string inside the namespace. - * Standard convention is to have a `Class._PLURALS` key for a on a class - * @param string|array $default If passed as a string, treated as a symfony format specifier. - * If passed as an array, treated as a ruby i18n pluralised form. - * @param int $count Number to pluralise against - * @param array $injection Additional parameters - * @return string Localised number + * @param string $string Input string + * @return array List of plural forms, or empty array if not plural */ - public static function pluralise($entity, $default = '', $count = 0, $injection = []) + public static function parse_plurals($string) { - return static::getMessageProvider()->pluralise($entity, $default, $count, $injection); + if (strstr($string, '|') && strstr($string, '{count}')) { + $keys = i18n::config()->get('default_plurals'); + $values = explode('|', $string); + if (count($keys) == count($values)) { + return array_combine($keys, $values); + } + } + return []; + } + + /** + * Convert CLDR array plural form to `|` pipe-delimited string. + * Unlike parse_plurals, this supports all locale forms (not just en) + * + * @param array $plurals + * @return string Delimited string, or null if not plurals + */ + public static function encode_plurals($plurals) + { + // Validate against global plural list + $forms = i18n::config()->get('plurals'); + $forms = array_combine($forms, $forms); + $intersect = array_intersect_key($plurals, $forms); + if ($intersect) { + return implode('|', $intersect); + } + return null; } /** @@ -2452,7 +2495,6 @@ class i18n implements TemplateGlobalProvider 'i18nLocale' => 'get_locale', 'get_locale', 'i18nScriptDirection' => 'get_script_direction', - 'pluralise', ); } diff --git a/tests/php/Security/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index 50d19dbf8..893a3f670 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -172,7 +172,10 @@ class MemberAuthenticatorTest extends SapphireTest ); $form->restoreFormState(); $this->assertEmpty($result); - $this->assertEquals(_t('Member.ERRORWRONGCRED'), $form->getMessage()); + $this->assertEquals( + _t('Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'), + $form->getMessage() + ); $this->assertEquals(ValidationResult::TYPE_ERROR, $form->getMessageType()); $this->assertEquals(ValidationResult::CAST_TEXT, $form->getMessageCast()); } diff --git a/tests/php/i18n/YamlReaderTest.php b/tests/php/i18n/YamlReaderTest.php index 55c748ca0..2f285723a 100644 --- a/tests/php/i18n/YamlReaderTest.php +++ b/tests/php/i18n/YamlReaderTest.php @@ -2,7 +2,6 @@ namespace SilverStripe\i18n\Tests; -use SilverStripe\Dev\Debug; use SilverStripe\Dev\SapphireTest; use SilverStripe\i18n\Messages\YamlReader; @@ -27,11 +26,15 @@ class YamlReaderTest extends SapphireTest 'i18nTestModule.WITHNAMESPACE' => 'Include Entity with Namespace', 'i18nTestModule.LAYOUTTEMPLATE' => 'Layout Template', 'i18nTestModule.SPRINTFNAMESPACE' => 'My replacement: %s', - 'i18nTestModule.PLURAL' => [ + 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'My include replacement: %s', + 'i18nTestModule.PLURALS' => [ 'one' => 'A test', 'other' => '{count} tests', ], - 'i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE' => 'My include replacement: %s', + 'Month.PLURALS' => [ + 'one' => 'A month', + 'other' => '{count} months', + ], ]; $this->assertEquals($expected, $output); } diff --git a/tests/php/i18n/YamlWriterTest.php b/tests/php/i18n/YamlWriterTest.php index 7da982ebf..c8927336f 100644 --- a/tests/php/i18n/YamlWriterTest.php +++ b/tests/php/i18n/YamlWriterTest.php @@ -15,25 +15,47 @@ class YamlWriterTest extends SapphireTest 'Level1.Level2.EntityName' => 'Text', 'Level1.OtherEntityName' => 'Other Text', 'Level1.Plurals' => [ + 'context' => 'Some ignored context', 'one' => 'An item', 'other' => '{count} items', ], + 'Level1.PluralString1' => 'An item|{count} items', + 'Level1.PluralString2' => [ + 'context' => 'Another ignored context', + 'default' => 'An item|{count} items', + ], + // Some near-false-positives for plurals + 'Level1.NotPlural1' => 'Not a plural|string', // no count + 'Level1.NotPlural2' => 'Not|a|plural|string{count}', // unexpected number + 'Level1.NotPlural3' => 'Not a plural string {count}', // no pipe 'Level1.BoolTest' => 'True', 'Level1.FlagTest' => 'No', 'Level1.TextTest' => 'Maybe', + 'Template.ss.Key' => 'Template var', 'TopLevel' => 'The Top', ]; $yaml = <<update('missing_default_warning', false); /** @var SymfonyMessageProvider $provider */ $provider = Injector::inst()->get(MessageProvider::class); @@ -322,6 +325,8 @@ class i18nTest extends SapphireTest * */ public function testNewTemplateTranslation() { + i18n::config()->update('missing_default_warning', false); + /** @var SymfonyMessageProvider $provider */ $provider = Injector::inst()->get(MessageProvider::class); $provider->getTranslator()->addResource( @@ -432,6 +437,46 @@ class i18nTest extends SapphireTest ); } + public function pluralisationDataProvider() + { + return [ + // English - 2 plural forms + ['en_NZ', 0, '0 months'], + ['en_NZ', 1, 'A month'], + ['en_NZ', 2, '2 months'], + ['en_NZ', 5, '5 months'], + ['en_NZ', 10, '10 months'], + // Polish - 4 plural forms + ['pl_PL', 0, '0 miesięcy'], + ['pl_PL', 1, '1 miesiąc'], + ['pl_PL', 2, '2 miesiące'], + ['pl_PL', 5, '5 miesięcy'], + ['pl_PL', 10, '10 miesięcy'], + // Japanese - 1 plural form + ['ja_JP', 0, '0日'], + ['ja_JP', 1, '1日'], + ['ja_JP', 2, '2日'], + ['ja_JP', 5, '5日'], + ['ja_JP', 10, '10日'], + ]; + } + + /** + * @dataProvider pluralisationDataProvider() + * @param string $locale + * @param int $count + * @param string $expected + */ + public function testPluralisation($locale, $count, $expected) + { + i18n::set_locale($locale); + $this->assertEquals( + $expected, + _t('Month.PLURALS', 'A month|{count} months', ['count' => $count]), + "Plural form in locale $locale with count $count should be $expected" + ); + } + public function testGetLanguageName() { i18n::config()->update( diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php b/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php index a978f91da..ef1bd581d 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18nothermodule/code/i18nProviderClass.php @@ -9,10 +9,12 @@ class i18nProviderClass implements i18nEntityProvider return [ 'i18nProviderClass.TITLE' => 'My Provider Class', 'i18nProviderClass.PLURALS' => [ + 'comment' => 'Plural forms for the test class', 'one' => 'A class', 'other' => '{count} classes', ], 'i18nProviderClass.OTHER_MODULE' => [ + 'comment' => 'Test string in another module', 'default' => 'i18ntestmodule string defined in i18nothermodule', 'module' => 'i18ntestmodule' ], diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml index 66c49b332..f3ea1a627 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/en.yml @@ -11,8 +11,12 @@ en: WITHNAMESPACE: Include Entity with Namespace LAYOUTTEMPLATE: Layout Template SPRINTFNAMESPACE: My replacement: %s - PLURAL: + PLURALS: one: 'A test' other: '{count} tests' i18nTestModuleInclude.ss: SPRINTFINCLUDENAMESPACE: My include replacement: %s + Month: + PLURALS: + one: 'A month' + other: '{count} months' diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/ja.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/ja.yml new file mode 100644 index 000000000..f778958a4 --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/ja.yml @@ -0,0 +1,4 @@ +ja: + Month: + PLURALS: + other: '{count}日' diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/pl.yml b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/pl.yml new file mode 100644 index 000000000..399de32cc --- /dev/null +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/lang/pl.yml @@ -0,0 +1,7 @@ +pl: + Month: + PLURALS: + one: '1 miesiąc' + few: '{count} miesiące' + many: '{count} miesięcy' + other: '{count} miesiąca' diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss index aec117959..5e46088ce 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/Layout/i18nTestModule.ss @@ -8,4 +8,5 @@ <%t i18nTestModule.INJECTIONS_DOES_NOT_EXIST "Hello {name} {greeting}. But it is late, {goodbye}" name="Mark" greeting="welcome" goodbye="bye" %> <%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" name="Paul" greeting="good you are here" goodbye="see you" %> <%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" is "New context (this should be ignored)" name="Steffen" greeting="willkommen" goodbye="wiedersehen" %> -<%t i18nTestModule.INJECTIONS "Hello {name} {greeting}. But it is late, {goodbye}" name=$absoluteBaseURL greeting=$get_locale goodbye="global calls" %> +<%t i18nTestModule.INJECTIONS name="Cat" greeting='meow' goodbye="meow" %> +<%t i18nTestModule.INJECTIONS name=$absoluteBaseURL greeting=$get_locale goodbye="global calls" %> diff --git a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss index 27ef419f2..e14ba28b8 100644 --- a/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss +++ b/tests/php/i18n/i18nTest/_fakewebroot/i18ntestmodule/templates/i18nTestModule.ss @@ -2,6 +2,6 @@ $Layout lonely _t() call that should be ignored <% _t('i18nTestModule.NEWENTITY',"Not stored in master file yet") %> -Single: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 1) -Multiple: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 4) -None: $pluralise('i18nTestModule.PLURALS', 'An item|{count} items', 0) +Single: <%t i18nTestModule.PLURALS 'An item|{count} items' count=1 %> +Multiple: <%t i18nTestModule.PLURALS 'An item|{count} items' count=4 %> +None: <%t i18nTestModule.PLURALS 'An item|{count} items' count=0 %> diff --git a/tests/php/i18n/i18nTextCollectorTest.php b/tests/php/i18n/i18nTextCollectorTest.php index 3d84f79bd..1a25b2a4b 100644 --- a/tests/php/i18n/i18nTextCollectorTest.php +++ b/tests/php/i18n/i18nTextCollectorTest.php @@ -60,7 +60,10 @@ _t( PHP; $this->assertEquals( array( - 'Test.CONCATENATED' => "Line 1 and Line '2' and Line \"3\"", + 'Test.CONCATENATED' => [ + 'default' => "Line 1 and Line '2' and Line \"3\"", + 'comment' => 'Comment' + ], 'Test.CONCATENATED2' => "Line \"4\" and Line 5" ), $c->collectFromCode($php, 'mymodule') @@ -70,6 +73,8 @@ PHP; public function testCollectFromNewTemplateSyntaxUsingParserSubclass() { $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); + $html = << <%t i18nTestModule.NEWMETHODSIG "New _t method signature test" %> @@ -78,6 +83,7 @@ PHP; <%t i18nTestModule.INJECTIONS_2 "Hello {name} {greeting}" is "context (ignored)" name="Steffen" greeting="Wilkommen" %> <%t i18nTestModule.INJECTIONS_3 name="Cat" greeting='meow' goodbye="meow" %> <%t i18nTestModule.INJECTIONS_4 name=\$absoluteBaseURL greeting=\$get_locale goodbye="global calls" %> +<%t i18nTestModule.INJECTIONS_9 "An item|{count} items" is "Test Pluralisation" count=4 %> SS; $c->collectFromTemplate($html, 'mymodule', 'Test'); @@ -87,10 +93,26 @@ SS; 'i18nTestModule.NEWMETHODSIG' => "New _t method signature test", 'i18nTestModule.INJECTIONS_0' => "Hello {name} {greeting}, and {goodbye}", 'i18nTestModule.INJECTIONS_1' => "Hello {name} {greeting}, and {goodbye}", - 'i18nTestModule.INJECTIONS_2' => "Hello {name} {greeting}", + 'i18nTestModule.INJECTIONS_2' => [ + 'default' => "Hello {name} {greeting}", + 'comment' => 'context (ignored)', + ], + 'i18nTestModule.INJECTIONS_9' => [ + 'one' => 'An item', + 'other' => '{count} items', + 'comment' => 'Test Pluralisation' + ], ], $c->collectFromTemplate($html, 'mymodule', 'Test') ); + + // Test warning is raised on empty default + $c->setWarnOnEmptyDefault(true); + $this->setExpectedException( + PHPUnit_Framework_Error_Notice::class, + 'Missing localisation default for key i18nTestModule.INJECTIONS_3' + ); + $c->collectFromTemplate($html, 'mymodule', 'Test'); } public function testCollectFromTemplateSimple() @@ -125,6 +147,7 @@ SS; public function testCollectFromTemplateAdvanced() { $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); $html = << SS; $this->assertEquals( - [ 'Test.PRIOANDCOMMENT' => ' Prio and Value with "Double Quotes"' ], + [ 'Test.PRIOANDCOMMENT' => [ + 'default' => ' Prio and Value with "Double Quotes"', + 'comment' => 'Comment with "Double Quotes"', + ]], $c->collectFromTemplate($html, 'mymodule', 'Test') ); @@ -158,9 +184,29 @@ SS; ) %> SS; $this->assertEquals( - [ 'Test.PRIOANDCOMMENT' => " Prio and Value with 'Single Quotes'" ], + [ 'Test.PRIOANDCOMMENT' => [ + 'default' => " Prio and Value with 'Single Quotes'", + 'comment' => "Comment with 'Single Quotes'", + ]], $c->collectFromTemplate($html, 'mymodule', 'Test') ); + + // Test empty + $html = << +SS; + $this->assertEquals( + [], + $c->collectFromTemplate($html, 'mymodule', 'Test') + ); + + // Test warning is raised on empty default + $c->setWarnOnEmptyDefault(true); + $this->setExpectedException( + PHPUnit_Framework_Error_Notice::class, + 'Missing localisation default for key Test.PRIOANDCOMMENT' + ); + $c->collectFromTemplate($html, 'mymodule', 'Test'); } @@ -209,7 +255,12 @@ _t( ); PHP; $this->assertEquals( - [ 'Test.PRIOANDCOMMENT' => ' Value with "Double Quotes"' ], + [ + 'Test.PRIOANDCOMMENT' => [ + 'default' => ' Value with "Double Quotes"', + 'comment' => 'Comment with "Double Quotes"', + ] + ], $c->collectFromCode($php, 'mymodule') ); @@ -222,7 +273,10 @@ _t( ); PHP; $this->assertEquals( - [ 'Test.PRIOANDCOMMENT' => " Value with 'Single Quotes'" ], + [ 'Test.PRIOANDCOMMENT' => [ + 'default' => " Value with 'Single Quotes'", + 'comment' => "Comment with 'Single Quotes'" + ] ], $c->collectFromCode($php, 'mymodule') ); @@ -241,6 +295,8 @@ PHP; _t( 'Test.PRIOANDCOMMENT', "Doublequoted Value with 'Unescaped Single Quotes'" + + ); PHP; $this->assertEquals( @@ -287,6 +343,7 @@ PHP; public function testCollectFromCodeNewSignature() { $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); // Disable warnings for tests $php = <<"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen")); +_t('i18nTestModule.INJECTIONS4', array("name"=>"Cat", "greeting"=>"meow", "goodbye"=>"meow")); _t('i18nTestModule.INJECTIONS6', "Hello {name} {greeting}. But it is late, {goodbye}", ["name"=>"Paul", "greeting"=>"good you are here", "goodbye"=>"see you"]); _t("i18nTestModule.INJECTIONS7", "Hello {name} {greeting}. But it is late, {goodbye}", "New context (this should be ignored)", ["name"=>"Steffen", "greeting"=>"willkommen", "goodbye"=>"wiedersehen"]); +_t('i18nTestModule.INJECTIONS8', ["name"=>"Cat", "greeting"=>"meow", "goodbye"=>"meow"]); +_t('i18nTestModule.INJECTIONS9', "An item|{count} items", ['count' => 4], "Test Pluralisation"); PHP; $collectedTranslatables = $c->collectFromCode($php, 'mymodule'); $expectedArray = [ 'i18nTestModule.INJECTIONS2' => "Hello {name} {greeting}. But it is late, {goodbye}", - 'i18nTestModule.INJECTIONS3' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.INJECTIONS3' => [ + 'default' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'comment' => 'New context (this should be ignored)' + ], 'i18nTestModule.INJECTIONS6' => "Hello {name} {greeting}. But it is late, {goodbye}", - 'i18nTestModule.INJECTIONS7' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'i18nTestModule.INJECTIONS7' => [ + 'default' => "Hello {name} {greeting}. But it is late, {goodbye}", + 'comment' => "New context (this should be ignored)", + ], + 'i18nTestModule.INJECTIONS9' => [ + 'one' => 'An item', + 'other' => '{count} items', + 'comment' => 'Test Pluralisation', + ], 'i18nTestModule.NEWMETHODSIG' => "New _t method signature test", ]; $this->assertEquals($expectedArray, $collectedTranslatables); - // Test warning is raised + // Test warning is raised on empty default $this->setExpectedException( PHPUnit_Framework_Error_Notice::class, 'Missing localisation default for key i18nTestModule.INJECTIONS4' @@ -321,6 +392,7 @@ PHP; $php = <<"Cat", "greeting"=>"meow", "goodbye"=>"meow")); PHP; + $c->setWarnOnEmptyDefault(true); $c->collectFromCode($php, 'mymodule'); } @@ -345,6 +417,7 @@ PHP; public function testCollectFromIncludedTemplates() { $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); // Disable warnings for tests $templateFilePath = $this->alternateBasePath . '/i18ntestmodule/templates/Layout/i18nTestModule.ss'; $html = file_get_contents($templateFilePath); @@ -419,6 +492,7 @@ PHP; public function testCollectMergesWithExisting() { $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); $c->setWriter(new YamlWriter()); $c->basePath = $this->alternateBasePath; $c->baseSavePath = $this->alternateBaseSavePath; @@ -441,18 +515,22 @@ PHP; $entitiesByModule['i18ntestmodule'] ); $this->assertEquals( - 'i18ntestmodule string defined in i18nothermodule', + [ + 'comment' => 'Test string in another module', + 'default' => 'i18ntestmodule string defined in i18nothermodule', + ], $entitiesByModule['i18ntestmodule']['i18nProviderClass.OTHER_MODULE'] ); } public function testCollectFromFilesystemAndWriteMasterTables() { - $local = i18n::get_locale(); i18n::set_locale('en_US'); //set the locale to the US locale expected in the asserts i18n::config()->update('default_locale', 'en_US'); + i18n::config()->update('missing_default_warning', false); $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); $c->setWriter(new YamlWriter()); $c->basePath = $this->alternateBasePath; $c->baseSavePath = $this->alternateBaseSavePath; @@ -564,8 +642,6 @@ PHP; " MAINTEMPLATE: 'Theme2 Main Template'\n", $theme2LangFileContent ); - - i18n::set_locale($local); //set the locale to the US locale expected in the asserts } public function testCollectFromEntityProvidersInCustomObject() @@ -574,6 +650,8 @@ PHP; $this->popManifests(); $c = i18nTextCollector::create(); + + // Collect from MyObject.php $filePath = __DIR__ . '/i18nTest/MyObject.php'; $matches = $c->collectFromEntityProviders($filePath); $this->assertEquals( @@ -593,6 +671,36 @@ PHP; ); } + public function testCollectFromEntityProvidersInWebRoot() + { + // Collect from i18nProviderClass + $c = i18nTextCollector::create(); + $c->setWarnOnEmptyDefault(false); + $c->setWriter(new YamlWriter()); + $c->basePath = $this->alternateBasePath; + $c->baseSavePath = $this->alternateBaseSavePath; + $entitiesByModule = $c->collect(null, false); + $this->assertEquals( + [ + 'comment' => 'Plural forms for the test class', + 'one' => 'A class', + 'other' => '{count} classes', + ], + $entitiesByModule['i18nothermodule']['i18nProviderClass.PLURALS'] + ); + $this->assertEquals( + 'My Provider Class', + $entitiesByModule['i18nothermodule']['i18nProviderClass.TITLE'] + ); + $this->assertEquals( + [ + 'comment' => 'Test string in another module', + 'default' => 'i18ntestmodule string defined in i18nothermodule', + ], + $entitiesByModule['i18ntestmodule']['i18nProviderClass.OTHER_MODULE'] + ); + } + /** * Test that duplicate keys are resolved to the appropriate modules */