Translatable   F
last analyzed

Complexity

Total Complexity 278

Size/Duplication

Total Lines 1882
Duplicated Lines 1.01 %

Coupling/Cohesion

Components 1
Dependencies 1

Importance

Changes 0
Metric Value
wmc 278
lcom 1
cbo 1
dl 19
loc 1882
rs 0.8
c 0
b 0
f 0

75 Methods

Rating   Name   Duplication   Size   Complexity  
A reset() 0 7 1
A choose_site_locale() 0 19 6
A default_locale() 0 4 1
A set_default_locale() 0 16 4
A get_current_locale() 0 4 2
A set_current_locale() 0 8 3
A get_one_by_locale() 0 16 3
B get_by_locale() 0 25 7
A locale_filter_enabled() 0 4 1
A enable_locale_filter() 0 6 2
A disable_locale_filter() 0 6 1
B getTranslatedLocales() 0 39 5
A getAllowedLocalesForMember() 0 16 5
A get_langs_by_id() 0 5 2
A enable() 0 6 2
A disable() 0 6 2
A is_enabled() 0 8 2
A __construct() 0 19 1
A setOwner() 0 16 4
A get_extra_config() 0 12 1
B filtersOnLocale() 0 20 6
B augmentSQL() 0 40 11
A augmentDataQueryCreation() 0 5 1
A augmentDatabase() 0 26 3
D requireDefaultRecords() 0 88 17
A addTranslationGroup() 19 32 5
A getTranslationGroup() 0 15 2
A removeTranslationGroup() 0 7 1
A isVersionedTable() 0 4 1
A contentcontrollerInit() 0 4 1
A modelascontrollerInit() 0 4 1
A initgetEditForm() 0 4 1
C onBeforeWrite() 0 83 17
A onAfterWrite() 0 18 4
A onBeforeDelete() 0 13 2
B alternateGetByLink() 0 25 6
A applyTranslatableFieldsUpdate() 0 8 2
C updateCMSFields() 0 78 12
A updateSettingsFields() 0 4 1
A updateRelativeLink() 0 8 3
F addTranslatableFields() 0 98 20
A getTranslatableFields() 0 4 1
A baseTable() 0 6 3
A extendWithSuffix() 0 4 1
B getTranslations() 0 59 10
A getTranslation() 0 9 4
B populateSiteConfigDefaults() 0 49 10
A populateDefaults() 0 13 4
C createTranslation() 0 74 12
B canTranslate() 0 37 11
A canEdit() 0 7 3
A hasTranslation() 0 11 4
A AllChildrenIncludingDeleted() 0 6 1
A MetaTags() 0 17 3
B providePermissions() 0 40 6
A get_existing_content_languages() 0 22 5
A get_homepage_link_by_locale() 0 14 3
A get_homepage_urlsegment_by_locale() 0 9 1
A set_allowed_locales() 0 4 1
A get_allowed_locales() 0 4 1
A get_homepage_urlsegment_by_language() 0 4 1
A is_default_lang() 0 4 1
A set_default_lang() 0 4 1
A get_default_lang() 0 4 1
A current_lang() 0 4 1
A set_reading_lang() 0 4 1
A get_reading_lang() 0 4 1
A default_lang() 0 4 1
A get_by_lang() 0 8 1
A get_one_by_lang() 0 4 1
A isTranslation() 0 4 2
A choose_site_lang() 0 4 1
A getTranslatedLangs() 0 4 1
A cacheKeyComponent() 0 4 1
B augmentValidURLSegment() 0 31 7

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Translatable often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Translatable, and based on these observations, apply Extract Interface, too.

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 PermissionProvider
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
    public static function reset()
234
    {
235
        self::enable_locale_filter();
236
        self::$default_locale = 'en_US';
237
        self::$current_locale = null;
238
        self::$allowed_locales = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array of property $allowed_locales.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
239
    }
240
    
241
    /**
242
     * Choose the language the site is currently on.
243
     *
244
     * If $_GET['locale'] is currently set, then that locale will be used. 
245
     * Otherwise the member preference (if logged
246
     * in) or default locale will be used.
247
     * 
248
     * @todo Re-implement cookie and member option
249
     * 
250
     * @param $langsAvailable array A numerical array of languages which are valid choices (optional)
251
     * @return string Selected language (also saved in $current_locale).
252
     */
253
    public static function choose_site_locale($langsAvailable = array())
254
    {
255
        if (self::$current_locale) {
256
            return self::$current_locale;
257
        }
258
259
        if (
260
            (isset($_REQUEST['locale']) && !$langsAvailable)
0 ignored issues
show
Bug Best Practice introduced by
The expression $langsAvailable of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
261
            || (isset($_REQUEST['locale'])
262
            && in_array($_REQUEST['locale'], $langsAvailable))
263
        ) {
264
            // get from request parameter
265
            self::set_current_locale($_REQUEST['locale']);
266
        } else {
267
            self::set_current_locale(self::default_locale());
268
        }
269
270
        return self::$current_locale;
271
    }
272
        
273
    /**
274
     * Get the current reading language.
275
     * This value has to be set before the schema is built with translatable enabled,
276
     * any changes after this can cause unintended side-effects.
277
     * 
278
     * @return string
279
     */
280
    public static function default_locale()
281
    {
282
        return self::$default_locale;
283
    }
284
    
285
    /**
286
     * Set default language. Please set this value *before* creating
287
     * any database records (like pages), as this locale will be attached
288
     * to all new records.
289
     * 
290
     * @param $locale String
291
     */
292
    public static function set_default_locale($locale)
293
    {
294
        if ($locale && !i18n::validate_locale($locale)) {
295
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
296
        }
297
        
298
        $localeList = i18n::config()->all_locales;
299
        if (isset($localeList[$locale])) {
300
            self::$default_locale = $locale;
301
        } else {
302
            user_error(
303
                "Translatable::set_default_locale(): '$locale' is not a valid locale.",
304
                E_USER_WARNING
305
            );
306
        }
307
    }
308
309
    /**
310
     * Get the current reading language.
311
     * If its not chosen, call {@link choose_site_locale()}.
312
     * 
313
     * @return string
314
     */
315
    public static function get_current_locale()
316
    {
317
        return (self::$current_locale) ? self::$current_locale : self::choose_site_locale();
318
    }
319
        
320
    /**
321
     * Set the reading language, either namespaced to 'site' (website content)
322
     * or 'cms' (management backend). This value is used in {@link augmentSQL()}
323
     * to "auto-filter" all SELECT queries by this language.
324
     * See {@link disable_locale_filter()} on how to override this behaviour temporarily.
325
     * 
326
     * @param string $lang New reading language.
0 ignored issues
show
Bug introduced by
There is no parameter named $lang. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
327
     */
328
    public static function set_current_locale($locale)
329
    {
330
        if ($locale && !i18n::validate_locale($locale)) {
331
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
332
        }
333
        
334
        self::$current_locale = $locale;
335
    }
336
    
337
    /**
338
     * Get a singleton instance of a class in the given language.
339
     * @param string $class The name of the class.
340
     * @param string $locale  The name of the language.
341
     * @param string $filter A filter to be inserted into the WHERE clause.
342
     * @param boolean $cache Use caching (default: false)
343
     * @param string $orderby A sort expression to be inserted into the ORDER BY clause.
344
     * @return DataObject
345
     */
346
    public static function get_one_by_locale($class, $locale, $filter = '', $cache = false, $orderby = "")
347
    {
348
        if ($locale && !i18n::validate_locale($locale)) {
349
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
350
        }
351
        
352
        $orig = Translatable::get_current_locale();
353
        Translatable::set_current_locale($locale);
354
        $do = $class::get()
355
            ->where($filter)
356
            ->where(sprintf('"Locale" = \'%s\'', Convert::raw2sql($locale)))
357
            ->sort($orderby)
358
            ->First();
359
        Translatable::set_current_locale($orig);
360
        return $do;
361
    }
362
363
    /**
364
     * Get all the instances of the given class translated to the given language
365
     *
366
     * @param string $class The name of the class
367
     * @param string $locale  The name of the language
368
     * @param string $filter A filter to be inserted into the WHERE clause.
369
     * @param string $sort A sort expression to be inserted into the ORDER BY clause.
370
     * @param string $join A single join clause.  This can be used for filtering, only 1 
371
     *               instance of each DataObject will be returned.
372
     * @param string $limit A limit expression to be inserted into the LIMIT clause.
373
     * @param string $containerClass The container class to return the results in.
0 ignored issues
show
Bug introduced by
There is no parameter named $containerClass. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
374
     * @param string $having A filter to be inserted into the HAVING clause.
0 ignored issues
show
Bug introduced by
There is no parameter named $having. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
375
     * @return mixed The objects matching the conditions.
376
     */
377
    public static function get_by_locale($class, $locale, $filter = '', $sort = '', $join = "", $limit = "")
378
    {
379
        if ($locale && !i18n::validate_locale($locale)) {
380
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
381
        }
382
        
383
        $oldLang = self::get_current_locale();
384
        self::set_current_locale($locale);
385
        $result = $class::get();
386
        if ($filter) {
387
            $result = $result->where($filter);
388
        }
389
        if ($sort) {
390
            $result = $result->sort($sort);
391
        }
392
        if ($join) {
393
            $result = $result->leftJoin($join);
394
        }
395
        if ($limit) {
396
            $result = $result->limit($limit);
397
        }
398
        self::set_current_locale($oldLang);
399
400
        return $result;
401
    }
402
    
403
    /**
404
     * @return bool
405
     */
406
    public static function locale_filter_enabled()
407
    {
408
        return self::$locale_filter_enabled;
409
    }
410
    
411
    /**
412
     * Enables automatic filtering by locale. This is normally called after is has been
413
     * disabled using {@link disable_locale_filter()}.
414
     *
415
     * @param $enabled (default true), if false this call is a no-op - see {@link disable_locale_filter()}
416
     */
417
    public static function enable_locale_filter($enabled = true)
418
    {
419
        if ($enabled) {
420
            self::$locale_filter_enabled = true;
421
        }
422
    }
423
    
424
    /**
425
     * Disables automatic locale filtering in {@link augmentSQL()}. This can be re-enabled
426
     * using {@link enable_locale_filter()}.
427
     *
428
     * Note that all places that disable the locale filter should generally re-enable it
429
     * before returning from that block of code (function, etc). This is made easier by
430
     * using the following pattern:
431
     *
432
     * <code>
433
     * $enabled = Translatable::disable_locale_filter();
434
     * // do some work here
435
     * Translatable::enable_locale_filter($enabled);
436
     * return $whateverYouNeedTO;
437
     * </code>
438
     *
439
     * By using this pattern, the call to enable the filter will not re-enable it if it
440
     * was not enabled initially.  That will keep code that called your function from
441
     * breaking if it had already disabled the locale filter since it will not expect
442
     * calling your function to change the global state by re-enabling the filter.
443
     *
444
     * @return boolean true if the locale filter was enabled, false if it was not
445
     */
446
    public static function disable_locale_filter()
447
    {
448
        $enabled = self::$locale_filter_enabled;
449
        self::$locale_filter_enabled = false;
450
        return $enabled;
451
    }
452
    
453
    /**
454
     * Gets all translations for this specific page.
455
     * Doesn't include the language of the current record.
456
     * 
457
     * @return array Numeric array of all locales, sorted alphabetically.
458
     */
459
    public function getTranslatedLocales()
460
    {
461
        $langs = array();
462
        
463
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class); //Base Class
464
        $translationGroupClass = $baseDataClass . "_translationgroups";
465
        if ($this->owner->hasExtension("Versioned")  && Versioned::current_stage() == "Live") {
466
            $baseDataClass = $baseDataClass . "_Live";
467
        }
468
        
469
        $translationGroupID = $this->getTranslationGroup();
470
        if (is_numeric($translationGroupID)) {
471
            $query = new SQLSelect(
472
                'DISTINCT "Locale"',
473
                sprintf(
474
                    '"%s" LEFT JOIN "%s" ON "%s"."OriginalID" = "%s"."ID"',
475
                    $baseDataClass,
476
                    $translationGroupClass,
477
                    $translationGroupClass,
478
                    $baseDataClass
479
                ), // from
480
                sprintf(
481
                    '"%s"."TranslationGroupID" = %d AND "%s"."Locale" != \'%s\'',
482
                    $translationGroupClass,
483
                    $translationGroupID,
484
                    $baseDataClass,
485
                    $this->owner->Locale
486
                ) // where
487
            );
488
            $langs = $query->execute()->column();
489
        }
490
        if ($langs) {
491
            $langCodes = array_values($langs);
492
            sort($langCodes);
493
            return $langCodes;
494
        } else {
495
            return array();
496
        };
497
    }
498
    
499
    /**
500
     * Gets all locales that a member can access
501
     * as defined by {@link $allowed_locales}
502
     * and {@link canTranslate()}.
503
     * If {@link $allowed_locales} is not set and
504
     * the user has the `TRANSLATE_ALL` permission,
505
     * the method will return all available locales in the system.
506
     * 
507
     * @param Member $member
508
     * @return array Map of locales
509
     */
510
    public function getAllowedLocalesForMember($member)
511
    {
512
        $locales = self::get_allowed_locales();
513
        if (!$locales) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locales of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
514
            $locales = i18n::get_common_locales();
515
        }
516
        if ($locales) {
517
            foreach ($locales as $k => $locale) {
518
                if (!$this->canTranslate($member, $locale)) {
519
                    unset($locales[$k]);
520
                }
521
            }
522
        }
523
524
        return $locales;
525
    }
526
527
    /**
528
     * Get a list of languages in which a given element has been translated.
529
     * 
530
     * @deprecated 2.4 Use {@link getTranslations()}
531
     *
532
     * @param string $class Name of the class of the element
533
     * @param int $id ID of the element
534
     * @return array List of languages
535
     */
536
    public static function get_langs_by_id($class, $id)
537
    {
538
        $do = DataObject::get_by_id($class, $id);
539
        return ($do ? $do->getTranslatedLocales() : array());
540
    }
541
542
    /**
543
     * Enables the multilingual feature
544
     *
545
     * @deprecated 2.4 Use SiteTree::add_extension('Translatable')
546
     */
547
    public static function enable()
548
    {
549
        if (class_exists('SiteTree')) {
550
            SiteTree::add_extension('Translatable');
551
        }
552
    }
553
554
    /**
555
     * Disable the multilingual feature
556
     *
557
     * @deprecated 2.4 Use SiteTree::remove_extension('Translatable')
558
     */
559
    public static function disable()
560
    {
561
        if (class_exists('SiteTree')) {
562
            SiteTree::remove_extension('Translatable');
563
        }
564
    }
565
    
566
    /**
567
     * Check whether multilingual support has been enabled
568
     *
569
     * @deprecated 2.4 Use SiteTree::has_extension('Translatable')
570
     * @return boolean True if enabled
571
     */
572
    public static function is_enabled()
573
    {
574
        if (class_exists('SiteTree')) {
575
            return SiteTree::has_extension('Translatable');
576
        } else {
577
            return false;
578
        }
579
    }
580
    
581
        
582
    /**
583
     * Construct a new Translatable object.
584
     * @var array $translatableFields The different fields of the object that can be translated.
585
     * This is currently not implemented, all fields are marked translatable (see {@link setOwner()}).
586
     */
587
    public function __construct($translatableFields = null)
588
    {
589
        parent::__construct();
590
591
        // @todo Disabled selection of translatable fields - we're setting all fields as 
592
        // translatable in setOwner()
593
        /*
594
        if(!is_array($translatableFields)) {
595
            $translatableFields = func_get_args();
596
        }
597
        $this->translatableFields = $translatableFields;
598
        */
599
600
        // workaround for extending a method on another decorator (Hierarchy):
601
        // split the method into two calls, and overwrite the wrapper AllChildrenIncludingDeleted()
602
        // Has to be executed even with Translatable disabled, as it overwrites the method with same name
603
        // on Hierarchy class, and routes through to Hierarchy->doAllChildrenIncludingDeleted() instead.
604
        // Caution: There's an additional method for augmentAllChildrenIncludingDeleted()
605
    }
606
    
607
    public function setOwner($owner, $ownerBaseClass = null)
608
    {
609
        parent::setOwner($owner, $ownerBaseClass);
610
611
        // setting translatable fields by inspecting owner - this should really be done in the constructor
612
        if ($this->owner && $this->translatableFields === null) {
613
            $this->translatableFields = array_merge(
614
                array_keys($this->owner->db()),
615
                array_keys($this->owner->hasMany()),
616
                array_keys($this->owner->manyMany())
617
            );
618
            foreach (array_keys($this->owner->hasOne()) as $fieldname) {
619
                $this->translatableFields[] = $fieldname.'ID';
620
            }
621
        }
622
    }
623
624
    public static function get_extra_config($class, $extensionClass, $args = null)
625
    {
626
        $config = array();
627
        $config['defaults'] = array(
628
            "Locale" => Translatable::default_locale() // as an overloaded getter as well: getLang()
629
        );
630
        $config['db'] = array(
631
            "Locale" => "DBLocale",
632
            //"TranslationMasterID" => "Int" // optional relation to a "translation master"
633
        );
634
        return $config;
635
    }
636
637
    /**
638
     * Check if a given SQLSelect filters on the Locale field
639
     *
640
     * @param SQLSelect $query
641
     * @return boolean
642
     */
643
    protected function filtersOnLocale($query)
644
    {
645
        foreach ($query->getWhere() as $condition) {
646
            // Compat for 3.1/3.2 where syntax
647
            if (is_array($condition)) {
648
                // In >=3.2 each $condition is a single length array('condition' => array('params'))
649
                reset($condition);
650
                $condition = key($condition);
651
            }
652
            
653
            // >=3.2 allows conditions to be expressed as evaluatable objects
654
            if (interface_exists('SQLConditionGroup') && ($condition instanceof SQLConditionGroup)) {
0 ignored issues
show
Bug introduced by
The class SQLConditionGroup does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
655
                $condition = $condition->conditionSQL($params);
0 ignored issues
show
Bug introduced by
The variable $params does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
656
            }
657
            
658
            if (preg_match('/("|\'|`)Locale("|\'|`)/', $condition)) {
659
                return true;
660
            }
661
        }
662
    }
663
664
    /**
665
     * Changes any SELECT query thats not filtering on an ID
666
     * to limit by the current language defined in {@link get_current_locale()}.
667
     * It falls back to "Locale='' OR Lang IS NULL" and assumes that
668
     * this implies querying for the default language.
669
     * 
670
     * Use {@link disable_locale_filter()} to temporarily disable this "auto-filtering".
671
     */
672
    public function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null)
673
    {
674
        // If the record is saved (and not a singleton), and has a locale,
675
        // limit the current call to its locale. This fixes a lot of problems
676
        // with other extensions like Versioned
677
        if ($this->owner->ID && !empty($this->owner->Locale)) {
678
            $locale = $this->owner->Locale;
679
        } else {
680
            $locale = Translatable::get_current_locale();
681
        }
682
        
683
        $baseTable = ClassInfo::baseDataClass($this->owner->class);
684
        if (
685
            $locale
686
            // unless the filter has been temporarily disabled
687
            && self::locale_filter_enabled()
688
            // or it was disabled when the DataQuery was created
689
            && $dataQuery->getQueryParam(self::QUERY_LOCALE_FILTER_ENABLED)
690
            // DataObject::get_by_id() should work independently of language
691
            && !$query->filtersOnID()
692
            // the query contains this table
693
            // @todo Isn't this always the case?!
694
            && array_search($baseTable, array_keys($query->getFrom())) !== false
695
            //&& !$query->filtersOnFK()
696
        ) {
697
            // Or we're already filtering by Lang (either from an earlier augmentSQL() 
698
            // call or through custom SQL filters)
699
            $filtersOnLocale = array_filter($query->getWhere(), function ($predicates) {
700
                foreach ($predicates as $predicate => $params) {
701
                    if (preg_match('/("|\'|`)Locale("|\'|`)/', $predicate)) {
702
                        return true;
703
                    }
704
                }
705
            });
706
            if (!$filtersOnLocale) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filtersOnLocale of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
707
                $qry = sprintf('"%s"."Locale" = \'%s\'', $baseTable, Convert::raw2sql($locale));
708
                $query->addWhere($qry);
709
            }
710
        }
711
    }
712
713
    public function augmentDataQueryCreation(SQLSelect $sqlQuery, DataQuery $dataQuery)
714
    {
715
        $enabled = self::locale_filter_enabled();
716
        $dataQuery->setQueryParam(self::QUERY_LOCALE_FILTER_ENABLED, $enabled);
717
    }
718
    
719
    /**
720
     * Create <table>_translation database table to enable
721
     * tracking of "translation groups" in which each related
722
     * translation of an object acts as a sibling, rather than
723
     * a parent->child relation.
724
     */
725
    public function augmentDatabase()
726
    {
727
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
728
        if ($this->owner->class != $baseDataClass) {
729
            return;
730
        }
731
        
732
        $fields = array(
733
            'OriginalID' => 'Int',
734
            'TranslationGroupID' => 'Int',
735
        );
736
        $indexes = array(
737
            'OriginalID' => true,
738
            'TranslationGroupID' => true
739
        );
740
741
        // Add new tables if required
742
        DB::requireTable("{$baseDataClass}_translationgroups", $fields, $indexes);
743
        
744
        // Remove 2.2 style tables
745
        DB::dontRequireTable("{$baseDataClass}_lang");
746
        if ($this->owner->hasExtension('Versioned')) {
747
            DB::dontRequireTable("{$baseDataClass}_lang_Live");
748
            DB::dontRequireTable("{$baseDataClass}_lang_versions");
749
        }
750
    }
751
    
752
    /**
753
     * @todo Find more appropriate place to hook into database building
754
     */
755
    public function requireDefaultRecords()
756
    {
757
        // @todo This relies on the Locale attribute being on the base data class, and not any subclasses
758
        if ($this->owner->class != ClassInfo::baseDataClass($this->owner->class)) {
759
            return false;
760
        }
761
        
762
        // Permissions: If a group doesn't have any specific TRANSLATE_<locale> edit rights,
763
        // but has CMS_ACCESS_CMSMain (general CMS access), then assign TRANSLATE_ALL permissions as a default.
764
        // Auto-setting permissions based on these intransparent criteria is a bit hacky,
765
        // but unavoidable until we can determine when a certain permission code was made available first 
766
        // (see http://open.silverstripe.org/ticket/4940)
767
        $groups = Permission::get_groups_by_permission(array(
768
            'CMS_ACCESS_CMSMain',
769
            'CMS_ACCESS_LeftAndMain',
770
            'ADMIN'
771
        ));
772
        if ($groups) {
773
            foreach ($groups as $group) {
774
                $codes = $group->Permissions()->column('Code');
775
                $hasTranslationCode = false;
776
                foreach ($codes as $code) {
777
                    if (preg_match('/^TRANSLATE_/', $code)) {
778
                        $hasTranslationCode = true;
779
                    }
780
                }
781
            // Only add the code if no more restrictive code exists 
782
            if (!$hasTranslationCode) {
783
                Permission::grant($group->ID, 'TRANSLATE_ALL');
784
            }
785
            }
786
        }
787
        
788
        // If the Translatable extension was added after the first records were already
789
        // created in the database, make sure to update the Locale property if
790
        // if wasn't set before
791
        $idsWithoutLocale = DB::query(sprintf(
792
            'SELECT "ID" FROM "%s" WHERE "Locale" IS NULL OR "Locale" = \'\'',
793
            ClassInfo::baseDataClass($this->owner->class)
794
        ))->column();
795
        if (!$idsWithoutLocale) {
796
            return;
797
        }
798
        
799
        if (class_exists('SiteTree') && $this->owner->class == 'SiteTree') {
800
            foreach (array('Stage', 'Live') as $stage) {
801
                foreach ($idsWithoutLocale as $id) {
802
                    $obj = Versioned::get_one_by_stage(
803
                        $this->owner->class,
804
                        $stage,
805
                        sprintf('"SiteTree"."ID" = %d', $id)
806
                    );
807
                    if (!$obj || $obj->ObsoleteClassName) {
808
                        continue;
809
                    }
810
811
                    $obj->Locale = Translatable::default_locale();
812
                    
813
                    $oldMode = Versioned::get_reading_mode();
814
                    Versioned::reading_stage($stage);
815
                    $obj->writeWithoutVersion();
816
                    Versioned::set_reading_mode($oldMode);
817
                    
818
                    $obj->addTranslationGroup($obj->ID);
819
                    $obj->destroy();
820
                    unset($obj);
821
                }
822
            }
823
        } else {
824
            foreach ($idsWithoutLocale as $id) {
825
                $obj = DataObject::get_by_id($this->owner->class, $id);
826
                if (!$obj || $obj->ObsoleteClassName) {
827
                    continue;
828
                }
829
830
                $obj->Locale = Translatable::default_locale();
831
                $obj->write();
832
                $obj->addTranslationGroup($obj->ID);
833
                $obj->destroy();
834
                unset($obj);
835
            }
836
        }
837
        DB::alteration_message(sprintf(
838
            "Added default locale '%s' to table %s", "changed",
839
            Translatable::default_locale(),
840
            $this->owner->class
841
        ));
842
    }
843
    
844
    /**
845
     * Add a record to a "translation group",
846
     * so its relationship to other translations
847
     * based off the same object can be determined later on.
848
     * See class header for further comments.
849
     * 
850
     * @param int $originalID Either the primary key of the record this new translation is based on,
851
     *  or the primary key of this record, to create a new translation group
852
     * @param boolean $overwrite
853
     */
854
    public function addTranslationGroup($originalID, $overwrite = false)
855
    {
856
        if (!$this->owner->exists()) {
857
            return false;
858
        }
859
        
860
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
861
        $existingGroupID = $this->getTranslationGroup($originalID);
862
        
863
        // Remove any existing groups if overwrite flag is set
864 View Code Duplication
        if ($existingGroupID && $overwrite) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
865
            $sql = sprintf(
866
                'DELETE FROM "%s_translationgroups" WHERE "TranslationGroupID" = %d AND "OriginalID" = %d',
867
                $baseDataClass,
868
                $existingGroupID,
869
                $this->owner->ID
870
            );
871
            DB::query($sql);
872
            $existingGroupID = null;
873
        }
874
        
875
        // Add to group (only if not in existing group or $overwrite flag is set)
876 View Code Duplication
        if (!$existingGroupID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $existingGroupID of type null|integer is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
877
            $sql = sprintf(
878
                'INSERT INTO "%s_translationgroups" ("TranslationGroupID","OriginalID") VALUES (%d,%d)',
879
                $baseDataClass,
880
                $originalID,
881
                $this->owner->ID
882
            );
883
            DB::query($sql);
884
        }
885
    }
886
    
887
    /**
888
     * Gets the translation group for the current record.
889
     * This ID might equal the record ID, but doesn't have to -
890
     * it just points to one "original" record in the list.
891
     * 
892
     * @return int Numeric ID of the translationgroup in the <classname>_translationgroup table
893
     */
894
    public function getTranslationGroup()
895
    {
896
        if (!$this->owner->exists()) {
897
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type documented by Translatable::getTranslationGroup of type integer.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
898
        }
899
        
900
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
901
        return DB::query(
902
            sprintf(
903
                'SELECT "TranslationGroupID" FROM "%s_translationgroups" WHERE "OriginalID" = %d',
904
                $baseDataClass,
905
                $this->owner->ID
906
            )
907
        )->value();
908
    }
909
    
910
    /**
911
     * Removes a record from the translation group lookup table.
912
     * Makes no assumptions on other records in the group - meaning
913
     * if this happens to be the last record assigned to the group,
914
     * this group ceases to exist.
915
     */
916
    public function removeTranslationGroup()
917
    {
918
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
919
        DB::query(
920
            sprintf('DELETE FROM "%s_translationgroups" WHERE "OriginalID" = %d', $baseDataClass, $this->owner->ID)
921
        );
922
    }
923
    
924
    /**
925
     * Determine if a table needs Versioned support
926
     * This is called at db/build time
927
     *
928
     * @param string $table Table name
929
     * @return boolean
930
     */
931
    public function isVersionedTable($table)
932
    {
933
        return false;
934
    }
935
936
    /**
937
     * Note: The bulk of logic is in ModelAsController->getNestedController()
938
     * and ContentController->handleRequest()
939
     */
940
    public function contentcontrollerInit($controller)
941
    {
942
        $controller->Locale = Translatable::choose_site_locale();
943
    }
944
    
945
    public function modelascontrollerInit($controller)
946
    {
947
        //$this->contentcontrollerInit($controller);
948
    }
949
    
950
    public function initgetEditForm($controller)
951
    {
952
        $this->contentcontrollerInit($controller);
953
    }
954
955
    /**
956
     * Recursively creates translations for parent pages in this language
957
     * if they aren't existing already. This is a necessity to make
958
     * nested pages accessible in a translated CMS page tree.
959
     * It would be more userfriendly to grey out untranslated pages,
960
     * but this involves complicated special cases in AllChildrenIncludingDeleted().
961
     * 
962
     * {@link SiteTree->onBeforeWrite()} will ensure that each translation will get
963
     * a unique URL across languages, by means of {@link SiteTree::get_by_link()}
964
     * and {@link Translatable->alternateGetByURL()}.
965
     */
966
    public function onBeforeWrite()
967
    {
968
        // If language is not set explicitly, set it to current_locale.
969
        // This might be a bit overzealous in assuming the language
970
        // of the content, as a "single language" website might be expanded
971
        // later on. See {@link requireDefaultRecords()} for batch setting
972
        // of empty Locale columns on each dev/build call.
973
        if (!$this->owner->Locale) {
974
            $this->owner->Locale = Translatable::get_current_locale();
975
        }
976
977
        // Specific logic for SiteTree subclasses.
978
        // If page has untranslated parents, create (unpublished) translations
979
        // of those as well to avoid having inaccessible children in the sitetree.
980
        // Caution: This logic is very sensitve to infinite loops when translation status isn't determined properly
981
        // If a parent for the newly written translation was existing before this
982
        // onBeforeWrite() call, it will already have been linked correctly through createTranslation()
983
        if (
984
            class_exists('SiteTree')
985
            && $this->owner->hasField('ParentID')
986
            && $this->owner instanceof SiteTree
0 ignored issues
show
Bug introduced by
The class SiteTree does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
987
        ) {
988
            if (
989
                !$this->owner->ID
990
                && $this->owner->ParentID
991
                && !$this->owner->Parent()->hasTranslation($this->owner->Locale)
992
            ) {
993
                $parentTranslation = $this->owner->Parent()->createTranslation($this->owner->Locale);
994
                $this->owner->ParentID = $parentTranslation->ID;
995
            }
996
        }
997
        
998
        // Has to be limited to the default locale, the assumption is that the "page type"
999
        // dropdown is readonly on all translations.
1000
        if ($this->owner->ID && $this->owner->Locale == Translatable::default_locale()) {
1001
            $changedFields = $this->owner->getChangedFields();
1002
            $changed = isset($changedFields['ClassName']);
1003
1004
            if ($changed && $this->owner->hasExtension('Versioned')) {
1005
                // this is required because when publishing a node the before/after
1006
                // values of $changedFields['ClassName'] will be the same because
1007
                // the record was already written to the stage/draft table and thus
1008
                // the record was updated, and then publish('Stage', 'Live') is
1009
                // called, which uses forceChange, which will make all the fields
1010
                // act as though they are changed, although the before/after values
1011
                // will be the same
1012
                // So, we load one from the current stage and test against it
1013
                // This is to prevent the overhead of writing all translations when
1014
                // the class didn't actually change.
1015
                $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
1016
                $currentStage = Versioned::current_stage();
0 ignored issues
show
Unused Code introduced by
$currentStage is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1017
                $fresh = Versioned::get_one_by_stage(
1018
                    $baseDataClass,
1019
                    Versioned::current_stage(),
1020
                    '"ID" = ' . $this->owner->ID,
1021
                    null
1022
                );
1023
                if ($fresh) {
1024
                    $changed = $changedFields['ClassName']['after'] != $fresh->ClassName;
1025
                }
1026
            }
1027
1028
            if ($changed) {
1029
                $this->owner->ClassName = $changedFields['ClassName']['before'];
1030
                $translations = $this->owner->getTranslations();
1031
                $this->owner->ClassName = $changedFields['ClassName']['after'];
1032
                if ($translations) {
1033
                    foreach ($translations as $translation) {
1034
                        $translation->setClassName($this->owner->ClassName);
1035
                        $translation = $translation->newClassInstance($translation->ClassName);
1036
                        $translation->populateDefaults();
1037
                        $translation->forceChange();
1038
                        $translation->write();
1039
                    }
1040
                }
1041
            }
1042
        }
1043
        
1044
        // see onAfterWrite()
1045
        if (!$this->owner->ID) {
1046
            $this->owner->_TranslatableIsNewRecord = true;
1047
        }
1048
    }
1049
    
1050
    public function onAfterWrite()
1051
    {
1052
        // hacky way to determine if the record was created in the database,
1053
        // or just updated
1054
        if ($this->owner->_TranslatableIsNewRecord) {
1055
            // this would kick in for all new records which are NOT
1056
            // created through createTranslation(), meaning they don't
1057
            // have the translation group automatically set.
1058
            $translationGroupID = $this->getTranslationGroup();
1059
            if (!$translationGroupID) {
1060
                $this->addTranslationGroup(
1061
                    $this->owner->_TranslationGroupID ? $this->owner->_TranslationGroupID : $this->owner->ID
1062
                );
1063
            }
1064
            unset($this->owner->_TranslatableIsNewRecord);
1065
            unset($this->owner->_TranslationGroupID);
1066
        }
1067
    }
1068
    
1069
    /**
1070
     * Remove the record from the translation group mapping.
1071
     */
1072
    public function onBeforeDelete()
1073
    {
1074
        // @todo Coupling to Versioned, we need to avoid removing
1075
        // translation groups if records are just deleted from a stage
1076
        // (="unpublished"). Ideally the translation group tables would
1077
        // be specific to different Versioned changes, making this restriction unnecessary.
1078
        // This will produce orphaned translation group records for SiteTree subclasses.
1079
        if (!$this->owner->hasExtension('Versioned')) {
1080
            $this->removeTranslationGroup();
1081
        }
1082
1083
        parent::onBeforeDelete();
1084
    }
1085
    
1086
    /**
1087
     * Attempt to get the page for a link in the default language that has been translated.
1088
     *
1089
     * @param string $URLSegment
1090
     * @param int|null $parentID
1091
     * @return SiteTree
1092
     */
1093
    public function alternateGetByLink($URLSegment, $parentID)
1094
    {
1095
        // If the parentID value has come from a translated page, 
1096
        // then we need to find the corresponding parentID value
1097
        // in the default Locale.
1098
        if (
1099
            is_int($parentID)
1100
            && $parentID > 0
1101
            && ($parent = DataObject::get_by_id('SiteTree', $parentID))
1102
            && ($parent->isTranslation())
1103
        ) {
1104
            $parentID = $parent->getTranslationGroup();
1105
        }
1106
        
1107
        // Find the locale language-independent of the page
1108
        self::disable_locale_filter();
1109
        $default = SiteTree::get()->where(sprintf(
1110
            '"URLSegment" = \'%s\'%s',
1111
            Convert::raw2sql($URLSegment),
1112
            (is_int($parentID) ? " AND \"ParentID\" = $parentID" : null)
1113
        ))->First();
1114
        self::enable_locale_filter();
1115
        
1116
        return $default;
1117
    }
1118
    
1119
    //-----------------------------------------------------------------------------------------------//
1120
1121
    public function applyTranslatableFieldsUpdate($fields, $type)
1122
    {
1123
        if (method_exists($this, $type)) {
1124
            $this->$type($fields);
1125
        } else {
1126
            throw new InvalidArgumentException("Method $type does not exist on object of type ".  get_class($this));
1127
        }
1128
    }
1129
    
1130
    /**
1131
     * If the record is not shown in the default language, this method
1132
     * will try to autoselect a master language which is shown alongside
1133
     * the normal formfields as a readonly representation.
1134
     * This gives translators a powerful tool for their translation workflow
1135
     * without leaving the translated page interface.
1136
     * Translatable also adds a new tab "Translation" which shows existing
1137
     * translations, as well as a formaction to create new translations based
1138
     * on a dropdown with available languages.
1139
     *
1140
     * This method can be called multiple times on the same FieldList
1141
     * because it checks which fields have already been added or modified.
1142
     * 
1143
     * @todo This is specific to SiteTree and CMSMain
1144
     * @todo Implement a special "translation mode" which triggers display of the
1145
     * readonly fields, so you can translation INTO the "default language" while
1146
     * seeing readonly fields as well.
1147
     */
1148
    public function updateCMSFields(FieldList $fields)
1149
    {
1150
        $this->addTranslatableFields($fields);
1151
        
1152
        // Show a dropdown to create a new translation.
1153
        // This action is possible both when showing the "default language"
1154
        // and a translation. Include the current locale (record might not be saved yet).
1155
        $alreadyTranslatedLocales = $this->getTranslatedLocales();
1156
        $alreadyTranslatedLocales[$this->owner->Locale] = $this->owner->Locale;
1157
        $alreadyTranslatedLocales = array_combine($alreadyTranslatedLocales, $alreadyTranslatedLocales);
1158
1159
        // Check if fields exist already to avoid adding them twice on repeat invocations
1160
        $tab = $fields->findOrMakeTab('Root.Translations', _t('Translatable.TRANSLATIONS', 'Translations'));
1161
        if (!$tab->fieldByName('CreateTransHeader')) {
1162
            $tab->push(new HeaderField(
1163
                'CreateTransHeader',
1164
                _t('Translatable.CREATE', 'Create new translation'),
1165
                2
1166
            ));
1167
        }
1168
        if (!$tab->fieldByName('NewTransLang') && !$tab->fieldByName('AllTransCreated')) {
1169
            $langDropdown = LanguageDropdownField::create(
1170
                "NewTransLang",
1171
                _t('Translatable.NEWLANGUAGE', 'New language'),
1172
                $alreadyTranslatedLocales,
1173
                'SiteTree',
1174
                'Locale-English',
1175
                $this->owner
1176
            )->addExtraClass('languageDropdown no-change-track');
1177
            $tab->push($langDropdown);
1178
            $canAddLocale = (count($langDropdown->getSource()) > 0);
1179
1180
            if ($canAddLocale) {
1181
                // Only add create button if new languages are available
1182
                $tab->push(
1183
                    $createButton = InlineFormAction::create(
1184
                        'createtranslation',
1185
                        _t('Translatable.CREATEBUTTON', 'Create')
1186
                    )->addExtraClass('createTranslationButton')
1187
                );
1188
                $createButton->includeDefaultJS(false); // not fluent API...
1189
            } else {
1190
                $tab->removeByName('NewTransLang');
1191
                $tab->push(new LiteralField(
1192
                    'AllTransCreated',
1193
                    _t('Translatable.ALLCREATED', 'All allowed translations have been created.')
1194
                ));
1195
            }
1196
        }
1197
        if ($alreadyTranslatedLocales) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $alreadyTranslatedLocales of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1198
            if (!$tab->fieldByName('ExistingTransHeader')) {
1199
                $tab->push(new HeaderField(
1200
                    'ExistingTransHeader',
1201
                    _t('Translatable.EXISTING', 'Existing translations'),
1202
                    3
1203
                ));
1204
                if (!$tab->fieldByName('existingtrans')) {
1205
                    $existingTransHTML = '<ul>';
1206
                    if ($existingTranslations = $this->getTranslations()) {
1207
                        foreach ($existingTranslations as $existingTranslation) {
1208
                            if ($existingTranslation && $existingTranslation->hasMethod('CMSEditLink')) {
1209
                                $existingTransHTML .= sprintf(
1210
                                    '<li><a href="%s">%s</a></li>',
1211
                                    Controller::join_links(
1212
                                        $existingTranslation->CMSEditLink(),
1213
                                        '?Locale=' . $existingTranslation->Locale
1214
                                    ),
1215
                                    i18n::get_locale_name($existingTranslation->Locale)
1216
                                );
1217
                            }
1218
                        }
1219
                    }
1220
                    $existingTransHTML .= '</ul>';
1221
                    $tab->push(new LiteralField('existingtrans', $existingTransHTML));
1222
                }
1223
            }
1224
        }
1225
    }
1226
    
1227
    public function updateSettingsFields(&$fields)
1228
    {
1229
        $this->addTranslatableFields($fields);
1230
    }
1231
1232
    public function updateRelativeLink(&$base, &$action)
1233
    {
1234
        // Prevent home pages for non-default locales having their urlsegments
1235
        // reduced to the site root.
1236
        if ($base === null && $this->owner->Locale != self::default_locale()) {
1237
            $base = $this->owner->URLSegment;
1238
        }
1239
    }
1240
1241
    /**
1242
     * This method can be called multiple times on the same FieldList
1243
     * because it checks which fields have already been added or modified.
1244
     */
1245
    protected function addTranslatableFields(&$fields)
1246
    {
1247
        // used in LeftAndMain->init() to set language state when reading/writing record
1248
        $fields->push(new HiddenField("Locale", "Locale", $this->owner->Locale));
1249
        
1250
        // Don't apply these modifications for normal DataObjects - they rely on CMSMain logic
1251
        if (!class_exists('SiteTree')) {
1252
            return;
1253
        }
1254
        if (!($this->owner instanceof SiteTree)) {
0 ignored issues
show
Bug introduced by
The class SiteTree does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1255
            return;
1256
        }
1257
        
1258
        // Don't allow translation of virtual pages because of data inconsistencies (see #5000)
1259
        if (class_exists('VirtualPage')) {
1260
            $excludedPageTypes = array('VirtualPage');
1261
            foreach ($excludedPageTypes as $excludedPageType) {
1262
                if (is_a($this->owner, $excludedPageType)) {
1263
                    return;
1264
                }
1265
            }
1266
        }
1267
        
1268
        // Get excluded fields from translation
1269
        $excludeFields = $this->owner->config()->translate_excluded_fields;
1270
1271
        // if a language other than default language is used, we're in "translation mode",
1272
        // hence have to modify the original fields
1273
        $baseClass = $this->owner->class;
1274
        while (($p = get_parent_class($baseClass)) != "DataObject") {
1275
            $baseClass = $p;
1276
        }
1277
1278
        // try to get the record in "default language"
1279
        $originalRecord = $this->owner->getTranslation(Translatable::default_locale());
1280
        // if no translation in "default language", fall back to first translation
1281
        if (!$originalRecord) {
1282
            $translations = $this->owner->getTranslations();
1283
            $originalRecord = ($translations) ? $translations->First() : null;
1284
        }
1285
        
1286
        $isTranslationMode = $this->owner->Locale != Translatable::default_locale();
1287
        
1288
        if ($originalRecord && $isTranslationMode) {
1289
            // Remove parent page dropdown
1290
            $fields->removeByName("ParentType");
1291
            $fields->removeByName("ParentID");
1292
            
1293
            $translatableFieldNames = $this->getTranslatableFields();
1294
            $allDataFields = $fields->dataFields();
1295
            
1296
            $transformation = new Translatable_Transformation($originalRecord);
1297
            
1298
            // iterate through sequential list of all datafields in fieldset
1299
            // (fields are object references, so we can replace them with the translatable CompositeField)
1300
            foreach ($allDataFields as $dataField) {
1301
                // Transformation is a visual helper for CMS authors, so ignore hidden fields
1302
                if ($dataField instanceof HiddenField) {
0 ignored issues
show
Bug introduced by
The class HiddenField does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1303
                    continue;
1304
                }
1305
                // Some fields are explicitly excluded from transformation
1306
                if (in_array($dataField->getName(), $excludeFields)) {
1307
                    continue;
1308
                }
1309
                // Readonly field which has been added previously
1310
                if (preg_match('/_original$/', $dataField->getName())) {
1311
                    continue;
1312
                }
1313
                // Field already has been transformed
1314
                if (isset($allDataFields[$dataField->getName() . '_original'])) {
1315
                    continue;
1316
                }
1317
                // CheckboxField which is already transformed
1318
                if (preg_match('/class=\"originalvalue\"/', $dataField->Title())) {
1319
                    continue;
1320
                }
1321
                
1322
                if (in_array($dataField->getName(), $translatableFieldNames)) {
1323
                    // if the field is translatable, perform transformation
1324
                    $fields->replaceField($dataField->getName(), $transformation->transformFormField($dataField));
1325
                } elseif (!$dataField->isReadonly()) {
1326
                    // else field shouldn't be editable in translation-mode, make readonly
1327
                    $fields->replaceField($dataField->getName(), $dataField->performReadonlyTransformation());
1328
                }
1329
            }
1330
        } elseif ($this->owner->isNew()) {
1331
            $fields->addFieldsToTab(
1332
                'Root',
1333
                new Tab(_t('Translatable.TRANSLATIONS', 'Translations'),
1334
                    new LiteralField('SaveBeforeCreatingTranslationNote',
1335
                        sprintf('<p class="message">%s</p>',
1336
                            _t('Translatable.NOTICENEWPAGE', 'Please save this page before creating a translation')
1337
                        )
1338
                    )
1339
                )
1340
            );
1341
        }
1342
    }
1343
        
1344
    /**
1345
     * Get the names of all translatable fields on this class as a numeric array.
1346
     * @todo Integrate with blacklist once branches/translatable is merged back.
1347
     * 
1348
     * @return array
1349
     */
1350
    public function getTranslatableFields()
1351
    {
1352
        return $this->translatableFields;
1353
    }
1354
        
1355
    /**
1356
     * Return the base table - the class that directly extends DataObject.
1357
     * @return string
1358
     */
1359
    public function baseTable($stage = null)
1360
    {
1361
        $tableClasses = ClassInfo::dataClassesFor($this->owner->class);
1362
        $baseClass = array_shift($tableClasses);
1363
        return (!$stage || $stage == $this->defaultStage) ? $baseClass : $baseClass . "_$stage";
1364
    }
1365
    
1366
    public function extendWithSuffix($table)
1367
    {
1368
        return $table;
1369
    }
1370
        
1371
    /**
1372
     * Gets all related translations for the current object,
1373
     * excluding itself. See {@link getTranslation()} to retrieve
1374
     * a single translated object.
1375
     * 
1376
     * Getter with $stage parameter is specific to {@link Versioned} extension,
1377
     * mostly used for {@link SiteTree} subclasses.
1378
     * 
1379
     * @param string $locale
1380
     * @param string $stage 
1381
     * @return DataObjectSet
1382
     */
1383
    public function getTranslations($locale = null, $stage = null)
1384
    {
1385
        if ($locale && !i18n::validate_locale($locale)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locale of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1386
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1387
        }
1388
        
1389
        if (!$this->owner->exists()) {
1390
            return new ArrayList();
0 ignored issues
show
Bug Best Practice introduced by
The return type of return new \ArrayList(); (ArrayList) is incompatible with the return type documented by Translatable::getTranslations of type DataObjectSet.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
1391
        }
1392
1393
        // HACK need to disable language filtering in augmentSQL(), 
1394
        // as we purposely want to get different language
1395
        // also save state of locale-filter, revert to this state at the
1396
        // end of this method
1397
        $localeFilterEnabled = false;
1398
        if (self::locale_filter_enabled()) {
1399
            self::disable_locale_filter();
1400
            $localeFilterEnabled = true;
1401
        }
1402
1403
        $translationGroupID = $this->getTranslationGroup();
1404
        
1405
        $baseDataClass = ClassInfo::baseDataClass($this->owner->class);
1406
        $filter = sprintf('"%s_translationgroups"."TranslationGroupID" = %d', $baseDataClass, $translationGroupID);
1407
        if ($locale) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locale of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1408
            $filter .= sprintf(' AND "%s"."Locale" = \'%s\'', $baseDataClass, Convert::raw2sql($locale));
1409
        } else {
1410
            // exclude the language of the current owner
1411
            $filter .= sprintf(' AND "%s"."Locale" != \'%s\'', $baseDataClass, $this->owner->Locale);
1412
        }
1413
        $currentStage = Versioned::current_stage();
1414
        $joinOnClause = sprintf('"%s_translationgroups"."OriginalID" = "%s"."ID"', $baseDataClass, $baseDataClass);
1415
        if ($this->owner->hasExtension("Versioned")) {
1416
            if ($stage) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stage of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1417
                Versioned::reading_stage($stage);
1418
            }
1419
            $translations = Versioned::get_by_stage(
1420
                $baseDataClass,
1421
                Versioned::current_stage(),
1422
                $filter,
1423
                null
1424
            )->leftJoin("{$baseDataClass}_translationgroups", $joinOnClause);
1425
            if ($stage) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stage of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1426
                Versioned::reading_stage($currentStage);
1427
            }
1428
        } else {
1429
            $class = $this->owner->class;
0 ignored issues
show
Unused Code introduced by
$class is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1430
            $translations = $baseDataClass::get()
1431
                ->where($filter)
1432
                ->leftJoin("{$baseDataClass}_translationgroups", $joinOnClause);
1433
        }
1434
1435
        // only re-enable locale-filter if it was enabled at the beginning of this method
1436
        if ($localeFilterEnabled) {
1437
            self::enable_locale_filter();
1438
        }
1439
1440
        return $translations;
1441
    }
1442
    
1443
    /**
1444
     * Gets an existing translation based on the language code.
1445
     * Use {@link hasTranslation()} as a quicker alternative to check
1446
     * for an existing translation without getting the actual object.
1447
     * 
1448
     * @param String $locale
1449
     * @return DataObject Translated object
1450
     */
1451
    public function getTranslation($locale, $stage = null)
1452
    {
1453
        if ($locale && !i18n::validate_locale($locale)) {
1454
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1455
        }
1456
        
1457
        $translations = $this->getTranslations($locale, $stage);
1458
        return ($translations) ? $translations->First() : null;
1459
    }
1460
    
1461
    /**
1462
     * When the SiteConfig object is automatically instantiated, we should ensure that
1463
     * 1. All SiteConfig objects belong to the same group
1464
     * 2. Defaults are correctly initiated from the base object
1465
     * 3. The creation mechanism uses the createTranslation function in order to be consistent
1466
     * This function ensures that any already created "vanilla" SiteConfig object is populated 
1467
     * correctly with translated values.
1468
     * This function DOES populate the ID field with the newly created object ID
1469
     * @see SiteConfig
1470
     */
1471
    protected function populateSiteConfigDefaults()
1472
    {
1473
        
1474
        // Work-around for population of defaults during database initialisation.
1475
        // When the database is being setup singleton('SiteConfig') is called.
1476
        if (!DB::getConn()->hasTable($this->owner->class)) {
1477
            return;
1478
        }
1479
        if (!DB::getConn()->hasField($this->owner->class, 'Locale')) {
1480
            return;
1481
        }
1482
        if (DB::getConn()->isSchemaUpdating()) {
1483
            return;
1484
        }
1485
        
1486
        // Find the best base translation for SiteConfig
1487
        $enabled = Translatable::locale_filter_enabled();
1488
        Translatable::disable_locale_filter();
1489
        $existingConfig = SiteConfig::get()->filter(array(
1490
            'Locale' => Translatable::default_locale()
1491
        ))->first();
1492
        if (!$existingConfig) {
1493
            $existingConfig = SiteConfig::get()->first();
1494
        }
1495
        if ($enabled) {
1496
            Translatable::enable_locale_filter();
1497
        }
1498
1499
        // Stage this SiteConfig and copy into the current object
1500
        if (
1501
            $existingConfig
1502
            // Double-up of SiteConfig in the same locale can be ignored. Often caused by singleton(SiteConfig)	
1503
            && !$existingConfig->getTranslation(Translatable::get_current_locale())
1504
            // If translation is not allowed by the current user then do not 
1505
            // allow this code to attempt any behind the scenes translation.
1506
            && $existingConfig->canTranslate(null, Translatable::get_current_locale())
1507
        ) {
1508
            // Create an unsaved "staging" translated object using the correct createTranslation mechanism
1509
            $stagingConfig = $existingConfig->createTranslation(Translatable::get_current_locale(), false);
1510
            $this->owner->update($stagingConfig->toMap());
1511
        }
1512
        
1513
        // Maintain single translation group for SiteConfig
1514
        if ($existingConfig) {
1515
            $this->owner->_TranslationGroupID = $existingConfig->getTranslationGroup();
1516
        }
1517
        
1518
        $this->owner->Locale = Translatable::get_current_locale();
1519
    }
1520
    
1521
    /**
1522
     * Enables automatic population of SiteConfig fields using createTranslation if
1523
     * created outside of the Translatable module
1524
     * @var boolean
1525
     */
1526
    public static $enable_siteconfig_generation = true;
1527
1528
    /**
1529
     * Hooks into the DataObject::populateDefaults() method 
1530
     */
1531
    public function populateDefaults()
1532
    {
1533
        if (
1534
            empty($this->owner->ID)
1535
            && ($this->owner instanceof SiteConfig)
0 ignored issues
show
Bug introduced by
The class SiteConfig does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1536
            && self::$enable_siteconfig_generation
1537
        ) {
1538
            // Use enable_siteconfig_generation to prevent infinite loop during object creation
1539
            self::$enable_siteconfig_generation = false;
1540
            $this->populateSiteConfigDefaults();
1541
            self::$enable_siteconfig_generation = true;
1542
        }
1543
    }
1544
    
1545
    /**
1546
     * Creates a new translation for the owner object of this decorator.
1547
     * Checks {@link getTranslation()} to return an existing translation
1548
     * instead of creating a duplicate. Writes the record to the database before
1549
     * returning it. Use this method if you want the "translation group"
1550
     * mechanism to work, meaning that an object knows which group of translations
1551
     * it belongs to. For "original records" which are not created through this
1552
     * method, the "translation group" is set in {@link onAfterWrite()}.
1553
     * 
1554
     * @param string $locale Target locale to translate this object into
1555
     * @param boolean $saveTranslation Flag indicating whether the new record 
1556
     * should be saved to the database.
1557
     * @return DataObject The translated object
1558
     */
1559
    public function createTranslation($locale, $saveTranslation = true)
1560
    {
1561
        if ($locale && !i18n::validate_locale($locale)) {
1562
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1563
        }
1564
        
1565
        if (!$this->owner->exists()) {
1566
            user_error(
1567
                'Translatable::createTranslation(): Please save your record before creating a translation',
1568
                E_USER_ERROR
1569
            );
1570
        }
1571
        
1572
        // permission check
1573
        if (!$this->owner->canTranslate(null, $locale)) {
1574
            throw new Exception(sprintf(
1575
                'Creating a new translation in locale "%s" is not allowed for this user',
1576
                $locale
1577
            ));
1578
            return;
0 ignored issues
show
Unused Code introduced by
return; does not seem to be reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
1579
        }
1580
        
1581
        $existingTranslation = $this->getTranslation($locale);
1582
        if ($existingTranslation) {
1583
            return $existingTranslation;
1584
        }
1585
        
1586
        $class = $this->owner->class;
1587
        $newTranslation = new $class;
1588
        
1589
        // copy all fields from owner (apart from ID)
1590
        $newTranslation->update(array_diff_key($this->owner->toMap(), array('Version' => null)));
1591
        
1592
        // If the object has Hierarchy extension,
1593
        // check for existing translated parents and assign
1594
        // their ParentID (and overwrite any existing ParentID relations
1595
        // to parents in other language). If no parent translations exist,
1596
        // they are automatically created in onBeforeWrite()
1597
        if ($newTranslation->hasField('ParentID')) {
1598
            $origParent = $this->owner->Parent();
1599
            $newTranslationParent = $origParent->getTranslation($locale);
1600
            if ($newTranslationParent) {
1601
                $newTranslation->ParentID = $newTranslationParent->ID;
1602
            }
1603
        }
1604
        
1605
        $newTranslation->ID = 0;
1606
        $newTranslation->Locale = $locale;
1607
        $newTranslation->Version = 0;
1608
        
1609
        $originalPage = $this->getTranslation(self::default_locale());
1610
        if ($originalPage) {
1611
            $urlSegment = $originalPage->URLSegment;
1612
        } else {
1613
            $urlSegment = $newTranslation->URLSegment;
1614
        }
1615
1616
        // Only make segment unique if it should be enforced
1617
        if (Config::inst()->get('Translatable', 'enforce_global_unique_urls')) {
1618
            $newTranslation->URLSegment = $urlSegment . '-' . i18n::convert_rfc1766($locale);
1619
        }
1620
1621
        // hacky way to set an existing translation group in onAfterWrite()
1622
        $translationGroupID = $this->getTranslationGroup();
1623
        $newTranslation->_TranslationGroupID = $translationGroupID ? $translationGroupID : $this->owner->ID;
1624
        if ($saveTranslation) {
1625
            $newTranslation->write();
1626
        }
1627
        
1628
        // run callback on page for translation related hooks
1629
        $newTranslation->invokeWithExtensions('onTranslatableCreate', $saveTranslation);
1630
        
1631
        return $newTranslation;
1632
    }
1633
    
1634
    /**
1635
     * Caution: Does not consider the {@link canEdit()} permissions.
1636
     * 
1637
     * @param DataObject|int $member
1638
     * @param string $locale
1639
     * @return boolean
1640
     */
1641
    public function canTranslate($member = null, $locale)
1642
    {
1643
        if ($locale && !i18n::validate_locale($locale)) {
1644
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1645
        }
1646
        
1647
        if (!$member || !(is_a($member, 'Member')) || is_numeric($member)) {
1648
            $member = Member::currentUser();
1649
        }
1650
1651
        // check for locale
1652
        $allowedLocale = (
1653
            !is_array(self::get_allowed_locales())
1654
            || in_array($locale, self::get_allowed_locales())
1655
        );
1656
1657
        if (!$allowedLocale) {
1658
            return false;
1659
        }
1660
        
1661
        // By default, anyone who can edit a page can edit the default locale
1662
        if ($locale == self::default_locale()) {
1663
            return true;
1664
        }
1665
        
1666
        // check for generic translation permission
1667
        if (Permission::checkMember($member, 'TRANSLATE_ALL')) {
1668
            return true;
1669
        }
1670
        
1671
        // check for locale specific translate permission
1672
        if (!Permission::checkMember($member, 'TRANSLATE_' . $locale)) {
1673
            return false;
1674
        }
1675
        
1676
        return true;
1677
    }
1678
    
1679
    /**
1680
     * @return boolean
1681
     */
1682
    public function canEdit($member)
1683
    {
1684
        if (!$this->owner->Locale) {
1685
            return null;
1686
        }
1687
        return $this->owner->canTranslate($member, $this->owner->Locale) ? null : false;
1688
    }
1689
    
1690
    /**
1691
     * Returns TRUE if the current record has a translation in this language.
1692
     * Use {@link getTranslation()} to get the actual translated record from
1693
     * the database.
1694
     * 
1695
     * @param string $locale
1696
     * @return boolean
1697
     */
1698
    public function hasTranslation($locale)
1699
    {
1700
        if ($locale && !i18n::validate_locale($locale)) {
1701
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1702
        }
1703
        
1704
        return (
1705
            $this->owner->Locale == $locale
1706
            || array_search($locale, $this->getTranslatedLocales()) !== false
1707
        );
1708
    }
1709
    
1710
    public function AllChildrenIncludingDeleted($context = null)
1711
    {
1712
        $children = $this->owner->doAllChildrenIncludingDeleted($context);
1713
        
1714
        return $children;
1715
    }
1716
    
1717
    /**
1718
     * Returns <link rel="alternate"> markup for insertion into
1719
     * a HTML4/XHTML compliant <head> section, listing all available translations
1720
     * of a page.
1721
     * 
1722
     * @see http://www.w3.org/TR/html4/struct/links.html#edef-LINK
1723
     * @see http://www.w3.org/International/articles/language-tags/
1724
     * 
1725
     * @return string HTML
1726
     */
1727
    public function MetaTags(&$tags)
1728
    {
1729
        $template = '<link rel="alternate" type="text/html" title="%s" hreflang="%s" href="%s" />' . "\n";
1730
        $translations = $this->owner->getTranslations();
1731
        if ($translations) {
1732
            $translations = $translations->toArray();
1733
            $translations[] = $this->owner;
1734
            
1735
            foreach ($translations as $translation) {
1736
                $tags .= sprintf($template,
1737
                Convert::raw2xml($translation->Title),
1738
                i18n::convert_rfc1766($translation->Locale),
1739
                $translation->AbsoluteLink()
1740
                );
1741
            }
1742
        }
1743
    }
1744
    
1745
    public function providePermissions()
1746
    {
1747
        if (!SiteTree::has_extension('Translatable') || !class_exists('SiteTree')) {
1748
            return false;
1749
        }
1750
        
1751
        $locales = self::get_allowed_locales();
1752
        
1753
        // Fall back to any locales used in existing translations (see #4939)
1754
        if (!$locales) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locales of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1755
            $locales = DB::query('SELECT "Locale" FROM "SiteTree" GROUP BY "Locale"')->column();
1756
        }
1757
        
1758
        $permissions = array();
1759
        if ($locales) {
1760
            foreach ($locales as $locale) {
1761
                $localeName = i18n::get_locale_name($locale);
1762
                $permissions['TRANSLATE_' . $locale] = sprintf(
1763
                _t(
1764
                    'Translatable.TRANSLATEPERMISSION',
1765
                    'Translate %s',
1766
                    'Translate pages into a language'
1767
                ),
1768
                $localeName
1769
            );
1770
            }
1771
        }
1772
        
1773
        $permissions['TRANSLATE_ALL'] = _t(
1774
            'Translatable.TRANSLATEALLPERMISSION',
1775
            'Translate into all available languages'
1776
        );
1777
        
1778
        $permissions['VIEW_LANGS'] = _t(
1779
            'Translatable.TRANSLATEVIEWLANGS',
1780
            'View language dropdown'
1781
        );
1782
        
1783
        return $permissions;
1784
    }
1785
    
1786
    /**
1787
     * Get a list of languages with at least one element translated in (including the default language)
1788
     *
1789
     * @param string $className Look for languages in elements of this class
1790
     * @param string $where Optional SQL WHERE statement
1791
     * @return array Map of languages in the form locale => langName
1792
     */
1793
    public static function get_existing_content_languages($className = 'SiteTree', $where = '')
1794
    {
1795
        $baseTable = ClassInfo::baseDataClass($className);
1796
        $query = new SQLSelect("Distinct \"Locale\"", "\"$baseTable\"", $where, '', "\"Locale\"");
1797
        $dbLangs = $query->execute()->column();
1798
        $langlist = array_merge((array)Translatable::default_locale(), (array)$dbLangs);
1799
        $returnMap = array();
1800
        $allCodes = array_merge(
1801
            Config::inst()->get('i18n', 'all_locales'),
1802
            Config::inst()->get('i18n', 'common_locales')
1803
        );
1804
        foreach ($langlist as $langCode) {
1805
            if ($langCode && isset($allCodes[$langCode])) {
1806
                if (is_array($allCodes[$langCode])) {
1807
                    $returnMap[$langCode] = $allCodes[$langCode]['name'];
1808
                } else {
1809
                    $returnMap[$langCode] = $allCodes[$langCode];
1810
                }
1811
            }
1812
        }
1813
        return $returnMap;
1814
    }
1815
    
1816
    /**
1817
     * Get the RelativeLink value for a home page in another locale. This is found by searching for the default home
1818
     * page in the default language, then returning the link to the translated version (if one exists).
1819
     *
1820
     * @return string
1821
     */
1822
    public static function get_homepage_link_by_locale($locale)
1823
    {
1824
        $originalLocale = self::get_current_locale();
1825
1826
        self::set_current_locale(self::default_locale());
1827
        $original = SiteTree::get_by_link(RootURLController::config()->default_homepage_link);
1828
        self::set_current_locale($originalLocale);
1829
1830
        if ($original) {
1831
            if ($translation = $original->getTranslation($locale)) {
1832
                return trim($translation->RelativeLink(true), '/');
1833
            }
1834
        }
1835
    }
1836
    
1837
    
1838
    /**
1839
     * @deprecated 2.4 Use {@link Translatable::get_homepage_link_by_locale()}
1840
     */
1841
    public static function get_homepage_urlsegment_by_locale($locale)
1842
    {
1843
        user_error(
1844
            'Translatable::get_homepage_urlsegment_by_locale() is deprecated, please use get_homepage_link_by_locale()',
1845
            E_USER_NOTICE
1846
        );
1847
        
1848
        return self::get_homepage_link_by_locale($locale);
1849
    }
1850
    
1851
    /**
1852
     * Define all locales which in which a new translation is allowed.
1853
     * Checked in {@link canTranslate()}.
1854
     *
1855
     * @param array List of allowed locale codes (see {@link i18n::$all_locales}).
1856
     *  Example: array('de_DE','ja_JP')
1857
     */
1858
    public static function set_allowed_locales($locales)
1859
    {
1860
        self::$allowed_locales = $locales;
1861
    }
1862
    
1863
    /**
1864
     * Get all locales which are generally permitted to be translated.
1865
     * Use {@link canTranslate()} to check if a specific member has permission
1866
     * to translate a record.
1867
     * 
1868
     * @return array
1869
     */
1870
    public static function get_allowed_locales()
1871
    {
1872
        return self::$allowed_locales;
1873
    }
1874
    
1875
    /**
1876
     * @deprecated 2.4 Use get_homepage_urlsegment_by_locale()
1877
     */
1878
    public static function get_homepage_urlsegment_by_language($locale)
1879
    {
1880
        return self::get_homepage_urlsegment_by_locale($locale);
0 ignored issues
show
Deprecated Code introduced by
The method Translatable::get_homepage_urlsegment_by_locale() has been deprecated with message: 2.4 Use {@link Translatable::get_homepage_link_by_locale()}

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1881
    }
1882
    
1883
    /**
1884
     * @deprecated 2.4 Use custom check: self::$default_locale == self::get_current_locale()
1885
     */
1886
    public static function is_default_lang()
1887
    {
1888
        return (self::$default_locale == self::get_current_locale());
1889
    }
1890
    
1891
    /**
1892
     * @deprecated 2.4 Use set_default_locale()
1893
     */
1894
    public static function set_default_lang($lang)
1895
    {
1896
        self::set_default_locale(i18n::get_locale_from_lang($lang));
1897
    }
1898
    
1899
    /**
1900
     * @deprecated 2.4 Use get_default_locale()
1901
     */
1902
    public static function get_default_lang()
1903
    {
1904
        return i18n::get_lang_from_locale(self::default_locale());
1905
    }
1906
    
1907
    /**
1908
     * @deprecated 2.4 Use get_current_locale()
1909
     */
1910
    public static function current_lang()
1911
    {
1912
        return i18n::get_lang_from_locale(self::get_current_locale());
1913
    }
1914
    
1915
    /**
1916
     * @deprecated 2.4 Use set_current_locale()
1917
     */
1918
    public static function set_reading_lang($lang)
1919
    {
1920
        self::set_current_locale(i18n::get_locale_from_lang($lang));
1921
    }
1922
    
1923
    /**
1924
     * @deprecated 2.4 Use get_reading_locale()
1925
     */
1926
    public static function get_reading_lang()
1927
    {
1928
        return i18n::get_lang_from_locale(self::get_reading_locale());
1929
    }
1930
    
1931
    /**
1932
     * @deprecated 2.4 Use default_locale()
1933
     */
1934
    public static function default_lang()
1935
    {
1936
        return i18n::get_lang_from_locale(self::default_locale());
1937
    }
1938
    
1939
    /**
1940
     * @deprecated 2.4 Use get_by_locale()
1941
     */
1942
    public static function get_by_lang($class, $lang, $filter = '', $sort = '',
1943
        $join = "", $limit = "", $containerClass = "DataObjectSet", $having = ""
1944
    ) {
1945
        return self::get_by_locale(
1946
            $class, i18n::get_locale_from_lang($lang), $filter,
1947
            $sort, $join, $limit, $containerClass, $having
1948
        );
1949
    }
1950
    
1951
    /**
1952
     * @deprecated 2.4 Use get_one_by_locale()
1953
     */
1954
    public static function get_one_by_lang($class, $lang, $filter = '', $cache = false, $orderby = "")
1955
    {
1956
        return self::get_one_by_locale($class, i18n::get_locale_from_lang($lang), $filter, $cache, $orderby);
1957
    }
1958
    
1959
    /**
1960
     * Determines if the record has a locale,
1961
     * and if this locale is different from the "default locale"
1962
     * set in {@link Translatable::default_locale()}.
1963
     * Does not look at translation groups to see if the record
1964
     * is based on another record.
1965
     * 
1966
     * @return boolean
1967
     * @deprecated 2.4
1968
     */
1969
    public function isTranslation()
1970
    {
1971
        return ($this->owner->Locale && ($this->owner->Locale != Translatable::default_locale()));
1972
    }
1973
    
1974
    /**
1975
     * @deprecated 2.4 Use choose_site_locale()
1976
     */
1977
    public static function choose_site_lang($langsAvail=null)
1978
    {
1979
        return self::choose_site_locale($langsAvail);
1980
    }
1981
    
1982
    /**
1983
     * @deprecated 2.4 Use getTranslatedLocales()
1984
     */
1985
    public function getTranslatedLangs()
1986
    {
1987
        return $this->getTranslatedLocales();
1988
    }
1989
1990
    /**
1991
     * Return a piece of text to keep DataObject cache keys appropriately specific
1992
     */
1993
    public function cacheKeyComponent()
1994
    {
1995
        return 'locale-'.self::get_current_locale();
1996
    }
1997
    
1998
    /**
1999
     * Extends the SiteTree::validURLSegment() method, to do checks appropriate
2000
     * to Translatable
2001
     * 
2002
     * @return bool
2003
     */
2004
    public function augmentValidURLSegment()
2005
    {
2006
        $reEnableFilter = false;
2007
        if (!Config::inst()->get('Translatable', 'enforce_global_unique_urls')) {
2008
            self::enable_locale_filter();
2009
        } elseif (self::locale_filter_enabled()) {
2010
            self::disable_locale_filter();
2011
            $reEnableFilter = true;
2012
        }
2013
2014
        $IDFilter = ($this->owner->ID) ? "AND \"SiteTree\".\"ID\" <> {$this->owner->ID}" :  null;
2015
        $parentFilter = null;
2016
2017
        if (Config::inst()->get('SiteTree', 'nested_urls')) {
2018
            if ($this->owner->ParentID) {
2019
                $parentFilter = " AND \"SiteTree\".\"ParentID\" = {$this->owner->ParentID}";
2020
            } else {
2021
                $parentFilter = ' AND "SiteTree"."ParentID" = 0';
2022
            }
2023
        }
2024
2025
        $existingPage = SiteTree::get()
2026
            // disable get_one cache, as this otherwise may pick up results from when locale_filter was on
2027
            ->where("\"URLSegment\" = '{$this->owner->URLSegment}' $IDFilter $parentFilter")->First();
2028
        if ($reEnableFilter) {
2029
            self::enable_locale_filter();
2030
        }
2031
        
2032
        // By returning TRUE or FALSE, we overrule the base SiteTree->validateURLSegment() logic
2033
        return !$existingPage;
2034
    }
2035
}
2036
2037
/**
2038
 * Transform a formfield to a "translatable" representation,
2039
 * consisting of the original formfield plus a readonly-version
2040
 * of the original value, wrapped in a CompositeField.
2041
 * 
2042
 * @param DataObject $original Needs the original record as we populate 
2043
 *                   the readonly formfield with the original value
2044
 * 
2045
 * @package translatable
2046
 * @subpackage misc
2047
 */
2048
class Translatable_Transformation extends FormTransformation
2049
{
2050
    /**
2051
     * @var DataObject
2052
     */
2053
    private $original = null;
2054
    
2055
    public function __construct(DataObject $original)
2056
    {
2057
        $this->original = $original;
2058
        parent::__construct();
2059
    }
2060
    
2061
    /**
2062
     * Returns the original DataObject attached to the Transformation
2063
     *
2064
     * @return DataObject
2065
     */
2066
    public function getOriginal()
2067
    {
2068
        return $this->original;
2069
    }
2070
    
2071
    public function transformFormField(FormField $field)
2072
    {
2073
        $newfield = $field->performReadOnlyTransformation();
2074
        $fn = 'transform' . $field->class;
2075
        return $this->hasMethod($fn) ? $this->$fn($newfield, $field) : $this->baseTransform($newfield, $field);
2076
    }
2077
    
2078
    /**
2079
     * Transform a translatable CheckboxField to show the field value from the default language
2080
     * in the label.
2081
     * 
2082
     * @param FormField $nonEditableField The readonly field to contain the original value
2083
     * @param FormField $originalField The original editable field containing the translated value
2084
     * @return CheckboxField The field with a modified label
2085
     */
2086
    protected function transformCheckboxField(CheckboxField_Readonly $nonEditableField, CheckboxField $originalField)
2087
    {
2088
        $label = $originalField->Title();
2089
        $fieldName = $originalField->getName();
2090
        $value = ($this->original->$fieldName)
2091
            ? _t('Translatable_Transform.CheckboxValueYes', 'Yes')
2092
            : _t('Translatable_Transform.CheckboxValueNo', 'No');
2093
        $originalLabel = _t(
2094
            'Translatable_Transform.OriginalCheckboxLabel',
2095
            'Original: {value}',
2096
            'Addition to a checkbox field label showing the original value of the translatable field.',
2097
            array('value'=>$value)
2098
        );
2099
        $originalField->setTitle($label . ' <span class="originalvalue">(' . $originalLabel . ')</span>');
2100
        return $originalField;
2101
    }
2102
    
2103
    /**
2104
     * Transform a translatable field to show the field value from the default language
2105
     * DataObject below the translated field.
2106
     * 
2107
     * This is a fallback function which handles field types that aren't transformed by
2108
     * $this->transform{FieldType} functions.
2109
     * 
2110
     * @param FormField $nonEditableField The readonly field to contain the original value
2111
     * @param FormField $originalField The original editable field containing the translated value
2112
     * @return \CompositeField The transformed field
2113
     */
2114
    protected function baseTransform($nonEditableField, $originalField)
2115
    {
2116
        $fieldname = $originalField->getName();
2117
        
2118
        $nonEditableField_holder = new CompositeField($nonEditableField);
2119
        $nonEditableField_holder->setName($fieldname.'_holder');
2120
        $nonEditableField_holder->addExtraClass('originallang_holder');
2121
        $nonEditableField->setValue($this->original->$fieldname);
2122
        $nonEditableField->setName($fieldname.'_original');
2123
        $nonEditableField->addExtraClass('originallang');
2124
        $nonEditableField->setTitle(_t(
2125
            'Translatable_Transform.OriginalFieldLabel',
2126
            'Original {title}',
2127
            'Label for the original value of the translatable field.',
2128
            array('title'=>$originalField->Title())
2129
        ));
2130
        
2131
        $nonEditableField_holder->insertBefore($originalField, $fieldname.'_original');
2132
        return $nonEditableField_holder;
2133
    }
2134
}
2135