Completed
Pull Request — master (#264)
by Robbie
13:29
created

Translatable::choose_site_locale()   B

Complexity

Conditions 6
Paths 3

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 18
rs 8.8571
c 0
b 0
f 0
cc 6
eloc 10
nc 3
nop 1
1
<?php
2
3
namespace SilverStripe\Translatable\Model;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\CMS\Controllers\RootURLController;
8
use SilverStripe\CMS\Model\SiteTree;
9
use SilverStripe\CMS\Model\VirtualPage;
10
use SilverStripe\Control\Controller;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\Core\ClassInfo;
14
use SilverStripe\Forms\FieldList;
15
use SilverStripe\Forms\HeaderField;
16
use SilverStripe\Forms\HiddenField;
17
use SilverStripe\Forms\LiteralField;
18
use SilverStripe\Forms\Tab;
19
use SilverStripe\i18n\i18n;
20
use SilverStripe\i18n\Data\Intl\IntlLocales;
21
use SilverStripe\ORM\ArrayList;
22
use SilverStripe\ORM\DataExtension;
23
use SilverStripe\ORM\DataObject;
24
use SilverStripe\ORM\DataQuery;
25
use SilverStripe\ORM\DB;
26
use SilverStripe\ORM\FieldType\DBLocale;
27
use SilverStripe\ORM\Queries\SQLConditionGroup;
28
use SilverStripe\ORM\Queries\SQLSelect;
29
use SilverStripe\Security\Member;
30
use SilverStripe\Security\Permission;
31
use SilverStripe\Security\PermissionProvider;
32
use SilverStripe\SiteConfig\SiteConfig;
33
use SilverStripe\Translatable\Forms\LanguageDropdownField;
34
use SilverStripe\Translatable\Model\Translatable;
35
use SilverStripe\Versioned\Versioned;
36
37
38
39
40
use SilverStripe\Translatable\Forms\TemporaryInlineFormAction;
41
42
/**
43
 * The Translatable decorator allows your DataObjects to have versions in different languages,
44
 * defining which fields are can be translated. Translatable can be applied
45
 * to any {@link DataObject} subclass, but is mostly used with {@link SiteTree}.
46
 * Translatable is compatible with the {@link Versioned} extension.
47
 * To avoid cluttering up the database-schema of the 99% of sites without multiple languages,
48
 * the translation-feature is disabled by default.
49
 *
50
 * Locales (e.g. 'en_US') are used in Translatable for identifying a record by language,
51
 * see section "Locales and Language Tags".
52
 *
53
 * <h2>Configuration</h2>
54
 *
55
 * The extension is automatically enabled for SiteTree and SiteConfig records,
56
 * if they can be found. Add the following to your config.yml in order to
57
 * register a custom class:
58
 *
59
 * <code>
60
 * MyClass:
61
 *   extensions:
62
 *     - SilverStripe\Translatable\Model\Translatable
63
 * </code>
64
 *
65
 * Make sure to rebuild the database through /dev/build after enabling translatable.
66
 * Use the correct {@link set_default_locale()} before building the database
67
 * for the first time, as this locale will be written on all new records.
68
 *
69
 * <h3>"Default" locales</h3>
70
 *
71
 * Important: If the "default language" of your site is not US-English (en_US),
72
 * please ensure to set the appropriate default language for
73
 * your content before building the database with Translatable enabled:
74
 * <code>
75
 * Translatable::set_default_locale(<locale>); // e.g. 'de_DE' or 'fr_FR'
76
 * </code>
77
 *
78
 * For the Translatable class, a "locale" consists of a language code plus a region
79
 * code separated by an underscore,
80
 * for example "de_AT" for German language ("de") in the region Austria ("AT").
81
 * See http://www.w3.org/International/articles/language-tags/ for a detailed description.
82
 *
83
 * <h2>Usage</h2>
84
 *
85
 * Getting a translation for an existing instance:
86
 * <code>
87
 * $translatedObj = Translatable::get_one_by_locale('MyObject', 'de_DE');
88
 * </code>
89
 *
90
 * Getting a translation for an existing instance:
91
 * <code>
92
 * $obj = DataObject::get_by_id('MyObject', 99); // original language
93
 * $translatedObj = $obj->getTranslation('de_DE');
94
 * </code>
95
 *
96
 * Getting translations through {@link Translatable::set_current_locale()}.
97
 * This is *not* a recommended approach, but sometimes inavoidable (e.g. for {@link Versioned} methods).
98
 * <code>
99
 * $origLocale = Translatable::get_current_locale();
100
 * Translatable::set_current_locale('de_DE');
101
 * $obj = Versioned::get_one_by_stage('MyObject', "ID = 99");
102
 * Translatable::set_current_locale($origLocale);
103
 * </code>
104
 *
105
 * Creating a translation:
106
 * <code>
107
 * $obj = new MyObject();
108
 * $translatedObj = $obj->createTranslation('de_DE');
109
 * </code>
110
 *
111
 * <h2>Usage for SiteTree</h2>
112
 *
113
 * Translatable can be used for subclasses of {@link SiteTree},
114
 * it is automatically configured if this class is foun.
115
 *
116
 * If a child page translation is requested without the parent
117
 * page already having a translation in this language, the extension
118
 * will recursively create translations up the tree.
119
 * Caution: The "URLSegment" property is enforced to be unique across
120
 * languages by auto-appending the language code at the end.
121
 * You'll need to ensure that the appropriate "reading language" is set
122
 * before showing links to other pages on a website through $_GET['locale'].
123
 * Pages in different languages can have different publication states
124
 * through the {@link Versioned} extension.
125
 *
126
 * Note: You can't get Children() for a parent page in a different language
127
 * through set_current_locale(). Get the translated parent first.
128
 *
129
 * <code>
130
 * // wrong
131
 * Translatable::set_current_locale('de_DE');
132
 * $englishParent->Children();
133
 * // right
134
 * $germanParent = $englishParent->getTranslation('de_DE');
135
 * $germanParent->Children();
136
 * </code>
137
 *
138
 * <h2>Translation groups</h2>
139
 *
140
 * Each translation can have one or more related pages in other languages.
141
 * This relation is optional, meaning you can
142
 * create translations which have no representation in the "default language".
143
 * This means you can have a french translation with a german original,
144
 * without either of them having a representation
145
 * in the default english language tree.
146
 * Caution: There is no versioning for translation groups,
147
 * meaning associating an object with a group will affect both stage and live records.
148
 *
149
 * SiteTree database table (abbreviated)
150
 * ^ ID ^ URLSegment ^ Title ^ Locale ^
151
 * | 1 | about-us | About us | en_US |
152
 * | 2 | ueber-uns | Über uns | de_DE |
153
 * | 3 | contact | Contact | en_US |
154
 *
155
 * SiteTree_translationgroups database table
156
 * ^ TranslationGroupID ^ OriginalID ^
157
 * | 99 | 1 |
158
 * | 99 | 2 |
159
 * | 199 | 3 |
160
 *
161
 * <h2>Character Sets</h2>
162
 *
163
 * Caution: Does not apply any character-set conversion, it is assumed that all content
164
 * is stored and represented in UTF-8 (Unicode). Please make sure your database and
165
 * HTML-templates adjust to this.
166
 *
167
 * <h2>Permissions</h2>
168
 *
169
 * Authors without administrative access need special permissions to edit locales other than
170
 * the default locale.
171
 *
172
 * - TRANSLATE_ALL: Translate into all locales
173
 * - Translate_<locale>: Translate a specific locale. Only available for all locales set in
174
 *   `Translatable::set_allowed_locales()`.
175
 *
176
 * Note: If user-specific view permissions are required, please overload `SiteTree->canView()`.
177
 *
178
 * <h2>Uninstalling/Disabling</h2>
179
 *
180
 * Disabling Translatable after creating translations will lead to all
181
 * pages being shown in the default sitetree regardless of their language.
182
 * It is advised to start with a new database after uninstalling Translatable,
183
 * or manually filter out translated objects through their "Locale" property
184
 * in the database.
185
 *
186
 * @see http://doc.silverstripe.org/doku.php?id=multilingualcontent
187
 *
188
 * @author Ingo Schommer <ingo (at) silverstripe (dot) com>
189
 * @author Michael Gall <michael (at) wakeless (dot) net>
190
 * @author Bernat Foj Capell <[email protected]>
191
 *
192
 * @package translatable
193
 */
194
class Translatable extends DataExtension implements PermissionProvider
195
{
196
    const QUERY_LOCALE_FILTER_ENABLED = 'Translatable.LocaleFilterEnabled';
197
198
    /**
199
     * The 'default' language.
200
     * @var string
201
     */
202
    protected static $default_locale = 'en_US';
203
204
    /**
205
     * The language in which we are reading dataobjects.
206
     *
207
     * @var string
208
     */
209
    protected static $current_locale = null;
210
211
    /**
212
     * A cached list of existing tables
213
     *
214
     * @var mixed
215
     */
216
    protected static $tableList = null;
217
218
    /**
219
     * An array of fields that can be translated.
220
     * @var array
221
     */
222
    protected $translatableFields = null;
223
224
    /**
225
     * A map of the field values of the original (untranslated) DataObject record
226
     * @var array
227
     */
228
    protected $original_values = null;
229
230
    /**
231
     * If this is set to TRUE then {@link augmentSQL()} will automatically add a filter
232
     * clause to limit queries to the current {@link get_current_locale()}. This camn be
233
     * disabled using {@link disable_locale_filter()}
234
     *
235
     * @var bool
236
     */
237
    protected static $locale_filter_enabled = true;
238
239
    /**
240
     * @var array All locales in which a translation can be created.
241
     * This limits the choice in the CMS language dropdown in the
242
     * "Translation" tab, as well as the language dropdown above
243
     * the CMS tree. If not set, it will default to showing all
244
     * common locales.
245
     */
246
    protected static $allowed_locales = null;
247
248
    /**
249
     * @var boolean Check other languages for URLSegment values (only applies to {@link SiteTree}).
250
     * Turn this off to handle language setting yourself, e.g. through language-specific subdomains
251
     * or URL path prefixes like "/en/mypage".
252
     */
253
    private static $enforce_global_unique_urls = true;
0 ignored issues
show
Unused Code introduced by
The property $enforce_global_unique_urls is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
254
255
    /**
256
     * Exclude these fields from translation
257
     *
258
     * @var array
259
     * @config
260
     */
261
    private static $translate_excluded_fields = array(
0 ignored issues
show
Unused Code introduced by
The property $translate_excluded_fields is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
262
        'ViewerGroups',
263
        'EditorGroups',
264
        'CanViewType',
265
        'CanEditType',
266
        'NewTransLang',
267
        'createtranslation'
268
    );
269
270
    /**
271
     * Reset static configuration variables to their default values
272
     */
273
    public static function reset()
274
    {
275
        self::enable_locale_filter();
276
        self::$default_locale = 'en_US';
277
        self::$current_locale = null;
278
        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...
279
    }
280
281
    /**
282
     * Choose the language the site is currently on.
283
     *
284
     * If $_GET['locale'] is currently set, then that locale will be used.
285
     * Otherwise the member preference (if logged
286
     * in) or default locale will be used.
287
     *
288
     * @todo Re-implement cookie and member option
289
     *
290
     * @param $langsAvailable array A numerical array of languages which are valid choices (optional)
291
     * @return string Selected language (also saved in $current_locale).
292
     */
293
    public static function choose_site_locale($langsAvailable = array())
0 ignored issues
show
Coding Style introduced by
choose_site_locale uses the super-global variable $_REQUEST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

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

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
294
    {
295
        if (self::$current_locale) {
296
            return self::$current_locale;
297
        }
298
299
        if ((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...
300
            || (isset($_REQUEST['locale'])
301
            && in_array($_REQUEST['locale'], $langsAvailable))
302
        ) {
303
            // get from request parameter
304
            self::set_current_locale($_REQUEST['locale']);
305
        } else {
306
            self::set_current_locale(self::default_locale());
307
        }
308
309
        return self::$current_locale;
310
    }
311
312
    /**
313
     * Get the current reading language.
314
     * This value has to be set before the schema is built with translatable enabled,
315
     * any changes after this can cause unintended side-effects.
316
     *
317
     * @return string
318
     */
319
    public static function default_locale()
320
    {
321
        return self::$default_locale;
322
    }
323
324
    /**
325
     * Set default language. Please set this value *before* creating
326
     * any database records (like pages), as this locale will be attached
327
     * to all new records.
328
     *
329
     * @param $locale String
330
     * @throws InvalidArgumentException if the locale is not valid
331
     */
332
    public static function set_default_locale($locale)
333
    {
334
        if ($locale && !self::isLocaleValid($locale)) {
335
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
336
        }
337
338
        $localeList = i18n::getData()->config()->locales;
339
        if (isset($localeList[$locale])) {
340
            self::$default_locale = $locale;
341
        } else {
342
            throw new InvalidArgumentException("Translatable::set_default_locale(): '$locale' is not a valid locale.");
343
        }
344
    }
345
346
    /**
347
     * Get the current reading language.
348
     * If its not chosen, call {@link choose_site_locale()}.
349
     *
350
     * @return string
351
     */
352
    public static function get_current_locale()
353
    {
354
        return (self::$current_locale) ? self::$current_locale : self::choose_site_locale();
355
    }
356
357
    /**
358
     * Set the reading language, either namespaced to 'site' (website content)
359
     * or 'cms' (management backend). This value is used in {@link augmentSQL()}
360
     * to "auto-filter" all SELECT queries by this language.
361
     * See {@link disable_locale_filter()} on how to override this behaviour temporarily.
362
     *
363
     * @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...
364
     */
365
    public static function set_current_locale($locale)
366
    {
367
        if ($locale && !self::isLocaleValid($locale)) {
368
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
369
        }
370
371
        self::$current_locale = $locale;
372
    }
373
374
    /**
375
     * Get a singleton instance of a class in the given language.
376
     * @param string $class The name of the class.
377
     * @param string $locale  The name of the language.
378
     * @param string $filter A filter to be inserted into the WHERE clause.
379
     * @param boolean $cache Use caching (default: false)
380
     * @param string $orderby A sort expression to be inserted into the ORDER BY clause.
381
     * @return DataObject
382
     * @throws InvalidArgumentException
383
     */
384
    public static function get_one_by_locale($class, $locale, $filter = '', $cache = false, $orderby = "")
0 ignored issues
show
Unused Code introduced by
The parameter $cache is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
385
    {
386
        if ($locale && !self::isLocaleValid($locale)) {
387
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
388
        }
389
390
        $orig = Translatable::get_current_locale();
391
        Translatable::set_current_locale($locale);
392
        $do = $class::get()
393
            ->where($filter)
394
            ->where(sprintf('"Locale" = \'%s\'', Convert::raw2sql($locale)))
395
            ->sort($orderby)
396
            ->First();
397
        Translatable::set_current_locale($orig);
398
        return $do;
399
    }
400
401
    /**
402
     * Get all the instances of the given class translated to the given language
403
     *
404
     * @param string $class The name of the class
405
     * @param string $locale  The name of the language
406
     * @param string $filter A filter to be inserted into the WHERE clause.
407
     * @param string $sort A sort expression to be inserted into the ORDER BY clause.
408
     * @param string $join A single join clause.  This can be used for filtering, only 1
409
     *               instance of each DataObject will be returned.
410
     * @param string $limit A limit expression to be inserted into the LIMIT clause.
411
     * @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...
412
     * @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...
413
     * @return mixed The objects matching the conditions.
414
     */
415
    public static function get_by_locale($class, $locale, $filter = '', $sort = '', $join = "", $limit = "")
416
    {
417
        if ($locale && !self::isLocaleValid($locale)) {
418
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
419
        }
420
421
        $oldLang = self::get_current_locale();
422
        self::set_current_locale($locale);
423
        $result = $class::get();
424
        if ($filter) {
425
            $result = $result->where($filter);
426
        }
427
        if ($sort) {
428
            $result = $result->sort($sort);
429
        }
430
        if ($join) {
431
            $result = $result->leftJoin($join);
432
        }
433
        if ($limit) {
434
            $result = $result->limit($limit);
435
        }
436
        self::set_current_locale($oldLang);
437
438
        return $result;
439
    }
440
441
    /**
442
     * @return bool
443
     */
444
    public static function locale_filter_enabled()
445
    {
446
        return self::$locale_filter_enabled;
447
    }
448
449
    /**
450
     * Enables automatic filtering by locale. This is normally called after is has been
451
     * disabled using {@link disable_locale_filter()}.
452
     *
453
     * @param $enabled (default true), if false this call is a no-op - see {@link disable_locale_filter()}
454
     */
455
    public static function enable_locale_filter($enabled = true)
456
    {
457
        if ($enabled) {
458
            self::$locale_filter_enabled = true;
459
        }
460
    }
461
462
    /**
463
     * Disables automatic locale filtering in {@link augmentSQL()}. This can be re-enabled
464
     * using {@link enable_locale_filter()}.
465
     *
466
     * Note that all places that disable the locale filter should generally re-enable it
467
     * before returning from that block of code (function, etc). This is made easier by
468
     * using the following pattern:
469
     *
470
     * <code>
471
     * $enabled = Translatable::disable_locale_filter();
472
     * // do some work here
473
     * Translatable::enable_locale_filter($enabled);
474
     * return $whateverYouNeedTO;
475
     * </code>
476
     *
477
     * By using this pattern, the call to enable the filter will not re-enable it if it
478
     * was not enabled initially.  That will keep code that called your function from
479
     * breaking if it had already disabled the locale filter since it will not expect
480
     * calling your function to change the global state by re-enabling the filter.
481
     *
482
     * @return boolean true if the locale filter was enabled, false if it was not
483
     */
484
    public static function disable_locale_filter()
485
    {
486
        $enabled = self::$locale_filter_enabled;
487
        self::$locale_filter_enabled = false;
488
        return $enabled;
489
    }
490
491
    /**
492
     * Gets all translations for this specific page.
493
     * Doesn't include the language of the current record.
494
     *
495
     * @return array Numeric array of all locales, sorted alphabetically.
496
     */
497
    public function getTranslatedLocales()
498
    {
499
        $langs = array();
500
501
        $baseDataClass = DataObject::getSchema()->baseDataTable(get_class($this->owner)); //Base Class
502
        $translationGroupClass = $baseDataClass . "_translationgroups";
503
        if ($this->owner->hasExtension(Versioned::class)  && Versioned::get_stage() == "Live") {
504
            $baseDataClass = $baseDataClass . "_Live";
505
        }
506
507
        $translationGroupID = $this->getTranslationGroup();
508
        if (is_numeric($translationGroupID)) {
509
            $query = new SQLSelect(
510
                'DISTINCT "Locale"',
511
                sprintf(
512
                    '"%s" LEFT JOIN "%s" ON "%s"."OriginalID" = "%s"."ID"',
513
                    $baseDataClass,
514
                    $translationGroupClass,
515
                    $translationGroupClass,
516
                    $baseDataClass
517
                ), // from
518
                sprintf(
0 ignored issues
show
Documentation introduced by
sprintf('"%s"."Translati..., $this->owner->Locale) is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
519
                    '"%s"."TranslationGroupID" = %d AND "%s"."Locale" != \'%s\'',
520
                    $translationGroupClass,
521
                    $translationGroupID,
522
                    $baseDataClass,
523
                    $this->owner->Locale
524
                ) // where
525
            );
526
            $langs = $query->execute()->column();
527
        }
528
        if ($langs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $langs 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...
529
            $langCodes = array_values($langs);
530
            sort($langCodes);
531
            return $langCodes;
532
        } else {
533
            return array();
534
        };
535
    }
536
537
    /**
538
     * Gets all locales that a member can access
539
     * as defined by {@link $allowed_locales}
540
     * and {@link canTranslate()}.
541
     * If {@link $allowed_locales} is not set and
542
     * the user has the `TRANSLATE_ALL` permission,
543
     * the method will return all available locales in the system.
544
     *
545
     * @param Member $member
546
     * @return array Map of locales
547
     */
548
    public function getAllowedLocalesForMember($member)
549
    {
550
        $locales = self::get_allowed_locales();
551
        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...
552
            $locales = i18n::get_common_locales();
0 ignored issues
show
Bug introduced by
The method get_common_locales() does not seem to exist on object<SilverStripe\i18n\i18n>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
553
        }
554
        if ($locales) {
555
            foreach ($locales as $k => $locale) {
556
                if (!$this->canTranslate($member, $locale)) {
557
                    unset($locales[$k]);
558
                }
559
            }
560
        }
561
562
        return $locales;
563
    }
564
565
    public function setOwner($owner, $ownerBaseClass = null)
566
    {
567
        parent::setOwner($owner, $ownerBaseClass);
568
569
        // setting translatable fields by inspecting owner - this should really be done in the constructor
570
        if ($this->owner && $this->translatableFields === null) {
571
            $this->translatableFields = array_merge(
572
                array_keys(DataObject::getSchema()->databaseFields($this->owner)),
573
                array_keys($this->owner->hasMany()),
574
                array_keys($this->owner->manyMany())
575
            );
576
            foreach (array_keys($this->owner->hasOne()) as $fieldname) {
577
                $this->translatableFields[] = $fieldname.'ID';
578
            }
579
        }
580
    }
581
582
    public static function get_extra_config($class, $extensionClass, $args = null)
0 ignored issues
show
Unused Code introduced by
The parameter $class is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $extensionClass is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $args is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
583
    {
584
        $config = array();
585
        $config['defaults'] = array(
586
            "Locale" => Translatable::default_locale() // as an overloaded getter as well: getLang()
587
        );
588
        $config['db'] = array(
589
            "Locale" => DBLocale::class,
590
            //"TranslationMasterID" => "Int" // optional relation to a "translation master"
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
591
        );
592
        return $config;
593
    }
594
595
    /**
596
     * Check if a given SQLSelect filters on the Locale field
597
     *
598
     * @param SQLSelect $query
599
     * @return boolean
600
     */
601
    protected function filtersOnLocale($query)
602
    {
603
        foreach ($query->getWhere() as $condition) {
604
            // Compat for 3.1/3.2 where syntax
605
            if (is_array($condition)) {
606
                // In >=3.2 each $condition is a single length array('condition' => array('params'))
607
                reset($condition);
608
                $condition = key($condition);
609
            }
610
611
            // >=3.2 allows conditions to be expressed as evaluatable objects
612
            if (interface_exists(SQLConditionGroup::class) && ($condition instanceof SQLConditionGroup)) {
613
                $condition = $condition->conditionSQL($params);
614
            }
615
616
            if (preg_match('/("|\'|`)Locale("|\'|`)/', $condition)) {
617
                return true;
618
            }
619
        }
620
    }
621
622
    /**
623
     * Changes any SELECT query thats not filtering on an ID
624
     * to limit by the current language defined in {@link get_current_locale()}.
625
     * It falls back to "Locale='' OR Lang IS NULL" and assumes that
626
     * this implies querying for the default language.
627
     *
628
     * Use {@link disable_locale_filter()} to temporarily disable this "auto-filtering".
629
     */
630
    public function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null)
631
    {
632
        // If the record is saved (and not a singleton), and has a locale,
633
        // limit the current call to its locale. This fixes a lot of problems
634
        // with other extensions like Versioned
635
        if ($this->owner->ID && !empty($this->owner->Locale)) {
636
            $locale = $this->owner->Locale;
637
        } else {
638
            $locale = Translatable::get_current_locale();
639
        }
640
641
        $baseTable = DataObject::getSchema()->baseDataTable(get_class($this->owner));
642
        if ($locale
643
            // unless the filter has been temporarily disabled
644
            && self::locale_filter_enabled()
645
            // or it was disabled when the DataQuery was created
646
            && $dataQuery->getQueryParam(self::QUERY_LOCALE_FILTER_ENABLED)
0 ignored issues
show
Bug introduced by
It seems like $dataQuery is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
647
            // DataObject::get_by_id() should work independently of language
648
            && !$query->filtersOnID()
649
            // the query contains this table
650
            // @todo Isn't this always the case?!
651
            && array_search($baseTable, array_keys($query->getFrom())) !== false
652
            //&& !$query->filtersOnFK()
0 ignored issues
show
Unused Code Comprehensibility introduced by
75% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
653
        ) {
654
            // Or we're already filtering by Lang (either from an earlier augmentSQL()
655
            // call or through custom SQL filters)
656
            $filtersOnLocale = array_filter($query->getWhere(), function ($predicates) {
657
                foreach ($predicates as $predicate => $params) {
658
                    if (preg_match('/("|\'|`)Locale("|\'|`)/', $predicate)) {
659
                        return true;
660
                    }
661
                }
662
            });
663
            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...
664
                $qry = sprintf('"%s"."Locale" = \'%s\'', $baseTable, Convert::raw2sql($locale));
665
                $query->addWhere($qry);
666
            }
667
        }
668
    }
669
670
    public function augmentDataQueryCreation(SQLSelect $sqlQuery, DataQuery $dataQuery)
0 ignored issues
show
Unused Code introduced by
The parameter $sqlQuery is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
671
    {
672
        $enabled = self::locale_filter_enabled();
673
        $dataQuery->setQueryParam(self::QUERY_LOCALE_FILTER_ENABLED, $enabled);
0 ignored issues
show
Documentation introduced by
$enabled is of type boolean, but the function expects a string|array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
674
    }
675
676
    /**
677
     * Create <table>_translation database table to enable
678
     * tracking of "translation groups" in which each related
679
     * translation of an object acts as a sibling, rather than
680
     * a parent->child relation.
681
     */
682
    public function augmentDatabase()
683
    {
684
        $baseDataClass = DataObject::getSchema()->baseDataClass(get_class($this->owner));
685
        if (get_class($this->owner) != $baseDataClass) {
686
            return;
687
        }
688
        $baseDataTable = DataObject::getSchema()->baseDataTable(get_class($this->owner));
689
690
        $fields = [
691
            'OriginalID' => 'Int',
692
            'TranslationGroupID' => 'Int',
693
        ];
694
        $indexes = [
695
            'OriginalID' => ['type' => 'index', 'columns' => ['OriginalID']],
696
            'TranslationGroupID' => ['type' => 'index', 'columns' => ['TranslationGroupID']]
697
        ];
698
699
        // Add new tables if required
700
        DB::get_schema()->requireTable("{$baseDataTable}_translationgroups", $fields, $indexes);
701
702
        // Remove 2.2 style tables
703
        DB::get_schema()->dontRequireTable("{$baseDataTable}_lang");
704
        if ($this->owner->hasExtension(Versioned::class)) {
705
            DB::get_schema()->dontRequireTable("{$baseDataTable}_lang_Live");
706
            DB::get_schema()->dontRequireTable("{$baseDataTable}_lang_versions");
707
        }
708
    }
709
710
    /**
711
     * @todo Find more appropriate place to hook into database building
712
     */
713
    public function requireDefaultRecords()
714
    {
715
        // @todo This relies on the Locale attribute being on the base data class, and not any subclasses
716
        $baseDataClass = DataObject::getSchema()->baseDataClass(get_class($this->owner));
717
        if (get_class($this->owner) != $baseDataClass) {
718
            return;
719
        }
720
        $baseDataTable = DataObject::getSchema()->baseDataTable(get_class($this->owner));
721
722
        // Permissions: If a group doesn't have any specific TRANSLATE_<locale> edit rights,
723
        // but has CMS_ACCESS_CMSMain (general CMS access), then assign TRANSLATE_ALL permissions as a default.
724
        // Auto-setting permissions based on these intransparent criteria is a bit hacky,
725
        // but unavoidable until we can determine when a certain permission code was made available first
726
        // (see http://open.silverstripe.org/ticket/4940)
727
        $groups = Permission::get_groups_by_permission(array(
728
            'CMS_ACCESS_CMSMain',
729
            'CMS_ACCESS_LeftAndMain',
730
            'ADMIN'
731
        ));
732
        if ($groups) {
733
            foreach ($groups as $group) {
734
                $codes = $group->Permissions()->column('Code');
735
                $hasTranslationCode = false;
736
                foreach ($codes as $code) {
737
                    if (preg_match('/^TRANSLATE_/', $code)) {
738
                        $hasTranslationCode = true;
739
                    }
740
                }
741
                // Only add the code if no more restrictive code exists
742
                if (!$hasTranslationCode) {
743
                    Permission::grant($group->ID, 'TRANSLATE_ALL');
744
                }
745
            }
746
        }
747
748
        // If the Translatable extension was added after the first records were already
749
        // created in the database, make sure to update the Locale property if
750
        // if wasn't set before
751
        $idsWithoutLocale = DB::query(sprintf(
752
            'SELECT "ID" FROM "%s" WHERE "Locale" IS NULL OR "Locale" = \'\'',
753
            $baseDataTable
754
        ))->column();
755
        if (!$idsWithoutLocale) {
756
            return;
757
        }
758
759
        if (class_exists(SiteTree::class) && get_class($this->owner) == SiteTree::class) {
760
            foreach (array('Stage', 'Live') as $stage) {
761
                foreach ($idsWithoutLocale as $id) {
762
                    $obj = Versioned::get_one_by_stage(
763
                        get_class($this->owner),
764
                        $stage,
765
                        sprintf('"SiteTree"."ID" = %d', $id)
766
                    );
767
                    if (!$obj || $obj->ObsoleteClassName) {
0 ignored issues
show
Bug introduced by
The property ObsoleteClassName does not seem to exist. Did you mean ClassName?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
768
                        continue;
769
                    }
770
771
                    $obj->Locale = Translatable::default_locale();
772
773
                    $oldMode = Versioned::get_reading_mode();
774
                    Versioned::set_stage($stage);
775
                    $obj->writeWithoutVersion();
776
                    Versioned::set_reading_mode($oldMode);
777
778
                    $obj->addTranslationGroup($obj->ID);
779
                    $obj->destroy();
780
                    unset($obj);
781
                }
782
            }
783
        } else {
784
            foreach ($idsWithoutLocale as $id) {
785
                $obj = DataObject::get_by_id(get_class($this->owner), $id);
786
                if (!$obj || $obj->ObsoleteClassName) {
0 ignored issues
show
Bug introduced by
The property ObsoleteClassName does not seem to exist. Did you mean ClassName?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
787
                    continue;
788
                }
789
790
                $obj->Locale = Translatable::default_locale();
791
                $obj->write();
792
                $obj->addTranslationGroup($obj->ID);
793
                $obj->destroy();
794
                unset($obj);
795
            }
796
        }
797
        DB::alteration_message(sprintf(
798
            "Added default locale '%s' to table %s",
799
            "changed",
800
            Translatable::default_locale(),
801
            get_class($this->owner)
802
        ));
803
    }
804
805
    /**
806
     * Add a record to a "translation group",
807
     * so its relationship to other translations
808
     * based off the same object can be determined later on.
809
     * See class header for further comments.
810
     *
811
     * @param int $originalID Either the primary key of the record this new translation is based on,
812
     *  or the primary key of this record, to create a new translation group
813
     * @param boolean $overwrite
814
     */
815
    public function addTranslationGroup($originalID, $overwrite = false)
816
    {
817
        if (!$this->owner->exists()) {
818
            return false;
819
        }
820
821
        $baseDataClass = DataObject::getSchema()->baseDataTable(get_class($this->owner));
822
        $existingGroupID = $this->getTranslationGroup($originalID);
0 ignored issues
show
Unused Code introduced by
The call to Translatable::getTranslationGroup() has too many arguments starting with $originalID.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
823
824
        // Remove any existing groups if overwrite flag is set
825 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...
826
            $sql = sprintf(
827
                'DELETE FROM "%s_translationgroups" WHERE "TranslationGroupID" = %d AND "OriginalID" = %d',
828
                $baseDataClass,
829
                $existingGroupID,
830
                $this->owner->ID
831
            );
832
            DB::query($sql);
833
            $existingGroupID = null;
834
        }
835
836
        // Add to group (only if not in existing group or $overwrite flag is set)
837 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...
838
            $sql = sprintf(
839
                'INSERT INTO "%s_translationgroups" ("TranslationGroupID","OriginalID") VALUES (%d,%d)',
840
                $baseDataClass,
841
                $originalID,
842
                $this->owner->ID
843
            );
844
            DB::query($sql);
845
        }
846
    }
847
848
    /**
849
     * Gets the translation group for the current record.
850
     * This ID might equal the record ID, but doesn't have to -
851
     * it just points to one "original" record in the list.
852
     *
853
     * @return int Numeric ID of the translationgroup in the <classname>_translationgroup table
854
     */
855
    public function getTranslationGroup()
856
    {
857
        if (!$this->owner->exists()) {
858
            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 SilverStripe\Translatabl...le::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...
859
        }
860
861
        $baseDataClass = DataObject::getSchema()->baseDataTable(get_class($this->owner));
862
        return DB::query(
863
            sprintf(
864
                'SELECT "TranslationGroupID" FROM "%s_translationgroups" WHERE "OriginalID" = %d',
865
                $baseDataClass,
866
                $this->owner->ID
867
            )
868
        )->value();
869
    }
870
871
    /**
872
     * Removes a record from the translation group lookup table.
873
     * Makes no assumptions on other records in the group - meaning
874
     * if this happens to be the last record assigned to the group,
875
     * this group ceases to exist.
876
     */
877
    public function removeTranslationGroup()
878
    {
879
        $baseDataClass = DataObject::getSchema()->baseDataTable(get_class($this->owner));
880
        DB::query(
881
            sprintf('DELETE FROM "%s_translationgroups" WHERE "OriginalID" = %d', $baseDataClass, $this->owner->ID)
882
        );
883
    }
884
885
    /**
886
     * Determine if a table needs Versioned support
887
     * This is called at db/build time
888
     *
889
     * @param string $table Table name
890
     * @return boolean
891
     */
892
    public function isVersionedTable($table)
0 ignored issues
show
Unused Code introduced by
The parameter $table is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
893
    {
894
        return false;
895
    }
896
897
    /**
898
     * Note: The bulk of logic is in ModelAsController->getNestedController()
899
     * and ContentController->handleRequest()
900
     */
901
    public function contentcontrollerInit($controller)
902
    {
903
        $controller->Locale = Translatable::choose_site_locale();
904
    }
905
906
    public function modelascontrollerInit($controller)
0 ignored issues
show
Unused Code introduced by
The parameter $controller is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
907
    {
908
        //$this->contentcontrollerInit($controller);
0 ignored issues
show
Unused Code Comprehensibility introduced by
86% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
909
    }
910
911
    public function initgetEditForm($controller)
912
    {
913
        $this->contentcontrollerInit($controller);
914
    }
915
916
    /**
917
     * Recursively creates translations for parent pages in this language
918
     * if they aren't existing already. This is a necessity to make
919
     * nested pages accessible in a translated CMS page tree.
920
     * It would be more userfriendly to grey out untranslated pages,
921
     * but this involves complicated special cases in AllChildrenIncludingDeleted().
922
     *
923
     * {@link SiteTree->onBeforeWrite()} will ensure that each translation will get
924
     * a unique URL across languages, by means of {@link SiteTree::get_by_link()}
925
     * and {@link Translatable->alternateGetByURL()}.
926
     */
927
    public function onBeforeWrite()
928
    {
929
        // If language is not set explicitly, set it to current_locale.
930
        // This might be a bit overzealous in assuming the language
931
        // of the content, as a "single language" website might be expanded
932
        // later on. See {@link requireDefaultRecords()} for batch setting
933
        // of empty Locale columns on each dev/build call.
934
        if (!$this->owner->Locale) {
935
            $this->owner->Locale = Translatable::get_current_locale();
936
        }
937
938
        // Specific logic for SiteTree subclasses.
939
        // If page has untranslated parents, create (unpublished) translations
940
        // of those as well to avoid having inaccessible children in the sitetree.
941
        // Caution: This logic is very sensitve to infinite loops when translation status isn't determined properly
942
        // If a parent for the newly written translation was existing before this
943
        // onBeforeWrite() call, it will already have been linked correctly through createTranslation()
944
        if (class_exists(SiteTree::class)
945
            && $this->owner->hasField('ParentID')
946
            && $this->owner instanceof SiteTree
947
        ) {
948
            if (!$this->owner->ID
949
                && $this->owner->ParentID
950
                && !$this->owner->Parent()->hasTranslation($this->owner->Locale)
951
            ) {
952
                $parentTranslation = $this->owner->Parent()->createTranslation($this->owner->Locale);
953
                $this->owner->ParentID = $parentTranslation->ID;
954
            }
955
        }
956
957
        // Has to be limited to the default locale, the assumption is that the "page type"
958
        // dropdown is readonly on all translations.
959
        if ($this->owner->ID && $this->owner->Locale == Translatable::default_locale()) {
960
            $changedFields = $this->owner->getChangedFields();
961
            $changed = isset($changedFields['ClassName']);
962
963
            if ($changed && $this->owner->hasExtension(Versioned::class)) {
964
                // this is required because when publishing a node the before/after
965
                // values of $changedFields['ClassName'] will be the same because
966
                // the record was already written to the stage/draft table and thus
967
                // the record was updated, and then publish('Stage', 'Live') is
968
                // called, which uses forceChange, which will make all the fields
969
                // act as though they are changed, although the before/after values
970
                // will be the same
971
                // So, we load one from the current stage and test against it
972
                // This is to prevent the overhead of writing all translations when
973
                // the class didn't actually change.
974
                $baseDataClass = DataObject::getSchema()->baseDataClass(get_class($this->owner));
975
                $currentStage = Versioned::get_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...
976
                $fresh = Versioned::get_one_by_stage(
977
                    $baseDataClass,
978
                    Versioned::get_stage(),
979
                    '"ID" = ' . $this->owner->ID,
980
                    null
981
                );
982
                if ($fresh) {
983
                    $changed = $changedFields['ClassName']['after'] != $fresh->ClassName;
984
                }
985
            }
986
987
            if ($changed) {
988
                $this->owner->ClassName = $changedFields['ClassName']['before'];
989
                $translations = $this->owner->getTranslations();
990
                $this->owner->ClassName = $changedFields['ClassName']['after'];
991
                if ($translations) {
992
                    foreach ($translations as $translation) {
993
                        $translation->setClassName($this->owner->ClassName);
994
                        $translation = $translation->newClassInstance($translation->ClassName);
995
                        $translation->populateDefaults();
996
                        $translation->forceChange();
997
                        $translation->write();
998
                    }
999
                }
1000
            }
1001
        }
1002
1003
        // see onAfterWrite()
1004
        if (!$this->owner->ID) {
1005
            $this->owner->_TranslatableIsNewRecord = true;
0 ignored issues
show
Bug introduced by
The property _TranslatableIsNewRecord does not seem to exist. Did you mean record?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1006
        }
1007
    }
1008
1009
    public function onAfterWrite()
1010
    {
1011
        // hacky way to determine if the record was created in the database,
1012
        // or just updated
1013
        if ($this->owner->_TranslatableIsNewRecord) {
0 ignored issues
show
Bug introduced by
The property _TranslatableIsNewRecord does not seem to exist. Did you mean record?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

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

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1190
    {
1191
        // Prevent home pages for non-default locales having their urlsegments
1192
        // reduced to the site root.
1193
        if ($base === null && $this->owner->Locale != self::default_locale()) {
1194
            $base = $this->owner->URLSegment;
1195
        }
1196
    }
1197
1198
    /**
1199
     * This method can be called multiple times on the same FieldList
1200
     * because it checks which fields have already been added or modified.
1201
     */
1202
    protected function addTranslatableFields(&$fields)
1203
    {
1204
        // used in LeftAndMain->init() to set language state when reading/writing record
1205
        $fields->push(new HiddenField("Locale", "Locale", $this->owner->Locale));
1206
1207
        // Don't apply these modifications for normal DataObjects - they rely on CMSMain logic
1208
        if (!class_exists(SiteTree::class)) {
1209
            return;
1210
        }
1211
        if (!($this->owner instanceof SiteTree)) {
1212
            return;
1213
        }
1214
1215
        // Don't allow translation of virtual pages because of data inconsistencies (see #5000)
1216
        if (class_exists(VirtualPage::class)) {
1217
            $excludedPageTypes = array(VirtualPage::class);
1218
            foreach ($excludedPageTypes as $excludedPageType) {
1219
                if (is_a($this->owner, $excludedPageType)) {
1220
                    return;
1221
                }
1222
            }
1223
        }
1224
1225
        // Get excluded fields from translation
1226
        $excludeFields = $this->owner->config()->translate_excluded_fields;
1227
1228
        // if a language other than default language is used, we're in "translation mode",
1229
        // hence have to modify the original fields
1230
        $baseClass = get_class($this->owner);
1231
        while (($p = get_parent_class($baseClass)) != DataObject::class) {
1232
            $baseClass = $p;
1233
        }
1234
1235
        // try to get the record in "default language"
1236
        $originalRecord = $this->owner->getTranslation(Translatable::default_locale());
1237
        // if no translation in "default language", fall back to first translation
1238
        if (!$originalRecord) {
1239
            $translations = $this->owner->getTranslations();
1240
            $originalRecord = ($translations) ? $translations->First() : null;
1241
        }
1242
1243
        $isTranslationMode = $this->owner->Locale != Translatable::default_locale();
1244
1245
        if ($originalRecord && $isTranslationMode) {
1246
            // Remove parent page dropdown
1247
            $fields->removeByName("ParentType");
1248
            $fields->removeByName("ParentID");
1249
1250
            $translatableFieldNames = $this->getTranslatableFields();
1251
            $allDataFields = $fields->dataFields();
1252
1253
            $transformation = new Translatable\Transformation($originalRecord);
1254
1255
            // iterate through sequential list of all datafields in fieldset
1256
            // (fields are object references, so we can replace them with the translatable CompositeField)
1257
            foreach ($allDataFields as $dataField) {
1258
                // Transformation is a visual helper for CMS authors, so ignore hidden fields
1259
                if ($dataField instanceof HiddenField) {
1260
                    continue;
1261
                }
1262
                // Some fields are explicitly excluded from transformation
1263
                if (in_array($dataField->getName(), $excludeFields)) {
1264
                    continue;
1265
                }
1266
                // Readonly field which has been added previously
1267
                if (preg_match('/_original$/', $dataField->getName())) {
1268
                    continue;
1269
                }
1270
                // Field already has been transformed
1271
                if (isset($allDataFields[$dataField->getName() . '_original'])) {
1272
                    continue;
1273
                }
1274
                // CheckboxField which is already transformed
1275
                if (preg_match('/class=\"originalvalue\"/', $dataField->Title())) {
1276
                    continue;
1277
                }
1278
1279
                if (in_array($dataField->getName(), $translatableFieldNames)) {
1280
                    // if the field is translatable, perform transformation
1281
                    $fields->replaceField($dataField->getName(), $transformation->transformFormField($dataField));
1282
                } elseif (!$dataField->isReadonly()) {
1283
                    // else field shouldn't be editable in translation-mode, make readonly
1284
                    $fields->replaceField($dataField->getName(), $dataField->performReadonlyTransformation());
1285
                }
1286
            }
1287
        } elseif ($this->owner->isNew()) {
1288
            $fields->addFieldsToTab(
1289
                'Root',
1290
                new Tab(
1291
                    _t('Translatable.TRANSLATIONS', 'Translations'),
1292
                    new LiteralField(
1293
                        'SaveBeforeCreatingTranslationNote',
1294
                        sprintf(
1295
                            '<p class="message">%s</p>',
1296
                            _t('Translatable.NOTICENEWPAGE', 'Please save this page before creating a translation')
1297
                        )
1298
                    )
1299
                )
1300
            );
1301
        }
1302
    }
1303
1304
    /**
1305
     * Get the names of all translatable fields on this class as a numeric array.
1306
     * @todo Integrate with blacklist once branches/translatable is merged back.
1307
     *
1308
     * @return array
1309
     */
1310
    public function getTranslatableFields()
1311
    {
1312
        return $this->translatableFields;
1313
    }
1314
1315
    /**
1316
     * Return the base table - the class that directly extends DataObject.
1317
     * @return string
1318
     */
1319
    public function baseTable($stage = null)
1320
    {
1321
        $baseTable = DataObject::getSchema()->baseDataTable(get_class($this->owner));
0 ignored issues
show
Unused Code introduced by
$baseTable 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...
1322
        return (!$stage || $stage == $this->defaultStage) ? $baseClass : $baseClass . "_$stage";
0 ignored issues
show
Bug introduced by
The property defaultStage does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
Bug introduced by
The variable $baseClass 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...
1323
    }
1324
1325
    public function extendWithSuffix($table)
1326
    {
1327
        return $table;
1328
    }
1329
1330
    /**
1331
     * Gets all related translations for the current object,
1332
     * excluding itself. See {@link getTranslation()} to retrieve
1333
     * a single translated object.
1334
     *
1335
     * Getter with $stage parameter is specific to {@link Versioned} extension,
1336
     * mostly used for {@link SiteTree} subclasses.
1337
     *
1338
     * @param string $locale
1339
     * @param string $stage
1340
     * @return DataObjectSet
1341
     */
1342
    public function getTranslations($locale = null, $stage = null)
1343
    {
1344
        if ($locale && !$this->isLocaleValid($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...
1345
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1346
        }
1347
1348
        if (!$this->owner->exists()) {
1349
            return new ArrayList();
0 ignored issues
show
Bug Best Practice introduced by
The return type of return new \SilverStripe\ORM\ArrayList(); (SilverStripe\ORM\ArrayList) is incompatible with the return type documented by SilverStripe\Translatabl...atable::getTranslations of type SilverStripe\Translatable\Model\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...
1350
        }
1351
1352
        // HACK need to disable language filtering in augmentSQL(),
1353
        // as we purposely want to get different language
1354
        // also save state of locale-filter, revert to this state at the
1355
        // end of this method
1356
        $localeFilterEnabled = false;
1357
        if (self::locale_filter_enabled()) {
1358
            self::disable_locale_filter();
1359
            $localeFilterEnabled = true;
1360
        }
1361
1362
        $translationGroupID = $this->getTranslationGroup();
1363
1364
        $baseDataClass = DataObject::getSchema()->baseDataClass(get_class($this->owner));
1365
        $baseDataTable = DataObject::getSchema()->tableName($baseDataClass);
1366
1367
        $filter = sprintf('"%s_translationgroups"."TranslationGroupID" = %d', $baseDataTable, $translationGroupID);
1368
        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...
1369
            $filter .= sprintf(' AND "%s"."Locale" = \'%s\'', $baseDataTable, Convert::raw2sql($locale));
1370
        } else {
1371
            // exclude the language of the current owner
1372
            $filter .= sprintf(' AND "%s"."Locale" != \'%s\'', $baseDataTable, $this->owner->Locale);
1373
        }
1374
        $currentStage = Versioned::get_stage();
1375
        $joinOnClause = sprintf('"%s_translationgroups"."OriginalID" = "%s"."ID"', $baseDataTable, $baseDataTable);
1376
        if ($this->owner->hasExtension(Versioned::class)) {
1377
            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...
1378
                Versioned::set_stage($stage);
1379
            }
1380
            $translations = Versioned::get_by_stage(
1381
                $baseDataClass,
1382
                Versioned::get_stage(),
1383
                $filter,
1384
                null
1385
            )->leftJoin("{$baseDataTable}_translationgroups", $joinOnClause);
1386
            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...
1387
                Versioned::set_stage($currentStage);
1388
            }
1389
        } else {
1390
            $class = get_class($this->owner);
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...
1391
            $translations = $baseDataClass::get()
1392
                ->where($filter)
1393
                ->leftJoin("{$baseDataTable}_translationgroups", $joinOnClause);
1394
        }
1395
1396
        // only re-enable locale-filter if it was enabled at the beginning of this method
1397
        if ($localeFilterEnabled) {
1398
            self::enable_locale_filter();
1399
        }
1400
1401
        return $translations;
1402
    }
1403
1404
    /**
1405
     * Gets an existing translation based on the language code.
1406
     * Use {@link hasTranslation()} as a quicker alternative to check
1407
     * for an existing translation without getting the actual object.
1408
     *
1409
     * @param String $locale
1410
     * @return DataObject Translated object
1411
     */
1412
    public function getTranslation($locale, $stage = null)
1413
    {
1414
        if ($locale && !$this->isLocaleValid($locale)) {
1415
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1416
        }
1417
1418
        $translations = $this->getTranslations($locale, $stage);
1419
        return ($translations) ? $translations->First() : null;
1420
    }
1421
1422
    /**
1423
     * When the SiteConfig object is automatically instantiated, we should ensure that
1424
     * 1. All SiteConfig objects belong to the same group
1425
     * 2. Defaults are correctly initiated from the base object
1426
     * 3. The creation mechanism uses the createTranslation function in order to be consistent
1427
     * This function ensures that any already created "vanilla" SiteConfig object is populated
1428
     * correctly with translated values.
1429
     * This function DOES populate the ID field with the newly created object ID
1430
     * @see SiteConfig
1431
     */
1432
    protected function populateSiteConfigDefaults()
1433
    {
1434
        $table = DataObject::getSchema()->tableName(get_class($this->owner));
1435
1436
        // Work-around for population of defaults during database initialisation.
1437
        // When the database is being setup singleton('SiteConfig') is called.
1438
        if (!DB::get_schema()->hasTable($table)) {
1439
            return;
1440
        }
1441
        if (!DB::get_schema()->hasField($table, 'Locale')) {
1442
            return;
1443
        }
1444
        if (DB::get_schema()->isSchemaUpdating()) {
1445
            return;
1446
        }
1447
1448
        // Find the best base translation for SiteConfig
1449
        $enabled = Translatable::locale_filter_enabled();
1450
        Translatable::disable_locale_filter();
1451
        $existingConfig = SiteConfig::get()->filter(array(
1452
            'Locale' => Translatable::default_locale()
1453
        ))->first();
1454
        if (!$existingConfig) {
1455
            $existingConfig = SiteConfig::get()->first();
1456
        }
1457
        if ($enabled) {
1458
            Translatable::enable_locale_filter();
1459
        }
1460
1461
        // Stage this SiteConfig and copy into the current object
1462
        if ($existingConfig
1463
            // Double-up of SiteConfig in the same locale can be ignored. Often caused by singleton(SiteConfig)
1464
            && !$existingConfig->getTranslation(Translatable::get_current_locale())
1465
            // If translation is not allowed by the current user then do not
1466
            // allow this code to attempt any behind the scenes translation.
1467
            && $existingConfig->canTranslate(null, Translatable::get_current_locale())
1468
        ) {
1469
            // Create an unsaved "staging" translated object using the correct createTranslation mechanism
1470
            $stagingConfig = $existingConfig->createTranslation(Translatable::get_current_locale(), false);
1471
            $this->owner->update($stagingConfig->toMap());
1472
        }
1473
1474
        // Maintain single translation group for SiteConfig
1475
        if ($existingConfig) {
1476
            $this->owner->_TranslationGroupID = $existingConfig->getTranslationGroup();
1477
        }
1478
1479
        $this->owner->Locale = Translatable::get_current_locale();
1480
    }
1481
1482
    /**
1483
     * Enables automatic population of SiteConfig fields using createTranslation if
1484
     * created outside of the Translatable module
1485
     * @var boolean
1486
     */
1487
    public static $enable_siteconfig_generation = true;
1488
1489
    /**
1490
     * Hooks into the DataObject::populateDefaults() method
1491
     */
1492
    public function populateDefaults()
1493
    {
1494
        if (empty($this->owner->ID)
1495
            && ($this->owner instanceof SiteConfig)
1496
            && self::$enable_siteconfig_generation
1497
        ) {
1498
            // Use enable_siteconfig_generation to prevent infinite loop during object creation
1499
            self::$enable_siteconfig_generation = false;
1500
            $this->populateSiteConfigDefaults();
1501
            self::$enable_siteconfig_generation = true;
1502
        }
1503
    }
1504
1505
    /**
1506
     * Creates a new translation for the owner object of this decorator.
1507
     * Checks {@link getTranslation()} to return an existing translation
1508
     * instead of creating a duplicate. Writes the record to the database before
1509
     * returning it. Use this method if you want the "translation group"
1510
     * mechanism to work, meaning that an object knows which group of translations
1511
     * it belongs to. For "original records" which are not created through this
1512
     * method, the "translation group" is set in {@link onAfterWrite()}.
1513
     *
1514
     * @param string $locale Target locale to translate this object into
1515
     * @param boolean $saveTranslation Flag indicating whether the new record
1516
     * should be saved to the database.
1517
     * @return DataObject The translated object
1518
     * @throws InvalidArgumentException If the locale is invalid
1519
     * @throws Exception if the user is not allowed to create a new translation
1520
     */
1521
    public function createTranslation($locale, $saveTranslation = true)
1522
    {
1523
        if ($locale && !$this->isLocaleValid($locale)) {
1524
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1525
        }
1526
1527
        if (!$this->owner->exists()) {
1528
            user_error(
1529
                'Translatable::createTranslation(): Please save your record before creating a translation',
1530
                E_USER_ERROR
1531
            );
1532
        }
1533
1534
        // permission check
1535
        if (!$this->owner->canTranslate(null, $locale)) {
1536
            throw new Exception(sprintf(
1537
                'Creating a new translation in locale "%s" is not allowed for this user',
1538
                $locale
1539
            ));
1540
            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...
1541
        }
1542
1543
        $existingTranslation = $this->getTranslation($locale);
1544
        if ($existingTranslation) {
1545
            return $existingTranslation;
1546
        }
1547
1548
        $class = get_class($this->owner);
1549
        $newTranslation = new $class;
1550
1551
        // copy all fields from owner (apart from ID)
1552
        $newTranslation->update(array_diff_key($this->owner->toMap(), array('Version' => null)));
1553
1554
        // If the object has Hierarchy extension,
1555
        // check for existing translated parents and assign
1556
        // their ParentID (and overwrite any existing ParentID relations
1557
        // to parents in other language). If no parent translations exist,
1558
        // they are automatically created in onBeforeWrite()
1559
        if ($newTranslation->hasField('ParentID')) {
1560
            $origParent = $this->owner->Parent();
1561
            $newTranslationParent = $origParent->getTranslation($locale);
1562
            if ($newTranslationParent) {
1563
                $newTranslation->ParentID = $newTranslationParent->ID;
1564
            }
1565
        }
1566
1567
        $newTranslation->ID = 0;
1568
        $newTranslation->Locale = $locale;
1569
        $newTranslation->Version = 0;
1570
1571
        $originalPage = $this->getTranslation(self::default_locale());
1572
        if ($originalPage) {
1573
            $urlSegment = $originalPage->URLSegment;
1574
        } else {
1575
            $urlSegment = $newTranslation->URLSegment;
1576
        }
1577
1578
        // Only make segment unique if it should be enforced
1579
        if (Config::inst()->get(Translatable::class, 'enforce_global_unique_urls')) {
1580
            $newTranslation->URLSegment = $urlSegment . '-' . i18n::convert_rfc1766($locale);
1581
        }
1582
1583
        // hacky way to set an existing translation group in onAfterWrite()
1584
        $translationGroupID = $this->getTranslationGroup();
1585
        $newTranslation->_TranslationGroupID = $translationGroupID ? $translationGroupID : $this->owner->ID;
1586
        if ($saveTranslation) {
1587
            $newTranslation->write();
1588
        }
1589
1590
        // run callback on page for translation related hooks
1591
        $newTranslation->invokeWithExtensions('onTranslatableCreate', $saveTranslation);
1592
1593
        return $newTranslation;
1594
    }
1595
1596
    /**
1597
     * Caution: Does not consider the {@link canEdit()} permissions.
1598
     *
1599
     * @param DataObject|int $member
1600
     * @param string $locale
1601
     * @return boolean
1602
     * @throws InvalidArgumentException If the locale is invalid
1603
     */
1604
    public function canTranslate($member = null, $locale)
1605
    {
1606
        if ($locale && !$this->isLocaleValid($locale)) {
1607
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1608
        }
1609
1610
        if (!$member || !(is_a($member, Member::class)) || is_numeric($member)) {
1611
            $member = Member::currentUser();
0 ignored issues
show
Deprecated Code introduced by
The method SilverStripe\Security\Member::currentUser() has been deprecated with message: 5.0.0 use Security::getCurrentUser()

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...
1612
        }
1613
1614
        // check for locale
1615
        $allowedLocale = (
1616
            !is_array(self::get_allowed_locales())
1617
            || in_array($locale, self::get_allowed_locales())
1618
        );
1619
1620
        if (!$allowedLocale) {
1621
            return false;
1622
        }
1623
1624
        // By default, anyone who can edit a page can edit the default locale
1625
        if ($locale == self::default_locale()) {
1626
            return true;
1627
        }
1628
1629
        // check for generic translation permission
1630
        if (Permission::checkMember($member, 'TRANSLATE_ALL')) {
1631
            return true;
1632
        }
1633
1634
        // check for locale specific translate permission
1635
        if (!Permission::checkMember($member, 'TRANSLATE_' . $locale)) {
1636
            return false;
1637
        }
1638
1639
        return true;
1640
    }
1641
1642
    /**
1643
     * @return boolean
1644
     */
1645
    public function canEdit($member)
1646
    {
1647
        if (!$this->owner->Locale) {
1648
            return null;
1649
        }
1650
        return $this->owner->canTranslate($member, $this->owner->Locale) ? null : false;
1651
    }
1652
1653
    /**
1654
     * Returns TRUE if the current record has a translation in this language.
1655
     * Use {@link getTranslation()} to get the actual translated record from
1656
     * the database.
1657
     *
1658
     * @param string $locale
1659
     * @return boolean
1660
     * @throws InvalidArgumentException If the locale is invalid
1661
     */
1662
    public function hasTranslation($locale)
1663
    {
1664
        if ($locale && !$this->isLocaleValid($locale)) {
1665
            throw new InvalidArgumentException(sprintf('Invalid locale "%s"', $locale));
1666
        }
1667
1668
        return (
1669
            $this->owner->Locale == $locale
1670
            || array_search($locale, $this->getTranslatedLocales()) !== false
1671
        );
1672
    }
1673
1674
    /**
1675
     * Returns <link rel="alternate"> markup for insertion into
1676
     * a HTML4/XHTML compliant <head> section, listing all available translations
1677
     * of a page.
1678
     *
1679
     * @see http://www.w3.org/TR/html4/struct/links.html#edef-LINK
1680
     * @see http://www.w3.org/International/articles/language-tags/
1681
     *
1682
     * @return string HTML
1683
     */
1684
    public function MetaTags(&$tags)
1685
    {
1686
        $template = '<link rel="alternate" type="text/html" title="%s" hreflang="%s" href="%s" />' . "\n";
1687
        $translations = $this->owner->getTranslations();
1688
        if ($translations) {
1689
            $translations = $translations->toArray();
1690
            $translations[] = $this->owner;
1691
1692
            foreach ($translations as $translation) {
1693
                $tags .= sprintf(
1694
                    $template,
1695
                    Convert::raw2xml($translation->Title),
1696
                    i18n::convert_rfc1766($translation->Locale),
1697
                    $translation->AbsoluteLink()
1698
                );
1699
            }
1700
        }
1701
    }
1702
1703
    public function providePermissions()
1704
    {
1705
        if (!SiteTree::has_extension(Translatable::class) || !class_exists(SiteTree::class)) {
1706
            return false;
1707
        }
1708
1709
        $locales = self::get_allowed_locales();
1710
1711
        // Fall back to any locales used in existing translations (see #4939)
1712
        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...
1713
            $locales = DB::query('SELECT "Locale" FROM "SiteTree" GROUP BY "Locale"')->column();
1714
        }
1715
1716
        $permissions = array();
1717
        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...
1718
            foreach ($locales as $locale) {
1719
                $localeName = i18n::getData()->localeName($locale);
1720
                $permissions['TRANSLATE_' . $locale] = sprintf(
1721
                    _t(
1722
                        'Translatable.TRANSLATEPERMISSION',
1723
                        'Translate %s',
1724
                        'Translate pages into a language'
1725
                    ),
1726
                    $localeName
1727
                );
1728
            }
1729
        }
1730
1731
        $permissions['TRANSLATE_ALL'] = _t(
1732
            'Translatable.TRANSLATEALLPERMISSION',
1733
            'Translate into all available languages'
1734
        );
1735
1736
        $permissions['VIEW_LANGS'] = _t(
1737
            'Translatable.TRANSLATEVIEWLANGS',
1738
            'View language dropdown'
1739
        );
1740
1741
        return $permissions;
1742
    }
1743
1744
    /**
1745
     * Get a list of languages with at least one element translated in (including the default language)
1746
     *
1747
     * @param string $className Look for languages in elements of this class
1748
     * @param string $where Optional SQL WHERE statement
1749
     * @return array Map of languages in the form locale => langName
1750
     */
1751
    public static function get_existing_content_languages($className = SiteTree::class, $where = '')
1752
    {
1753
        $baseTable = DataObject::getSchema()->baseDataTable($className);
1754
        $query = new SQLSelect("Distinct \"Locale\"", "\"$baseTable\"", $where, '', "\"Locale\"");
0 ignored issues
show
Documentation introduced by
$where is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Documentation introduced by
'' is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Documentation introduced by
'"Locale"' is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
1755
        $dbLangs = $query->execute()->column();
1756
        $langlist = array_merge((array) Translatable::default_locale(), (array) $dbLangs);
1757
        $returnMap = [];
1758
        $allCodes = i18n::getData()->getLocales();
1759
        foreach ($langlist as $langCode) {
1760
            if ($langCode && isset($allCodes[$langCode])) {
1761
                if (is_array($allCodes[$langCode])) {
1762
                    $returnMap[$langCode] = $allCodes[$langCode]['name'];
1763
                } else {
1764
                    $returnMap[$langCode] = $allCodes[$langCode];
1765
                }
1766
            }
1767
        }
1768
        return $returnMap;
1769
    }
1770
1771
    /**
1772
     * Get the RelativeLink value for a home page in another locale. This is found by searching for the default home
1773
     * page in the default language, then returning the link to the translated version (if one exists).
1774
     *
1775
     * @return string
1776
     */
1777
    public static function get_homepage_link_by_locale($locale)
1778
    {
1779
        $originalLocale = self::get_current_locale();
1780
1781
        self::set_current_locale(self::default_locale());
1782
        $original = SiteTree::get_by_link(RootURLController::config()->default_homepage_link);
1783
        self::set_current_locale($originalLocale);
1784
1785
        if ($original) {
1786
            if ($translation = $original->getTranslation($locale)) {
1787
                return trim($translation->RelativeLink(true), '/');
1788
            }
1789
        }
1790
    }
1791
1792
    /**
1793
     * Define all locales which in which a new translation is allowed.
1794
     * Checked in {@link canTranslate()}.
1795
     *
1796
     * @param array List of allowed locale codes (see {@link i18n::$all_locales}).
1797
     *  Example: array('de_DE','ja_JP')
1798
     */
1799
    public static function set_allowed_locales($locales)
1800
    {
1801
        self::$allowed_locales = $locales;
1802
    }
1803
1804
    /**
1805
     * Get all locales which are generally permitted to be translated.
1806
     * Use {@link canTranslate()} to check if a specific member has permission
1807
     * to translate a record.
1808
     *
1809
     * @return array
1810
     */
1811
    public static function get_allowed_locales()
1812
    {
1813
        return self::$allowed_locales;
1814
    }
1815
1816
    /**
1817
     * Return a piece of text to keep DataObject cache keys appropriately specific
1818
     */
1819
    public function cacheKeyComponent()
1820
    {
1821
        return 'locale-' . self::get_current_locale();
1822
    }
1823
1824
    /**
1825
     * Extends the SiteTree::validURLSegment() method, to do checks appropriate
1826
     * to Translatable
1827
     *
1828
     * @return bool
1829
     */
1830
    public function augmentValidURLSegment()
1831
    {
1832
        $reEnableFilter = false;
1833
        if (!Config::inst()->get(Translatable::class, 'enforce_global_unique_urls')) {
1834
            self::enable_locale_filter();
1835
        } elseif (self::locale_filter_enabled()) {
1836
            self::disable_locale_filter();
1837
            $reEnableFilter = true;
1838
        }
1839
1840
        $IDFilter = ($this->owner->ID) ? "AND \"SiteTree\".\"ID\" <> {$this->owner->ID}" :  null;
1841
        $parentFilter = null;
1842
1843
        if (Config::inst()->get(SiteTree::class, 'nested_urls')) {
1844
            if ($this->owner->ParentID) {
1845
                $parentFilter = " AND \"SiteTree\".\"ParentID\" = {$this->owner->ParentID}";
1846
            } else {
1847
                $parentFilter = ' AND "SiteTree"."ParentID" = 0';
1848
            }
1849
        }
1850
1851
        $existingPage = SiteTree::get()
1852
            // disable get_one cache, as this otherwise may pick up results from when locale_filter was on
1853
            ->where("\"URLSegment\" = '{$this->owner->URLSegment}' $IDFilter $parentFilter")->First();
1854
        if ($reEnableFilter) {
1855
            self::enable_locale_filter();
1856
        }
1857
1858
        // By returning TRUE or FALSE, we overrule the base SiteTree->validateURLSegment() logic
1859
        return !$existingPage;
1860
    }
1861
1862
    /**
1863
     * Determine whether the given locale is valid
1864
     *
1865
     * @param  string $locale
1866
     * @return bool
1867
     */
1868
    public static function isLocaleValid($locale)
1869
    {
1870
        return (bool) i18n::getData()->validate($locale);
1871
    }
1872
}
1873