├── .gitignore ├── README.md ├── _config.php ├── _config └── fluentextra.yml ├── code ├── controller │ └── CompatibleFluentRootURLController.php └── extensions │ ├── ExtraTable_FluentExtension.php │ ├── ExtraTable_FluentSiteTree.php │ └── Fluent_Extension.php └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /.buildpath 3 | /.settings 4 | /.externalToolBuilders 5 | .DS_Store 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Silverstripe Fluent Extra Table 2 | 3 | This module is extended from [Fluent](https://github.com/tractorcow/silverstripe-fluent). 4 | 5 | ## Store Locale Content In Extra Tables 6 | 7 | Fluent module is awesome and easy to use. However, all extra locale columns are stored in the same data object table. It could cause MYSQL problems if there are too many columns defined in one table. For example, [Row size too large](https://github.com/tractorcow/silverstripe-fluent/issues?utf8=%E2%9C%93&q=row%20size) error. 8 | 9 | This module extends Fluent feature and locale data are stored in separated table with locale name suffix. 10 | 11 | ## Install 12 | ```bash 13 | composer require internetrix/silverstripe-fluent-extra-table:1.0.0 14 | ``` 15 | 16 | ## Translatable Versioned Dataobjects 17 | Put code below for tranlsateable versioned data objects. 18 | ``` 19 | Fluent: 20 | VersionedFluentDataObjects: 21 | - 22 | ``` 23 | ## Current issue with Versioned 24 | Need to implement function allVersions() as below in Page.php to avoid error when clicking history on a page. 25 | 26 | ```php 27 | 28 | public function allVersions($filter = "", $sort = "", $limit = "", $join = "", $having = "") { 29 | // Make sure the table names are not postfixed (e.g. _Live) 30 | $oldMode = Versioned::get_reading_mode(); 31 | Versioned::reading_stage('Stage'); 32 | 33 | $list = DataObject::get(get_class($this), $filter, $sort, $join, $limit); 34 | if($having) $having = $list->having($having); 35 | 36 | $query = $list->dataQuery()->query(); 37 | 38 | foreach($query->getFrom() as $table => $tableJoin) { 39 | if(is_string($tableJoin) && $tableJoin[0] == '"') { 40 | $baseTable = str_replace('"','',$tableJoin); 41 | } elseif(is_string($tableJoin) && substr($tableJoin,0,5) != 'INNER') { 42 | $query->setFrom(array( 43 | $table => "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\"=\"{$baseTable}_versions\".\"RecordID\"" 44 | . " AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\"" 45 | )); 46 | } 47 | 48 | // fix locale table names 49 | $locale = Fluent::current_locale(); 50 | $default = Fluent::default_locale(); 51 | if(strpos($table, $locale) !== false){ 52 | $table = str_replace('_' . $locale,'',$table); 53 | $query->renameTable($table, $table . '_versions' . '_' . $locale); 54 | } else if (strpos($table, $default) !== false){ 55 | $table = str_replace('_' . $default,'',$table); 56 | $query->renameTable($table, $table . '_versions' . '_' . $default); 57 | } else { 58 | $query->renameTable($table, $table . '_versions'); 59 | } 60 | 61 | } 62 | 63 | // Add all _versions columns 64 | foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) { 65 | $query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name); 66 | } 67 | 68 | $query->addWhere(array( 69 | "\"{$baseTable}_versions\".\"RecordID\" = ?" => $this->ID 70 | )); 71 | $query->setOrderBy(($sort) ? $sort 72 | : "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC"); 73 | 74 | $records = $query->execute(); 75 | $versions = new ArrayList(); 76 | 77 | foreach($records as $record) { 78 | $versions->push(new Versioned_Version($record)); 79 | } 80 | 81 | Versioned::set_reading_mode($oldMode); 82 | return $versions; 83 | } 84 | ``` -------------------------------------------------------------------------------- /_config.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class CompatibleFluentRootURLController extends FluentRootURLController 10 | { 11 | public function handleRequest(SS_HTTPRequest $request, DataModel $model = null) 12 | { 13 | self::$is_at_root = true; 14 | $this->setDataModel($model); 15 | 16 | $this->pushCurrent(); 17 | $this->init(); 18 | $this->setRequest($request); 19 | 20 | // Check for existing routing parameters, redirecting to another locale automatically if necessary 21 | $locale = Fluent::get_request_locale(); 22 | if (empty($locale)) { 23 | 24 | // Determine if this user should be redirected 25 | $locale = $this->getRedirectLocale(); 26 | $this->extend('updateRedirectLocale', $locale); 27 | 28 | // Check if the user should be redirected 29 | $domainDefault = Fluent::default_locale(true); 30 | if (Fluent::is_locale($locale) && ($locale !== $domainDefault)) { 31 | // Check new traffic with detected locale 32 | return $this->redirect(Fluent::locale_baseurl($locale)); 33 | } 34 | 35 | // Reset parameters to act in the default locale 36 | $locale = $domainDefault; 37 | Fluent::set_persist_locale($locale); 38 | $params = $request->routeParams(); 39 | $params[Fluent::config()->query_param] = $locale; 40 | $request->setRouteParams($params); 41 | } 42 | 43 | if (!DB::isActive() || !ClassInfo::hasTable('SiteTree')) { 44 | $this->response = new SS_HTTPResponse(); 45 | $this->response->redirect(Director::absoluteBaseURL() . 'dev/build?returnURL=' . (isset($_GET['url']) ? urlencode($_GET['url']) : null)); 46 | return $this->response; 47 | } 48 | 49 | $localeURL = Fluent::alias($locale); 50 | $request->setUrl(self::fluent_homepage_link($localeURL)); 51 | $request->match($localeURL . '/$URLSegment//$Action', true); 52 | 53 | $controllerClass = Fluent::config()->handling_controller; 54 | $controller = new $controllerClass(); 55 | $result = $controller->handleRequest($request, $model); 56 | 57 | $this->popCurrent(); 58 | return $result; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /code/extensions/ExtraTable_FluentExtension.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | class ExtraTable_FluentExtension extends FluentExtension 7 | { 8 | /** 9 | * Override 10 | * 11 | * Determines the fields to translate on the given class 12 | * 13 | * @return array List of field names and data types 14 | */ 15 | public static function translated_fields_for($class) 16 | { 17 | if (isset(self::$translated_fields_for_cache[$class])) { 18 | return self::$translated_fields_for_cache[$class]; 19 | } 20 | return self::$translated_fields_for_cache[$class] = self::without_fluent_fields(function () use ($class) { 21 | $db = DataObject::custom_database_fields($class); 22 | $filter = Config::inst()->get($class, 'translate', Config::UNINHERITED); 23 | $filterIn = Config::inst()->get($class, 'translate_append', Config::UNINHERITED); 24 | if( $filter === 'none' ){ 25 | if( $filterIn === 'none' ) { 26 | return array(); 27 | } 28 | } 29 | // Data and field filters 30 | $fieldsInclude = Fluent::config()->field_include; 31 | $fieldsExclude = Fluent::config()->field_exclude; 32 | $dataInclude = Fluent::config()->data_include; 33 | $dataExclude = Fluent::config()->data_exclude; 34 | 35 | // filter out DB 36 | if ($db) { 37 | foreach ($db as $field => $type) { 38 | if (!empty($filter)) { 39 | // If given an explicit field name filter, then remove non-presented fields 40 | if ( !in_array($field, $filter) ) { 41 | unset($db[$field]); 42 | } 43 | } elseif( !empty($filterIn) && in_array($field, $filterIn )) { 44 | // keep this puppy 45 | } else { 46 | // Without a name filter then check against each filter type 47 | if (($fieldsInclude && !Fluent::any_match($field, $fieldsInclude)) 48 | || ($fieldsExclude && Fluent::any_match($field, $fieldsExclude)) 49 | || ($dataInclude && !Fluent::any_match($type, $dataInclude)) 50 | || ($dataExclude && Fluent::any_match($type, $dataExclude)) 51 | ) { 52 | unset($db[$field]); 53 | } 54 | } 55 | } 56 | } 57 | 58 | return $db; 59 | }); 60 | } 61 | 62 | /** 63 | * Override 64 | * 65 | * Get all database tables in the class ancestry and their respective 66 | * translatable fields 67 | * 68 | * @return array 69 | */ 70 | protected function getTranslatedTables() 71 | { 72 | $includedTables = parent::getTranslatedTables(); 73 | 74 | if( ! empty($includedTables)){ 75 | foreach ($includedTables as $class => $translatedFields){ 76 | // Make sure Versioned tables have fluent support. 77 | if(Object::has_extension($class, 'Versioned')){ 78 | $includedTables["{$class}_versions"] = $translatedFields; 79 | $includedTables["{$class}_Live"] = $translatedFields; 80 | } 81 | } 82 | } 83 | 84 | return $includedTables; 85 | } 86 | 87 | 88 | /** 89 | * Override - stop generating locales db table columns. 90 | */ 91 | public static function get_extra_config($class, $extension, $args) 92 | { 93 | self::$disable_fluent_fields = true; 94 | return array(); 95 | } 96 | 97 | 98 | /** 99 | * Generates a select fragment based on a field with a fallback 100 | * 101 | * @param string $class Table/Class name 102 | * @param string $select Column to select from 103 | * @param string $fallback Column to fallback to if $select is empty 104 | * @return string Select fragment 105 | */ 106 | protected function localiseTableSelect($class, $select, $fallback, $locale) 107 | { 108 | return "CASE COALESCE(CAST(\"{$class}_{$locale}\".\"{$select}\" AS CHAR), '') 109 | WHEN '' THEN \"{$class}\".\"{$fallback}\" 110 | WHEN '0' THEN \"{$class}\".\"{$fallback}\" 111 | ELSE \"{$class}_{$locale}\".\"{$select}\" END"; 112 | } 113 | 114 | /** 115 | * Replaces all columns in the given condition with any localised 116 | * 117 | * @param string $condition Condition SQL string 118 | * @param array $includedTables 119 | * @param string $locale Locale to localise to 120 | * @return string $condition parameter with column names replaced 121 | */ 122 | protected function localiseFilterCondition($condition, $includedTables, $locale) 123 | { 124 | foreach ($includedTables as $table => $columns) { 125 | foreach ($columns as $column) { 126 | $columnLocalised = Fluent::db_field_for_locale($column, $locale); 127 | $identifier = "\"{$table}\".\"{$column}\""; 128 | $identifierLocalised = "\"{$table}_{$locale}\".\"{$columnLocalised}\""; 129 | $condition = preg_replace("/".preg_quote($identifier, '/')."/", $identifierLocalised, $condition); 130 | } 131 | } 132 | return $condition; 133 | } 134 | 135 | /** 136 | * Left join locale tables to SQLQuery. 137 | * 138 | * @param SQLQuery $query 139 | * @param string $locale 140 | * @param string $includedTables 141 | */ 142 | protected function localiseJoin(SQLQuery &$query, $locale, $includedTables) 143 | { 144 | $fromArray = $query->getFrom(); 145 | 146 | $isLiveMod = ( Versioned::current_stage() == 'Live' ) ? true : false; 147 | 148 | $default = Fluent::default_locale(); 149 | 150 | if(count($fromArray)){ 151 | foreach ($fromArray as $table => $config){ 152 | // get DB table name 153 | if(is_array($config) && isset($config['table']) && $config['table']){ 154 | $primaryTable = $config['table']; 155 | }else{ 156 | $primaryTable = $table; 157 | } 158 | 159 | //check if this table require fluent translation 160 | if( ! isset($includedTables[$primaryTable])){ 161 | continue; 162 | } 163 | 164 | // join locale table 165 | $localeTable = $primaryTable . '_' . $locale; 166 | if(DB::get_schema()->hasTable($localeTable) && ! isset($fromArray[$localeTable])){ 167 | $query->addLeftJoin($localeTable, "\"{$primaryTable}\".\"ID\" = \"$localeTable\".\"ID\""); 168 | } 169 | 170 | //check version mode for locale table 171 | $baseLiveTableName = $primaryTable . '_Live'; 172 | if($isLiveMod && isset($includedTables[$baseLiveTableName])){ 173 | $query->renameTable($localeTable, $baseLiveTableName . '_' . $locale); 174 | } 175 | 176 | // join default table 177 | $defaultTable = $primaryTable . '_' . $default; 178 | if(DB::get_schema()->hasTable($defaultTable) && ! isset($fromArray[$defaultTable])){ 179 | $query->addLeftJoin($defaultTable, "\"{$primaryTable}\".\"ID\" = \"$defaultTable\".\"ID\""); 180 | } 181 | 182 | //check version mode for default table 183 | $baseLiveTableName = $primaryTable . '_Live'; 184 | if($isLiveMod && isset($includedTables[$baseLiveTableName])){ 185 | $query->renameTable($defaultTable, $baseLiveTableName . '_' . $default); 186 | } 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * Override 193 | * 194 | * @see FluentExtension::augmentSQL() 195 | */ 196 | public function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) 197 | { 198 | // Get locale and translation zone to use 199 | $default = Fluent::default_locale(); 200 | $locale = $dataQuery->getQueryParam('Fluent.Locale') ?: Fluent::current_locale(); 201 | 202 | // Get all tables to translate fields for, and their respective field names 203 | $includedTables = $this->getTranslatedTables(); 204 | 205 | // Join locale table 206 | $this->localiseJoin($query, $locale, $includedTables); 207 | 208 | // Iterate through each select clause, replacing each with the translated version 209 | foreach ($query->getSelect() as $alias => $select) { 210 | 211 | // Skip fields without table context 212 | if (!preg_match('/^"(?[\w\\\\]+)"\."(?\w+)"$/i', $select, $matches)) { 213 | continue; 214 | } 215 | 216 | $class = $matches['class']; 217 | $field = $matches['field']; 218 | 219 | // If this table doesn't have translated fields then skip 220 | if (empty($includedTables[$class])) { 221 | continue; 222 | } 223 | 224 | // If this field shouldn't be translated, skip 225 | if (!in_array($field, $includedTables[$class])) { 226 | continue; 227 | } 228 | $isFieldNullable = $this->isFieldNullable($field); 229 | 230 | // Select visible field from translated fields (Title_fr_FR || Title => Title) 231 | $translatedField = Fluent::db_field_for_locale($field, $locale); 232 | if ($isFieldNullable) { 233 | // Table_locale.Field_locale => Table.Field 234 | $expression = "\"{$class}_{$locale}\".\"{$translatedField}\""; 235 | } else { 236 | // Table_locale.Field_locale || Table.Field => Table.Field 237 | $expression = $this->localiseTableSelect($class, $translatedField, $field, $locale); 238 | } 239 | $query->selectField($expression, $alias); 240 | 241 | // At the same time, rewrite the selector for the default field to make sure that 242 | // (in the case it is blank, which happens if installing fluent for the first time) 243 | // that it also populated from the root field. 244 | $defaultField = Fluent::db_field_for_locale($field, $default); 245 | if ($isFieldNullable) { 246 | // Force Table_default.Field_default => Table_default.Field_default 247 | $defaultExpression = "\"{$class}_{$default}\".\"{$defaultField}\""; 248 | } else { 249 | // Table_default.Field_default || Table.Field => Table.Field_default 250 | $defaultExpression = $this->localiseTableSelect($class, $defaultField, $field, $default); 251 | } 252 | $query->selectField($defaultExpression, $defaultField); 253 | } 254 | 255 | // Rewrite where conditions with parameterised query (3.2 +) 256 | $where = $query 257 | ->toAppropriateExpression() 258 | ->getWhere(); 259 | foreach ($where as $index => $condition) { 260 | // Extract parameters from condition 261 | if ($condition instanceof SQLConditionGroup) { 262 | $parameters = array(); 263 | $predicate = $condition->conditionSQL($parameters); 264 | } else { 265 | $parameters = array_values(reset($condition)); 266 | $predicate = key($condition); 267 | } 268 | 269 | // Find the first localised column that this condition matches. 270 | // Use this as the basis of determining how to rewrite this query 271 | $filterColumnArray = $this->detectFilterColumn($predicate, $includedTables); 272 | if (empty($filterColumnArray)) { 273 | continue; 274 | } 275 | list($table, $column) = $filterColumnArray; 276 | $filterColumn = "\"{$table}_{$locale}\".\"".Fluent::db_field_for_locale($column, $locale)."\""; 277 | 278 | // Duplicate the condition with all localisable fields replaced 279 | $localisedPredicate = $this->localiseFilterCondition($predicate, $includedTables, $locale); 280 | if ($localisedPredicate === $predicate) { 281 | continue; 282 | } 283 | 284 | // Determine rewrite behaviour based on nullability of the "root" column in this condition 285 | // This behaviour in imprecise, as the condition may contain filters with mixed nullability 286 | // but it is a good approximation. 287 | // For better accuracy of rewrite, ensure that each condition in a query is a separate where. 288 | if ($this->isFieldNullable($column)) { 289 | // If this field is nullable, then the condition is a simple rewrite of Table.Field => Table.Field_locale 290 | $where[$index] = array( 291 | $localisedPredicate => $parameters 292 | ); 293 | } else { 294 | // Generate new condition that conditionally executes one of the two conditions 295 | // depending on field nullability. 296 | // If the filterColumn is null or empty, then it's considered untranslated, and 297 | // thus the query should continue running on the default column unimpeded. 298 | $castColumn = "COALESCE(CAST($filterColumn AS CHAR), '')"; 299 | $newPredicate = " 300 | ($castColumn != '' AND $castColumn != '0' AND ($localisedPredicate)) 301 | OR ( 302 | ($castColumn = '' OR $castColumn = '0') AND ($predicate) 303 | )"; 304 | // Duplicate this condition with parameters duplicated 305 | $where[$index] = array( 306 | $newPredicate => array_merge($parameters, $parameters) 307 | ); 308 | } 309 | } 310 | $query->setWhere($where); 311 | 312 | // Augment search if applicable 313 | if ($adapter = Fluent::search_adapter()) { 314 | $adapter->augmentSearch($query, $dataQuery); 315 | } 316 | } 317 | 318 | public function augmentWrite(&$manipulation) 319 | { 320 | 321 | // Bypass augment write if requested 322 | if (!self::$_enable_write_augmentation) { 323 | return; 324 | } 325 | 326 | // Get locale and translation zone to use 327 | $locale = $this->owner->getSourceQueryParam('Fluent.Locale') ?: Fluent::current_locale(); 328 | $defaultLocale = Fluent::default_locale(); 329 | 330 | // Get all tables to translate fields for, and their respective field names 331 | $includedTables = $this->getTranslatedTables(); 332 | 333 | // Versioned fields 334 | $versionFields = array("RecordID", "Version"); 335 | 336 | // Iterate through each select clause, replacing each with the translated version 337 | foreach ($manipulation as $class => $updates) { 338 | 339 | $localeTable = $class . "_" . $locale; 340 | 341 | $fluentFieldNames = array(); 342 | 343 | $fluentFields = array(); 344 | $fluentFields[$localeTable] = $updates; 345 | 346 | // If this table doesn't have translated fields then skip 347 | if (empty($includedTables[$class])) { 348 | continue; 349 | } 350 | 351 | foreach ($includedTables[$class] as $field) { 352 | 353 | //put all fluent field names of $class into array $fluentFieldNames 354 | $updateField = Fluent::db_field_for_locale($field, $locale); 355 | $fluentFieldNames[] = $updateField; 356 | 357 | // Skip translated field if not updated in this request 358 | if (empty($updates['fields']) || !array_key_exists($field, $updates['fields'])) { 359 | continue; 360 | } 361 | 362 | // Copy the updated value to the locale specific table.field 363 | $fluentFields[$localeTable]['fields'][$updateField] = $updates['fields'][$field]; 364 | 365 | // If not on the default locale, write the stored default field back to the main field 366 | // (if Title_en_NZ then Title_en_NZ => Title) 367 | // If the default subfield has no value, then save using the current locale 368 | if ($locale !== $defaultLocale) { 369 | $defaultField = Fluent::db_field_for_locale($field, $defaultLocale); 370 | 371 | // Write default value back if a value exists, 372 | // but if this field can be nullable, write it back even if empty. 373 | if (!empty($updates['fields'][$defaultField]) || $this->isFieldNullable($field)) { 374 | $updates['fields'][$field] = isset($updates['fields'][$defaultField]) ?: null; 375 | } else { 376 | unset($updates['fields'][$field]); 377 | } 378 | } 379 | } 380 | 381 | // Save back modifications to the manipulation 382 | $manipulation[$class] = $updates; 383 | 384 | // Save locale data. 385 | if(isset($fluentFields[$localeTable]['fields']) && count($fluentFields[$localeTable]['fields'])){ 386 | if(count($fluentFieldNames)){ 387 | foreach ($fluentFields[$localeTable]['fields'] as $fieldName => $fieldValue){ 388 | if( ! in_array($fieldName, $fluentFieldNames)){ 389 | //skip non-locale fields 390 | unset($fluentFields[$localeTable]['fields'][$fieldName]); 391 | } 392 | } 393 | } 394 | 395 | $manipulation[$localeTable] = $fluentFields[$localeTable]; 396 | 397 | //check *_versions table. if this is Versioned table, copy 'Version' and 'RecordID' to locale version table 398 | if(stripos($class, '_versions') !== false && count($versionFields)){ 399 | foreach ($versionFields as $versionFieldName){ 400 | if(isset($manipulation[$class]['fields'][$versionFieldName])) { 401 | $manipulation[$localeTable]['fields'][$versionFieldName] = $manipulation[$class]['fields'][$versionFieldName]; 402 | } 403 | } 404 | } 405 | } 406 | } 407 | } 408 | 409 | public function onAfterDelete() { 410 | 411 | $class = $this->owner->class; 412 | 413 | $includedTables = $this->getTranslatedTables(); 414 | 415 | if(empty($includedTables[$class])){ 416 | return; 417 | } 418 | 419 | if($this->owner->hasExtension('Versioned')){ 420 | //has Versioned ext. check mode and current locale. 421 | $mode = (Versioned::current_stage() == 'Live') ? '_Live' : ''; 422 | 423 | $locale = Fluent::current_locale(); 424 | 425 | $localeSuffix = $locale ? '_' . $locale : ''; 426 | 427 | DB::prepared_query( 428 | "DELETE FROM \"{$class}{$mode}{$localeSuffix}\" WHERE \"ID\" = ?", 429 | array($this->owner->ID) 430 | ); 431 | }else{ 432 | // no Versioned ext. delete all records from all locale tables 433 | foreach (Fluent::locales() as $locale) { 434 | //delete records from all locale tables. 435 | $localeTable = $class . '_' . $locale; 436 | 437 | DB::prepared_query( 438 | "DELETE FROM \"{$localeTable}\" WHERE \"ID\" = ?", 439 | array($this->owner->ID) 440 | ); 441 | } 442 | } 443 | } 444 | 445 | public function updateCMSFields(FieldList $fields) 446 | { 447 | // perform parent modifications 448 | parent::updateCMSFields($fields); 449 | 450 | // fix modified label/title of translated fields 451 | $translated = $this->getTranslatedTables(); 452 | foreach ($translated as $table => $translatedFields) { 453 | foreach ($translatedFields as $translatedField) { 454 | 455 | // Find field matching this translated field 456 | // If the translated field has an ID suffix also check for the non-suffixed version 457 | // E.g. UploadField() 458 | $field = $fields->dataFieldByName($translatedField); 459 | if (!$field && preg_match('/^(?\w+)ID$/', $translatedField, $matches)) { 460 | $field = $fields->dataFieldByName($matches['field']); 461 | } 462 | 463 | // fix classes/labels of reset link 464 | if ($field && $field->hasClass('LocalisedField')) { 465 | if (!$field->hasClass('UpdatedLocalisedField')) { 466 | 467 | $locale = Fluent::current_locale(); 468 | $isModified = Fluent_Extension::isTableFieldModified($this->owner, $field, $locale); 469 | 470 | if ($isModified) { 471 | $dom = new DOMDocument(); 472 | $dom->loadHTML($field->Title()); 473 | $spans = $dom->getElementsByTagName('span'); 474 | if (isset($spans[0])) { 475 | $classes = $spans[0]->getAttribute('class'); 476 | if (strpos($classes, 'fluent-modified-value') === false) { 477 | // update classes 478 | $classes .= ' fluent-modified-value'; 479 | $spans[0]->removeAttribute('class'); 480 | $spans[0]->setAttribute('class', $classes); 481 | // updat etitle 482 | $spans[0]->removeAttribute('title'); 483 | $spans[0]->setAttribute('title', 'Modified from default locale value - click to reset'); 484 | } 485 | } 486 | $body = $dom->getElementsByTagName('body')->item(0); 487 | $title = ''; 488 | foreach ($body->childNodes as $child){ 489 | $title .= $dom->saveHTML($child); 490 | } 491 | $field->setTitle($title); 492 | } 493 | 494 | $field->addExtraClass('UpdatedLocalisedField'); 495 | } 496 | } 497 | } 498 | } 499 | } 500 | 501 | public static function ConfigVersionedDataObject(){ 502 | //remove old FluentExtension and FluentSiteTree extensions. 503 | SiteTree::remove_extension('FluentSiteTree'); 504 | SiteConfig::remove_extension('FluentExtension'); 505 | 506 | //Fix versioned dataobjects. 507 | // 1. SiteTree 508 | self::ChangeExtensionOrder('SiteTree', 'ExtraTable_FluentSiteTree'); 509 | 510 | // 2. User defined DataObject has Versioned extension. 511 | $list = Config::inst()->get('Fluent', 'VersionedFluentDataObjects'); 512 | if(is_array($list) && count($list)){ 513 | foreach ($list as $dataObjectName){ 514 | if($dataObjectName::has_extension('ExtraTable_FluentExtension') && $dataObjectName::has_extension('Versioned')){ 515 | self::ChangeExtensionOrder($dataObjectName); 516 | } 517 | } 518 | } 519 | } 520 | 521 | /** 522 | * have to move fluent related extension to bottom of ext list to make it work for Versioned extension. 523 | * 524 | * @TODO find a better way to define extensions order.... 525 | * 526 | * e.g. SiteTree extension order need to be like that. 'Versioned' should be above 'FluentSiteTree' or 'ExtraTable_FluentExtension' 527 | * 528 | * 1 => string 'Hierarchy' 529 | * 2 => string 'Versioned('Stage', 'Live')' 530 | * 3 => string 'SiteTreeLinkTracking' 531 | * 4 => string 'ExtraTable_FluentSiteTree' 532 | * 533 | * replicate the following setting in your mysite/_config.php if you add ExtraTable_FluentExtension for Versioned DataObject like SiteTree. 534 | * 535 | * Don't worry about sub classes of SiteTree or Versioned DataObject. 536 | * 537 | */ 538 | public static function ChangeExtensionOrder($class, $extension = 'ExtraTable_FluentExtension'){ 539 | $class::remove_extension($extension); 540 | 541 | $data = Config::inst()->get($class, 'extensions'); 542 | 543 | $data[] = $extension; 544 | 545 | Config::inst()->remove($class, 'extensions'); 546 | Config::inst()->update($class, 'extensions', $data); 547 | } 548 | 549 | public function augmentDatabase(){ 550 | $includedTables = $this->getTranslatedTables(); 551 | 552 | if(isset($includedTables[$this->owner->class])){ 553 | foreach (Fluent::locales() as $locale) { 554 | //loop all locale. create extra table for each locale. 555 | $this->owner->requireExtraTable($locale, $includedTables[$this->owner->class]); 556 | } 557 | } 558 | } 559 | 560 | public function requireExtraTable($locale, $includedFields) { 561 | $suffix = $locale; 562 | 563 | // Only build the table if we've actually got fields 564 | $fields = DataObject::database_fields($this->owner->class); 565 | $extensions = $this->owner->database_extensions($this->owner->class); 566 | $indexes = $this->owner->databaseIndexes(); 567 | 568 | if($fields) { 569 | $fields = $this->generateLocaleDBFields($fields, $includedFields, $locale); 570 | $indexes = $this->generateLocaleIndexesFields($indexes, $fields, $locale); 571 | 572 | $hasAutoIncPK = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class)); 573 | DB::require_table("{$this->owner->class}_{$suffix}", $fields, $indexes, $hasAutoIncPK, $this->owner->stat('create_table_options'), $extensions); 574 | } else { 575 | DB::dont_require_table("{$this->owner->class}_{$suffix}"); 576 | } 577 | 578 | //check if need Versions extension table 579 | if($this->owner->hasExtension('Versioned')){ 580 | 581 | $this->owner->requireExtraVersionedTable($this->owner->class, $includedFields, $locale); 582 | 583 | } 584 | } 585 | 586 | public function generateLocaleDBFields($baseFields, $includedFields, $locale){ 587 | // Generate $db for class 588 | $db = array(); 589 | if ($baseFields) { 590 | foreach ($baseFields as $field => $type) { 591 | if(is_array($includedFields) && ! in_array($field, $includedFields)){ 592 | continue; 593 | } 594 | 595 | // Transform has_one relations into basic int fields to prevent interference with ORM 596 | if ($type === 'ForeignKey') { 597 | $type = 'Int'; 598 | } 599 | $translatedName = Fluent::db_field_for_locale($field, $locale); 600 | $db[$translatedName] = $type; 601 | } 602 | } 603 | 604 | return empty($db) ? null : $db; 605 | } 606 | 607 | public function generateLocaleIndexesFields($baseIndexes, $baseFields, $locale){ 608 | $indexes = array(); 609 | if ($baseIndexes) { 610 | foreach ($baseIndexes as $baseIndex => $baseSpec) { 611 | if ($baseSpec === 1 || $baseSpec === true) { 612 | if (isset($baseFields[$baseIndex])) { 613 | // Single field is translated, so add multiple indexes for each locale 614 | // Transform has_one relations into basic int fields to prevent interference with ORM 615 | $translatedName = Fluent::db_field_for_locale($baseIndex, $locale); 616 | $indexes[$translatedName] = $baseSpec; 617 | } 618 | } else { 619 | // Check format of spec 620 | $baseSpec = self::parse_index_spec($baseIndex, $baseSpec); 621 | 622 | // Check if columns overlap with translated 623 | $columns = self::explode_column_string($baseSpec['value']); 624 | $translatedColumns = array_intersect(array_keys($baseFields), $columns); 625 | if ($translatedColumns) { 626 | // Generate locale specific version of this index 627 | $newColumns = array(); 628 | foreach ($columns as $column) { 629 | $newColumns[] = isset($baseFields[$column]) 630 | ? Fluent::db_field_for_locale($column, $locale) 631 | : $column; 632 | } 633 | 634 | // Inject new columns and save 635 | $newSpec = array_merge($baseSpec, array( 636 | 'name' => Fluent::db_field_for_locale($baseIndex, $locale), 637 | 'value' => self::implode_column_list($newColumns) 638 | )); 639 | $indexes[$newSpec['name']] = $newSpec; 640 | } 641 | } 642 | } 643 | } 644 | 645 | return empty($indexes) ? null : $indexes; 646 | } 647 | 648 | 649 | public function requireExtraVersionedTable($classTable, $includedFields, $locale){ 650 | 651 | $versionExtObj = $this->owner->getExtensionInstance('Versioned'); /* @var $versionExtObj Versioned */ 652 | 653 | $this->stages = $versionExtObj->getVersionedStages(); 654 | $this->defaultStage = $versionExtObj->getDefaultStage(); 655 | 656 | /** 657 | * ================================================================ 658 | * Most of following codes are copied from Versioned->augmentDatabase(). 659 | * Changed some codes. 660 | * ================================================================ 661 | */ 662 | 663 | $isRootClass = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class)); 664 | 665 | // Build a list of suffixes whose tables need versioning 666 | $allSuffixes = array(); 667 | $allSuffixes[] = $locale; 668 | 669 | // Add the default table with an empty suffix to the list (table name = class name) 670 | array_push($allSuffixes,''); 671 | 672 | foreach ($allSuffixes as $key => $suffix) { 673 | // check that this is a valid suffix 674 | if (!is_int($key) || ! $suffix) continue; 675 | 676 | $table = $classTable; 677 | 678 | $fields = DataObject::database_fields($this->owner->class); 679 | 680 | $fields = $this->generateLocaleDBFields($fields, $includedFields, $suffix); 681 | 682 | if($fields) { 683 | $options = Config::inst()->get($this->owner->class, 'create_table_options', Config::FIRST_SET); 684 | 685 | $indexes = $this->owner->databaseIndexes(); 686 | $indexes = $this->generateLocaleIndexesFields($indexes, $fields, $suffix); 687 | 688 | // Create tables for other stages 689 | foreach($this->stages as $stage) { 690 | // Extra tables for _Live, etc. 691 | // Change unique indexes to 'index'. Versioned tables may run into unique indexing difficulties 692 | // otherwise. 693 | if($indexes && count($indexes)) $indexes = $this->uniqueToIndex($indexes); 694 | 695 | if($stage != $this->defaultStage) { 696 | DB::require_table("{$table}_{$stage}_{$suffix}", $fields, $indexes, false, $options); 697 | } 698 | 699 | // Version fields on each root table (including Stage) 700 | /* 701 | if($isRootClass) { 702 | $stageTable = ($stage == $this->defaultStage) ? $table : "{$table}_$stage"; 703 | $parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)0); 704 | $values=Array('type'=>'int', 'parts'=>$parts); 705 | DB::requireField($stageTable, 'Version', $values); 706 | } 707 | */ 708 | } 709 | 710 | if($isRootClass) { 711 | // Create table for all versions 712 | $versionFields = array_merge( 713 | Config::inst()->get('Versioned', 'db_for_versions_table'), 714 | (array)$fields 715 | ); 716 | 717 | $versionIndexes = array_merge( 718 | Config::inst()->get('Versioned', 'indexes_for_versions_table'), 719 | (array)$indexes 720 | ); 721 | } else { 722 | // Create fields for any tables of subclasses 723 | $versionFields = array_merge( 724 | array( 725 | "RecordID" => "Int", 726 | "Version" => "Int", 727 | ), 728 | (array)$fields 729 | ); 730 | 731 | //Unique indexes will not work on versioned tables, so we'll convert them to standard indexes: 732 | if($indexes && count($indexes)) $indexes = $this->uniqueToIndex($indexes); 733 | 734 | $versionIndexes = array_merge( 735 | array( 736 | 'RecordID_Version' => array('type' => 'unique', 'value' => '"RecordID","Version"'), 737 | 'RecordID' => true, 738 | 'Version' => true, 739 | ), 740 | (array)$indexes 741 | ); 742 | } 743 | 744 | if(DB::get_schema()->hasTable("{$table}_versions")) { 745 | // Fix data that lacks the uniqueness constraint (since this was added later and 746 | // bugs meant that the constraint was validated) 747 | $duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\" 748 | FROM \"{$table}_versions\" GROUP BY \"RecordID\", \"Version\" 749 | HAVING COUNT(*) > 1"); 750 | 751 | foreach($duplications as $dup) { 752 | DB::alteration_message("Removing {$table}_versions duplicate data for " 753 | ."{$dup['RecordID']}/{$dup['Version']}" ,"deleted"); 754 | DB::prepared_query( 755 | "DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = ? 756 | AND \"Version\" = ? AND \"ID\" != ?", 757 | array($dup['RecordID'], $dup['Version'], $dup['ID']) 758 | ); 759 | } 760 | 761 | // Remove junk which has no data in parent classes. Only needs to run the following 762 | // when versioned data is spread over multiple tables 763 | if(!$isRootClass && ($versionedTables = ClassInfo::dataClassesFor($table))) { 764 | 765 | foreach($versionedTables as $child) { 766 | if($table === $child) break; // only need subclasses 767 | 768 | // Select all orphaned version records 769 | $orphanedQuery = SQLSelect::create() 770 | ->selectField("\"{$table}_versions\".\"ID\"") 771 | ->setFrom("\"{$table}_versions\""); 772 | 773 | // If we have a parent table limit orphaned records 774 | // to only those that exist in this 775 | if(DB::get_schema()->hasTable("{$child}_versions")) { 776 | $orphanedQuery 777 | ->addLeftJoin( 778 | "{$child}_versions", 779 | "\"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\" 780 | AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\"" 781 | ) 782 | ->addWhere("\"{$child}_versions\".\"ID\" IS NULL"); 783 | } 784 | 785 | $count = $orphanedQuery->count(); 786 | if($count > 0) { 787 | DB::alteration_message("Removing {$count} orphaned versioned records", "deleted"); 788 | $ids = $orphanedQuery->execute()->column(); 789 | foreach($ids as $id) { 790 | DB::prepared_query( 791 | "DELETE FROM \"{$table}_versions\" WHERE \"ID\" = ?", 792 | array($id) 793 | ); 794 | } 795 | } 796 | } 797 | } 798 | } 799 | 800 | DB::require_table("{$table}_versions_{$suffix}", $versionFields, $versionIndexes, true, $options); 801 | } else { 802 | DB::dont_require_table("{$table}_versions_{$suffix}"); 803 | foreach($this->stages as $stage) { 804 | if($stage != $this->defaultStage) DB::dont_require_table("{$table}_{$stage}_{$suffix}"); 805 | } 806 | } 807 | } 808 | } 809 | 810 | /** 811 | * Helper for augmentDatabase() to find unique indexes and convert them to non-unique 812 | * 813 | * @param array $indexes The indexes to convert 814 | * @return array $indexes 815 | */ 816 | private function uniqueToIndex($indexes) { 817 | $unique_regex = '/unique/i'; 818 | $results = array(); 819 | foreach ($indexes as $key => $index) { 820 | $results[$key] = $index; 821 | 822 | // support string descriptors 823 | if (is_string($index)) { 824 | if (preg_match($unique_regex, $index)) { 825 | $results[$key] = preg_replace($unique_regex, 'index', $index); 826 | } 827 | } 828 | 829 | // canonical, array-based descriptors 830 | elseif (is_array($index)) { 831 | if (strtolower($index['type']) == 'unique') { 832 | $results[$key]['type'] = 'index'; 833 | } 834 | } 835 | } 836 | return $results; 837 | } 838 | 839 | } 840 | -------------------------------------------------------------------------------- /code/extensions/ExtraTable_FluentSiteTree.php: -------------------------------------------------------------------------------- 1 | FluentSiteTree.php 4 | * 5 | * @package fluent-extra 6 | * @author Jason Zhang 7 | */ 8 | class ExtraTable_FluentSiteTree extends ExtraTable_FluentExtension 9 | { 10 | 11 | public function MetaTags(&$tags) 12 | { 13 | if(Fluent::config()->perlang_persite){ 14 | $tags .= $this->owner->renderWith('FluentSiteTree_MetaTags'); 15 | } 16 | } 17 | 18 | public function onBeforeWrite() 19 | { 20 | // Fix issue with MenuTitle not containing the correct translated value 21 | // Unless it's a virtualpage 22 | if (! ($this->owner instanceof VirtualPage) ) { 23 | $this->owner->setField('MenuTitle', $this->owner->MenuTitle); 24 | } 25 | 26 | parent::onBeforeWrite(); 27 | } 28 | 29 | /** 30 | * Ensure that the controller is correctly initialised 31 | * 32 | * @param ContentController $controller 33 | */ 34 | public function contentcontrollerInit($controller) 35 | { 36 | Fluent::install_locale(); 37 | } 38 | 39 | public function updateRelativeLink(&$base, &$action) 40 | { 41 | 42 | if(Director::is_absolute_url($base)) return; 43 | 44 | 45 | if ( 46 | // Don't inject locale to subpages 47 | ($this->owner->ParentID && SiteTree::config()->nested_urls) && 48 | // add compatibility with Multisites 49 | !(class_exists('Site') && in_array($this->owner->ParentID, Site::get()->getIDList())) 50 | ) { 51 | return; 52 | } 53 | 54 | // For blank/temp pages such as Security controller fallback to querystring 55 | $locale = Fluent::current_locale(); 56 | if (!$this->owner->exists()) { 57 | $base = Controller::join_links($base, '?'.Fluent::config()->query_param.'='.urlencode($locale)); 58 | return; 59 | } 60 | 61 | // Check if this locale is the default for its own domain 62 | $domain = Fluent::domain_for_locale($locale); 63 | if ($locale === Fluent::default_locale($domain)) { 64 | // For home page in the default locale, do not alter home url 65 | if ($base === null) { 66 | return; 67 | } 68 | 69 | // If default locale shouldn't have prefix, then don't add prefix 70 | if (Fluent::disable_default_prefix()) { 71 | return; 72 | } 73 | 74 | // For all pages on a domain where there is only a single locale, 75 | // then the domain itself is sufficient to distinguish that domain 76 | // See https://github.com/tractorcow/silverstripe-fluent/issues/75 77 | $domainLocales = Fluent::locales($domain); 78 | if (count($domainLocales) === 1) { 79 | return; 80 | } 81 | } 82 | 83 | // Simply join locale root with base relative URL 84 | $localeURL = Fluent::alias($locale); 85 | $base = Controller::join_links($localeURL, $base); 86 | } 87 | 88 | public function LocaleLink($locale) 89 | { 90 | 91 | // For blank/temp pages such as Security controller fallback to querystring 92 | if (!$this->owner->exists()) { 93 | $url = Controller::curr()->getRequest()->getURL(); 94 | return Controller::join_links($url, '?'.Fluent::config()->query_param.'='.urlencode($locale)); 95 | } 96 | 97 | return parent::LocaleLink($locale); 98 | } 99 | 100 | /** 101 | * @param FieldList $fields 102 | */ 103 | public function updateCMSFields(FieldList $fields) 104 | { 105 | parent::updateCMSFields($fields); 106 | 107 | // Fix URLSegment field issue for root pages 108 | if (!SiteTree::config()->nested_urls || empty($this->owner->ParentID)) { 109 | $baseUrl = Director::baseURL(); 110 | if (class_exists('Subsite') && $this->owner->SubsiteID) { 111 | $baseUrl = Director::protocol() . $this->owner->Subsite()->domain() . '/'; 112 | } 113 | $baseLink = Director::absoluteURL(Controller::join_links( 114 | $baseUrl, 115 | Fluent::alias(Fluent::current_locale()), 116 | '/' 117 | )); 118 | $urlsegment = $fields->dataFieldByName('URLSegment'); 119 | $urlsegment->setURLPrefix($baseLink); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /code/extensions/Fluent_Extension.php: -------------------------------------------------------------------------------- 1 | 5 | */ 6 | class Fluent_Extension extends DataExtension 7 | { 8 | public function updateRegenerateRoutes(&$routes){ 9 | 10 | $controller = Fluent::config()->handling_controller; 11 | 12 | $ownerClass = $this->ownerBaseClass; 13 | 14 | // Explicit routes 15 | foreach ($ownerClass::locales() as $locale) { 16 | $url = $ownerClass::alias($locale); 17 | $routes[$url.'/$URLSegment!//$Action/$ID/$OtherID'] = array( 18 | 'Controller' => $controller, 19 | $ownerClass::config()->query_param => $locale 20 | ); 21 | $routes[$url] = array( 22 | 'Controller' => 'CompatibleFluentRootURLController', 23 | $ownerClass::config()->query_param => $locale 24 | ); 25 | } 26 | 27 | // Home page route 28 | $routes[''] = array( 29 | 'Controller' => 'CompatibleFluentRootURLController', 30 | ); 31 | 32 | } 33 | 34 | /** 35 | * Given a field on an object and optionally a locale, compare its locale value against the default locale value to 36 | * determine if the value is changed at the given locale. 37 | * 38 | * @param DataObject $object 39 | * @param FormField $field 40 | * @param string|null $locale Optional: if not provided, will be gathered from the request 41 | * @return boolean 42 | */ 43 | public static function isTableFieldModified(DataObject $object, FormField $field, $locale = null) 44 | { 45 | if (is_null($locale)) { 46 | $locale = Fluent::current_locale(); 47 | } 48 | 49 | if ($locale === $defaultLocale = Fluent::default_locale()) { 50 | // It's the default locale, so it's never "modified" from the default locale value 51 | return false; 52 | } 53 | 54 | $defaultField = Fluent::db_field_for_locale($field->getName(), $defaultLocale); 55 | $localeField = $field->getName(); 56 | 57 | $defaultValue = $object->$defaultField; 58 | $localeValue = $object->$localeField; 59 | 60 | if ((!empty($defaultValue) && empty($localeValue)) 61 | || ($defaultValue === $localeValue) 62 | ) { 63 | // Unchanged from default 64 | return false; 65 | } 66 | 67 | return true; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "internetrix/silverstripe-fluent-extra-table", 3 | "description" : "Extended silverstripe fluent module. Locales data are be stored in seperate tables.", 4 | "type" : "silverstripe-module", 5 | "keywords" : [ 6 | "silverstripe", 7 | "localisation", 8 | "localization", 9 | "translation", 10 | "language", 11 | "locale", 12 | "multilingual", 13 | "translatable", 14 | "i18n", 15 | "internationalisation", 16 | "internationalization", 17 | "extended", 18 | "extra", 19 | "table" 20 | ], 21 | "license" : "BSD-3-Clause", 22 | "authors" : [{ 23 | "name" : "Jason Zhang", 24 | "email" : "jason.zhang@internetrix.com.au" 25 | } 26 | ], 27 | "require" : { 28 | "silverstripe/framework" : "~3.3", 29 | "silverstripe/cms" : "~3.3", 30 | "tractorcow/silverstripe-fluent" : "~3.7" 31 | }, 32 | "extra" : { 33 | "installer-name" : "fluent-extra-table" 34 | } 35 | } --------------------------------------------------------------------------------