Completed
Push — dev2 ( 17a3b8...48fafe )
by Gordon
03:14
created

Translatable::getTranslations()   C

Complexity

Conditions 8
Paths 41

Size

Total Lines 48
Code Lines 32

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 48
rs 5.9322
cc 8
eloc 32
nc 41
nop 2
1
<?php
2
/**
3
 * The Translatable decorator allows your DataObjects to have versions in different languages,
4
 * defining which fields are can be translated. Translatable can be applied
5
 * to any {@link DataObject} subclass, but is mostly used with {@link SiteTree}.
6
 * Translatable is compatible with the {@link Versioned} extension.
7
 * To avoid cluttering up the database-schema of the 99% of sites without multiple languages,
8
 * the translation-feature is disabled by default.
9
 *
10
 * Locales (e.g. 'en_US') are used in Translatable for identifying a record by language,
11
 * see section "Locales and Language Tags".
12
 *
13
 * <h2>Configuration</h2>
14
 *
15
 * The extension is automatically enabled for SiteTree and SiteConfig records,
16
 * if they can be found. Add the following to your config.yml in order to
17
 * register a custom class:
18
 *
19
 * <code>
20
 * MyClass:
21
 *   extensions:
22
 *     Translatable
23
 * </code>
24
 *
25
 * Make sure to rebuild the database through /dev/build after enabling translatable.
26
 * Use the correct {@link set_default_locale()} before building the database
27
 * for the first time, as this locale will be written on all new records.
28
 *
29
 * <h3>"Default" locales</h3>
30
 *
31
 * Important: If the "default language" of your site is not US-English (en_US),
32
 * please ensure to set the appropriate default language for
33
 * your content before building the database with Translatable enabled:
34
 * <code>
35
 * Translatable::set_default_locale(<locale>); // e.g. 'de_DE' or 'fr_FR'
36
 * </code>
37
 *
38
 * For the Translatable class, a "locale" consists of a language code plus a region
39
 * code separated by an underscore,
40
 * for example "de_AT" for German language ("de") in the region Austria ("AT").
41
 * See http://www.w3.org/International/articles/language-tags/ for a detailed description.
42
 *
43
 * <h2>Usage</h2>
44
 *
45
 * Getting a translation for an existing instance:
46
 * <code>
47
 * $translatedObj = Translatable::get_one_by_locale('MyObject', 'de_DE');
48
 * </code>
49
 *
50
 * Getting a translation for an existing instance:
51
 * <code>
52
 * $obj = DataObject::get_by_id('MyObject', 99); // original language
53
 * $translatedObj = $obj->getTranslation('de_DE');
54
 * </code>
55
 *
56
 * Getting translations through {@link Translatable::set_current_locale()}.
57
 * This is *not* a recommended approach, but sometimes inavoidable (e.g. for {@link Versioned} methods).
58
 * <code>
59
 * $origLocale = Translatable::get_current_locale();
60
 * Translatable::set_current_locale('de_DE');
61
 * $obj = Versioned::get_one_by_stage('MyObject', "ID = 99");
62
 * Translatable::set_current_locale($origLocale);
63
 * </code>
64
 *
65
 * Creating a translation:
66
 * <code>
67
 * $obj = new MyObject();
68
 * $translatedObj = $obj->createTranslation('de_DE');
69
 * </code>
70
 *
71
 * <h2>Usage for SiteTree</h2>
72
 *
73
 * Translatable can be used for subclasses of {@link SiteTree},
74
 * it is automatically configured if this class is foun.
75
 *
76
 * If a child page translation is requested without the parent
77
 * page already having a translation in this language, the extension
78
 * will recursively create translations up the tree.
79
 * Caution: The "URLSegment" property is enforced to be unique across
80
 * languages by auto-appending the language code at the end.
81
 * You'll need to ensure that the appropriate "reading language" is set
82
 * before showing links to other pages on a website through $_GET['locale'].
83
 * Pages in different languages can have different publication states
84
 * through the {@link Versioned} extension.
85
 *
86
 * Note: You can't get Children() for a parent page in a different language
87
 * through set_current_locale(). Get the translated parent first.
88
 *
89
 * <code>
90
 * // wrong
91
 * Translatable::set_current_locale('de_DE');
92
 * $englishParent->Children();
93
 * // right
94
 * $germanParent = $englishParent->getTranslation('de_DE');
95
 * $germanParent->Children();
96
 * </code>
97
 *
98
 * <h2>Translation groups</h2>
99
 *
100
 * Each translation can have one or more related pages in other languages.
101
 * This relation is optional, meaning you can
102
 * create translations which have no representation in the "default language".
103
 * This means you can have a french translation with a german original,
104
 * without either of them having a representation
105
 * in the default english language tree.
106
 * Caution: There is no versioning for translation groups,
107
 * meaning associating an object with a group will affect both stage and live records.
108
 *
109
 * SiteTree database table (abbreviated)
110
 * ^ ID ^ URLSegment ^ Title ^ Locale ^
111
 * | 1 | about-us | About us | en_US |
112
 * | 2 | ueber-uns | Über uns | de_DE |
113
 * | 3 | contact | Contact | en_US |
114
 *
115
 * SiteTree_translationgroups database table
116
 * ^ TranslationGroupID ^ OriginalID ^
117
 * | 99 | 1 |
118
 * | 99 | 2 |
119
 * | 199 | 3 |
120
 *
121
 * <h2>Character Sets</h2>
122
 *
123
 * Caution: Does not apply any character-set conversion, it is assumed that all content
124
 * is stored and represented in UTF-8 (Unicode). Please make sure your database and
125
 * HTML-templates adjust to this.
126
 *
127
 * <h2>Permissions</h2>
128
 *
129
 * Authors without administrative access need special permissions to edit locales other than
130
 * the default locale.
131
 *
132
 * - TRANSLATE_ALL: Translate into all locales
133
 * - Translate_<locale>: Translate a specific locale. Only available for all locales set in
134
 *   `Translatable::set_allowed_locales()`.
135
 *
136
 * Note: If user-specific view permissions are required, please overload `SiteTree->canView()`.
137
 *
138
 * <h2>Uninstalling/Disabling</h2>
139
 *
140
 * Disabling Translatable after creating translations will lead to all
141
 * pages being shown in the default sitetree regardless of their language.
142
 * It is advised to start with a new database after uninstalling Translatable,
143
 * or manually filter out translated objects through their "Locale" property
144
 * in the database.
145
 *
146
 * @see http://doc.silverstripe.org/doku.php?id=multilingualcontent
147
 *
148
 * @author Ingo Schommer <ingo (at) silverstripe (dot) com>
149
 * @author Michael Gall <michael (at) wakeless (dot) net>
150
 * @author Bernat Foj Capell <[email protected]>
151
 *
152
 * @package translatable
153
 */
154
class Translatable extends DataExtension implements TestOnly {
155
156
	const QUERY_LOCALE_FILTER_ENABLED = 'Translatable.LocaleFilterEnabled';
157
158
	/**
159
	 * The 'default' language.
160
	 * @var string
161
	 */
162
	protected static $default_locale = 'en_US';
163
164
	/**
165
	 * The language in which we are reading dataobjects.
166
	 *
167
	 * @var string
168
	 */
169
	protected static $current_locale = null;
170
171
	/**
172
	 * A cached list of existing tables
173
	 *
174
	 * @var mixed
175
	 */
176
	protected static $tableList = null;
177
178
	/**
179
	 * An array of fields that can be translated.
180
	 * @var array
181
	 */
182
	protected $translatableFields = null;
183
184
	/**
185
	 * A map of the field values of the original (untranslated) DataObject record
186
	 * @var array
187
	 */
188
	protected $original_values = null;
189
190
	/**
191
	 * If this is set to TRUE then {@link augmentSQL()} will automatically add a filter
192
	 * clause to limit queries to the current {@link get_current_locale()}. This camn be
193
	 * disabled using {@link disable_locale_filter()}
194
	 *
195
	 * @var bool
196
	 */
197
	protected static $locale_filter_enabled = true;
198
199
	/**
200
	 * @var array All locales in which a translation can be created.
201
	 * This limits the choice in the CMS language dropdown in the
202
	 * "Translation" tab, as well as the language dropdown above
203
	 * the CMS tree. If not set, it will default to showing all
204
	 * common locales.
205
	 */
206
	protected static $allowed_locales = null;
207
208
	/**
209
	 * @var boolean Check other languages for URLSegment values (only applies to {@link SiteTree}).
210
	 * Turn this off to handle language setting yourself, e.g. through language-specific subdomains
211
	 * or URL path prefixes like "/en/mypage".
212
	 */
213
	private static $enforce_global_unique_urls = true;
214
215
	/**
216
	 * Exclude these fields from translation
217
	 *
218
	 * @var array
219
	 * @config
220
	 */
221
	private static $translate_excluded_fields = array(
222
		'ViewerGroups',
223
		'EditorGroups',
224
		'CanViewType',
225
		'CanEditType',
226
		'NewTransLang',
227
		'createtranslation'
228
	);
229
230
	/**
231
	 * Reset static configuration variables to their default values
232
	 */
233
	static function reset() {
234
		self::enable_locale_filter();
235
		self::$default_locale = 'en_US';
236
		self::$current_locale = null;
237
		self::$allowed_locales = null;
238
	}
239
240
	/**
241
	 * Choose the language the site is currently on.
242
	 *
243
	 * If $_GET['locale'] is currently set, then that locale will be used.
244
	 * Otherwise the member preference (if logged
245
	 * in) or default locale will be used.
246
	 *
247
	 * @todo Re-implement cookie and member option
248
	 *
249
	 * @param $langsAvailable array A numerical array of languages which are valid choices (optional)
250
	 * @return string Selected language (also saved in $current_locale).
251
	 */
252
	static function choose_site_locale($langsAvailable = array()) {
253
		self::set_current_locale(self::default_locale());
254
		return self::$current_locale;
255
	}
256
257
	/**
258
	 * Get the current reading language.
259
	 * This value has to be set before the schema is built with translatable enabled,
260
	 * any changes after this can cause unintended side-effects.
261
	 *
262
	 * @return string
263
	 */
264
	static function default_locale() {
265
		return self::$default_locale;
266
	}
267
268
269
	/**
270
	 * Get the current reading language.
271
	 * If its not chosen, call {@link choose_site_locale()}.
272
	 *
273
	 * @return string
274
	 */
275
	static function get_current_locale() {
276
		return (self::$current_locale) ? self::$current_locale : self::choose_site_locale();
277
	}
278
279
	/**
280
	 * Set the reading language, either namespaced to 'site' (website content)
281
	 * or 'cms' (management backend). This value is used in {@link augmentSQL()}
282
	 * to "auto-filter" all SELECT queries by this language.
283
	 * See {@link disable_locale_filter()} on how to override this behaviour temporarily.
284
	 *
285
	 * @param string $lang New reading language.
286
	 */
287
	static function set_current_locale($locale) {
288
		self::$current_locale = $locale;
289
	}
290
291
292
293
	/**
294
	 * @return bool
295
	 */
296
	public static function locale_filter_enabled() {
297
		return self::$locale_filter_enabled;
298
	}
299
300
	/**
301
	 * Enables automatic filtering by locale. This is normally called after is has been
302
	 * disabled using {@link disable_locale_filter()}.
303
	 *
304
	 * @param $enabled (default true), if false this call is a no-op - see {@link disable_locale_filter()}
305
	 */
306
	public static function enable_locale_filter($enabled = true) {
307
		if ($enabled) {
308
			self::$locale_filter_enabled = true;
309
		}
310
	}
311
312
	/**
313
	 * Disables automatic locale filtering in {@link augmentSQL()}. This can be re-enabled
314
	 * using {@link enable_locale_filter()}.
315
	 *
316
	 * Note that all places that disable the locale filter should generally re-enable it
317
	 * before returning from that block of code (function, etc). This is made easier by
318
	 * using the following pattern:
319
	 *
320
	 * <code>
321
	 * $enabled = Translatable::disable_locale_filter();
322
	 * // do some work here
323
	 * Translatable::enable_locale_filter($enabled);
324
	 * return $whateverYouNeedTO;
325
	 * </code>
326
	 *
327
	 * By using this pattern, the call to enable the filter will not re-enable it if it
328
	 * was not enabled initially.  That will keep code that called your function from
329
	 * breaking if it had already disabled the locale filter since it will not expect
330
	 * calling your function to change the global state by re-enabling the filter.
331
	 *
332
	 * @return boolean true if the locale filter was enabled, false if it was not
333
	 */
334
	public static function disable_locale_filter() {
335
		$enabled = self::$locale_filter_enabled;
336
		self::$locale_filter_enabled = false;
337
		return $enabled;
338
	}
339
340
341
342
343
	function setOwner($owner, $ownerBaseClass = null) {
344
		parent::setOwner($owner, $ownerBaseClass);
345
346
		// setting translatable fields by inspecting owner - this should really be done in the constructor
347
		if($this->owner && $this->translatableFields === null) {
348
			$this->translatableFields = array_merge(
349
				array_keys($this->owner->db()),
350
				array_keys($this->owner->has_many()),
351
				array_keys($this->owner->many_many())
352
			);
353
			foreach (array_keys($this->owner->has_one()) as $fieldname) {
354
				$this->translatableFields[] = $fieldname.'ID';
355
			}
356
		}
357
	}
358
359
	// FIXME - REMOVING THIS BREAKS TEST, BUT ZERO TEST COVERAGE...
360
	static function get_extra_config($class, $extensionClass, $args = null) {
361
		$config = array();
362
		$config['defaults'] = array(
363
			"Locale" => Translatable::default_locale() // as an overloaded getter as well: getLang()
364
		);
365
		$config['db'] = array(
366
			"Locale" => "DBLocale",
367
			//"TranslationMasterID" => "Int" // optional relation to a "translation master"
368
		);
369
		return $config;
370
	}
371
372
	/**
373
	 * Check if a given SQLQuery filters on the Locale field
374
	 *
375
	 * @param SQLQuery $query
376
	 * @return boolean
377
	 */
378
	protected function filtersOnLocale($query) {
379
		foreach($query->getWhere() as $condition) {
380
			if(preg_match('/("|\'|`)Locale("|\'|`)/', $condition)) return true;
381
		}
382
	}
383
384
	/**
385
	 * Changes any SELECT query thats not filtering on an ID
386
	 * to limit by the current language defined in {@link get_current_locale()}.
387
	 * It falls back to "Locale='' OR Lang IS NULL" and assumes that
388
	 * this implies querying for the default language.
389
	 *
390
	 * Use {@link disable_locale_filter()} to temporarily disable this "auto-filtering".
391
	 */
392
	public function augmentSQL(SQLQuery &$query, DataQuery $dataQuery = null) {
393
		// If the record is saved (and not a singleton), and has a locale,
394
		// limit the current call to its locale. This fixes a lot of problems
395
		// with other extensions like Versioned
396
		if($this->owner->ID && !empty($this->owner->Locale)) {
397
			$locale = $this->owner->Locale;
398
		} else {
399
			$locale = Translatable::get_current_locale();
400
		}
401
402
		$baseTable = ClassInfo::baseDataClass($this->owner->class);
403
		if(
404
			$locale
405
			// unless the filter has been temporarily disabled
406
			&& self::locale_filter_enabled()
407
			// or it was disabled when the DataQuery was created
408
			&& $dataQuery->getQueryParam(self::QUERY_LOCALE_FILTER_ENABLED)
409
			// DataObject::get_by_id() should work independently of language
410
			&& !$query->filtersOnID()
411
			// the query contains this table
412
			// @todo Isn't this always the case?!
413
			&& array_search($baseTable, array_keys($query->getFrom())) !== false
414
			// or we're already filtering by Lang (either from an earlier augmentSQL()
415
			// call or through custom SQL filters)
416
			&& !$this->filtersOnLocale($query)
417
			//&& !$query->filtersOnFK()
418
		)  {
419
			$qry = sprintf('"%s"."Locale" = \'%s\'', $baseTable, Convert::raw2sql($locale));
420
			$query->addWhere($qry);
421
		}
422
	}
423
424
	function augmentDataQueryCreation(SQLQuery &$sqlQuery, DataQuery &$dataQuery) {
425
		$enabled = self::locale_filter_enabled();
426
		$dataQuery->setQueryParam(self::QUERY_LOCALE_FILTER_ENABLED, $enabled);
427
	}
428
429
	 // FIXME - NO TEST COVERAGE BUT REQUIRED
430
	function augmentDatabase() {
431
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
432
		if($this->owner->class != $baseDataClass) return;
433
434
		$fields = array(
435
			'OriginalID' => 'Int',
436
			'TranslationGroupID' => 'Int',
437
		);
438
		$indexes = array(
439
			'OriginalID' => true,
440
			'TranslationGroupID' => true
441
		);
442
443
		// Add new tables if required
444
		DB::requireTable("{$baseDataClass}_translationgroups", $fields, $indexes);
445
446
		// Remove 2.2 style tables
447
		DB::dontRequireTable("{$baseDataClass}_lang");
448
		if($this->owner->hasExtension('Versioned')) {
449
			DB::dontRequireTable("{$baseDataClass}_lang_Live");
450
			DB::dontRequireTable("{$baseDataClass}_lang_versions");
451
		}
452
	}
453
454
	/**
455
	 * @todo Find more appropriate place to hook into database building
456
	 */
457
	public function requireDefaultRecords() {
458
		// @todo This relies on the Locale attribute being on the base data class, and not any subclasses
459
		if($this->owner->class != ClassInfo::baseDataClass($this->owner->class)) return false;
460
461
		// Permissions: If a group doesn't have any specific TRANSLATE_<locale> edit rights,
462
		// but has CMS_ACCESS_CMSMain (general CMS access), then assign TRANSLATE_ALL permissions as a default.
463
		// Auto-setting permissions based on these intransparent criteria is a bit hacky,
464
		// but unavoidable until we can determine when a certain permission code was made available first
465
		// (see http://open.silverstripe.org/ticket/4940)
466
		$groups = Permission::get_groups_by_permission(array(
467
			'CMS_ACCESS_CMSMain',
468
			'CMS_ACCESS_LeftAndMain',
469
			'ADMIN'
470
		));
471
		if($groups) foreach($groups as $group) {
472
			$codes = $group->Permissions()->column('Code');
473
			$hasTranslationCode = false;
474
			foreach($codes as $code) {
475
				if(preg_match('/^TRANSLATE_/', $code)) $hasTranslationCode = true;
476
			}
477
			// Only add the code if no more restrictive code exists
478
			if(!$hasTranslationCode) Permission::grant($group->ID, 'TRANSLATE_ALL');
479
		}
480
481
		// If the Translatable extension was added after the first records were already
482
		// created in the database, make sure to update the Locale property if
483
		// if wasn't set before
484
		$idsWithoutLocale = DB::query(sprintf(
485
			'SELECT "ID" FROM "%s" WHERE "Locale" IS NULL OR "Locale" = \'\'',
486
			ClassInfo::baseDataClass($this->owner->class)
487
		))->column();
488
		if(!$idsWithoutLocale) return;
489
490
491
492
	}
493
494
	/**
495
	 * Add a record to a "translation group",
496
	 * so its relationship to other translations
497
	 * based off the same object can be determined later on.
498
	 * See class header for further comments.
499
	 *
500
	 * @param int $originalID Either the primary key of the record this new translation is based on,
501
	 *  or the primary key of this record, to create a new translation group
502
	 * @param boolean $overwrite
503
	 */
504
	public function addTranslationGroup($originalID, $overwrite = false) {
505
		if(!$this->owner->exists()) return false;
506
507
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
508
		$existingGroupID = $this->getTranslationGroup($originalID);
509
510
		// Remove any existing groups if overwrite flag is set
511
		if($existingGroupID && $overwrite) {
512
			$sql = sprintf(
513
				'DELETE FROM "%s_translationgroups" WHERE "TranslationGroupID" = %d AND "OriginalID" = %d',
514
				$baseDataClass,
515
				$existingGroupID,
516
				$this->owner->ID
517
			);
518
			DB::query($sql);
519
			$existingGroupID = null;
520
		}
521
522
		// Add to group (only if not in existing group or $overwrite flag is set)
523
		if(!$existingGroupID) {
524
			$sql = sprintf(
525
				'INSERT INTO "%s_translationgroups" ("TranslationGroupID","OriginalID") VALUES (%d,%d)',
526
				$baseDataClass,
527
				$originalID,
528
				$this->owner->ID
529
			);
530
			DB::query($sql);
531
		}
532
	}
533
534
	/**
535
	 * Gets the translation group for the current record.
536
	 * This ID might equal the record ID, but doesn't have to -
537
	 * it just points to one "original" record in the list.
538
	 *
539
	 * @return int Numeric ID of the translationgroup in the <classname>_translationgroup table
540
	 */
541
	public function getTranslationGroup() {
542
		if(!$this->owner->exists()) return false;
543
544
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
545
		return DB::query(
546
			sprintf(
547
				'SELECT "TranslationGroupID" FROM "%s_translationgroups" WHERE "OriginalID" = %d',
548
				$baseDataClass,
549
				$this->owner->ID
550
			)
551
		)->value();
552
	}
553
554
	/**
555
	 * Removes a record from the translation group lookup table.
556
	 * Makes no assumptions on other records in the group - meaning
557
	 * if this happens to be the last record assigned to the group,
558
	 * this group ceases to exist.
559
	 */
560
	public function removeTranslationGroup() {
561
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
562
		DB::query(
563
			sprintf('DELETE FROM "%s_translationgroups" WHERE "OriginalID" = %d', $baseDataClass, $this->owner->ID)
564
		);
565
	}
566
567
	// FIXME - no coverage but is called
568
	function isVersionedTable($table) {
569
		return false;
570
	}
571
572
573
	/**
574
	 * Recursively creates translations for parent pages in this language
575
	 * if they aren't existing already. This is a necessity to make
576
	 * nested pages accessible in a translated CMS page tree.
577
	 * It would be more userfriendly to grey out untranslated pages,
578
	 * but this involves complicated special cases in AllChildrenIncludingDeleted().
579
	 *
580
	 * {@link SiteTree->onBeforeWrite()} will ensure that each translation will get
581
	 * a unique URL across languages, by means of {@link SiteTree::get_by_link()}
582
	 * and {@link Translatable->alternateGetByURL()}.
583
	 */
584
	function onBeforeWrite() {
585
		// If language is not set explicitly, set it to current_locale.
586
		// This might be a bit overzealous in assuming the language
587
		// of the content, as a "single language" website might be expanded
588
		// later on. See {@link requireDefaultRecords()} for batch setting
589
		// of empty Locale columns on each dev/build call.
590
		if(!$this->owner->Locale) {
591
			$this->owner->Locale = Translatable::get_current_locale();
592
		}
593
594
		// Specific logic for SiteTree subclasses.
595
		// If page has untranslated parents, create (unpublished) translations
596
		// of those as well to avoid having inaccessible children in the sitetree.
597
		// Caution: This logic is very sensitve to infinite loops when translation status isn't determined properly
598
		// If a parent for the newly written translation was existing before this
599
		// onBeforeWrite() call, it will already have been linked correctly through createTranslation()
600
		if(
601
			class_exists('SiteTree')
602
			&& $this->owner->hasField('ParentID')
603
			&& $this->owner instanceof SiteTree
604
		) {
605
			if(
606
				!$this->owner->ID
607
				&& $this->owner->ParentID
608
				&& !$this->owner->Parent()->hasTranslation($this->owner->Locale)
609
			) {
610
				$parentTranslation = $this->owner->Parent()->createTranslation($this->owner->Locale);
611
				$this->owner->ParentID = $parentTranslation->ID;
612
			}
613
		}
614
615
		// Has to be limited to the default locale, the assumption is that the "page type"
616
		// dropdown is readonly on all translations.
617
		if($this->owner->ID && $this->owner->Locale == Translatable::default_locale()) {
618
			$changedFields = $this->owner->getChangedFields();
619
			$changed = isset($changedFields['ClassName']);
620
621
			if ($changed && $this->owner->hasExtension('Versioned')) {
622
				// this is required because when publishing a node the before/after
623
				// values of $changedFields['ClassName'] will be the same because
624
				// the record was already written to the stage/draft table and thus
625
				// the record was updated, and then publish('Stage', 'Live') is
626
				// called, which uses forceChange, which will make all the fields
627
				// act as though they are changed, although the before/after values
628
				// will be the same
629
				// So, we load one from the current stage and test against it
630
				// This is to prevent the overhead of writing all translations when
631
				// the class didn't actually change.
632
				$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
633
				$currentStage = Versioned::current_stage();
634
				$fresh = Versioned::get_one_by_stage(
635
					$baseDataClass,
636
					Versioned::current_stage(),
637
					'"ID" = ' . $this->owner->ID,
638
					null
639
				);
640
				if ($fresh) {
641
					$changed = $changedFields['ClassName']['after'] != $fresh->ClassName;
642
				}
643
			}
644
645
			if($changed) {
646
				$this->owner->ClassName = $changedFields['ClassName']['before'];
647
				$translations = $this->owner->getTranslations();
648
				$this->owner->ClassName = $changedFields['ClassName']['after'];
649
				if($translations) foreach($translations as $translation) {
650
					$translation->setClassName($this->owner->ClassName);
651
					$translation = $translation->newClassInstance($translation->ClassName);
652
					$translation->populateDefaults();
653
					$translation->forceChange();
654
					$translation->write();
655
				}
656
			}
657
		}
658
659
		// see onAfterWrite()
660
		if(!$this->owner->ID) {
661
			$this->owner->_TranslatableIsNewRecord = true;
662
		}
663
	}
664
665
	function onAfterWrite() {
666
		// hacky way to determine if the record was created in the database,
667
		// or just updated
668
		if($this->owner->_TranslatableIsNewRecord) {
669
			// this would kick in for all new records which are NOT
670
			// created through createTranslation(), meaning they don't
671
			// have the translation group automatically set.
672
			$translationGroupID = $this->getTranslationGroup();
673
			if(!$translationGroupID) {
674
				$this->addTranslationGroup(
675
					$this->owner->_TranslationGroupID ? $this->owner->_TranslationGroupID : $this->owner->ID
676
				);
677
			}
678
			unset($this->owner->_TranslatableIsNewRecord);
679
			unset($this->owner->_TranslationGroupID);
680
		}
681
682
	}
683
684
685
686
	/**
687
	 * Attempt to get the page for a link in the default language that has been translated.
688
	 *
689
	 * @param string $URLSegment
690
	 * @param int|null $parentID
691
	 * @return SiteTree
692
	 */
693
	public function alternateGetByLink($URLSegment, $parentID) {
694
		// If the parentID value has come from a translated page,
695
		// then we need to find the corresponding parentID value
696
		// in the default Locale.
697
		if (
698
			is_int($parentID)
699
			&& $parentID > 0
700
			&& ($parent = DataObject::get_by_id('SiteTree', $parentID))
701
			&& ($parent->isTranslation())
702
		) {
703
			$parentID = $parent->getTranslationGroup();
704
		}
705
706
		// Find the locale language-independent of the page
707
		self::disable_locale_filter();
708
		$default = SiteTree::get()->where(sprintf (
709
			'"URLSegment" = \'%s\'%s',
710
			Convert::raw2sql($URLSegment),
711
			(is_int($parentID) ? " AND \"ParentID\" = $parentID" : null)
712
		))->First();
713
		self::enable_locale_filter();
714
715
		return $default;
716
	}
717
718
	//-----------------------------------------------------------------------------------------------//
719
720
721
	public function updateRelativeLink(&$base, &$action) {
722
		// Prevent home pages for non-default locales having their urlsegments
723
		// reduced to the site root.
724
		if($base === null && $this->owner->Locale != self::default_locale()){
725
			$base = $this->owner->URLSegment;
726
		}
727
	}
728
729
730
731
	function extendWithSuffix($table) {
732
		return $table;
733
	}
734
735
	/**
736
	 * Gets all related translations for the current object,
737
	 * excluding itself. See {@link getTranslation()} to retrieve
738
	 * a single translated object.
739
	 *
740
	 * Getter with $stage parameter is specific to {@link Versioned} extension,
741
	 * mostly used for {@link SiteTree} subclasses.
742
	 *
743
	 * @param string $locale
744
	 * @param string $stage
745
	 * @return DataObjectSet
746
	 */
747
	function getTranslations($locale = null, $stage = null) {
748
		if(!$this->owner->exists()) return new ArrayList();
749
750
		// HACK need to disable language filtering in augmentSQL(),
751
		// as we purposely want to get different language
752
		// also save state of locale-filter, revert to this state at the
753
		// end of this method
754
		$localeFilterEnabled = false;
755
		if(self::locale_filter_enabled()) {
756
			self::disable_locale_filter();
757
			$localeFilterEnabled = true;
758
		}
759
760
		$translationGroupID = $this->getTranslationGroup();
761
762
		$baseDataClass = ClassInfo::baseDataClass($this->owner->class);
763
		$filter = sprintf('"%s_translationgroups"."TranslationGroupID" = %d', $baseDataClass, $translationGroupID);
764
		if($locale) {
765
			$filter .= sprintf(' AND "%s"."Locale" = \'%s\'', $baseDataClass, Convert::raw2sql($locale));
766
		} else {
767
			// exclude the language of the current owner
768
			$filter .= sprintf(' AND "%s"."Locale" != \'%s\'', $baseDataClass, $this->owner->Locale);
769
		}
770
		$currentStage = Versioned::current_stage();
771
		$joinOnClause = sprintf('"%s_translationgroups"."OriginalID" = "%s"."ID"', $baseDataClass, $baseDataClass);
772
		if($this->owner->hasExtension("Versioned")) {
773
			if($stage) Versioned::reading_stage($stage);
774
			$translations = Versioned::get_by_stage(
775
				$baseDataClass,
776
				Versioned::current_stage(),
777
				$filter,
778
				null
779
			)->leftJoin("{$baseDataClass}_translationgroups", $joinOnClause);
780
			if($stage) Versioned::reading_stage($currentStage);
781
		} else {
782
			$class = $this->owner->class;
783
			$translations = $baseDataClass::get()
784
				->where($filter)
785
				->leftJoin("{$baseDataClass}_translationgroups", $joinOnClause);
786
		}
787
788
		// only re-enable locale-filter if it was enabled at the beginning of this method
789
		if($localeFilterEnabled) {
790
			self::enable_locale_filter();
791
		}
792
793
		return $translations;
794
	}
795
796
	/**
797
	 * Gets an existing translation based on the language code.
798
	 * Use {@link hasTranslation()} as a quicker alternative to check
799
	 * for an existing translation without getting the actual object.
800
	 *
801
	 * @param String $locale
802
	 * @return DataObject Translated object
803
	 */
804
	function getTranslation($locale, $stage = null) {
805
		$translations = $this->getTranslations($locale, $stage);
806
		return ($translations) ? $translations->First() : null;
807
	}
808
809
810
811
	/**
812
	 * Enables automatic population of SiteConfig fields using createTranslation if
813
	 * created outside of the Translatable module
814
	 * @var boolean
815
	 */
816
	public static $enable_siteconfig_generation = true;
817
818
819
820
821
	/**
822
	 * Get a list of languages with at least one element translated in (including the default language)
823
	 *
824
	 * @param string $className Look for languages in elements of this class
825
	 * @param string $where Optional SQL WHERE statement
826
	 * @return array Map of languages in the form locale => langName
827
	 */
828
	static function get_existing_content_languages($className = 'SiteTree', $where = '') {
829
		$baseTable = ClassInfo::baseDataClass($className);
830
		$query = new SQLQuery("Distinct \"Locale\"","\"$baseTable\"",$where, '', "\"Locale\"");
831
		$dbLangs = $query->execute()->column();
832
		$langlist = array_merge((array)Translatable::default_locale(), (array)$dbLangs);
833
		$returnMap = array();
834
		$allCodes = array_merge(
835
			Config::inst()->get('i18n', 'all_locales'),
836
			Config::inst()->get('i18n', 'common_locales')
837
		);
838
		foreach ($langlist as $langCode) {
839
			if($langCode && isset($allCodes[$langCode])) {
840
				if(is_array($allCodes[$langCode])) {
841
					$returnMap[$langCode] = $allCodes[$langCode]['name'];
842
				} else {
843
					$returnMap[$langCode] = $allCodes[$langCode];
844
				}
845
			}
846
		}
847
		return $returnMap;
848
	}
849
850
	/**
851
	 * Get the RelativeLink value for a home page in another locale. This is found by searching for the default home
852
	 * page in the default language, then returning the link to the translated version (if one exists).
853
	 *
854
	 * @return string
855
	 */
856
	public static function get_homepage_link_by_locale($locale) {
857
		$originalLocale = self::get_current_locale();
858
859
		self::set_current_locale(self::default_locale());
860
		$original = SiteTree::get_by_link(RootURLController::config()->default_homepage_link);
861
		self::set_current_locale($originalLocale);
862
863
		if($original) {
864
			if($translation = $original->getTranslation($locale)) return trim($translation->RelativeLink(true), '/');
865
		}
866
	}
867
868
869
	/**
870
	 * @deprecated 2.4 Use {@link Translatable::get_homepage_link_by_locale()}
871
	 */
872
	static function get_homepage_urlsegment_by_locale($locale) {
873
		user_error (
874
			'Translatable::get_homepage_urlsegment_by_locale() is deprecated, please use get_homepage_link_by_locale()',
875
			E_USER_NOTICE
876
		);
877
878
		return self::get_homepage_link_by_locale($locale);
879
	}
880
881
882
883
	/**
884
	 * Return a piece of text to keep DataObject cache keys appropriately specific
885
	 */
886
	function cacheKeyComponent() {
887
		return 'locale-'.self::get_current_locale();
888
	}
889
890
	/**
891
	 * Extends the SiteTree::validURLSegment() method, to do checks appropriate
892
	 * to Translatable
893
	 *
894
	 * @return bool
895
     */
896
	public function augmentValidURLSegment() {
897
		$reEnableFilter = false;
898
		if(!Config::inst()->get('Translatable', 'enforce_global_unique_urls')) {
899
			self::enable_locale_filter();
900
		} elseif(self::locale_filter_enabled()) {
901
			self::disable_locale_filter();
902
			$reEnableFilter = true;
903
		}
904
905
		$IDFilter = ($this->owner->ID) ? "AND \"SiteTree\".\"ID\" <> {$this->owner->ID}" :  null;
906
		$parentFilter = null;
907
908
		if (Config::inst()->get('SiteTree', 'nested_urls')) {
909
			if($this->owner->ParentID) {
910
				$parentFilter = " AND \"SiteTree\".\"ParentID\" = {$this->owner->ParentID}";
911
			} else {
912
				$parentFilter = ' AND "SiteTree"."ParentID" = 0';
913
			}
914
		}
915
916
		$existingPage = SiteTree::get()
917
			// disable get_one cache, as this otherwise may pick up results from when locale_filter was on
918
			->where("\"URLSegment\" = '{$this->owner->URLSegment}' $IDFilter $parentFilter")->First();
919
		if($reEnableFilter) self::enable_locale_filter();
920
921
		// By returning TRUE or FALSE, we overrule the base SiteTree->validateURLSegment() logic
922
		return !$existingPage;
923
	}
924
925
}
926