commit 2a3cc650dc55cef276e3b1690b9b1dfb4e51a054 Author: Ingo Schommer Date: Tue Mar 22 21:50:26 2011 +1300 MINOR Initial commit, moved files from 'sapphire' and 'cms' modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a5e0cfd --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +* Copyright (c) 2007-2011, Silverstripe Ltd. +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without +* modification, are permitted provided that the following conditions are met: +* * Redistributions of source code must retain the above copyright +* notice, this list of conditions and the following disclaimer. +* * Redistributions in binary form must reproduce the above copyright +* notice, this list of conditions and the following disclaimer in the +* documentation and/or other materials provided with the distribution. +* * Neither the name of the nor the +* names of its contributors may be used to endorse or promote products +* derived from this software without specific prior written permission. +* +* THIS SOFTWARE IS PROVIDED BY Silverstripe Ltd. ``AS IS'' AND ANY +* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +* DISCLAIMED. IN NO EVENT SHALL Silverstripe Ltd. BE LIABLE FOR ANY +* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f202582 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Translatable module for SilverStripe CMS # + +## Introduction ## + +Allows translation of DataObject and SiteTree records into multiple languages. +See `/docs/en/index.md` for details. + +## Requirements ## + + * SilverStripe 3.0 (both cms and sapphire modules) + +## Maintainers ## + + * Ingo Schommer + +## TODO ## + +This module was originally part of the SilverStripe CMS core codebase. +While the bulk of the logic has been separated out into this module, +there are still many places across SilverStripe CMS which this modules relies on: + +* CMSBatchActionHandler->handleAction() +* ContentController->handleRequest() +* ContentController->ContentLocale() +* ErrorPage::response_for() +* LeftAndMain->init() +* ModelAsController->getNestedController() +* RootURLController::get_homepage_link() +* SearchForm +* SiteConfig +* SiteTree->RelativeLink() +* SiteTree->getSiteConfig() + +These APIs mostly require either hooks for an Extension subclass, +or refactoring to allow better customization. \ No newline at end of file diff --git a/_config.php b/_config.php new file mode 100644 index 0000000..e30c975 --- /dev/null +++ b/_config.php @@ -0,0 +1,4 @@ + array( + 'createtranslation', + ) + ); + } + + function init() { + + // Locale" attribute is either explicitly added by LeftAndMain Javascript logic, + // or implied on a translated record (see {@link Translatable->updateCMSFields()}). + // $Lang serves as a "context" which can be inspected by Translatable - hence it + // has the same name as the database property on Translatable. + $req = $this->owner->getRequest(); + if($req->requestVar("Locale")) { + $this->owner->Locale = $req->requestVar("Locale"); + } elseif($req->requestVar("locale")) { + $this->owner->Locale = $req->requestVar("locale"); + } else { + $this->owner->Locale = Translatable::default_locale(); + } + Translatable::set_current_locale($this->owner->Locale); + + // collect languages for TinyMCE spellchecker plugin. + // see http://wiki.moxiecode.com/index.php/TinyMCE:Plugins/spellchecker + $langName = i18n::get_locale_name($this->owner->Locale); + HtmlEditorConfig::get('cms')->setOption('spellchecker_languages', "+{$langName}={$this->owner->Locale}"); + + Requirements::javascript('translatable/javascript/CMSMain.Translatable.js'); + Requirements::css('translatable/css/CMSMain.Translatable.css'); + } + + function updateEditForm(&$form) { + $siteConfig = SiteConfig::current_site_config(); + if($form->Name() == 'RootForm' && Object::has_extension('SiteConfig',"Translatable")) { + $form->Fields()->push(new HiddenField('Locale','', $siteConfig->Locale)); + } + } + + function updatePageOption(&$fields) { + $fields->push(new HiddenField("Locale", 'Locale', Translatable::get_current_locale())); + } + + /** + * Create a new translation from an existing item, switch to this language and reload the tree. + */ + function createtranslation($request) { + // Protect against CSRF on destructive action + if(!SecurityToken::inst()->checkRequest($request)) return $this->owner->httpError(400); + + $langCode = Convert::raw2sql($request->getVar('newlang')); + $originalLangID = (int)$request->getVar('ID'); + + $record = $this->owner->getRecord($originalLangID); + + $this->owner->Locale = $langCode; + Translatable::set_current_locale($langCode); + + // Create a new record in the database - this is different + // to the usual "create page" pattern of storing the record + // in-memory until a "save" is performed by the user, mainly + // to simplify things a bit. + // @todo Allow in-memory creation of translations that don't persist in the database before the user requests it + $translatedRecord = $record->createTranslation($langCode); + + $url = sprintf( + "%s/%d/?locale=%s", + $this->owner->Link('show'), + $translatedRecord->ID, + $langCode + ); + + return Director::redirect($url); + } + + /** + * Returns a form with all languages with languages already used appearing first. + * + * @return Form + */ + function LangForm() { + $member = Member::currentUser(); //check to see if the current user can switch langs or not + if(Permission::checkMember($member, 'VIEW_LANGS')) { + $field = new LanguageDropdownField( + 'Locale', + _t('CMSMAIN.LanguageDropdownLabel', 'Language'), + array(), + 'SiteTree', + 'Locale-English', + singleton('SiteTree') + ); + $field->setValue(Translatable::get_current_locale()); + } else { + // user doesn't have permission to switch langs + // so just show a string displaying current language + $field = new LiteralField( + 'Locale', + i18n::get_locale_name( Translatable::get_current_locale()) + ); + } + + $form = new Form( + $this->owner, + 'LangForm', + new FieldSet( + $field + ), + new FieldSet( + new FormAction('selectlang', _t('CMSMain_left.ss.GO','Go')) + ) + ); + $form->unsetValidator(); + + return $form; + } + + function selectlang($data, $form) { + return $this->owner; + } + + /** + * Determine if there are more than one languages in our site tree. + * + * @return boolean + */ + function MultipleLanguages() { + $langs = Translatable::get_existing_content_languages('SiteTree'); + + return (count($langs) > 1); + } + + /** + * @return boolean + */ + function IsTranslatableEnabled() { + return Object::has_extension('SiteTree', 'Translatable'); + } + +} \ No newline at end of file diff --git a/code/forms/LanguageDropdownField.php b/code/forms/LanguageDropdownField.php new file mode 100755 index 0000000..c2ef796 --- /dev/null +++ b/code/forms/LanguageDropdownField.php @@ -0,0 +1,71 @@ + $localeTitle) { + if( + ($allowedLocales && !in_array($locale, $allowedLocales)) + || ($excludeLocales && in_array($locale, $excludeLocales)) + || ($usedLocalesWithTitle && array_key_exists($locale, $usedLocalesWithTitle)) + ) { + unset($allLocalesWithTitle[$locale]); + } + } + // instance specific permissions + foreach($allLocalesWithTitle as $locale => $localeTitle) { + if($instance && !$instance->canTranslate(null, $locale)) { + unset($allLocalesWithTitle[$locale]); + } + } + foreach($usedLocalesWithTitle as $locale => $localeTitle) { + if($instance && !$instance->canTranslate(null, $locale)) { + unset($usedLocalesWithTitle[$locale]); + } + } + + // Sort by title (array value) + asort($allLocalesWithTitle); + + if(count($usedLocalesWithTitle)) { + asort($usedLocalesWithTitle); + $source = array( + _t('Form.LANGAVAIL', "Available languages") => $usedLocalesWithTitle, + _t('Form.LANGAOTHER', "Other languages") => $allLocalesWithTitle + ); + } else { + $source = $allLocalesWithTitle; + } + + parent::__construct($name, $title, $source); + } +} + +?> \ No newline at end of file diff --git a/code/model/Translatable.php b/code/model/Translatable.php new file mode 100755 index 0000000..4246609 --- /dev/null +++ b/code/model/Translatable.php @@ -0,0 +1,1593 @@ +Configuration + * + *

Through Object::add_extension()

+ * Enabling Translatable through {@link Object::add_extension()} in your _config.php: + * + * Object::add_extension('MyClass', 'Translatable'); + * + * This is the recommended approach for enabling Translatable. + * + *

Through $extensions

+ * + * class MyClass extends DataObject { + * static $extensions = array( + * "Translatable" + * ); + * } + * + * + * Make sure to rebuild the database through /dev/build after enabling translatable. + * Use the correct {@link set_default_locale()} before building the database + * for the first time, as this locale will be written on all new records. + * + *

"Default" locales

+ * + * Important: If the "default language" of your site is not US-English (en_US), + * please ensure to set the appropriate default language for + * your content before building the database with Translatable enabled: + * + * Translatable::set_default_locale(); // e.g. 'de_DE' or 'fr_FR' + * + * + * For the Translatable class, a "locale" consists of a language code plus a region code separated by an underscore, + * for example "de_AT" for German language ("de") in the region Austria ("AT"). + * See http://www.w3.org/International/articles/language-tags/ for a detailed description. + * + *

Usage

+ * + * Getting a translation for an existing instance: + * + * $translatedObj = Translatable::get_one_by_locale('MyObject', 'de_DE'); + * + * + * Getting a translation for an existing instance: + * + * $obj = DataObject::get_by_id('MyObject', 99); // original language + * $translatedObj = $obj->getTranslation('de_DE'); + * + * + * Getting translations through {@link Translatable::set_current_locale()}. + * This is *not* a recommended approach, but sometimes inavoidable (e.g. for {@link Versioned} methods). + * + * $origLocale = Translatable::get_current_locale(); + * Translatable::set_current_locale('de_DE'); + * $obj = Versioned::get_one_by_stage('MyObject', "ID = 99"); + * Translatable::set_current_locale($origLocale); + * + * + * Creating a translation: + * + * $obj = new MyObject(); + * $translatedObj = $obj->createTranslation('de_DE'); + * + * + *

Usage for SiteTree

+ * + * Translatable can be used for subclasses of {@link SiteTree} as well. + * + * + * Object::add_extension('SiteTree', 'Translatable'); + * Object::add_extension('SiteConig', 'Translatable'); + * + * + * If a child page translation is requested without the parent + * page already having a translation in this language, the extension + * will recursively create translations up the tree. + * Caution: The "URLSegment" property is enforced to be unique across + * languages by auto-appending the language code at the end. + * You'll need to ensure that the appropriate "reading language" is set + * before showing links to other pages on a website through $_GET['locale']. + * Pages in different languages can have different publication states + * through the {@link Versioned} extension. + * + * Note: You can't get Children() for a parent page in a different language + * through set_current_locale(). Get the translated parent first. + * + * + * // wrong + * Translatable::set_current_locale('de_DE'); + * $englishParent->Children(); + * // right + * $germanParent = $englishParent->getTranslation('de_DE'); + * $germanParent->Children(); + * + * + *

Translation groups

+ * + * Each translation can have one or more related pages in other languages. + * This relation is optional, meaning you can + * create translations which have no representation in the "default language". + * This means you can have a french translation with a german original, + * without either of them having a representation + * in the default english language tree. + * Caution: There is no versioning for translation groups, + * meaning associating an object with a group will affect both stage and live records. + * + * SiteTree database table (abbreviated) + * ^ ID ^ URLSegment ^ Title ^ Locale ^ + * | 1 | about-us | About us | en_US | + * | 2 | ueber-uns | Über uns | de_DE | + * | 3 | contact | Contact | en_US | + * + * SiteTree_translationgroups database table + * ^ TranslationGroupID ^ OriginalID ^ + * | 99 | 1 | + * | 99 | 2 | + * | 199 | 3 | + * + *

Character Sets

+ * + * Caution: Does not apply any character-set conversion, it is assumed that all content + * is stored and represented in UTF-8 (Unicode). Please make sure your database and + * HTML-templates adjust to this. + * + *

Permissions

+ * + * Authors without administrative access need special permissions to edit locales other than + * the default locale. + * + * - TRANSLATE_ALL: Translate into all locales + * - Translate_: Translate a specific locale. Only available for all locales set in + * `Translatable::set_allowed_locales()`. + * + * Note: If user-specific view permissions are required, please overload `SiteTree->canView()`. + * + *

Uninstalling/Disabling

+ * + * Disabling Translatable after creating translations will lead to all + * pages being shown in the default sitetree regardless of their language. + * It is advised to start with a new database after uninstalling Translatable, + * or manually filter out translated objects through their "Locale" property + * in the database. + * + * @see http://doc.silverstripe.org/doku.php?id=multilingualcontent + * + * @author Ingo Schommer + * @author Michael Gall + * @author Bernat Foj Capell + * + * @package sapphire + * @subpackage i18n + */ +class Translatable extends DataObjectDecorator implements PermissionProvider { + + /** + * The 'default' language. + * @var string + */ + protected static $default_locale = 'en_US'; + + /** + * The language in which we are reading dataobjects. + * + * @var string + */ + protected static $current_locale = null; + + /** + * A cached list of existing tables + * + * @var mixed + */ + protected static $tableList = null; + + /** + * An array of fields that can be translated. + * @var array + */ + protected $translatableFields = null; + + /** + * A map of the field values of the original (untranslated) DataObject record + * @var array + */ + protected $original_values = null; + + /** + * If this is set to TRUE then {@link augmentSQL()} will automatically add a filter + * clause to limit queries to the current {@link get_current_locale()}. This camn be + * disabled using {@link disable_locale_filter()} + * + * @var bool + */ + protected static $locale_filter_enabled = true; + + /** + * @var array All locales in which a translation can be created. + * This limits the choice in the CMS language dropdown in the + * "Translation" tab, as well as the language dropdown above + * the CMS tree. If not set, it will default to showing all + * common locales. + */ + protected static $allowed_locales = null; + + /** + * Reset static configuration variables to their default values + */ + static function reset() { + self::enable_locale_filter(); + self::$default_locale = 'en_US'; + self::$current_locale = null; + self::$allowed_locales = null; + } + + /** + * Choose the language the site is currently on. + * + * If $_GET['locale'] is currently set, then that locale will be used. Otherwise the member preference (if logged + * in) or default locale will be used. + * + * @todo Re-implement cookie and member option + * + * @param $langsAvailable array A numerical array of languages which are valid choices (optional) + * @return string Selected language (also saved in $current_locale). + */ + static function choose_site_locale($langsAvailable = array()) { + if(self::$current_locale) { + return self::$current_locale; + } + + if((isset($_GET['locale']) && !$langsAvailable) || (isset($_GET['locale']) && in_array($_GET['locale'], $langsAvailable))) { + // get from GET parameter + self::set_current_locale($_GET['locale']); + } else { + self::set_current_locale(self::default_locale()); + } + + return self::$current_locale; + } + + /** + * Get the current reading language. + * This value has to be set before the schema is built with translatable enabled, + * any changes after this can cause unintended side-effects. + * + * @return string + */ + static function default_locale() { + return self::$default_locale; + } + + /** + * Set default language. Please set this value *before* creating + * any database records (like pages), as this locale will be attached + * to all new records. + * + * @param $locale String + */ + static function set_default_locale($locale) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + $localeList = i18n::get_locale_list(); + if(isset($localeList[$locale])) { + self::$default_locale = $locale; + } else { + user_error("Translatable::set_default_locale(): '$locale' is not a valid locale.", E_USER_WARNING); + } + } + + /** + * Get the current reading language. + * If its not chosen, call {@link choose_site_locale()}. + * + * @return string + */ + static function get_current_locale() { + return (self::$current_locale) ? self::$current_locale : self::choose_site_locale(); + } + + /** + * Set the reading language, either namespaced to 'site' (website content) + * or 'cms' (management backend). This value is used in {@link augmentSQL()} + * to "auto-filter" all SELECT queries by this language. + * See {@link disable_locale_filter()} on how to override this behaviour temporarily. + * + * @param string $lang New reading language. + */ + static function set_current_locale($locale) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + self::$current_locale = $locale; + } + + /** + * Get a singleton instance of a class in the given language. + * @param string $class The name of the class. + * @param string $locale The name of the language. + * @param string $filter A filter to be inserted into the WHERE clause. + * @param boolean $cache Use caching (default: false) + * @param string $orderby A sort expression to be inserted into the ORDER BY clause. + * @return DataObject + */ + static function get_one_by_locale($class, $locale, $filter = '', $cache = false, $orderby = "") { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + $orig = Translatable::get_current_locale(); + Translatable::set_current_locale($locale); + $do = DataObject::get_one($class, $filter, $cache, $orderby); + Translatable::set_current_locale($orig); + return $do; + } + + /** + * Get all the instances of the given class translated to the given language + * + * @param string $class The name of the class + * @param string $locale The name of the language + * @param string $filter A filter to be inserted into the WHERE clause. + * @param string $sort A sort expression to be inserted into the ORDER BY clause. + * @param string $join A single join clause. This can be used for filtering, only 1 instance of each DataObject will be returned. + * @param string $limit A limit expression to be inserted into the LIMIT clause. + * @param string $containerClass The container class to return the results in. + * @param string $having A filter to be inserted into the HAVING clause. + * @return mixed The objects matching the conditions. + */ + static function get_by_locale($class, $locale, $filter = '', $sort = '', $join = "", $limit = "", $containerClass = "DataObjectSet", $having = "") { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + $oldLang = self::get_current_locale(); + self::set_current_locale($locale); + $result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass, $having); + self::set_current_locale($oldLang); + return $result; + } + + /** + * @return bool + */ + public static function locale_filter_enabled() { + return self::$locale_filter_enabled; + } + + /** + * Enables automatic filtering by locale. This is normally called after is has been + * disabled using {@link disable_locale_filter()}. + */ + public static function enable_locale_filter() { + self::$locale_filter_enabled = true; + } + + /** + * Disables automatic locale filtering in {@link augmentSQL()}. This can be re-enabled + * using {@link enable_locale_filter()}. + */ + public static function disable_locale_filter() { + self::$locale_filter_enabled = false; + } + + /** + * Gets all translations for this specific page. + * Doesn't include the language of the current record. + * + * @return array Numeric array of all locales, sorted alphabetically. + */ + function getTranslatedLocales() { + $langs = array(); + + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); //Base Class + $translationGroupClass = $baseDataClass . "_translationgroups"; + if($this->owner->hasExtension("Versioned") && Versioned::current_stage() == "Live") { + $baseDataClass = $baseDataClass . "_Live"; + } + + $translationGroupID = $this->getTranslationGroup(); + if(is_numeric($translationGroupID)) { + $query = new SQLQuery( + 'DISTINCT "Locale"', + sprintf( + '"%s" LEFT JOIN "%s" ON "%s"."OriginalID" = "%s"."ID"', + $baseDataClass, + $translationGroupClass, + $translationGroupClass, + $baseDataClass + ), // from + sprintf( + '"%s"."TranslationGroupID" = %d AND "%s"."Locale" != \'%s\'', + $translationGroupClass, + $translationGroupID, + $baseDataClass, + $this->owner->Locale + ) // where + ); + $langs = $query->execute()->column(); + } + if($langs) { + $langCodes = array_values($langs); + sort($langCodes); + return $langCodes; + } else { + return array(); + }; + } + + /** + * Gets all locales that a member can access + * as defined by {@link $allowed_locales} + * and {@link canTranslate()}. + * If {@link $allowed_locales} is not set and + * the user has the `TRANSLATE_ALL` permission, + * the method will return all available locales in the system. + * + * @param Member $member + * @return array Map of locales + */ + function getAllowedLocalesForMember($member) { + $locales = self::get_allowed_locales(); + if(!$locales) $locales = i18n::get_common_locales(); + if($locales) foreach($locales as $k => $locale) { + if(!$this->canTranslate($member, $locale)) unset($locales[$k]); + } + + return $locales; + } + + /** + * Get a list of languages in which a given element has been translated. + * + * @deprecated 2.4 Use {@link getTranslations()} + * + * @param string $class Name of the class of the element + * @param int $id ID of the element + * @return array List of languages + */ + static function get_langs_by_id($class, $id) { + $do = DataObject::get_by_id($class, $id); + return ($do ? $do->getTranslatedLocales() : array()); + } + + /** + * Enables the multilingual feature + * + * @deprecated 2.4 Use Object::add_extension('SiteTree', 'Translatable') + */ + static function enable() { + if(class_exists('SiteTree')) Object::add_extension('SiteTree', 'Translatable'); + } + + /** + * Disable the multilingual feature + * + * @deprecated 2.4 Use Object::remove_extension('SiteTree', 'Translatable') + */ + static function disable() { + if(class_exists('SiteTree')) Object::remove_extension('SiteTree', 'Translatable'); + } + + /** + * Check whether multilingual support has been enabled + * + * @deprecated 2.4 Use Object::has_extension('SiteTree', 'Translatable') + * @return boolean True if enabled + */ + static function is_enabled() { + if(class_exists('SiteTree')){ + return Object::has_extension('SiteTree', 'Translatable'); + }else{ + return false; + } + } + + + /** + * Construct a new Translatable object. + * @var array $translatableFields The different fields of the object that can be translated. + * This is currently not implemented, all fields are marked translatable (see {@link setOwner()}). + */ + function __construct($translatableFields = null) { + parent::__construct(); + + // @todo Disabled selection of translatable fields - we're setting all fields as translatable in setOwner() + /* + if(!is_array($translatableFields)) { + $translatableFields = func_get_args(); + } + $this->translatableFields = $translatableFields; + */ + + // workaround for extending a method on another decorator (Hierarchy): + // split the method into two calls, and overwrite the wrapper AllChildrenIncludingDeleted() + // Has to be executed even with Translatable disabled, as it overwrites the method with same name + // on Hierarchy class, and routes through to Hierarchy->doAllChildrenIncludingDeleted() instead. + // Caution: There's an additional method for augmentAllChildrenIncludingDeleted() + + } + + function setOwner($owner, $ownerBaseClass = null) { + parent::setOwner($owner, $ownerBaseClass); + + // setting translatable fields by inspecting owner - this should really be done in the constructor + if($this->owner && $this->translatableFields === null) { + $this->translatableFields = array_merge( + array_keys($this->owner->inheritedDatabaseFields()), + array_keys($this->owner->has_many()), + array_keys($this->owner->many_many()) + ); + } + } + + function extraStatics() { + return array( + "db" => array( + "Locale" => "DBLocale", + //"TranslationMasterID" => "Int" // optional relation to a "translation master" + ), + "defaults" => array( + "Locale" => Translatable::default_locale() // as an overloaded getter as well: getLang() + ) + ); + } + + /** + * Changes any SELECT query thats not filtering on an ID + * to limit by the current language defined in {@link get_current_locale()}. + * It falls back to "Locale='' OR Lang IS NULL" and assumes that + * this implies querying for the default language. + * + * Use {@link disable_locale_filter()} to temporarily disable this "auto-filtering". + */ + function augmentSQL(SQLQuery &$query) { + // If the record is saved (and not a singleton), and has a locale, + // limit the current call to its locale. This fixes a lot of problems + // with other extensions like Versioned + $locale = ($this->owner->ID && $this->owner->Locale) ? $this->owner->Locale : Translatable::get_current_locale(); + $baseTable = ClassInfo::baseDataClass($this->owner->class); + $where = $query->where; + if( + $locale + // unless the filter has been temporarily disabled + && self::locale_filter_enabled() + // DataObject::get_by_id() should work independently of language + && !$query->filtersOnID() + // the query contains this table + // @todo Isn't this always the case?! + && array_search($baseTable, array_keys($query->from)) !== false + // or we're already filtering by Lang (either from an earlier augmentSQL() call or through custom SQL filters) + && !preg_match('/("|\'|`)Locale("|\'|`)/', $query->getFilter()) + //&& !$query->filtersOnFK() + ) { + $qry = sprintf('"%s"."Locale" = \'%s\'', $baseTable, Convert::raw2sql($locale)); + $query->where[] = $qry; + } + } + + /** + * Create _translation database table to enable + * tracking of "translation groups" in which each related + * translation of an object acts as a sibling, rather than + * a parent->child relation. + */ + function augmentDatabase() { + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + if($this->owner->class != $baseDataClass) return; + + $fields = array( + 'OriginalID' => 'Int', + 'TranslationGroupID' => 'Int', + ); + $indexes = array( + 'OriginalID' => true, + 'TranslationGroupID' => true + ); + + // Add new tables if required + DB::requireTable("{$baseDataClass}_translationgroups", $fields, $indexes); + + // Remove 2.2 style tables + DB::dontRequireTable("{$baseDataClass}_lang"); + if($this->owner->hasExtension('Versioned')) { + DB::dontRequireTable("{$baseDataClass}_lang_Live"); + DB::dontRequireTable("{$baseDataClass}_lang_versions"); + } + } + + /** + * @todo Find more appropriate place to hook into database building + */ + function requireDefaultRecords() { + // @todo This relies on the Locale attribute being on the base data class, and not any subclasses + if($this->owner->class != ClassInfo::baseDataClass($this->owner->class)) return false; + + // Permissions: If a group doesn't have any specific TRANSLATE_ edit rights, + // but has CMS_ACCESS_CMSMain (general CMS access), then assign TRANSLATE_ALL permissions as a default. + // Auto-setting permissions based on these intransparent criteria is a bit hacky, + // but unavoidable until we can determine when a certain permission code was made available first + // (see http://open.silverstripe.org/ticket/4940) + $groups = Permission::get_groups_by_permission(array('CMS_ACCESS_CMSMain','CMS_ACCESS_LeftAndMain','ADMIN')); + if($groups) foreach($groups as $group) { + $codes = $group->Permissions()->column('Code'); + $hasTranslationCode = false; + foreach($codes as $code) { + if(preg_match('/^TRANSLATE_/', $code)) $hasTranslationCode = true; + } + // Only add the code if no more restrictive code exists + if(!$hasTranslationCode) Permission::grant($group->ID, 'TRANSLATE_ALL'); + } + + // If the Translatable extension was added after the first records were already + // created in the database, make sure to update the Locale property if + // if wasn't set before + $idsWithoutLocale = DB::query(sprintf( + 'SELECT "ID" FROM "%s" WHERE "Locale" IS NULL OR "Locale" = \'\'', + ClassInfo::baseDataClass($this->owner->class) + ))->column(); + if(!$idsWithoutLocale) return; + + if(class_exists('SiteTree') && $this->owner->class == 'SiteTree') { + foreach(array('Stage', 'Live') as $stage) { + foreach($idsWithoutLocale as $id) { + $obj = Versioned::get_one_by_stage( + $this->owner->class, + $stage, + sprintf('"SiteTree"."ID" = %d', $id) + ); + if(!$obj) continue; + + $obj->Locale = Translatable::default_locale(); + $obj->writeToStage($stage); + $obj->addTranslationGroup($obj->ID); + $obj->destroy(); + unset($obj); + } + } + } else { + foreach($idsWithoutLocale as $id) { + $obj = DataObject::get_by_id($this->owner->class, $id); + if(!$obj) continue; + + $obj->Locale = Translatable::default_locale(); + $obj->write(); + $obj->addTranslationGroup($obj->ID); + $obj->destroy(); + unset($obj); + } + } + DB::alteration_message(sprintf( + "Added default locale '%s' to table %s","changed", + Translatable::default_locale(), + $this->owner->class + )); + } + + /** + * Add a record to a "translation group", + * so its relationship to other translations + * based off the same object can be determined later on. + * See class header for further comments. + * + * @param int $originalID Either the primary key of the record this new translation is based on, + * or the primary key of this record, to create a new translation group + * @param boolean $overwrite + */ + public function addTranslationGroup($originalID, $overwrite = false) { + if(!$this->owner->exists()) return false; + + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + $existingGroupID = $this->getTranslationGroup($originalID); + + // Remove any existing groups if overwrite flag is set + if($existingGroupID && $overwrite) { + $sql = sprintf( + 'DELETE FROM "%s_translationgroups" WHERE "TranslationGroupID" = %d AND "OriginalID" = %d', + $baseDataClass, + $existingGroupID, + $this->owner->ID + ); + DB::query($sql); + $existingGroupID = null; + } + + // Add to group (only if not in existing group or $overwrite flag is set) + if(!$existingGroupID) { + $sql = sprintf( + 'INSERT INTO "%s_translationgroups" ("TranslationGroupID","OriginalID") VALUES (%d,%d)', + $baseDataClass, + $originalID, + $this->owner->ID + ); + DB::query($sql); + } + } + + /** + * Gets the translation group for the current record. + * This ID might equal the record ID, but doesn't have to - + * it just points to one "original" record in the list. + * + * @return int Numeric ID of the translationgroup in the _translationgroup table + */ + public function getTranslationGroup() { + if(!$this->owner->exists()) return false; + + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + return DB::query( + sprintf('SELECT "TranslationGroupID" FROM "%s_translationgroups" WHERE "OriginalID" = %d', $baseDataClass, $this->owner->ID) + )->value(); + } + + /** + * Removes a record from the translation group lookup table. + * Makes no assumptions on other records in the group - meaning + * if this happens to be the last record assigned to the group, + * this group ceases to exist. + */ + public function removeTranslationGroup() { + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + DB::query( + sprintf('DELETE FROM "%s_translationgroups" WHERE "OriginalID" = %d', $baseDataClass, $this->owner->ID) + ); + } + + /** + * Determine if a table needs Versioned support + * This is called at db/build time + * + * @param string $table Table name + * @return boolean + */ + function isVersionedTable($table) { + return false; + } + + /** + * Note: The bulk of logic is in ModelAsController->getNestedController() + * and ContentController->handleRequest() + */ + function contentcontrollerInit($controller) { + $controller->Locale = Translatable::choose_site_locale(); + } + + function modelascontrollerInit($controller) { + //$this->contentcontrollerInit($controller); + } + + function initgetEditForm($controller) { + $this->contentcontrollerInit($controller); + } + + /** + * Recursively creates translations for parent pages in this language + * if they aren't existing already. This is a necessity to make + * nested pages accessible in a translated CMS page tree. + * It would be more userfriendly to grey out untranslated pages, + * but this involves complicated special cases in AllChildrenIncludingDeleted(). + * + * {@link SiteTree->onBeforeWrite()} will ensure that each translation will get + * a unique URL across languages, by means of {@link SiteTree::get_by_link()} + * and {@link Translatable->alternateGetByURL()}. + */ + function onBeforeWrite() { + // If language is not set explicitly, set it to current_locale. + // This might be a bit overzealous in assuming the language + // of the content, as a "single language" website might be expanded + // later on. See {@link requireDefaultRecords()} for batch setting + // of empty Locale columns on each dev/build call. + if(!$this->owner->Locale) { + $this->owner->Locale = Translatable::get_current_locale(); + } + + // Specific logic for SiteTree subclasses. + // If page has untranslated parents, create (unpublished) translations + // of those as well to avoid having inaccessible children in the sitetree. + // Caution: This logic is very sensitve to infinite loops when translation status isn't determined properly + // If a parent for the newly written translation was existing before this + // onBeforeWrite() call, it will already have been linked correctly through createTranslation() + if(class_exists('SiteTree') && $this->owner->hasField('ParentID') && $this->owner instanceof SiteTree) { + if( + !$this->owner->ID + && $this->owner->ParentID + && !$this->owner->Parent()->hasTranslation($this->owner->Locale) + ) { + $parentTranslation = $this->owner->Parent()->createTranslation($this->owner->Locale); + $this->owner->ParentID = $parentTranslation->ID; + } + } + + // Has to be limited to the default locale, the assumption is that the "page type" + // dropdown is readonly on all translations. + if($this->owner->ID && $this->owner->Locale == Translatable::default_locale()) { + $changedFields = $this->owner->getChangedFields(); + if(isset($changedFields['ClassName'])) { + $this->owner->ClassName = $changedFields['ClassName']['before']; + $translations = $this->owner->getTranslations(); + $this->owner->ClassName = $changedFields['ClassName']['after']; + if($translations) foreach($translations as $translation) { + $translation->setClassName($this->owner->ClassName); + $translation = $translation->newClassInstance($translation->ClassName); + $translation->populateDefaults(); + $translation->forceChange(); + $translation->write(); + } + } + } + + // see onAfterWrite() + if(!$this->owner->ID) { + $this->owner->_TranslatableIsNewRecord = true; + } + } + + function onAfterWrite() { + // hacky way to determine if the record was created in the database, + // or just updated + if($this->owner->_TranslatableIsNewRecord) { + // this would kick in for all new records which are NOT + // created through createTranslation(), meaning they don't + // have the translation group automatically set. + $translationGroupID = $this->getTranslationGroup(); + if(!$translationGroupID) $this->addTranslationGroup($this->owner->_TranslationGroupID ? $this->owner->_TranslationGroupID : $this->owner->ID); + unset($this->owner->_TranslatableIsNewRecord); + unset($this->owner->_TranslationGroupID); + } + + } + + /** + * Remove the record from the translation group mapping. + */ + function onBeforeDelete() { + // @todo Coupling to Versioned, we need to avoid removing + // translation groups if records are just deleted from a stage + // (="unpublished"). Ideally the translation group tables would + // be specific to different Versioned changes, making this restriction unnecessary. + // This will produce orphaned translation group records for SiteTree subclasses. + if(!$this->owner->hasExtension('Versioned')) { + $this->removeTranslationGroup(); + } + + parent::onBeforeDelete(); + } + + /** + * Attempt to get the page for a link in the default language that has been translated. + * + * @param string $URLSegment + * @param int|null $parentID + * @return SiteTree + */ + public function alternateGetByLink($URLSegment, $parentID) { + // If the parentID value has come from a translated page, then we need to find the corresponding parentID value + // in the default Locale. + if ( + is_int($parentID) + && $parentID > 0 + && ($parent = DataObject::get_by_id('SiteTree', $parentID)) + && ($parent->isTranslation()) + ) { + $parentID = $parent->getTranslationGroup(); + } + + // Find the locale language-independent of the page + self::disable_locale_filter(); + $default = DataObject::get_one ( + 'SiteTree', + sprintf ( + '"URLSegment" = \'%s\'%s', + Convert::raw2sql($URLSegment), + (is_int($parentID) ? " AND \"ParentID\" = $parentID" : null) + ), + false + ); + self::enable_locale_filter(); + + return $default; + } + + //-----------------------------------------------------------------------------------------------// + + /** + * If the record is not shown in the default language, this method + * will try to autoselect a master language which is shown alongside + * the normal formfields as a readonly representation. + * This gives translators a powerful tool for their translation workflow + * without leaving the translated page interface. + * Translatable also adds a new tab "Translation" which shows existing + * translations, as well as a formaction to create new translations based + * on a dropdown with available languages. + * + * @todo This is specific to SiteTree and CMSMain + * @todo Implement a special "translation mode" which triggers display of the + * readonly fields, so you can translation INTO the "default language" while + * seeing readonly fields as well. + */ + function updateCMSFields(FieldSet &$fields) { + if(!class_exists('SiteTree')) return; + // Don't apply these modifications for normal DataObjects - they rely on CMSMain logic + if(!($this->owner instanceof SiteTree)) return; + + // used in CMSMain->init() to set language state when reading/writing record + $fields->push(new HiddenField("Locale", "Locale", $this->owner->Locale) ); + + // Don't allow translation of virtual pages because of data inconsistencies (see #5000) + if(class_exists('VirtualPage')){ + $excludedPageTypes = array('VirtualPage'); + foreach($excludedPageTypes as $excludedPageType) { + if(is_a($this->owner, $excludedPageType)) return; + } + } + + $excludeFields = array( + 'ViewerGroups', + 'EditorGroups', + 'CanViewType', + 'CanEditType' + ); + + // if a language other than default language is used, we're in "translation mode", + // hence have to modify the original fields + $creating = false; + $baseClass = $this->owner->class; + $allFields = $fields->toArray(); + while( ($p = get_parent_class($baseClass)) != "DataObject") $baseClass = $p; + + // try to get the record in "default language" + $originalRecord = $this->owner->getTranslation(Translatable::default_locale()); + // if no translation in "default language", fall back to first translation + if(!$originalRecord) { + $translations = $this->owner->getTranslations(); + $originalRecord = ($translations) ? $translations->First() : null; + } + + $isTranslationMode = $this->owner->Locale != Translatable::default_locale(); + + // Show a dropdown to create a new translation. + // This action is possible both when showing the "default language" + // and a translation. Include the current locale (record might not be saved yet). + $alreadyTranslatedLocales = $this->getTranslatedLocales(); + $alreadyTranslatedLocales[$this->owner->Locale] = $this->owner->Locale; + + if($originalRecord && $isTranslationMode) { + $originalLangID = Session::get($this->owner->ID . '_originalLangID'); + + // Remove parent page dropdown + $fields->removeByName("ParentType"); + $fields->removeByName("ParentID"); + + $translatableFieldNames = $this->getTranslatableFields(); + $allDataFields = $fields->dataFields(); + + $transformation = new Translatable_Transformation($originalRecord); + + // iterate through sequential list of all datafields in fieldset + // (fields are object references, so we can replace them with the translatable CompositeField) + foreach($allDataFields as $dataField) { + if($dataField instanceof HiddenField) continue; + if(in_array($dataField->Name(), $excludeFields)) continue; + + if(in_array($dataField->Name(), $translatableFieldNames)) { + // if the field is translatable, perform transformation + $fields->replaceField($dataField->Name(), $transformation->transformFormField($dataField)); + } else { + // else field shouldn't be editable in translation-mode, make readonly + $fields->replaceField($dataField->Name(), $dataField->performReadonlyTransformation()); + } + } + + } elseif($this->owner->isNew()) { + $fields->addFieldsToTab( + 'Root', + new Tab(_t('Translatable.TRANSLATIONS', 'Translations'), + new LiteralField('SaveBeforeCreatingTranslationNote', + sprintf('

%s

', + _t('Translatable.NOTICENEWPAGE', 'Please save this page before creating a translation') + ) + ) + ) + ); + } + + $fields->addFieldsToTab( + 'Root', + new Tab('Translations', _t('Translatable.TRANSLATIONS', 'Translations'), + new HeaderField('CreateTransHeader', _t('Translatable.CREATE', 'Create new translation'), 2), + $langDropdown = new LanguageDropdownField( + "NewTransLang", + _t('Translatable.NEWLANGUAGE', 'New language'), + $alreadyTranslatedLocales, + 'SiteTree', + 'Locale-English', + $this->owner + ), + $createButton = new InlineFormAction('createtranslation',_t('Translatable.CREATEBUTTON', 'Create')) + ) + ); + $createButton->includeDefaultJS(false); + + if($alreadyTranslatedLocales) { + $fields->addFieldToTab( + 'Root.Translations', + new HeaderField('ExistingTransHeader', _t('Translatable.EXISTING', 'Existing translations:'), 3) + ); + $existingTransHTML = '
    '; + foreach($alreadyTranslatedLocales as $i => $langCode) { + $existingTranslation = $this->owner->getTranslation($langCode); + if($existingTranslation) { + $existingTransHTML .= sprintf('
  • %s
  • ', + sprintf('admin/show/%d/?locale=%s', $existingTranslation->ID, $langCode), + i18n::get_locale_name($langCode) + ); + } + } + $existingTransHTML .= '
'; + $fields->addFieldToTab( + 'Root.Translations', + new LiteralField('existingtrans',$existingTransHTML) + ); + } + + $langDropdown->addExtraClass('languageDropdown'); + $createButton->addExtraClass('createTranslationButton'); + } + + /** + * Get the names of all translatable fields on this class + * as a numeric array. + * @todo Integrate with blacklist once branches/translatable is merged back. + * + * @return array + */ + function getTranslatableFields() { + return $this->translatableFields; + } + + /** + * Return the base table - the class that directly extends DataObject. + * @return string + */ + function baseTable($stage = null) { + $tableClasses = ClassInfo::dataClassesFor($this->owner->class); + $baseClass = array_shift($tableClasses); + return (!$stage || $stage == $this->defaultStage) ? $baseClass : $baseClass . "_$stage"; + } + + function extendWithSuffix($table) { + return $table; + } + + /** + * Gets all related translations for the current object, + * excluding itself. See {@link getTranslation()} to retrieve + * a single translated object. + * + * Getter with $stage parameter is specific to {@link Versioned} extension, + * mostly used for {@link SiteTree} subclasses. + * + * @param string $locale + * @param string $stage + * @return DataObjectSet + */ + function getTranslations($locale = null, $stage = null) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + if($this->owner->exists()) { + // HACK need to disable language filtering in augmentSQL(), + // as we purposely want to get different language + self::disable_locale_filter(); + + $translationGroupID = $this->getTranslationGroup(); + + $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + $filter = sprintf('"%s_translationgroups"."TranslationGroupID" = %d', $baseDataClass, $translationGroupID); + if($locale) { + $filter .= sprintf(' AND "%s"."Locale" = \'%s\'', $baseDataClass, Convert::raw2sql($locale)); + } else { + // exclude the language of the current owner + $filter .= sprintf(' AND "%s"."Locale" != \'%s\'', $baseDataClass, $this->owner->Locale); + } + $join = sprintf('LEFT JOIN "%s_translationgroups" ON "%s_translationgroups"."OriginalID" = "%s"."ID"', + $baseDataClass, + $baseDataClass, + $baseDataClass + ); + $currentStage = Versioned::current_stage(); + if($this->owner->hasExtension("Versioned")) { + if($stage) Versioned::reading_stage($stage); + $translations = Versioned::get_by_stage( + $this->owner->class, + Versioned::current_stage(), + $filter, + null, + $join + ); + if($stage) Versioned::reading_stage($currentStage); + } else { + $translations = DataObject::get($this->owner->class, $filter, null, $join); + } + + self::enable_locale_filter(); + + return $translations; + } + } + + /** + * Gets an existing translation based on the language code. + * Use {@link hasTranslation()} as a quicker alternative to check + * for an existing translation without getting the actual object. + * + * @param String $locale + * @return DataObject Translated object + */ + function getTranslation($locale, $stage = null) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + $translations = $this->getTranslations($locale, $stage); + return ($translations) ? $translations->First() : null; + } + + /** + * Creates a new translation for the owner object of this decorator. + * Checks {@link getTranslation()} to return an existing translation + * instead of creating a duplicate. Writes the record to the database before + * returning it. Use this method if you want the "translation group" + * mechanism to work, meaning that an object knows which group of translations + * it belongs to. For "original records" which are not created through this + * method, the "translation group" is set in {@link onAfterWrite()}. + * + * @param string $locale + * @return DataObject The translated object + */ + function createTranslation($locale) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + if(!$this->owner->exists()) { + user_error('Translatable::createTranslation(): Please save your record before creating a translation', E_USER_ERROR); + } + + // permission check + if(!$this->owner->canTranslate(null, $locale)) { + throw new Exception(sprintf( + 'Creating a new translation in locale "%s" is not allowed for this user', + $locale + )); + return; + } + + $existingTranslation = $this->getTranslation($locale); + if($existingTranslation) return $existingTranslation; + + $class = $this->owner->class; + $newTranslation = new $class; + + // copy all fields from owner (apart from ID) + $newTranslation->update($this->owner->toMap()); + + // If the object has Hierarchy extension, + // check for existing translated parents and assign + // their ParentID (and overwrite any existing ParentID relations + // to parents in other language). If no parent translations exist, + // they are automatically created in onBeforeWrite() + if($newTranslation->hasField('ParentID')) { + $origParent = $this->owner->Parent(); + $newTranslationParent = $origParent->getTranslation($locale); + if($newTranslationParent) $newTranslation->ParentID = $newTranslationParent->ID; + } + + $newTranslation->ID = 0; + $newTranslation->Locale = $locale; + + $originalPage = $this->getTranslation(self::default_locale()); + if ($originalPage) { + $urlSegment = $originalPage->URLSegment; + } else { + $urlSegment = $newTranslation->URLSegment; + } + $newTranslation->URLSegment = $urlSegment . '-' . i18n::convert_rfc1766($locale); + // hacky way to set an existing translation group in onAfterWrite() + $translationGroupID = $this->getTranslationGroup(); + $newTranslation->_TranslationGroupID = $translationGroupID ? $translationGroupID : $this->owner->ID; + $newTranslation->write(); + + return $newTranslation; + } + + /** + * Caution: Does not consider the {@link canEdit()} permissions. + * + * @param DataObject|int $member + * @param string $locale + * @return boolean + */ + function canTranslate($member = null, $locale) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + if(!$member || !(is_a($member, 'Member')) || is_numeric($member)) $member = Member::currentUser(); + + // check for locale + $allowedLocale = ( + !is_array(self::get_allowed_locales()) + || in_array($locale, self::get_allowed_locales()) + ); + + if(!$allowedLocale) return false; + + // By default, anyone who can edit a page can edit the default locale + if($locale == self::default_locale()) return true; + + // check for generic translation permission + if(Permission::checkMember($member, 'TRANSLATE_ALL')) return true; + + // check for locale specific translate permission + if(!Permission::checkMember($member, 'TRANSLATE_' . $locale)) return false; + + return true; + } + + /** + * @return boolean + */ + function canEdit($member) { + if(!$this->owner->Locale) return null; + return $this->owner->canTranslate($member, $this->owner->Locale) ? null : false; + } + + /** + * Returns TRUE if the current record has a translation in this language. + * Use {@link getTranslation()} to get the actual translated record from + * the database. + * + * @param string $locale + * @return boolean + */ + function hasTranslation($locale) { + if($locale && !i18n::validate_locale($locale)) throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale)); + + return ( + $this->owner->Locale == $locale + || array_search($locale, $this->getTranslatedLocales()) !== false + ); + } + + function AllChildrenIncludingDeleted($context = null) { + $children = $this->owner->doAllChildrenIncludingDeleted($context); + + return $children; + } + + /** + * Returns markup for insertion into + * a HTML4/XHTML compliant section, listing all available translations + * of a page. + * + * @see http://www.w3.org/TR/html4/struct/links.html#edef-LINK + * @see http://www.w3.org/International/articles/language-tags/ + * + * @return string HTML + */ + function MetaTags(&$tags) { + $template = '' . "\n"; + $translations = $this->owner->getTranslations(); + if($translations) foreach($translations as $translation) { + $tags .= sprintf($template, + Convert::raw2xml($translation->Title), + i18n::convert_rfc1766($translation->Locale), + $translation->Link() + ); + } + } + + function providePermissions() { + if(!Object::has_extension('SiteTree', 'Translatable') || !class_exists('SiteTree')) return false; + + $locales = self::get_allowed_locales(); + + // Fall back to any locales used in existing translations (see #4939) + if(!$locales) { + $locales = DB::query('SELECT "Locale" FROM "SiteTree" GROUP BY "Locale"')->column(); + } + + $permissions = array(); + if($locales) foreach($locales as $locale) { + $localeName = i18n::get_locale_name($locale); + $permissions['TRANSLATE_' . $locale] = sprintf( + _t( + 'Translatable.TRANSLATEPERMISSION', + 'Translate %s', + PR_MEDIUM, + 'Translate pages into a language' + ), + $localeName + ); + } + + $permissions['TRANSLATE_ALL'] = _t( + 'Translatable.TRANSLATEALLPERMISSION', + 'Translate into all available languages' + ); + + return $permissions; + } + + /** + * Get a list of languages with at least one element translated in (including the default language) + * + * @param string $className Look for languages in elements of this class + * @param string $where Optional SQL WHERE statement + * @return array Map of languages in the form locale => langName + */ + static function get_existing_content_languages($className = 'SiteTree', $where = '') { + $baseTable = ClassInfo::baseDataClass($className); + $query = new SQLQuery("Distinct \"Locale\"","\"$baseTable\"",$where, '', "\"Locale\""); + $dbLangs = $query->execute()->column(); + $langlist = array_merge((array)Translatable::default_locale(), (array)$dbLangs); + $returnMap = array(); + $allCodes = array_merge(i18n::$all_locales, i18n::$common_locales); + foreach ($langlist as $langCode) { + if($langCode && isset($allCodes[$langCode])) { + $returnMap[$langCode] = (is_array($allCodes[$langCode])) ? $allCodes[$langCode][0] : $allCodes[$langCode]; + } + } + return $returnMap; + } + + /** + * Get the RelativeLink value for a home page in another locale. This is found by searching for the default home + * page in the default language, then returning the link to the translated version (if one exists). + * + * @return string + */ + public static function get_homepage_link_by_locale($locale) { + $originalLocale = self::get_current_locale(); + + self::set_current_locale(self::default_locale()); + $original = SiteTree::get_by_link(RootURLController::get_default_homepage_link()); + self::set_current_locale($originalLocale); + + if($original) { + if($translation = $original->getTranslation($locale)) return trim($translation->RelativeLink(true), '/'); + } + } + + + /** + * @deprecated 2.4 Use {@link Translatable::get_homepage_link_by_locale()} + */ + static function get_homepage_urlsegment_by_locale($locale) { + user_error ( + 'Translatable::get_homepage_urlsegment_by_locale() is deprecated, please use get_homepage_link_by_locale()', + E_USER_NOTICE + ); + + return self::get_homepage_link_by_locale($locale); + } + + /** + * Define all locales which in which a new translation is allowed. + * Checked in {@link canTranslate()}. + * + * @param array List of allowed locale codes (see {@link i18n::$all_locales}). + * Example: array('de_DE','ja_JP') + */ + static function set_allowed_locales($locales) { + self::$allowed_locales = $locales; + } + + /** + * Get all locales which are generally permitted to be translated. + * Use {@link canTranslate()} to check if a specific member has permission + * to translate a record. + * + * @return array + */ + static function get_allowed_locales() { + return self::$allowed_locales; + } + + /** + * @deprecated 2.4 Use get_homepage_urlsegment_by_locale() + */ + static function get_homepage_urlsegment_by_language($locale) { + return self::get_homepage_urlsegment_by_locale($locale); + } + + /** + * @deprecated 2.4 Use custom check: self::$default_locale == self::get_current_locale() + */ + static function is_default_lang() { + return (self::$default_locale == self::get_current_locale()); + } + + /** + * @deprecated 2.4 Use set_default_locale() + */ + static function set_default_lang($lang) { + self::set_default_locale(i18n::get_locale_from_lang($lang)); + } + + /** + * @deprecated 2.4 Use get_default_locale() + */ + static function get_default_lang() { + return i18n::get_lang_from_locale(self::default_locale()); + } + + /** + * @deprecated 2.4 Use get_current_locale() + */ + static function current_lang() { + return i18n::get_lang_from_locale(self::get_current_locale()); + } + + /** + * @deprecated 2.4 Use set_current_locale() + */ + static function set_reading_lang($lang) { + self::set_current_locale(i18n::get_locale_from_lang($lang)); + } + + /** + * @deprecated 2.4 Use get_reading_locale() + */ + static function get_reading_lang() { + return i18n::get_lang_from_locale(self::get_reading_locale()); + } + + /** + * @deprecated 2.4 Use default_locale() + */ + static function default_lang() { + return i18n::get_lang_from_locale(self::default_locale()); + } + + /** + * @deprecated 2.4 Use get_by_locale() + */ + static function get_by_lang($class, $lang, $filter = '', $sort = '', $join = "", $limit = "", $containerClass = "DataObjectSet", $having = "") { + return self::get_by_locale($class, i18n::get_locale_from_lang($lang), $filter, $sort, $join, $limit, $containerClass, $having); + } + + /** + * @deprecated 2.4 Use get_one_by_locale() + */ + static function get_one_by_lang($class, $lang, $filter = '', $cache = false, $orderby = "") { + return self::get_one_by_locale($class, i18n::get_locale_from_lang($lang), $filter, $cache, $orderby); + } + + /** + * Determines if the record has a locale, + * and if this locale is different from the "default locale" + * set in {@link Translatable::default_locale()}. + * Does not look at translation groups to see if the record + * is based on another record. + * + * @return boolean + * @deprecated 2.4 + */ + function isTranslation() { + return ($this->owner->Locale && ($this->owner->Locale != Translatable::default_locale())); + } + + /** + * @deprecated 2.4 Use choose_site_locale() + */ + static function choose_site_lang($langsAvail=null) { + return self::choose_site_locale($langsAvail); + } + + /** + * @deprecated 2.4 Use getTranslatedLocales() + */ + function getTranslatedLangs() { + return $this->getTranslatedLocales(); + } + + /** + * Return a piece of text to keep DataObject cache keys appropriately specific + */ + function cacheKeyComponent() { + return 'locale-'.self::get_current_locale(); + } + + /** + * Extends the SiteTree::validURLSegment() method, to do checks appropriate + * to Translatable + * + * @return bool + */ + public function augmentValidURLSegment() { + if (self::locale_filter_enabled()) { + self::disable_locale_filter(); + $reEnableFilter = true; + } + $IDFilter = ($this->owner->ID) ? "AND \"SiteTree\".\"ID\" <> {$this->owner->ID}" : null; + $parentFilter = null; + + if(SiteTree::nested_urls()) { + if($this->owner->ParentID) { + $parentFilter = " AND \"SiteTree\".\"ParentID\" = {$this->owner->ParentID}"; + } else { + $parentFilter = ' AND "SiteTree"."ParentID" = 0'; + } + } + + $existingPage = DataObject::get_one( + 'SiteTree', + "\"URLSegment\" = '{$this->owner->URLSegment}' $IDFilter $parentFilter", + false // disable get_one cache, as this otherwise may pick up results from when locale_filter was on + ); + if ($reEnableFilter) { + self::enable_locale_filter(); + } + return !$existingPage; + } + +} + +/** + * Transform a formfield to a "translatable" representation, + * consisting of the original formfield plus a readonly-version + * of the original value, wrapped in a CompositeField. + * + * @param DataObject $original Needs the original record as we populate the readonly formfield with the original value + * + * @package sapphire + * @subpackage misc + */ +class Translatable_Transformation extends FormTransformation { + + /** + * @var DataObject + */ + private $original = null; + + function __construct(DataObject $original) { + $this->original = $original; + parent::__construct(); + } + + /** + * Returns the original DataObject attached to the Transformation + * + * @return DataObject + */ + function getOriginal() { + return $this->original; + } + + /** + * @todo transformTextareaField() not used at the moment + */ + function transformTextareaField(TextareaField $field) { + $nonEditableField = new ToggleField($fieldname,$field->Title(),'','+','-'); + $nonEditableField->labelMore = '+'; + $nonEditableField->labelLess = '-'; + return $this->baseTransform($nonEditableField, $field); + + return $nonEditableField; + } + + function transformFormField(FormField $field) { + $newfield = $field->performReadOnlyTransformation(); + return $this->baseTransform($newfield, $field); + } + + protected function baseTransform($nonEditableField, $originalField) { + $fieldname = $originalField->Name(); + + $nonEditableField_holder = new CompositeField($nonEditableField); + $nonEditableField_holder->setName($fieldname.'_holder'); + $nonEditableField_holder->addExtraClass('originallang_holder'); + $nonEditableField->setValue($this->original->$fieldname); + $nonEditableField->setName($fieldname.'_original'); + $nonEditableField->addExtraClass('originallang'); + $nonEditableField->setTitle('Original '.$originalField->Title()); + + $nonEditableField_holder->insertBefore($originalField, $fieldname.'_original'); + return $nonEditableField_holder; + } + + +} + +?> diff --git a/code/tasks/MigrateTranslatableTask.php b/code/tasks/MigrateTranslatableTask.php new file mode 100644 index 0000000..19b941c --- /dev/null +++ b/code/tasks/MigrateTranslatableTask.php @@ -0,0 +1,176 @@ +Limitations + * + * - Information from the {@link Versioned} extension (e.g. in "SiteTree_versions" table) + * will be discarded for translated records. + * - Custom translatable fields on your own {@link Page} class or subclasses thereof won't + * be migrated into the translation. + * - 2.1-style subtags of a language (e.g. "en") will be automatically disambiguated to their full + * locale value (e.g. "en_US"), by the lookup defined in {@link i18n::get_locale_from_lang()}. + * - Doesn't detect published translations when the script is run twice on the same data set + * + *

Usage

+ * + * PLEASE BACK UP YOUR DATABASE BEFORE RUNNING THIS SCRIPT. + * + * Warning: Please run dev/build on your 2.2 database to update the schema before running this task. + * The dev/build command will rename tables like "SiteTree_lang" to "_obsolete_SiteTree_lang". + * + *

Commandline

+ * Requires "sake" tool (see http://doc.silverstripe.com/?id=sake) + * + * sake dev/tasks/MigrateTranslatableTask + * + * + *

Browser

+ * + * http://mydomain.com/dev/tasks/MigrateTranslatableTask + * + * + * @package sapphire + * @subpackage tasks + */ +class MigrateTranslatableTask extends BuildTask { + protected $title = "Migrate Translatable Task"; + + protected $description = "Migrates site translations from SilverStripe 2.1/2.2 to new database structure."; + + function init() { + parent::init(); + + $canAccess = (Director::isDev() || Director::is_cli() || Permission::check("ADMIN")); + if(!$canAccess) return Security::permissionFailure($this); + } + + function run($request) { + $ids = array(); + + echo "#################################\n"; + echo "# Adding translation groups to existing records" . "\n"; + echo "#################################\n"; + + $allSiteTreeIDs = DB::query('SELECT "ID" FROM "SiteTree"')->column(); + if($allSiteTreeIDs) foreach($allSiteTreeIDs as $id) { + $original = DataObject::get_by_id('SiteTree', $id); + $existingGroupID = $original->getTranslationGroup(); + if(!$existingGroupID) $original->addTranslationGroup($original->ID); + $original->destroy(); + unset($original); + } + + DataObject::flush_and_destroy_cache(); + + echo sprintf("Created translation groups for %d records\n", count($allSiteTreeIDs)); + + foreach(array('Stage', 'Live') as $stage) { + echo "\n\n#################################\n"; + echo "# Migrating stage $stage" . "\n"; + echo "#################################\n"; + + $suffix = ($stage == 'Live') ? '_Live' : ''; + + // First get all entries in SiteTree_lang + // This should be all translated pages + $trans = DB::query(sprintf('SELECT * FROM "_obsolete_SiteTree_lang%s"', $suffix)); + + // Iterate over each translated pages + foreach($trans as $oldtrans) { + $newLocale = i18n::get_locale_from_lang($oldtrans['Lang']); + + echo sprintf( + "Migrating from %s to %s translation of '%s' (#%d)\n", + $oldtrans['Lang'], + $newLocale, + Convert::raw2xml($oldtrans['Title']), + $oldtrans['OriginalLangID'] + ); + + // Get the untranslated page + + $original = Versioned::get_one_by_stage( + $oldtrans['ClassName'], + $stage, + '"SiteTree"."ID" = ' . $oldtrans['OriginalLangID'] + ); + + if(!$original) { + echo sprintf("Couldn't find original for #%d", $oldtrans['OriginalLangID']); + continue; + } + + // write locale to $original + $original->Locale = i18n::get_locale_from_lang(Translatable::default_lang()); + $original->writeToStage($stage); + + // Clone the original, and set it up as a translation + $existingTrans = $original->getTranslation($newLocale, $stage); + + if($existingTrans) { + echo sprintf("Found existing new-style translation for #%d. Already merged? Skipping.\n", $oldtrans['OriginalLangID']); + continue; + } + + // Doesn't work with stage/live split + //$newtrans = $original->createTranslation($newLocale); + + $newtrans = $original->duplicate(false); + $newtrans->OriginalID = $original->ID; + // we have to "guess" a locale based on the language + $newtrans->Locale = $newLocale; + if($stage == 'Live' && array_key_exists($original->ID, $ids)) { + $newtrans->ID = $ids[$original->ID]; + } + + // Look at each class in the ancestry, and see if there is a _lang table for it + foreach(ClassInfo::ancestry($oldtrans['ClassName']) as $classname) { + $oldtransitem = false; + + // If the class is SiteTree, we already have the DB record, else check for the table and get the record + if($classname == 'SiteTree') { + $oldtransitem = $oldtrans; + } elseif(in_array(strtolower($classname) . '_lang', DB::tableList())) { + $oldtransitem = DB::query(sprintf( + 'SELECT * FROM "_obsolete_%s_lang%s" WHERE "OriginalLangID" = %d AND "Lang" = \'%s\'', + $classname, + $suffix, + $original->ID, + $oldtrans['Lang'] + ))->first(); + } + + // Copy each translated field into the new translation + if($oldtransitem) foreach($oldtransitem as $key => $value) { + if(!in_array($key, array('ID', 'OriginalLangID'))) { + $newtrans->$key = $value; + } + } + + } + + // Write the new translation to the database + $sitelang = Translatable::get_current_locale(); + Translatable::set_current_locale($newtrans->Locale); + $newtrans->writeToStage($stage); + Translatable::set_current_locale($sitelang); + + $newtrans->addTranslationGroup($original->getTranslationGroup(), true); + + + if($stage == 'Stage') { + $ids[$original->ID] = $newtrans->ID; + } + } + } + + echo "\n\n#################################\n"; + echo "Done!\n"; + } +} + +?> \ No newline at end of file diff --git a/css/CMSMain.Translatable.css b/css/CMSMain.Translatable.css new file mode 100644 index 0000000..82aaf03 --- /dev/null +++ b/css/CMSMain.Translatable.css @@ -0,0 +1,13 @@ +ul.tree span.untranslated a:link, +ul.tree span.untranslated a:hover, +ul.tree span.untranslated a:visited { + color: #ccc +} + +.right form .createTranslationButton .middleColumn { + background: none; +} + +.right form#Form_EditForm div.createTranslation { + margin-left: 0; +} diff --git a/docs/en/_images/translatable1.png b/docs/en/_images/translatable1.png new file mode 100644 index 0000000..dbe4fd0 Binary files /dev/null and b/docs/en/_images/translatable1.png differ diff --git a/docs/en/_images/translatable2.png b/docs/en/_images/translatable2.png new file mode 100644 index 0000000..2e9c313 Binary files /dev/null and b/docs/en/_images/translatable2.png differ diff --git a/docs/en/_images/translatable3.png b/docs/en/_images/translatable3.png new file mode 100644 index 0000000..6b6f59a Binary files /dev/null and b/docs/en/_images/translatable3.png differ diff --git a/docs/en/_images/translatable4_small.png b/docs/en/_images/translatable4_small.png new file mode 100644 index 0000000..77999a6 Binary files /dev/null and b/docs/en/_images/translatable4_small.png differ diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 0000000..5470e2d --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,417 @@ +# Translation + +## Introduction + +This page introduces developers to using the CMS for creating content in multiple languages. + +Please refer to the `i18n` class in `sapphire` for a internationalization, globalization and localization support of built-in datatypes as well as translating templates and PHP code. + +Translations can be enabled for all subclasses of `[api:DataObject]`, so it can easily be implemented into existing code +with minimal interference. + +Warning: If you're upgrading from a SilverStripe version prior to 2.3.2, please migrate your datamodel before using the +extension (see below). + +## Requirements + +*SilverStripe 2.3.2* + +## Screenshots + +![](_images/translatable4_small.png) + +*Translated website* + + +![](_images/translatable1.png) + +*CMS: Language dropdown* + +![](_images/translatable2.png) + +*CMS: Translatable field with original value* + +![](_images/translatable3.png) + +*CMS: Create a new translation* + + +## Usage + +### Configuration + +#### ThroughObject::add_extension() + +Enabling Translatable through *Object::add_extension()* in your *mysite/_config.php*: + + :::php + Object::add_extension('SiteTree', 'Translatable'); + Object::add_extension('SiteConfig', 'Translatable'); // 2.4 or newer only + + +#### Through $extensions + + :::php + class Page extends SiteTree { + static $extensions = array( + "Translatable" + ); + } + + +Make sure to rebuild the database through /dev/build after enabling `[api:Translatable]`. +Use the correct set_default_locale() before building the database +for the first time, as this locale will be written on all new records. + +#### Setting the default locale + +
+**Important:** If the "default language" of your site is not english (en_US), please ensure to set the appropriate default +language for your content before building the database with Translatable enabled +
+ +Example: + + :::php + Translatable::set_default_locale(); + // Important: Call add_extension() after setting the default locale + Object::add_extension('SiteTree', 'Translatable'); + + +For the Translatable class, a "locale" consists of a language code plus a region code separated by an underscore, +for example "de_AT" for German language ("de") in the region Austria ("AT"). +See http://www.w3.org/International/articles/language-tags/ for a detailed description. + +To ensure that your template declares the correct content language, please see [i18n](i18n#declaring_the_content_language_in_html). + +### Usage + +Getting a translation for an existing instance: + + :::php + $translatedObj = Translatable::get_one_by_locale('MyObject', 'de_DE'); + + +Getting a translation for an existing instance: + + :::php + $obj = DataObject::get_by_id('MyObject', 99); // original language + $translatedObj = $obj->getTranslation('de_DE'); + + +Getting translations through Translatable::set_reading_locale(). +This is *not* a recommended approach, but sometimes unavoidable (e.g. for `[api:Versioned]` methods). + + :::php + $origLocale = Translatable::get_reading_locale(); + Translatable::set_reading_locale('de_DE'); + $obj = Versioned::get_one_by_stage('MyObject', "ID = 99"); + Translatable::set_reading_locale($origLocale); + + +Creating a translation: + + :::php + $obj = new MyObject(); + $translatedObj = $obj->createTranslation('de_DE'); + + + +### Usage for SiteTree + +`[api:Translatable]` can be used for subclasses of SiteTree as well. +If a child page translation is requested without the parent +page already having a translation in this language, the extension +will recursively create translations up the tree. +Caution: The "URLSegment" property is enforced to be unique across +languages by auto-appending the language code at the end. +You'll need to ensure that the appropriate "reading language" is set +before showing links to other pages on a website through $_GET['locale']. +Pages in different languages can have different publication states +through the `[api:Versioned]` extension. + +Note: You can't get Children() for a parent page in a different language +through set_reading_locale(). Get the translated parent first. + + :::php + // wrong + Translatable::set_reading_lang('de_DE'); + $englishParent->Children(); + // right + $germanParent = $englishParent->getTranslation('de_DE'); + $germanParent->Children(); + + + + + +### Translating custom properties + +Keep in mind that the `[api:Translatable]` extension currently doesn't support the exclusion of properties from being +translated - all custom properties will automatically be fetched from their translated record on the database. This means +you don't have to explicitly mark any custom properties as being translatable. + +The `[api:Translatable]` decorator applies only to the getCMSFields() method on DataObject or SiteTree, not to any fields +added in overloaded getCMSFields() implementations. See Translatable->updateCMSFields() for details. By default, custom +fields in the CMS won't show an original readonly value on a translated record, although they will save correctly. You can +attach this behaviour to custom fields by using Translatable_Transformation as shown below. + + :::php + class Page extends SiteTree { + + public static $db = array( + 'AdditionalProperty' => 'Text', + ); + + function getCMSFields() { + $fields = parent::getCMSFields(); + + // Add fields as usual + $additionalField = new TextField('AdditionalProperty'); + $fields->addFieldToTab('Root.Content.Main', $additionalField); + + // If a translation exists, exchange them with + // original/translation field pairs + $translation = $this->getTranslation(Translatable::default_locale()); + if($translation && $this->Locale != Translatable::default_locale()) { + $transformation = new Translatable_Transformation($translation); + $fields->replaceField( + 'AdditionalProperty', + $transformation->transformFormField($additionalField) + ); + } + + return $fields; + } + + } + + + +### Translating theHomepage + +Every homepage has a distinct URL, the default language is /home, a German translation by default would be /home-de_DE. +They can be accessed like any other translated page. If you want to access different homepages from the "root" without a +URL, add a "locale" GET parameter. The German homepage would also be accessible through /?locale=de_DE. + +For this to work, please ensure that the translated homepage is a direct translation of the default homepage, and not a +new page created through "Create page...". + +### Translation groups + +Each translation can have an associated "master" object in another language which it is based on, +as defined by the "MasterTranslationID" property. This relation is optional, meaning you can +create translations which have no representation in the "default language". +This "original" doesn't have to be in a default language, meaning +a french translation can have a german original, without either of them having a representation +in the default english language tree. +Caution: There is no versioning for translation groups, +meaning associating an object with a group will affect both stage and live records. + +SiteTree database table (abbreviated) + | ID | URLSegment | Title | Locale | + | -- | ---------- | ----- | ------ | + | 1 | about-us | About us | en_US | + | 2 | ueber-uns | Über uns | de_DE | + | 3 | contact | Contact | en_US | + +SiteTree_translationgroups database table + | TranslationGroupID | OriginalID | + | ------------------ | ---------- | + | 99 | 1 | + | 99 | 2 | + | 199 | 3 | + + +### CharacterSets + +
+**Caution:** Does not apply any character-set conversion, it is assumed that all content +is stored and represented in UTF-8 (Unicode). Please make sure your database and +HTML-templates adjust to this. +
+ +### "Default" languages + +
+**Important:** If the "default language" of your site is not english (en_US), +please ensure to set the appropriate default language for +your content before building the database with Translatable enabled +
+ +Example: + + :::php + Translatable::set_default_locale(); + + + +### Locales and language tags + +For the Translatable class, a "locale" consists of a language code plus a region code separated by an underscore, +for example "de_AT" for German language ("de") in the region Austria ("AT"). +See [http://www.w3.org/International/articles/language-tags/](http://www.w3.org/International/articles/language-tags/) +for a detailed description. + +Uninstalling/Disabling + +Disabling Translatable after creating translations will lead to all +pages being shown in the default sitetree regardless of their language. +It is advised to start with a new database after uninstalling Translatable, +or manually filter out translated objects through their "Locale" property +in the database. + +## Recipes + + +### Switching languages + +A widget now exists to switch between languages, and is [available here](http://www.silverstripe.org/Language-Chooser-Widget/). +You can easily make your own switchers with the following basic tools. To stay friendly to caches and search engines, each +translation of a page must have a unique URL. + +By URL: + + :::php + http:///mypage/?locale=de_DE + + +By user preference (place this in your Page_Controller->init() method): + + :::php + $member = Member::currentUser(); + if($member && $member->Locale) { + Translatable::set_reading_locale($member->Locale); + } + +### Templates + +As every page has its own unique URL, language selection mostly happens explicitly: A user requests a page, which always +has only one language. But how does a user coming to your English default language know that there's a Japanese version +of this page? +By default, SilverStripe core doesn't provide any switching of languages through sessions or browser cookies. As a +SEO-friendly CMS, it contains all this information in the URL. Each page in SilverStripe is aware of its translations +through the *getTranslations()* method. We can use this method in our template to build a simple language switcher. It +shows all available translations in an unordered list with links to the same page in a different language. The example +below can be inserted in any of your templates, for example `themes/blackcandy/templates/Layout/Page.ss`. + + :::php + <% if Translations %> + + <% end_if %> + + +Keep in mind that this will only show you available translations for the current page. The $Locale.Nice casting will +just work if your locale value is registered in i18n::get_common_locales(). + +### Page-control + +If you want to put static links in your template, which link to a site by their url, normally you can use the `<% control +Page(page-url) %>`. For sites which use Translatable, this is not possible for more than one language, because the url's +of different pages differ. + +For this case place the following function in your Page_Controller: + + :::php + public function PageByLang($url, $lang) { + $SQL_url = Convert::raw2sql($url); + $SQL_lang = Convert::raw2sql($lang); + + $page = Translatable::get_one_by_lang('SiteTree', $SQL_lang, "URLSegment = '$SQL_url'"); + + if ($page->Locale != Translatable::get_current_locale()) { + $page = $page->getTranslation(Translatable::get_current_locale()); + } + return $page; + } + +So, for example if you have a german page "Kontakt", which should be translated to english as "Contact", you may use: + + <% control PageByLang(Kontakt,de_DE) %> + +The control displays the link in the right language, depending on the current locale. + +Example: + + <% control PageByLang(Kontakt,de_DE) %> +

$Title

+ <% end_control %> + + +### Language Chooser Widget + +You can use a widget on your website to provide a list of links for switching languages: +[download](http://silverstripe.org/Language-Chooser-Widget-2/) + + +### Enabling the _t() function in templates + +If you're looking to use [the _t() function](http://doc.silverstripe.com/doku.php?id=i18n#the_t_function) in template +files, you'll need to [set the i18n locale](/topics/translation#setting_the_i18n_locale) first. + +(The reasoning is as follows: Translatable doesn't set the i18n locale. Historically these were two separate systems, +but they're reasonably interchangeable for a front-end website. The distinction is mainly valid for the CMS, because you +want the CMS to be in English (`[api:i18n]`), but edit pages in different languages (`[api:Translatable]`).) + +### Migrating from 2.1 datamodel + +The datamodel of `[api:Translatable]` changed significantly between its original release in SilverStripe 2.1 and SilverStripe +2.3.2. See our [discussion on the +mailinglist](http://groups.google.com/group/silverstripe-dev/browse_thread/thread/91e26e1f78d3c1b4/bd276dd5bbc56283?lnk=gst&q=translatable#bd276dd5bbc56283). + +To migrate a database that was built with SilverStripe 2.1.x or 2.2.x, follow these steps: + +* Upgrade your SilverStripe installation to at least 2.3.2 (see [upgrading](/installation/upgrading)) +* Backup your database content +* Login as an administrator +* Run `http://mysite.com/dev/build` +* Run `http://mysite.com/dev/tasks/MigrateTranslatableTask` + +Please see the `[api:MigrateTranslatableTask]` for +limitations of this migration task - not all your data will be preserved. + + +### Setting the i18n locale + +You can set the `[api:i18n]` locale value which is used to format dates, currencies and other regionally different values to +the same as your current page locale. + + :::php + class Page_Controller extends ContentController { + public function init() { + parent::init(); + + if($this->dataRecord->hasExtension('Translatable')) { + i18n::set_locale($this->dataRecord->Locale); + } + } + } + + +### Adding a new locale + +The `[api:i18n]` logic has lookup tables for common locales in i18n::$common_locales, which is a subset of i18n::$all_locales. +If your locale is not present here, you can simply add it through `mysite/_config.php`: + + :::php + i18n::$common_locales['de_AT'] = 'Deutsch (Oestereich)'; + +This should e.g. enable you to use `$Locale.Nice` in template code. + + +## Related + +* [translate.silverstripe.org](http://translate.silverstripe.org): Starting point for community-driven translation of the Silverstripe UI +* [i18n](i18n): Developer-level documentation of Silverstripe's i18n capabilities +* `[api:Translatable]`: DataObject-interface powering the website-content translations +* ["Translatable ModelAdmin" module](http://silverstripe.org/translatablemodeladmin-module/): An extension which allows +translations of `[api:DataObject]`s inside `[api:ModelAdmin]` diff --git a/javascript/CMSMain.Translatable.js b/javascript/CMSMain.Translatable.js new file mode 100755 index 0000000..4a34ad7 --- /dev/null +++ b/javascript/CMSMain.Translatable.js @@ -0,0 +1,77 @@ +/** + * File: CMSMain.Translatable.js + */ +(function($) { + $.entwine('ss', function($){ + + /** + * Class: .CMSMain #Form_LangForm + * + * Dropdown with languages above CMS tree, causing a redirect upon translation + */ + $('.CMSMain #Form_LangForm').entwine({ + /** + * Constructor: onmatch + */ + onmatch: function() { + var self = this; + + // monitor form loading for any locale changes + $('#Form_EditForm').bind('loadnewpage', function(e) { + var newLocale = $(this).find(':input[name=Locale]').val(); + if(newLocale) self.val(newLocale); + }); + + // whenever a new value is selected, reload the whole CMS in the new locale + this.find(':input[name=Locale]').bind('change', function(e) { + var url = document.location.href; + url += (url.indexOf('?') != -1) ? '&' : '?'; + // TODO Replace existing locale GET params + url += 'locale=' + $(e.target).val(); + document.location = url; + return false; + }); + + this._super(); + } + }); + + /** + * Class: .CMSMain .createTranslation + * + * Loads /admin/createtranslation, which will create the new record, + * and redirect to an edit form. + * + * Dropdown in "Translation" tab in CMS forms, with button to + * trigger translating the currently loaded record. + * + * Requires: + * jquery.metadata + */ + $('.CMSMain .createTranslation').entwine({ + + /** + * Constructor: onmatch + */ + onmatch: function() { + var self = this; + + this.find(':input[name=action_createtranslation]').bind('click', function(e) { + var form = self.parents('form'); + // redirect to new URL + // TODO This should really be a POST request + + document.location.href = $('base').attr('href') + + jQuery(self).metadata().url + + '?ID=' + form.find(':input[name=ID]').val() + + '&newlang=' + self.find(':input[name=NewTransLang]').val() + + '&locale=' + form.find(':input[name=Locale]').val(); + + return false; + }); + + this._super(); + } + }); + }); +}(jQuery)); \ No newline at end of file diff --git a/tests/unit/TranslatableSearchFormTest.php b/tests/unit/TranslatableSearchFormTest.php new file mode 100644 index 0000000..d17e6e8 --- /dev/null +++ b/tests/unit/TranslatableSearchFormTest.php @@ -0,0 +1,101 @@ + array( + 'Translatable', + "FulltextSearchable('Title,MenuTitle,Content,MetaTitle,MetaDescription,MetaKeywords')", + ), + "File" => array( + "FulltextSearchable('Filename,Title,Content')", + ), + "ContentController" => array( + "ContentControllerSearchExtension", + ), + ); + + function waitUntilIndexingFinished() { + $db = DB::getConn(); + if (method_exists($db, 'waitUntilIndexingFinished')) DB::getConn()->waitUntilIndexingFinished(); + } + + function setUpOnce() { + // HACK Postgres doesn't refresh TSearch indexes when the schema changes after CREATE TABLE + if(is_a(DB::getConn(), 'PostgreSQLDatabase')) { + self::kill_temp_db(); + } + + parent::setUpOnce(); + } + + function setUp() { + parent::setUp(); + + $holderPage = $this->objFromFixture('SiteTree', 'searchformholder'); + $this->mockController = new ContentController($holderPage); + + // whenever a translation is created, canTranslate() is checked + $admin = $this->objFromFixture('Member', 'admin'); + $admin->logIn(); + + $this->waitUntilIndexingFinished(); + } + + + + function testPublishedPagesMatchedByTitleInDefaultLanguage() { + $sf = new SearchForm($this->mockController, 'SearchForm'); + + $publishedPage = $this->objFromFixture('SiteTree', 'publishedPage'); + $publishedPage->publish('Stage', 'Live'); + $translatedPublishedPage = $publishedPage->createTranslation('de_DE'); + $translatedPublishedPage->Title = 'translatedPublishedPage'; + $translatedPublishedPage->Content = 'German content'; + $translatedPublishedPage->write(); + $translatedPublishedPage->publish('Stage', 'Live'); + + $this->waitUntilIndexingFinished(); + + // Translatable::set_current_locale() can't be used because the context + // from the holder is not present here - we set the language explicitly + // through a pseudo GET variable in getResults() + + $lang = 'en_US'; + $results = $sf->getResults(null, array('Search'=>'content', 'locale'=>$lang)); + $this->assertContains( + $publishedPage->ID, + $results->column('ID'), + 'Published pages are found by searchform in default language' + ); + $this->assertNotContains( + $translatedPublishedPage->ID, + $results->column('ID'), + 'Published pages in another language are not found when searching in default language' + ); + + $lang = 'de_DE'; + $results = $sf->getResults(null, array('Search'=>'content', 'locale'=>$lang)); + $this->assertNotContains( + $publishedPage->ID, + $results->column('ID'), + 'Published pages in default language are not found when searching in another language' + ); + $actual = $results->column('ID'); + array_walk($actual, 'intval'); + $this->assertContains( + (int)$translatedPublishedPage->ID, + $actual, + 'Published pages in another language are found when searching in this language' + ); + } + +} +?> diff --git a/tests/unit/TranslatableSearchFormTest.yml b/tests/unit/TranslatableSearchFormTest.yml new file mode 100644 index 0000000..2fbf060 --- /dev/null +++ b/tests/unit/TranslatableSearchFormTest.yml @@ -0,0 +1,18 @@ +SiteTree: + searchformholder: + URLSegment: searchformholder + Title: searchformholder + publishedPage: + Title: publishedPage + Content: English content +Group: + admingroup: + Code: admingroup +Member: + admin: + FirstName: Admin + Groups: =>Group.admingroup +Permission: + admincode: + Code: ADMIN + Group: =>Group.admingroup \ No newline at end of file diff --git a/tests/unit/TranslatableSiteConfigTest.php b/tests/unit/TranslatableSiteConfigTest.php new file mode 100644 index 0000000..33bde9d --- /dev/null +++ b/tests/unit/TranslatableSiteConfigTest.php @@ -0,0 +1,53 @@ + array('Translatable'), + 'SiteConfig' => array('Translatable'), + ); + + protected $illegalExtensions = array( + 'SiteTree' => array('SiteTreeSubsites') + ); + + private $origLocale; + + function setUp() { + parent::setUp(); + + $this->origLocale = Translatable::default_locale(); + Translatable::set_default_locale("en_US"); + } + + function tearDown() { + Translatable::set_default_locale($this->origLocale); + Translatable::set_current_locale($this->origLocale); + + parent::tearDown(); + } + + function testCurrentCreatesDefaultForLocale() { + $configEn = SiteConfig::current_site_config(); + $configFr = SiteConfig::current_site_config('fr_FR'); + + $this->assertType('SiteConfig', $configFr); + $this->assertEquals($configFr->Locale, 'fr_FR'); + $this->assertEquals($configFr->Title, $configEn->Title, 'Copies title from existing config'); + } + + function testCanEditTranslatedRootPages() { + $configEn = $this->objFromFixture('SiteConfig', 'en_US'); + $configDe = $this->objFromFixture('SiteConfig', 'de_DE'); + + $pageEn = $this->objFromFixture('Page', 'root_en'); + $pageDe = $pageEn->createTranslation('de_DE'); + + $translatorDe = $this->objFromFixture('Member', 'translator_de'); + $translatorEn = $this->objFromFixture('Member', 'translator_en'); + + $this->assertFalse($pageEn->canEdit($translatorDe)); + $this->assertTrue($pageEn->canEdit($translatorEn)); + } +} \ No newline at end of file diff --git a/tests/unit/TranslatableSiteConfigTest.yml b/tests/unit/TranslatableSiteConfigTest.yml new file mode 100644 index 0000000..7f5151d --- /dev/null +++ b/tests/unit/TranslatableSiteConfigTest.yml @@ -0,0 +1,86 @@ +Permission: + cmsmain1: + Code: CMS_ACCESS_CMSMain + cmsmain2: + Code: CMS_ACCESS_CMSMain + translate_all1: + Code: TRANSLATE_ALL + translate_all2: + Code: TRANSLATE_ALL +Group: + translators_de: + Code: translators_de + Permissions: =>Permission.cmsmain1,=>Permission.translate_all1 + translators_en: + Code: translators_en + Permissions: =>Permission.cmsmain2,=>Permission.translate_all2 +Member: + translator_de: + Email: translator_de@test.com + Password: test + Groups: =>Group.translators_de + translator_en: + Email: translator_en@test.com + Password: test + Groups: =>Group.translators_en + websiteuser: + Email: websiteuser@test.com + Password: test +Page: + root_en: + URLSegment: root-en + Locale: en_US +SiteConfig: + en_US: + Title: My test site + Locale: en_US + CanEditType: OnlyTheseUsers + EditorGroups: =>Group.translators_en + de_DE: + Title: Meine Test Seite + Locale: de_DE + CanEditType: OnlyTheseUsers + EditorGroups: =>Group.translators_de +Permission: + cmsmain1: + Code: CMS_ACCESS_CMSMain + cmsmain2: + Code: CMS_ACCESS_CMSMain + translate_all1: + Code: TRANSLATE_ALL + translate_all2: + Code: TRANSLATE_ALL +Group: + translators_de: + Code: translators_de + Permissions: =>Permission.cmsmain1,=>Permission.translate_all1 + translators_en: + Code: translators_en + Permissions: =>Permission.cmsmain2,=>Permission.translate_all2 +Member: + translator_de: + Email: translator_de@test.com + Password: test + Groups: =>Group.translators_de + translator_en: + Email: translator_en@test.com + Password: test + Groups: =>Group.translators_en + websiteuser: + Email: websiteuser@test.com + Password: test +Page: + root_en: + URLSegment: root-en + Locale: en_US +SiteConfig: + en_US: + Title: My test site + Locale: en_US + CanEditType: OnlyTheseUsers + EditorGroups: =>Group.translators_en + de_DE: + Title: Meine Test Seite + Locale: de_DE + CanEditType: OnlyTheseUsers + EditorGroups: =>Group.translators_de \ No newline at end of file diff --git a/tests/unit/TranslatableTest.php b/tests/unit/TranslatableTest.php new file mode 100755 index 0000000..d97eaf4 --- /dev/null +++ b/tests/unit/TranslatableTest.php @@ -0,0 +1,987 @@ + array('Translatable'), + 'SiteConfig' => array('Translatable'), + 'TranslatableTest_DataObject' => array('Translatable'), + ); + + private $origLocale; + + protected $autoFollowRedirection = false; + + function setUp() { + parent::setUp(); + + // whenever a translation is created, canTranslate() is checked + $cmseditor = $this->objFromFixture('Member', 'cmseditor'); + $cmseditor->logIn(); + + $this->origLocale = Translatable::default_locale(); + Translatable::set_default_locale("en_US"); + } + + function tearDown() { + Translatable::set_default_locale($this->origLocale); + Translatable::set_current_locale($this->origLocale); + + parent::tearDown(); + } + + function testLocaleGetParamRedirectsToTranslation() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $origPage->publish('Stage', 'Live'); + $translatedPage = $origPage->createTranslation('de_DE'); + $translatedPage->URLSegment = 'ueber-uns'; + $translatedPage->write(); + $translatedPage->publish('Stage', 'Live'); + + $response = $this->get($origPage->URLSegment); + $this->assertEquals(200, $response->getStatusCode(), 'Page request without Locale GET param doesnt redirect'); + + $response = $this->get(Controller::join_links($origPage->URLSegment, '?locale=de_DE')); + $this->assertEquals(301, $response->getStatusCode(), 'Locale GET param causes redirect if it exists'); + $this->assertContains($translatedPage->URLSegment, $response->getHeader('Location')); + + $response = $this->get(Controller::join_links($origPage->URLSegment, '?locale=fr_FR')); + $this->assertEquals(200, $response->getStatusCode(), 'Locale GET param without existing translation shows original page'); + } + + function testTranslationGroups() { + // first in french + $frPage = new SiteTree(); + $frPage->Locale = 'fr_FR'; + $frPage->write(); + + // second in english (from french "original") + $enPage = $frPage->createTranslation('en_US'); + + // third in spanish (from the english translation) + $esPage = $enPage->createTranslation('es_ES'); + + // test french + + $array1=$frPage->getTranslations()->column('Locale'); + $array2=array('en_US','es_ES'); + sort($array1); + sort($array2); + $this->assertEquals( + $array1, + $array2 + ); + $this->assertNotNull($frPage->getTranslation('en_US')); + $this->assertEquals( + $frPage->getTranslation('en_US')->ID, + $enPage->ID + ); + $this->assertNotNull($frPage->getTranslation('es_ES')); + $this->assertEquals( + $frPage->getTranslation('es_ES')->ID, + $esPage->ID + ); + + // test english + $expected = array('es_ES', 'fr_FR'); + sort($expected); + $actual = $enPage->getTranslations()->column('Locale'); + sort($actual); + $this->assertEquals( + $expected, + $actual + ); + $this->assertNotNull($frPage->getTranslation('fr_FR')); + $this->assertEquals( + $enPage->getTranslation('fr_FR')->ID, + $frPage->ID + ); + $this->assertNotNull($frPage->getTranslation('es_ES')); + $this->assertEquals( + $enPage->getTranslation('es_ES')->ID, + $esPage->ID + ); + + // test spanish + $actual = $esPage->getTranslations()->column('Locale'); + sort($actual); + $expected = array('en_US', 'fr_FR'); + sort($expected); + $this->assertEquals( + $actual, + $expected + ); + $this->assertNotNull($esPage->getTranslation('fr_FR')); + $this->assertEquals( + $esPage->getTranslation('fr_FR')->ID, + $frPage->ID + ); + $this->assertNotNull($esPage->getTranslation('en_US')); + $this->assertEquals( + $esPage->getTranslation('en_US')->ID, + $enPage->ID + ); + } + + function testTranslationGroupNotRemovedWhenSiteTreeUnpublished() { + $enPage = new Page(); + $enPage->Locale = 'en_US'; + $enPage->write(); + $enPage->publish('Stage', 'Live'); + $enTranslationGroup = $enPage->getTranslationGroup(); + + $frPage = $enPage->createTranslation('fr_FR'); + $frPage->write(); + $frPage->publish('Stage', 'Live'); + $frTranslationGroup = $frPage->getTranslationGroup(); + + $enPage->doUnpublish(); + $this->assertEquals($enPage->getTranslationGroup(), $enTranslationGroup); + + $frPage->doUnpublish(); + $this->assertEquals($frPage->getTranslationGroup(), $frTranslationGroup); + } + + function testGetTranslationOnSiteTree() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + + $translatedPage = $origPage->createTranslation('fr_FR'); + $getTranslationPage = $origPage->getTranslation('fr_FR'); + + $this->assertNotNull($getTranslationPage); + $this->assertEquals($getTranslationPage->ID, $translatedPage->ID); + } + + function testGetTranslatedLanguages() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + + // through createTranslation() + $translationAf = $origPage->createTranslation('af_ZA'); + + // create a new language on an unrelated page which shouldnt be returned from $origPage + $otherPage = new Page(); + $otherPage->write(); + $otherTranslationEs = $otherPage->createTranslation('es_ES'); + + $this->assertEquals( + $origPage->getTranslatedLangs(), + array( + 'af_ZA', + //'en_US', // default language is not included + ), + 'Language codes are returned specifically for the queried page through getTranslatedLangs()' + ); + + $pageWithoutTranslations = new Page(); + $pageWithoutTranslations->write(); + $this->assertEquals( + $pageWithoutTranslations->getTranslatedLangs(), + array(), + 'A page without translations returns empty array through getTranslatedLangs(), ' . + 'even if translations for other pages exist in the database' + ); + + // manual creation of page without an original link + $translationDeWithoutOriginal = new Page(); + $translationDeWithoutOriginal->Locale = 'de_DE'; + $translationDeWithoutOriginal->write(); + $this->assertEquals( + $translationDeWithoutOriginal->getTranslatedLangs(), + array(), + 'A translated page without an original doesn\'t return anything through getTranslatedLang()' + ); + } + + function testTranslationCantHaveSameURLSegmentAcrossLanguages() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + $this->assertEquals($translatedPage->URLSegment, 'testpage-de-DE'); + + $translatedPage->URLSegment = 'testpage'; + $translatedPage->write(); + + $this->assertNotEquals($origPage->URLSegment, $translatedPage->URLSegment); + } + + function testUpdateCMSFieldsOnSiteTree() { + $pageOrigLang = $this->objFromFixture('Page', 'testpage_en'); + + // first test with default language + $fields = $pageOrigLang->getCMSFields(); + $this->assertType( + 'TextField', + $fields->dataFieldByName('Title'), + 'Translatable doesnt modify fields if called in default language (e.g. "non-translation mode")' + ); + $this->assertNull( + $fields->dataFieldByName('Title_original'), + 'Translatable doesnt modify fields if called in default language (e.g. "non-translation mode")' + ); + + // then in "translation mode" + $pageTranslated = $pageOrigLang->createTranslation('fr_FR'); + $fields = $pageTranslated->getCMSFields(); + $this->assertType( + 'TextField', + $fields->dataFieldByName('Title'), + 'Translatable leaves original formfield intact in "translation mode"' + ); + $readonlyField = $fields->dataFieldByName('Title')->performReadonlyTransformation(); + $this->assertType( + $readonlyField->class, + $fields->dataFieldByName('Title_original'), + 'Translatable adds the original value as a ReadonlyField in "translation mode"' + ); + + } + + function testDataObjectGetWithReadingLanguage() { + $origTestPage = $this->objFromFixture('Page', 'testpage_en'); + $otherTestPage = $this->objFromFixture('Page', 'othertestpage_en'); + $translatedPage = $origTestPage->createTranslation('de_DE'); + + // test in default language + $resultPagesDefaultLang = DataObject::get( + 'Page', + sprintf("\"SiteTree\".\"MenuTitle\" = '%s'", 'A Testpage') + ); + $resultPagesDefaultLangIDs = $resultPagesDefaultLang->column('ID'); + array_walk($resultPagesDefaultLangIDs, 'intval'); + $this->assertEquals($resultPagesDefaultLang->Count(), 2); + $this->assertContains((int)$origTestPage->ID, $resultPagesDefaultLangIDs); + $this->assertContains((int)$otherTestPage->ID, $resultPagesDefaultLangIDs); + $this->assertNotContains((int)$translatedPage->ID, $resultPagesDefaultLangIDs); + + // test in custom language + Translatable::set_current_locale('de_DE'); + $resultPagesCustomLang = DataObject::get( + 'Page', + sprintf("\"SiteTree\".\"MenuTitle\" = '%s'", 'A Testpage') + ); + $resultPagesCustomLangIDs = $resultPagesCustomLang->column('ID'); + array_walk($resultPagesCustomLangIDs, 'intval'); + $this->assertEquals($resultPagesCustomLang->Count(), 1); + $this->assertNotContains((int)$origTestPage->ID, $resultPagesCustomLangIDs); + $this->assertNotContains((int)$otherTestPage->ID, $resultPagesCustomLangIDs); + $this->assertContains((int)$translatedPage->ID, $resultPagesCustomLangIDs); + + Translatable::set_current_locale('en_US'); + } + + function testDataObjectGetByIdWithReadingLanguage() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + $compareOrigPage = DataObject::get_by_id('Page', $origPage->ID); + + $this->assertEquals( + $origPage->ID, + $compareOrigPage->ID, + 'DataObject::get_by_id() should work independently of the reading language' + ); + } + + function testDataObjectGetOneWithReadingLanguage() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + + // running the same query twice with different + Translatable::set_current_locale('de_DE'); + $compareTranslatedPage = DataObject::get_one( + 'Page', + sprintf("\"SiteTree\".\"Title\" = '%s'", $translatedPage->Title) + ); + $this->assertNotNull($compareTranslatedPage); + $this->assertEquals( + $translatedPage->ID, + $compareTranslatedPage->ID, + "Translated page is found through get_one() when reading lang is not the default language" + ); + + // reset language to default + Translatable::set_current_locale('en_US'); + } + + function testModifyTranslationWithDefaultReadingLang() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + + Translatable::set_current_locale('en_US'); + $translatedPage->Title = 'De Modified'; + $translatedPage->write(); + $savedTranslatedPage = $origPage->getTranslation('de_DE'); + $this->assertEquals( + $savedTranslatedPage->Title, + 'De Modified', + 'Modifying a record in language which is not the reading language should still write the record correctly' + ); + $this->assertEquals( + $origPage->Title, + 'Home', + 'Modifying a record in language which is not the reading language does not modify the original record' + ); + } + + function testSiteTreePublication() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + + Translatable::set_current_locale('en_US'); + $origPage->Title = 'En Modified'; + $origPage->write(); + // modifying a record in language which is not the reading language should still write the record correctly + $translatedPage->Title = 'De Modified'; + $translatedPage->write(); + $origPage->publish('Stage', 'Live'); + $liveOrigPage = Versioned::get_one_by_stage('Page', 'Live', "\"SiteTree\".\"ID\" = {$origPage->ID}"); + $this->assertEquals( + $liveOrigPage->Title, + 'En Modified', + 'Publishing a record in its original language publshes correct properties' + ); + } + + function testDeletingTranslationKeepsOriginal() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + $translatedPageID = $translatedPage->ID; + $translatedPage->delete(); + + $translatedPage->flushCache(); + $origPage->flushCache(); + + $this->assertNull($origPage->getTranslation('de_DE')); + $this->assertNotNull(DataObject::get_by_id('Page', $origPage->ID)); + } + + function testHierarchyChildren() { + $parentPage = $this->objFromFixture('Page', 'parent'); + $child1Page = $this->objFromFixture('Page', 'child1'); + $child2Page = $this->objFromFixture('Page', 'child2'); + $child3Page = $this->objFromFixture('Page', 'child3'); + $grandchildPage = $this->objFromFixture('Page', 'grandchild1'); + + $parentPageTranslated = $parentPage->createTranslation('de_DE'); + $child4PageTranslated = new SiteTree(); + $child4PageTranslated->Locale = 'de_DE'; + $child4PageTranslated->ParentID = $parentPageTranslated->ID; + $child4PageTranslated->write(); + + Translatable::set_current_locale('en_US'); + $actual = $parentPage->Children()->column('ID'); + sort($actual); + $expected = array( + $child1Page->ID, + $child2Page->ID, + $child3Page->ID + ); + $this->assertEquals( + $actual, + $expected, + "Showing Children() in default language doesnt show children in other languages" + ); + + Translatable::set_current_locale('de_DE'); + $parentPage->flushCache(); + $this->assertEquals( + $parentPageTranslated->Children()->column('ID'), + array($child4PageTranslated->ID), + "Showing Children() in translation mode doesnt show children in default languages" + ); + + // reset language + Translatable::set_current_locale('en_US'); + } + + function testHierarchyLiveStageChildren() { + $parentPage = $this->objFromFixture('Page', 'parent'); + $child1Page = $this->objFromFixture('Page', 'child1'); + $child1Page->publish('Stage', 'Live'); + $child2Page = $this->objFromFixture('Page', 'child2'); + $child3Page = $this->objFromFixture('Page', 'child3'); + $grandchildPage = $this->objFromFixture('Page', 'grandchild1'); + + $parentPageTranslated = $parentPage->createTranslation('de_DE'); + + $child4PageTranslated = new SiteTree(); + $child4PageTranslated->Locale = 'de_DE'; + $child4PageTranslated->ParentID = $parentPageTranslated->ID; + $child4PageTranslated->write(); + $child4PageTranslated->publish('Stage', 'Live'); + + $child5PageTranslated = new SiteTree(); + $child5PageTranslated->Locale = 'de_DE'; + $child5PageTranslated->ParentID = $parentPageTranslated->ID; + $child5PageTranslated->write(); + + Translatable::set_current_locale('en_US'); + $this->assertNotNull($parentPage->liveChildren()); + $this->assertEquals( + $parentPage->liveChildren()->column('ID'), + array( + $child1Page->ID + ), + "Showing liveChildren() in default language doesnt show children in other languages" + ); + $this->assertNotNull($parentPage->stageChildren()); + $actual = $parentPage->stageChildren()->column('ID'); + sort($actual); + $expected = array( + $child1Page->ID, + $child2Page->ID, + $child3Page->ID + ); + sort($expected); + $this->assertEquals( + $actual, + $expected, + "Showing stageChildren() in default language doesnt show children in other languages" + ); + + Translatable::set_current_locale('de_DE'); + $parentPage->flushCache(); + $this->assertNotNull($parentPageTranslated->liveChildren()); + $this->assertEquals( + $parentPageTranslated->liveChildren()->column('ID'), + array($child4PageTranslated->ID), + "Showing liveChildren() in translation mode doesnt show children in default languages" + ); + $this->assertNotNull($parentPageTranslated->stageChildren()); + $this->assertEquals( + $parentPageTranslated->stageChildren()->column('ID'), + array( + $child4PageTranslated->ID, + $child5PageTranslated->ID, + ), + "Showing stageChildren() in translation mode doesnt show children in default languages" + ); + + // reset language + Translatable::set_current_locale('en_US'); + } + + function testTranslatablePropertiesOnSiteTree() { + $origObj = $this->objFromFixture('TranslatableTest_Page', 'testpage_en'); + + $translatedObj = $origObj->createTranslation('fr_FR'); + $translatedObj->TranslatableProperty = 'fr_FR'; + $translatedObj->write(); + + $this->assertEquals( + $origObj->TranslatableProperty, + 'en_US', + 'Creating a translation doesnt affect database field on original object' + ); + $this->assertEquals( + $translatedObj->TranslatableProperty, + 'fr_FR', + 'Translated object saves database field independently of original object' + ); + } + + function testCreateTranslationOnSiteTree() { + $origPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedPage = $origPage->createTranslation('de_DE'); + + $this->assertEquals($translatedPage->Locale, 'de_DE'); + $this->assertNotEquals($translatedPage->ID, $origPage->ID); + + $subsequentTranslatedPage = $origPage->createTranslation('de_DE'); + $this->assertEquals( + $translatedPage->ID, + $subsequentTranslatedPage->ID, + 'Subsequent calls to createTranslation() dont cause new records in database' + ); + } + + function testTranslatablePropertiesOnDataObject() { + $origObj = $this->objFromFixture('TranslatableTest_DataObject', 'testobject_en'); + $translatedObj = $origObj->createTranslation('fr_FR'); + $translatedObj->TranslatableProperty = 'fr_FR'; + $translatedObj->TranslatableDecoratedProperty = 'fr_FR'; + $translatedObj->write(); + + $this->assertEquals( + $origObj->TranslatableProperty, + 'en_US', + 'Creating a translation doesnt affect database field on original object' + ); + $this->assertEquals( + $origObj->TranslatableDecoratedProperty, + 'en_US', + 'Creating a translation doesnt affect decorated database field on original object' + ); + $this->assertEquals( + $translatedObj->TranslatableProperty, + 'fr_FR', + 'Translated object saves database field independently of original object' + ); + $this->assertEquals( + $translatedObj->TranslatableDecoratedProperty, + 'fr_FR', + 'Translated object saves decorated database field independently of original object' + ); + } + + function testCreateTranslationWithoutOriginal() { + $origParentPage = $this->objFromFixture('Page', 'testpage_en'); + $translatedParentPage = $origParentPage->createTranslation('de_DE'); + + $translatedPageWithoutOriginal = new SiteTree(); + $translatedPageWithoutOriginal->ParentID = $translatedParentPage->ID; + $translatedPageWithoutOriginal->Locale = 'de_DE'; + $translatedPageWithoutOriginal->write(); + + Translatable::set_current_locale('de_DE'); + $this->assertEquals( + $translatedParentPage->stageChildren()->column('ID'), + array( + $translatedPageWithoutOriginal->ID + ), + "Children() still works on a translated page even if no translation group is set" + ); + + Translatable::set_current_locale('en_US'); + } + + function testCreateTranslationTranslatesUntranslatedParents() { + $parentPage = $this->objFromFixture('Page', 'parent'); + $child1Page = $this->objFromFixture('Page', 'child1'); + $child1PageOrigID = $child1Page->ID; + $grandChild1Page = $this->objFromFixture('Page', 'grandchild1'); + $grandChild2Page = $this->objFromFixture('Page', 'grandchild2'); + + $this->assertFalse($grandChild1Page->hasTranslation('de_DE')); + $this->assertFalse($child1Page->hasTranslation('de_DE')); + $this->assertFalse($parentPage->hasTranslation('de_DE')); + + $translatedGrandChild1Page = $grandChild1Page->createTranslation('de_DE'); + $translatedGrandChild2Page = $grandChild2Page->createTranslation('de_DE'); + $translatedChildPage = $child1Page->getTranslation('de_DE'); + $translatedParentPage = $parentPage->getTranslation('de_DE'); + + $this->assertTrue($grandChild1Page->hasTranslation('de_DE')); + $this->assertEquals($translatedGrandChild1Page->ParentID, $translatedChildPage->ID); + + $this->assertTrue($grandChild2Page->hasTranslation('de_DE')); + $this->assertEquals($translatedGrandChild2Page->ParentID, $translatedChildPage->ID); + + $this->assertTrue($child1Page->hasTranslation('de_DE')); + $this->assertEquals($translatedChildPage->ParentID, $translatedParentPage->ID); + + $this->assertTrue($parentPage->hasTranslation('de_DE')); + } + + function testHierarchyAllChildrenIncludingDeleted() { + // Original tree in 'en_US': + // parent + // child1 (Live only, deleted from stage) + // child2 (Stage only, never published) + // child3 (Stage only, never published, untranslated) + // Translated tree in 'de_DE': + // parent + // child1 (Live only, deleted from stage) + // child2 (Stage only) + + // Create parent + $parentPage = $this->objFromFixture('Page', 'parent'); + $parentPageID = $parentPage->ID; + + // Create parent translation + $translatedParentPage = $parentPage->createTranslation('de_DE'); + $translatedParentPageID = $translatedParentPage->ID; + + // Create child1 + $child1Page = $this->objFromFixture('Page', 'child1'); + $child1PageID = $child1Page->ID; + $child1Page->publish('Stage', 'Live'); + + // Create child1 translation + $child1PageTranslated = $child1Page->createTranslation('de_DE'); + $child1PageTranslatedID = $child1PageTranslated->ID; + $child1PageTranslated->publish('Stage', 'Live'); + $child1PageTranslated->deleteFromStage('Stage'); // deleted from stage only, record still exists on live + $child1Page->deleteFromStage('Stage'); // deleted from stage only, record still exists on live + + // Create child2 + $child2Page = $this->objFromFixture('Page', 'child2'); + $child2PageID = $child2Page->ID; + + // Create child2 translation + $child2PageTranslated = $child2Page->createTranslation('de_DE'); + $child2PageTranslatedID = $child2PageTranslated->ID; + + // Create child3 + $child3Page = $this->objFromFixture('Page', 'child3'); + $child3PageID = $child3Page->ID; + + // on original parent in default language + Translatable::set_current_locale('en_US'); + SiteTree::flush_and_destroy_cache(); + $parentPage = $this->objFromFixture('Page', 'parent'); + $children = $parentPage->AllChildrenIncludingDeleted(); + $expected = array( + $child2PageID, + $child3PageID, + $child1PageID // $child1Page was deleted from stage, so the original record doesn't have the ID set + ); + sort($expected); + $actual = $parentPage->AllChildrenIncludingDeleted()->column('ID'); + sort($actual); + $this->assertEquals( + $actual, + $expected, + "Showing AllChildrenIncludingDeleted() in default language doesnt show deleted children in other languages" + ); + + // on original parent in translation mode + Translatable::set_current_locale('de_DE'); + SiteTree::flush_and_destroy_cache(); + $parentPage = $this->objFromFixture('Page', 'parent'); + $this->assertEquals( + $translatedParentPage->AllChildrenIncludingDeleted()->column('ID'), + array( + $child2PageTranslatedID, + $child1PageTranslatedID // $child1PageTranslated was deleted from stage, so the original record doesn't have the ID set + ), + "Showing AllChildrenIncludingDeleted() in translation mode with parent page in translated language shows children in translated language" + ); + + Translatable::set_current_locale('de_DE'); + SiteTree::flush_and_destroy_cache(); + $parentPage = $this->objFromFixture('Page', 'parent'); + $this->assertEquals( + $parentPage->AllChildrenIncludingDeleted()->column('ID'), + array(), + "Showing AllChildrenIncludingDeleted() in translation mode with parent page in translated language shows children in default language" + ); + + // reset language + Translatable::set_current_locale('en_US'); + } + + function testRootUrlDefaultsToTranslatedLink() { + $origPage = $this->objFromFixture('Page', 'homepage_en'); + $origPage->publish('Stage', 'Live'); + $translationDe = $origPage->createTranslation('de_DE'); + $translationDe->URLSegment = 'heim'; + $translationDe->write(); + $translationDe->publish('Stage', 'Live'); + + // test with translatable + Translatable::set_current_locale('de_DE'); + $this->assertEquals( + RootURLController::get_homepage_link(), + 'heim', + 'Homepage with different URLSegment in non-default language is found' + ); + + // @todo Fix add/remove extension + // test with translatable disabled + // Object::remove_extension('Page', 'Translatable'); + // $_SERVER['HTTP_HOST'] = '/'; + // $this->assertEquals( + // RootURLController::get_homepage_urlsegment(), + // 'home', + // 'Homepage is showing in default language if ?lang GET variable is left out' + // ); + // Object::add_extension('Page', 'Translatable'); + + // setting back to default + Translatable::set_current_locale('en_US'); + } + + function testSiteTreeChangePageTypeInMaster() { + // create original + $origPage = new SiteTree(); + $origPage->Locale = 'en_US'; + $origPage->write(); + $origPageID = $origPage->ID; + + // create translation + $translatedPage = $origPage->createTranslation('de_DE'); + $translatedPageID = $translatedPage->ID; + + // change page type + $newPage = $origPage->newClassInstance('RedirectorPage'); + $newPage->write(); + + // re-fetch original page with new instance + $origPageChanged = DataObject::get_by_id('RedirectorPage', $origPageID); + $this->assertEquals($origPageChanged->ClassName, 'RedirectorPage', + 'A ClassName change to an original page doesnt change original classname' + ); + + // re-fetch the translation with new instance + Translatable::set_current_locale('de_DE'); + $translatedPageChanged = DataObject::get_by_id('RedirectorPage', $translatedPageID); + $translatedPageChanged = $origPageChanged->getTranslation('de_DE'); + $this->assertEquals($translatedPageChanged->ClassName, 'RedirectorPage', + 'ClassName change on an original page also changes ClassName attribute of translation' + ); + } + + function testLocalePersistsInAllPageTypes() { + $types = ClassInfo::subclassesFor('SiteTree'); + foreach($types as $type) { + if(singleton($type) instanceof TestOnly) continue; + + $enPage = new $type(); + $enPage->Locale = 'en_US'; + $enPage->write(); + + $dePage = $enPage->createTranslation('de_DE'); + $dePage->write(); + $this->assertEquals('de_DE', $dePage->Locale, "Page type $type retains Locale property"); + } + } + + function testGetTranslationByStage() { + $publishedPage = new SiteTree(); + $publishedPage->Locale = 'en_US'; + $publishedPage->Title = 'Published'; + $publishedPage->write(); + $publishedPage->publish('Stage', 'Live'); + $publishedPage->Title = 'Unpublished'; + $publishedPage->write(); + + $publishedTranslatedPage = $publishedPage->createTranslation('de_DE'); + $publishedTranslatedPage->Title = 'Publiziert'; + $publishedTranslatedPage->write(); + $publishedTranslatedPage->publish('Stage', 'Live'); + $publishedTranslatedPage->Title = 'Unpubliziert'; + $publishedTranslatedPage->write(); + + $compareStage = $publishedPage->getTranslation('de_DE', 'Stage'); + $this->assertNotNull($compareStage); + $this->assertEquals($compareStage->Title, 'Unpubliziert'); + + $compareLive = $publishedPage->getTranslation('de_DE', 'Live'); + $this->assertNotNull($compareLive); + $this->assertEquals($compareLive->Title, 'Publiziert'); + } + + function testCanTranslateAllowedLocales() { + $origAllowedLocales = Translatable::get_allowed_locales(); + + $cmseditor = $this->objFromFixture('Member', 'cmseditor'); + + $testPage = $this->objFromFixture('Page', 'testpage_en'); + $this->assertTrue( + $testPage->canTranslate($cmseditor, 'de_DE'), + "Users with canEdit() and TRANSLATE_ALL permission can create a new translation if locales are not limited" + ); + + Translatable::set_allowed_locales(array('ja_JP')); + $this->assertTrue( + $testPage->canTranslate($cmseditor, 'ja_JP'), + "Users with canEdit() and TRANSLATE_ALL permission can create a new translation if locale is in Translatable::get_allowed_locales()" + ); + $this->assertFalse( + $testPage->canTranslate($cmseditor, 'de_DE'), + "Users with canEdit() and TRANSLATE_ALL permission can't create a new translation if locale is not in Translatable::get_allowed_locales()" + ); + + $this->assertType( + 'Page', + $testPage->createTranslation('ja_JP') + ); + try { + $testPage->createTranslation('de_DE'); + $this->setExpectedException("Exception"); + } catch(Exception $e) {} + + Translatable::set_allowed_locales($origAllowedLocales); + } + + function testCanTranslatePermissionCodes() { + $origAllowedLocales = Translatable::get_allowed_locales(); + + Translatable::set_allowed_locales(array('ja_JP','de_DE')); + + $cmseditor = $this->objFromFixture('Member', 'cmseditor'); + + $testPage = $this->objFromFixture('Page', 'testpage_en'); + $this->assertTrue( + $testPage->canTranslate($cmseditor, 'de_DE'), + "Users with TRANSLATE_ALL permission can create a new translation" + ); + + $translator = $this->objFromFixture('Member', 'germantranslator'); + + $testPage = $this->objFromFixture('Page', 'testpage_en'); + $this->assertTrue( + $testPage->canTranslate($translator, 'de_DE'), + "Users with TRANSLATE_ permission can create a new translation" + ); + + $this->assertFalse( + $testPage->canTranslate($translator, 'ja_JP'), + "Users without TRANSLATE_ permission can create a new translation" + ); + + Translatable::set_allowed_locales($origAllowedLocales); + } + + function testLocalesForMember() { + $origAllowedLocales = Translatable::get_allowed_locales(); + Translatable::set_allowed_locales(array('de_DE', 'ja_JP')); + + $cmseditor = $this->objFromFixture('Member', 'cmseditor'); + $translator = $this->objFromFixture('Member', 'germantranslator'); + + $this->assertEquals( + array('de_DE', 'ja_JP'), + singleton('SiteTree')->getAllowedLocalesForMember($cmseditor), + 'Members with TRANSLATE_ALL permission can edit all locales' + ); + + $this->assertEquals( + array('de_DE'), + singleton('SiteTree')->getAllowedLocalesForMember($translator), + 'Members with TRANSLATE_ permission cant edit all locales' + ); + + Translatable::set_allowed_locales($origAllowedLocales); + } + + function testSavePageInCMS() { + $adminUser = $this->objFromFixture('Member', 'admin'); + $enPage = $this->objFromFixture('Page', 'testpage_en'); + + $group = new Group(); + $group->Title = 'Example Group'; + $group->write(); + + $frPage = $enPage->createTranslation('fr_FR'); + $frPage->write(); + + $adminUser->logIn(); + + $cmsMain = new CMSMain(); + + $origLocale = Translatable::get_current_locale(); + Translatable::set_current_locale('fr_FR'); + + $form = $cmsMain->getEditForm($frPage->ID); + $form->loadDataFrom(array( + 'Title' => 'Translated', // $db field + 'ViewerGroups' => $group->ID // $many_many field + )); + $form->saveInto($frPage); + $frPage->write(); + + $this->assertEquals('Translated', $frPage->Title); + $this->assertEquals(array($group->ID), $frPage->ViewerGroups()->column('ID')); + + $adminUser->logOut(); + Translatable::set_current_locale($origLocale); + } + + public function testAlternateGetByLink() { + $parent = $this->objFromFixture('Page', 'parent'); + $child = $this->objFromFixture('Page', 'child1'); + $grandchild = $this->objFromFixture('Page', 'grandchild1'); + + $parentTranslation = $parent->createTranslation('en_AU'); + $parentTranslation->write(); + + $childTranslation = $child->createTranslation('en_AU'); + $childTranslation->write(); + + $grandchildTranslation = $grandchild->createTranslation('en_AU'); + $grandchildTranslation->write(); + + SiteTree::enable_nested_urls(); + Translatable::set_current_locale('en_AU'); + + $this->assertEquals ( + $parentTranslation->ID, + Sitetree::get_by_link($parentTranslation->Link())->ID, + 'Top level pages can be found.' + ); + + $this->assertEquals ( + $childTranslation->ID, + SiteTree::get_by_link($childTranslation->Link())->ID, + 'Child pages can be found.' + ); + + $this->assertEquals ( + $grandchildTranslation->ID, + SiteTree::get_by_link($grandchildTranslation->Link())->ID, + 'Grandchild pages can be found.' + ); + + // TODO Re-enable test after clarifying with ajshort (see r88503). + // Its unclear if this is valid behaviour, and/or necessary for translated nested URLs + // to work properly + // + // $this->assertEquals ( + // $child->ID, + // SiteTree::get_by_link($parentTranslation->Link($child->URLSegment))->ID, + // 'Links can be made up of multiple languages' + // ); + } + + public function testSiteTreeGetByLinkFindsTranslationWithoutLocale() { + $parent = $this->objFromFixture('Page', 'parent'); + + $parentTranslation = $parent->createTranslation('en_AU'); + $parentTranslation->URLSegment = 'parent-en-AU'; + $parentTranslation->write(); + + $match = Sitetree::get_by_link($parentTranslation->URLSegment); + $this->assertNotNull( + $match, + 'SiteTree::get_by_link() doesnt need a locale setting to find translated pages' + ); + $this->assertEquals( + $parentTranslation->ID, + $match->ID, + 'SiteTree::get_by_link() doesnt need a locale setting to find translated pages' + ); + } +} + +class TranslatableTest_DataObject extends DataObject implements TestOnly { + // add_extension() used to add decorator at end of file + + static $db = array( + 'TranslatableProperty' => 'Text' + ); +} + +class TranslatableTest_Decorator extends DataObjectDecorator implements TestOnly { + + function extraStatics() { + return array( + 'db' => array( + 'TranslatableDecoratedProperty' => 'Text' + ) + ); + } +} + +class TranslatableTest_Page extends Page implements TestOnly { + // static $extensions is inherited from SiteTree, + // we don't need to explicitly specify the fields + + static $db = array( + 'TranslatableProperty' => 'Text' + ); +} + +DataObject::add_extension('TranslatableTest_DataObject', 'TranslatableTest_Decorator'); +?> diff --git a/tests/unit/TranslatableTest.yml b/tests/unit/TranslatableTest.yml new file mode 100644 index 0000000..43707e4 --- /dev/null +++ b/tests/unit/TranslatableTest.yml @@ -0,0 +1,82 @@ +Page: + homepage_en: + Title: Home + URLSegment: home + Locale: en_US + testpage_en: + Title: Home + MenuTitle: A Testpage + URLSegment: testpage + Locale: en_US + othertestpage_en: + Title: Other Testpage + MenuTitle: A Testpage + URLSegment: othertestpage + Locale: en_US + parent: + Title: Parent + URLSegment: parent + child1: + Title: Child 1 + URLSegment: child1 + Parent: =>Page.parent + child2: + Title: Child 2 + URLSegment: child2 + Parent: =>Page.parent + child3: + Title: Child 3 + URLSegment: child3 + Parent: =>Page.parent + grandchild1: + Title: Grandchild + URLSegment: grandchild1 + Parent: =>Page.child1 + grandchild2: + Title: Grandchild + URLSegment: grandchild2 + Parent: =>Page.child1 +TranslatableTest_DataObject: + testobject_en: + TranslatableProperty: en_US + TranslatableDecoratedProperty: en_US +TranslatableTest_Page: + testpage_en: + Title: En + TranslatableProperty: en_US + URLSegment: testpage-en +Group: + cmseditorgroup: + Code: cmseditorgroup + admingroup: + Code: admingroup + germantranslators: + Code: germantranslators +Member: + cmseditor: + FirstName: Editor + Groups: =>Group.cmseditorgroup + websiteuser: + FirstName: Website User + admin: + FirstName: Admin + Groups: =>Group.admingroup + germantranslator: + FirstName: German + Groups: =>Group.germantranslators +Permission: + admincode: + Code: ADMIN + Group: =>Group.admingroup + cmsmaincode: + Code: CMS_ACCESS_CMSMain + Group: =>Group.cmseditorgroup + translateAllCode: + Code: TRANSLATE_ALL + Group: =>Group.cmseditorgroup + cmsmaincode2: + Code: CMS_ACCESS_CMSMain + Group: =>Group.germantranslators + translateDeCode2: + Code: TRANSLATE_de_DE + Group: =>Group.germantranslators \ No newline at end of file