i18n::with_locale()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 6
nc 1
nop 2
dl 0
loc 8
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\i18n;
4
5
use SilverStripe\Core\Config\Configurable;
6
use SilverStripe\Core\Injector\Injector;
7
use SilverStripe\Dev\Deprecation;
8
use SilverStripe\i18n\Data\Locales;
9
use SilverStripe\i18n\Data\Sources;
10
use SilverStripe\i18n\Messages\MessageProvider;
11
use SilverStripe\View\TemplateGlobalProvider;
12
use InvalidArgumentException;
13
14
/**
15
 * Base-class for storage and retrieval of translated entities.
16
 *
17
 * Please see the 'translatable' module for managing translations of database-content.
18
 *
19
 * <b>Usage</b>
20
 *
21
 * PHP:
22
 * <code>
23
 * _t('MyNamespace.MYENTITY', 'My default natural language value');
24
 * _t('MyNamespace.MYENTITY', 'My default natural language value', 'My explanatory context');
25
 * _t('MyNamespace.MYENTITY', 'Counting {number} things', ['number' => 42]);
26
 * </code>
27
 *
28
 * Templates:
29
 * <code>
30
 * <%t MyNamespace.MYENTITY 'My default natural language value' %>
31
 * <%t MyNamespace.MYENTITY 'Counting {count} things' count=$ThingsCount %>
32
 * </code>
33
 *
34
 * Javascript (see framework/client/dist/js/i18n.js):
35
 * <code>
36
 * ss.i18n._t('MyEntity.MyNamespace','My default natural language value');
37
 * </code>
38
 *
39
 * File-based i18n-translations always have a "locale" (e.g. 'en_US').
40
 * Common language names (e.g. 'en') are mainly used in the 'translatable' module
41
 * database-entities.
42
 *
43
 * <b>Text Collection</b>
44
 *
45
 * Features a "textcollector-mode" that parses all files with a certain extension
46
 * (currently *.php and *.ss) for new translatable strings. Textcollector will write
47
 * updated string-tables to their respective folders inside the module, and automatically
48
 * namespace entities to the classes/templates they are found in (e.g. $lang['en_US']['AssetAdmin']['UPLOADFILES']).
49
 *
50
 * Caution: Does not apply any character-set conversion, it is assumed that all content
51
 * is stored and represented in UTF-8 (Unicode). Please make sure your files are created with the correct
52
 * character-set, and your HTML-templates render UTF-8.
53
 *
54
 * Caution: The language file has to be stored in the same module path as the "filename namespaces"
55
 * on the entities. So an entity stored in $lang['en_US']['AssetAdmin']['DETAILSTAB'] has to
56
 * in the language file cms/lang/en_US.php, as the referenced file (AssetAdmin.php) is stored
57
 * in the "cms" module.
58
 *
59
 * <b>Locales</b>
60
 *
61
 * For the i18n class, a "locale" consists of a language code plus a region code separated by an underscore,
62
 * for example "de_AT" for German language ("de") in the region Austria ("AT").
63
 * See http://www.w3.org/International/articles/language-tags/ for a detailed description.
64
 *
65
 * @see http://doc.silverstripe.org/i18n
66
 * @see http://www.w3.org/TR/i18n-html-tech-lang
67
 * @author Bernat Foj Capell <[email protected]>
68
 */
69
class i18n implements TemplateGlobalProvider
70
{
71
    use Configurable;
72
73
    /**
74
     * This static variable is used to store the current defined locale.
75
     *
76
     * @var string
77
     */
78
    protected static $current_locale = '';
79
80
    /**
81
     * @config
82
     * @var string
83
     */
84
    private static $default_locale = 'en_US';
0 ignored issues
show
introduced by
The private property $default_locale is not used, and could be removed.
Loading history...
85
86
    /**
87
     * System-wide date format. Will be overruled for CMS UI display
88
     * by the format defaults inferred from the browser as well as
89
     * any user-specific locale preferences.
90
     *
91
     * @config
92
     * @var string
93
     */
94
    private static $date_format = 'yyyy-MM-dd';
0 ignored issues
show
introduced by
The private property $date_format is not used, and could be removed.
Loading history...
95
96
    /**
97
     * System-wide time format. Will be overruled for CMS UI display
98
     * by the format defaults inferred from the browser as well as
99
     * any user-specific locale preferences.
100
     *
101
     * @config
102
     * @var string
103
     */
104
    private static $time_format = 'H:mm';
0 ignored issues
show
introduced by
The private property $time_format is not used, and could be removed.
Loading history...
105
106
    /**
107
     * Map of rails plurals into standard order (fewest to most)
108
     * Note: Default locale only supplies one|other, but non-default locales
109
     * can specify custom plurals.
110
     *
111
     * @config
112
     * @var array
113
     */
114
    private static $plurals = [
0 ignored issues
show
introduced by
The private property $plurals is not used, and could be removed.
Loading history...
115
        'zero',
116
        'one',
117
        'two',
118
        'few',
119
        'many',
120
        'other',
121
    ];
122
123
    /**
124
     * Plural forms in default (en) locale
125
     *
126
     * @var array
127
     */
128
    private static $default_plurals = [
0 ignored issues
show
introduced by
The private property $default_plurals is not used, and could be removed.
Loading history...
129
        'one',
130
        'other',
131
    ];
132
133
    /**
134
     * Warn if _t() invoked without a default.
135
     *
136
     * @config
137
     * @var bool
138
     */
139
    private static $missing_default_warning = true;
0 ignored issues
show
introduced by
The private property $missing_default_warning is not used, and could be removed.
Loading history...
140
141
    /**
142
     * This is the main translator function. Returns the string defined by $entity according to the
143
     * currently set locale.
144
     *
145
     * Also supports pluralisation of strings. Pass in a `count` argument, as well as a
146
     * default value with `|` pipe-delimited options for each plural form.
147
     *
148
     * @param string $entity Entity that identifies the string. It must be in the form
149
     * "Namespace.Entity" where Namespace will be usually the class name where this
150
     * string is used and Entity identifies the string inside the namespace.
151
     * @param mixed $arg,... Additional arguments are parsed as such:
152
     *  - Next string argument is a default. Pass in a `|` pipe-delimited value with `{count}`
153
     *    to do pluralisation.
154
     *  - Any other string argument after default is context for i18nTextCollector
155
     *  - Any array argument in any order is an injection parameter list. Pass in a `count`
156
     *    injection parameter to pluralise.
157
     * @return string
158
     */
159
    public static function _t($entity, $arg = null)
160
    {
161
        // Detect args
162
        $default = null;
163
        $injection = [];
164
        foreach (array_slice(func_get_args(), 1) as $arg) {
165
            if (is_array($arg)) {
166
                $injection = $arg;
167
            } elseif (!isset($default)) {
168
                $default = $arg ?: '';
169
            }
170
        }
171
172
        // Encourage the provision of default values so that text collector can discover new strings
173
        if (!$default && i18n::config()->uninherited('missing_default_warning')) {
174
            user_error("Missing default for localisation key $entity", E_USER_WARNING);
175
        }
176
177
        // Deprecate legacy injection format (`string %s, %d`)
178
        // inject the variables from injectionArray (if present)
179
        $sprintfArgs = [];
180
        if ($default && !preg_match('/\{[\w\d]*\}/i', $default) && preg_match('/%[s,d]/', $default)) {
181
            Deprecation::notice('5.0', 'sprintf style localisation variables are deprecated');
182
            $sprintfArgs = array_values($injection);
183
            $injection = [];
184
        }
185
186
        // If injection isn't associative, assume legacy injection format
187
        $failUnlessSprintf = false;
188
        if ($injection && array_values($injection) === $injection) {
189
            $failUnlessSprintf = true; // Note: Will trigger either a deprecation error or exception below
190
            $sprintfArgs = array_values($injection);
191
            $injection = [];
192
        }
193
194
        // Detect plurals: Has a {count} argument as well as a `|` pipe delimited string (if provided)
195
        $isPlural = isset($injection['count']);
196
        $count = $isPlural ? $injection['count'] : null;
197
        // Refine check against default
198
        if ($isPlural && $default && !static::parse_plurals($default)) {
199
            $isPlural = false;
200
        }
201
202
        // Pass back to translation backend
203
        if ($isPlural) {
204
            $result = static::getMessageProvider()->pluralise($entity, $default, $injection, $count);
205
        } else {
206
            $result = static::getMessageProvider()->translate($entity, $default, $injection);
207
        }
208
209
        // Sometimes default is omitted, so we don't know we have %s injection format until after translation
210
        if (!$default && !preg_match('/\{[\w\d]*\}/i', $result) && preg_match('/%[s,d]/', $result)) {
211
            Deprecation::notice('5.0', 'sprintf style localisation is deprecated');
212
            if ($injection) {
213
                $sprintfArgs = array_values($injection);
214
            }
215
        } elseif ($failUnlessSprintf) {
216
            // Note: After removing deprecated code, you can move this error up into the is-associative check
217
            // Neither default nor translated strings were %s substituted, and our array isn't associative
218
            throw new InvalidArgumentException('Injection must be an associative array');
219
        }
220
221
        // @deprecated (see above)
222
        if ($sprintfArgs) {
223
            return vsprintf($result, $sprintfArgs);
224
        }
225
226
        return $result;
227
    }
228
229
    /**
230
     * Split plural string into standard CLDR array form.
231
     * A string is considered a pluralised form if it has a {count} argument, and
232
     * a single `|` pipe-delimiting character.
233
     *
234
     * Note: Only splits in the default (en) locale as the string form contains limited metadata.
235
     *
236
     * @param string $string Input string
237
     * @return array List of plural forms, or empty array if not plural
238
     */
239
    public static function parse_plurals($string)
240
    {
241
        if (strstr($string, '|') && strstr($string, '{count}')) {
242
            $keys = i18n::config()->uninherited('default_plurals');
243
            $values = explode('|', $string);
244
            if (count($keys) == count($values)) {
245
                return array_combine($keys, $values);
246
            }
247
        }
248
        return [];
249
    }
250
251
    /**
252
     * Convert CLDR array plural form to `|` pipe-delimited string.
253
     * Unlike parse_plurals, this supports all locale forms (not just en)
254
     *
255
     * @param array $plurals
256
     * @return string Delimited string, or null if not plurals
257
     */
258
    public static function encode_plurals($plurals)
259
    {
260
        // Validate against global plural list
261
        $forms = i18n::config()->uninherited('plurals');
262
        $forms = array_combine($forms, $forms);
263
        $intersect = array_intersect_key($plurals, $forms);
0 ignored issues
show
Bug introduced by
It seems like $forms can also be of type false; however, parameter $array2 of array_intersect_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

263
        $intersect = array_intersect_key($plurals, /** @scrutinizer ignore-type */ $forms);
Loading history...
264
        if ($intersect) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $intersect 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...
265
            return implode('|', $intersect);
266
        }
267
        return null;
268
    }
269
270
    /**
271
     * Matches a given locale with the closest translation available in the system
272
     *
273
     * @param string $locale locale code
274
     * @return string Locale of closest available translation, if available
275
     */
276
    public static function get_closest_translation($locale)
277
    {
278
        // Check if exact match
279
        $pool = self::getSources()->getKnownLocales();
280
        if (isset($pool[$locale])) {
281
            return $locale;
282
        }
283
284
        // Fallback to best locale for common language
285
        $localesData = static::getData();
286
        $lang = $localesData->langFromLocale($locale);
287
        $candidate = $localesData->localeFromLang($lang);
288
        if (isset($pool[$candidate])) {
289
            return $candidate;
290
        }
291
        return null;
292
    }
293
294
    /**
295
     * Gets a RFC 1766 compatible language code,
296
     * e.g. "en-US".
297
     *
298
     * @see http://www.ietf.org/rfc/rfc1766.txt
299
     * @see http://tools.ietf.org/html/rfc2616#section-3.10
300
     *
301
     * @param string $locale
302
     * @return string
303
     */
304
    public static function convert_rfc1766($locale)
305
    {
306
        return str_replace('_', '-', $locale);
307
    }
308
309
    /**
310
     * Set the current locale, used as the default for
311
     * any localized classes, such as {@link FormField} or {@link DBField}
312
     * instances. Locales can also be persisted in {@link Member->Locale},
313
     * for example in the {@link CMSMain} interface the Member locale
314
     * overrules the global locale value set here.
315
     *
316
     * @param string $locale Locale to be set. See
317
     *                       http://unicode.org/cldr/data/diff/supplemental/languages_and_territories.html for a list
318
     *                       of possible locales.
319
     */
320
    public static function set_locale($locale)
321
    {
322
        if ($locale) {
323
            self::$current_locale = $locale;
324
        }
325
    }
326
327
    /**
328
     * Temporarily set the locale while invoking a callback
329
     *
330
     * @param string $locale
331
     * @param callable $callback
332
     * @return mixed
333
     */
334
    public static function with_locale($locale, $callback)
335
    {
336
        $oldLocale = self::$current_locale;
337
        static::set_locale($locale);
338
        try {
339
            return $callback();
340
        } finally {
341
            static::set_locale($oldLocale);
342
        }
343
    }
344
345
    /**
346
     * Get the current locale.
347
     * Used by {@link Member::populateDefaults()}
348
     *
349
     * @return string Current locale in the system
350
     */
351
    public static function get_locale()
352
    {
353
        if (!self::$current_locale) {
354
            self::$current_locale = i18n::config()->uninherited('default_locale');
355
        }
356
        return self::$current_locale;
357
    }
358
359
    /**
360
     * Returns the script direction in format compatible with the HTML "dir" attribute.
361
     *
362
     * @see http://www.w3.org/International/tutorials/bidi-xhtml/
363
     * @param string $locale Optional locale incl. region (underscored)
364
     * @return string "rtl" or "ltr"
365
     */
366
    public static function get_script_direction($locale = null)
367
    {
368
        return static::getData()->scriptDirection($locale);
369
    }
370
371
    public static function get_template_global_variables()
372
    {
373
        return array(
374
            'i18nLocale' => 'get_locale',
375
            'get_locale',
376
            'i18nScriptDirection' => 'get_script_direction',
377
        );
378
    }
379
380
    /**
381
     * @return MessageProvider
382
     */
383
    public static function getMessageProvider()
384
    {
385
        return Injector::inst()->get(MessageProvider::class);
386
    }
387
388
    /**
389
     * Localisation data source
390
     *
391
     * @return Locales
392
     */
393
    public static function getData()
394
    {
395
        return Injector::inst()->get(Locales::class);
396
    }
397
398
    /**
399
     * Get data sources for localisation strings
400
     *
401
     * @return Sources
402
     */
403
    public static function getSources()
404
    {
405
        return Injector::inst()->get(Sources::class);
406
    }
407
}
408