MINOR Initial commit, moved files from 'sapphire' and 'cms' modules

This commit is contained in:
Ingo Schommer 2011-03-22 21:50:26 +13:00
commit 2a3cc650dc
20 changed files with 3880 additions and 0 deletions

24
LICENSE Normal file
View File

@ -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 <organization> 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.

35
README.md Normal file
View File

@ -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 <ingo at silverstripe dot com>
## 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.

4
_config.php Normal file
View File

@ -0,0 +1,4 @@
<?php
Object::add_extension('SiteTree', 'Translatable');
Object::add_extension('SiteConfig', 'Translatable');
Object::add_extension('CMSMain', 'TranslatableCMSMainExtension');

View File

@ -0,0 +1,143 @@
<?php
class TranslatableCMSMainExtension extends Extension {
function extraStatics() {
return array(
'allowed_actions' => 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');
}
}

View File

@ -0,0 +1,71 @@
<?php
/**
* An extension to dropdown field, pre-configured to list languages.
* The languages already used in the site will be on top.
* @package forms
* @subpackage fields-relational
*/
class LanguageDropdownField extends GroupedDropdownField {
/**
* Create a new LanguageDropdownField
* @param string $name
* @param string $title
* @param array $excludeLocales List of locales that won't be included
* @param string $translatingClass Name of the class with translated instances where to look for used languages
* @param string $list Indicates the source language list. Can be either Common-English, Common-Native, Locale-English, Locale-Native
*/
function __construct($name, $title, $excludeLocales = array(), $translatingClass = 'SiteTree', $list = 'Common-English', $instance = null) {
$usedLocalesWithTitle = Translatable::get_existing_content_languages($translatingClass);
$usedLocalesWithTitle = array_diff_key($usedLocalesWithTitle, $excludeLocales);
if('Common-English' == $list) $allLocalesWithTitle = i18n::get_common_languages();
else if('Common-Native' == $list) $allLocalesWithTitle = i18n::get_common_languages(true);
else if('Locale-English' == $list) $allLocalesWithTitle = i18n::get_common_locales();
else if('Locale-Native' == $list) $allLocalesWithTitle = i18n::get_common_locales(true);
else $allLocalesWithTitle = i18n::get_locale_list();
if(isset($allLocales[Translatable::default_locale()])) unset($allLocales[Translatable::default_locale()]);
// Limit to allowed locales if defined
// Check for canTranslate() if an $instance is given
$allowedLocales = Translatable::get_allowed_locales();
foreach($allLocalesWithTitle as $locale => $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);
}
}
?>

1593
code/model/Translatable.php Executable file
View File

@ -0,0 +1,1593 @@
<?php
/**
* The Translatable decorator allows your DataObjects to have versions in different languages,
* defining which fields are can be translated. Translatable can be applied
* to any {@link DataObject} subclass, but is mostly used with {@link SiteTree}.
* Translatable is compatible with the {@link Versioned} extension.
* To avoid cluttering up the database-schema of the 99% of sites without multiple languages,
* the translation-feature is disabled by default.
*
* Locales (e.g. 'en_US') are used in Translatable for identifying a record by language,
* see section "Locales and Language Tags".
*
* <h2>Configuration</h2>
*
* <h3>Through Object::add_extension()</h3>
* Enabling Translatable through {@link Object::add_extension()} in your _config.php:
* <code>
* Object::add_extension('MyClass', 'Translatable');
* </code>
* This is the recommended approach for enabling Translatable.
*
* <h3>Through $extensions</h3>
* <code>
* class MyClass extends DataObject {
* static $extensions = array(
* "Translatable"
* );
* }
* </code>
*
* 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.
*
* <h3>"Default" locales</h3>
*
* 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:
* <code>
* Translatable::set_default_locale(<locale>); // e.g. 'de_DE' or 'fr_FR'
* </code>
*
* 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.
*
* <h2>Usage</h2>
*
* Getting a translation for an existing instance:
* <code>
* $translatedObj = Translatable::get_one_by_locale('MyObject', 'de_DE');
* </code>
*
* Getting a translation for an existing instance:
* <code>
* $obj = DataObject::get_by_id('MyObject', 99); // original language
* $translatedObj = $obj->getTranslation('de_DE');
* </code>
*
* Getting translations through {@link Translatable::set_current_locale()}.
* This is *not* a recommended approach, but sometimes inavoidable (e.g. for {@link Versioned} methods).
* <code>
* $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);
* </code>
*
* Creating a translation:
* <code>
* $obj = new MyObject();
* $translatedObj = $obj->createTranslation('de_DE');
* </code>
*
* <h2>Usage for SiteTree</h2>
*
* Translatable can be used for subclasses of {@link SiteTree} as well.
*
* <code>
* Object::add_extension('SiteTree', 'Translatable');
* Object::add_extension('SiteConig', 'Translatable');
* </code>
*
* 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.
*
* <code>
* // wrong
* Translatable::set_current_locale('de_DE');
* $englishParent->Children();
* // right
* $germanParent = $englishParent->getTranslation('de_DE');
* $germanParent->Children();
* </code>
*
* <h2>Translation groups</h2>
*
* 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 |
*
* <h2>Character Sets</h2>
*
* 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.
*
* <h2>Permissions</h2>
*
* Authors without administrative access need special permissions to edit locales other than
* the default locale.
*
* - TRANSLATE_ALL: Translate into all locales
* - Translate_<locale>: 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()`.
*
* <h2>Uninstalling/Disabling</h2>
*
* 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 <ingo (at) silverstripe (dot) com>
* @author Michael Gall <michael (at) wakeless (dot) net>
* @author Bernat Foj Capell <bernat@silverstripe.com>
*
* @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 <table>_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_<locale> 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 <classname>_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('<p class="message">%s</p>',
_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 = '<ul>';
foreach($alreadyTranslatedLocales as $i => $langCode) {
$existingTranslation = $this->owner->getTranslation($langCode);
if($existingTranslation) {
$existingTransHTML .= sprintf('<li><a href="%s">%s</a></li>',
sprintf('admin/show/%d/?locale=%s', $existingTranslation->ID, $langCode),
i18n::get_locale_name($langCode)
);
}
}
$existingTransHTML .= '</ul>';
$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 <link rel="alternate"> markup for insertion into
* a HTML4/XHTML compliant <head> 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 = '<link rel="alternate" type="text/html" title="%s" hreflang="%s" href="%s" />' . "\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;
}
}
?>

View File

@ -0,0 +1,176 @@
<?php
/**
* Migrates the old Translatable datamodel introduced in SilverStripe 2.1 to the new schema
* introduced in SilverStripe 2.3.2.
* Just works for {@link SiteTree} records and subclasses. If you have used the Translatable
* extension on other {@link DataObject} subclasses before, this script won't migrate them automatically.
*
* <h2>Limitations</h2>
*
* - 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
*
* <h2>Usage</h2>
*
* 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".
*
* <h3>Commandline</h3>
* Requires "sake" tool (see http://doc.silverstripe.com/?id=sake)
* <example>
* sake dev/tasks/MigrateTranslatableTask
* </example>
*
* <h3>Browser</h3>
* <example>
* http://mydomain.com/dev/tasks/MigrateTranslatableTask
* </example>
*
* @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";
}
}
?>

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

417
docs/en/index.md Normal file
View File

@ -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
<div class="notice" markdown='1'>
**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
</div>
Example:
:::php
Translatable::set_default_locale(<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
<div class="warning" markdown='1'>
**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.
</div>
### "Default" languages
<div class="warning" markdown='1'>
**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
</div>
Example:
:::php
Translatable::set_default_locale(<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://<mysite>/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 %>
<ul class="translations">
<% control Translations %>
<li class="$Locale.RFC1766">
<a href="$Link" hreflang="$Locale.RFC1766"
title="$Title">
<% sprintf(_t('SHOWINPAGE','Show page in %s'),$Locale.Nice) %>
</a>
</li>
<% end_control %>
</ul>
<% 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) %>
<h2><a href="$Link" title="$Title">$Title</a></h2>
<% 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]`

View File

@ -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));

View File

@ -0,0 +1,101 @@
<?php
/**
* @package sapphire
* @subpackage testing
*/
class TranslatableSearchFormTest extends FunctionalTest {
static $fixture_file = 'translatable/tests/unit/TranslatableSearchFormTest.yml';
protected $mockController;
protected $requiredExtensions = array(
'SiteTree' => 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'
);
}
}
?>

View File

@ -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

View File

@ -0,0 +1,53 @@
<?php
class TranslatableSiteConfigTest extends SapphireTest {
static $fixture_file = 'translatable/tests/unit/TranslatableSiteConfigTest.yml';
protected $requiredExtensions = array(
'SiteTree' => 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));
}
}

View File

@ -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

987
tests/unit/TranslatableTest.php Executable file
View File

@ -0,0 +1,987 @@
<?php
/**
* @todo Test Versioned getters
*
* @package sapphire
* @subpackage tests
*/
class TranslatableTest extends FunctionalTest {
static $fixture_file = 'translatable/tests/unit/TranslatableTest.yml';
protected $extraDataObjects = array(
'TranslatableTest_DataObject',
'TranslatableTest_Page',
);
protected $requiredExtensions = array(
'SiteTree' => 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_<locale> permission can create a new translation"
);
$this->assertFalse(
$testPage->canTranslate($translator, 'ja_JP'),
"Users without TRANSLATE_<locale> 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_<locale> 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');
?>

View File

@ -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