Completed
Push — master ( 90072e...c81959 )
by Daniel
11:23
created

i18nTextCollector::setDefaultLocale()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\i18n\TextCollection;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\Core\Manifest\ClassLoader;
8
use SilverStripe\Dev\Debug;
9
use SilverStripe\Control\Director;
10
use ReflectionClass;
11
use SilverStripe\Dev\Deprecation;
12
use SilverStripe\i18n\i18n;
13
use SilverStripe\i18n\i18nEntityProvider;
14
use SilverStripe\i18n\Messages\Reader;
15
use SilverStripe\i18n\Messages\Writer;
16
17
/**
18
 * SilverStripe-variant of the "gettext" tool:
19
 * Parses the string content of all PHP-files and SilverStripe templates
20
 * for ocurrences of the _t() translation method. Also uses the {@link i18nEntityProvider}
21
 * interface to get dynamically defined entities by executing the
22
 * {@link provideI18nEntities()} method on all implementors of this interface.
23
 *
24
 * Collects all found entities (and their natural language text for the default locale)
25
 * into language-files for each module in an array notation. Creates or overwrites these files,
26
 * e.g. framework/lang/en.yml.
27
 *
28
 * The collector needs to be run whenever you make new translatable
29
 * entities available. Please don't alter the arrays in language tables manually.
30
 *
31
 * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask
32
 * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule
33
 * Usage on CLI: sake dev/tasks/i18nTextCollectorTask
34
 * Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule
35
 *
36
 * @author Bernat Foj Capell <[email protected]>
37
 * @author Ingo Schommer <[email protected]>
38
 * @uses i18nEntityProvider
39
 * @uses i18n
40
 */
41
class i18nTextCollector
42
{
43
    use Injectable;
44
45
    /**
46
     * Default (master) locale
47
     *
48
     * @var string
49
     */
50
    protected $defaultLocale;
51
52
    /**
53
     * Trigger if warnings should be shown if default is omitted
54
     *
55
     * @var bool
56
     */
57
    protected $warnOnEmptyDefault = false;
58
59
    /**
60
     * The directory base on which the collector should act.
61
     * Usually the webroot set through {@link Director::baseFolder()}.
62
     *
63
     * @todo Fully support changing of basePath through {@link SSViewer} and {@link ManifestBuilder}
64
     *
65
     * @var string
66
     */
67
    public $basePath;
68
69
    /**
70
     * Save path
71
     *
72
     * @var string
73
     */
74
    public $baseSavePath;
75
76
    /**
77
     * @var Writer
78
     */
79
    protected $writer;
80
81
    /**
82
     * Translation reader
83
     *
84
     * @var Reader
85
     */
86
    protected $reader;
87
88
    /**
89
     * List of file extensions to parse
90
     *
91
     * @var array
92
     */
93
    protected $fileExtensions = array('php', 'ss');
94
95
    /**
96
     * @param $locale
97
     */
98
    public function __construct($locale = null)
99
    {
100
        $this->defaultLocale = $locale
101
            ? $locale
102
            : i18n::get_lang_from_locale(i18n::config()->get('default_locale'));
103
        $this->basePath = Director::baseFolder();
104
        $this->baseSavePath = Director::baseFolder();
105
        $this->setWarnOnEmptyDefault(i18n::config()->get('missing_default_warning'));
106
    }
107
108
    /**
109
     * Assign a writer
110
     *
111
     * @param Writer $writer
112
     * @return $this
113
     */
114
    public function setWriter($writer)
115
    {
116
        $this->writer = $writer;
117
        return $this;
118
    }
119
120
    /**
121
     * Gets the currently assigned writer, or the default if none is specified.
122
     *
123
     * @return Writer
124
     */
125
    public function getWriter()
126
    {
127
        return $this->writer;
128
    }
129
130
    /**
131
     * Get reader
132
     *
133
     * @return Reader
134
     */
135
    public function getReader()
136
    {
137
        return $this->reader;
138
    }
139
140
    /**
141
     * Set reader
142
     *
143
     * @param Reader $reader
144
     * @return $this
145
     */
146
    public function setReader(Reader $reader)
147
    {
148
        $this->reader = $reader;
149
        return $this;
150
    }
151
152
    /**
153
     * This is the main method to build the master string tables with the
154
     * original strings. It will search for existent modules that use the
155
     * i18n feature, parse the _t() calls and write the resultant files
156
     * in the lang folder of each module.
157
     *
158
     * @uses DataObject->collectI18nStatics()
159
     *
160
     * @param array $restrictToModules
161
     * @param bool $mergeWithExisting Merge new master strings with existing
162
     * ones already defined in language files, rather than replacing them.
163
     * This can be useful for long-term maintenance of translations across
164
     * releases, because it allows "translation backports" to older releases
165
     * without removing strings these older releases still rely on.
166
     */
167
    public function run($restrictToModules = null, $mergeWithExisting = false)
168
    {
169
        $entitiesByModule = $this->collect($restrictToModules, $mergeWithExisting);
0 ignored issues
show
Bug introduced by
It seems like $restrictToModules defined by parameter $restrictToModules on line 167 can also be of type null; however, SilverStripe\i18n\TextCo...extCollector::collect() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
170
        if (empty($entitiesByModule)) {
171
            return;
172
        }
173
174
        // Write each module language file
175
        foreach ($entitiesByModule as $module => $entities) {
176
            // Skip empty translations
177
            if (empty($entities)) {
178
                continue;
179
            }
180
181
            // Clean sorting prior to writing
182
            ksort($entities);
183
            $path = $this->baseSavePath . '/' . $module;
184
            $this->getWriter()->write($entities, $this->defaultLocale, $path);
185
        }
186
    }
187
188
    /**
189
     * Gets the list of modules in this installer
190
     *
191
     * @param string $directory Path to look in
192
     * @return array List of modules as paths relative to base
193
     */
194
    protected function getModules($directory)
195
    {
196
        // Include self as head module
197
        $modules = array();
198
199
        // Get all standard modules
200
        foreach (glob($directory."/*", GLOB_ONLYDIR) as $path) {
201
            // Check for _config
202
            if (!is_file("$path/_config.php") && !is_dir("$path/_config")) {
203
                continue;
204
            }
205
            $modules[] = basename($path);
206
        }
207
208
        // Get all themes
209
        foreach (glob($directory."/themes/*", GLOB_ONLYDIR) as $path) {
210
            // Check for templates
211
            if (is_dir("$path/templates")) {
212
                $modules[] = 'themes/'.basename($path);
213
            }
214
        }
215
216
        return $modules;
217
    }
218
219
    /**
220
     * Extract all strings from modules and return these grouped by module name
221
     *
222
     * @param array $restrictToModules
223
     * @param bool $mergeWithExisting
224
     * @return array
225
     */
226
    public function collect($restrictToModules = array(), $mergeWithExisting = false)
227
    {
228
        $entitiesByModule = $this->getEntitiesByModule();
229
230
        // Resolve conflicts between duplicate keys across modules
231
        $entitiesByModule = $this->resolveDuplicateConflicts($entitiesByModule);
232
233
        // Optionally merge with existing master strings
234
        if ($mergeWithExisting) {
235
            $entitiesByModule = $this->mergeWithExisting($entitiesByModule);
236
        }
237
238
        // Restrict modules we update to just the specified ones (if any passed)
239
        if (!empty($restrictToModules)) {
240
            foreach (array_diff(array_keys($entitiesByModule), $restrictToModules) as $module) {
241
                unset($entitiesByModule[$module]);
242
            }
243
        }
244
        return $entitiesByModule;
245
    }
246
247
    /**
248
     * Resolve conflicts between duplicate keys across modules
249
     *
250
     * @param array $entitiesByModule List of all modules with keys
251
     * @return array Filtered listo of modules with duplicate keys unassigned
252
     */
253
    protected function resolveDuplicateConflicts($entitiesByModule)
254
    {
255
        // Find all keys that exist across multiple modules
256
        $conflicts = $this->getConflicts($entitiesByModule);
257
        foreach ($conflicts as $conflict) {
258
            // Determine if we can narrow down the ownership
259
            $bestModule = $this->getBestModuleForKey($entitiesByModule, $conflict);
260
            if (!$bestModule) {
261
                continue;
262
            }
263
264
            // Remove foreign duplicates
265
            foreach ($entitiesByModule as $module => $entities) {
266
                if ($module !== $bestModule) {
267
                    unset($entitiesByModule[$module][$conflict]);
268
                }
269
            }
270
        }
271
        return $entitiesByModule;
272
    }
273
274
    /**
275
     * Find all keys in the entity list that are duplicated across modules
276
     *
277
     * @param array $entitiesByModule
278
     * @return array List of keys
279
     */
280
    protected function getConflicts($entitiesByModule)
281
    {
282
        $modules = array_keys($entitiesByModule);
283
        $allConflicts = array();
284
        // bubble-compare each group of modules
285
        for ($i = 0; $i < count($modules) - 1; $i++) {
286
            $left = array_keys($entitiesByModule[$modules[$i]]);
287
            for ($j = $i+1; $j < count($modules); $j++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
288
                $right = array_keys($entitiesByModule[$modules[$j]]);
289
                $conflicts = array_intersect($left, $right);
290
                $allConflicts = array_merge($allConflicts, $conflicts);
291
            }
292
        }
293
        return array_unique($allConflicts);
294
    }
295
296
    /**
297
     * Map of translation keys => module names
298
     * @var array
299
     */
300
    protected $classModuleCache = [];
301
302
    /**
303
     * Determine the best module to be given ownership over this key
304
     *
305
     * @param array $entitiesByModule
306
     * @param string $key
307
     * @return string Best module, if found
308
     */
309
    protected function getBestModuleForKey($entitiesByModule, $key)
310
    {
311
        // Check classes
312
        $class = current(explode('.', $key));
313
        if (array_key_exists($class, $this->classModuleCache)) {
314
            return $this->classModuleCache[$class];
315
        }
316
        $owner = $this->findModuleForClass($class);
317
        if ($owner) {
318
            $this->classModuleCache[$class] = $owner;
319
            return $owner;
320
        }
321
322
        // @todo - How to determine ownership of templates? Templates can
323
        // exist in multiple locations with the same name.
324
325
        // Display notice if not found
326
        Debug::message(
327
            "Duplicate key {$key} detected in no / multiple modules with no obvious owner",
328
            false
329
        );
330
331
        // Fall back to framework then cms modules
332
        foreach (array('framework', 'cms') as $module) {
333
            if (isset($entitiesByModule[$module][$key])) {
334
                $this->classModuleCache[$class] = $module;
335
                return $module;
336
            }
337
        }
338
339
        // Do nothing
340
        $this->classModuleCache[$class] = null;
341
        return null;
342
    }
343
344
    /**
345
     * Given a partial class name, attempt to determine the best module to assign strings to.
346
     *
347
     * @param string $class Either a FQN class name, or a non-qualified class name.
348
     * @return string Name of module
349
     */
350
    protected function findModuleForClass($class)
351
    {
352
        if (ClassInfo::exists($class)) {
353
            return i18n::get_owner_module($class);
354
        }
355
356
357
        // If we can't find a class, see if it needs to be fully qualified
358
        if (strpos($class, '\\') !== false) {
359
            return null;
360
        }
361
362
        // Find FQN that ends with $class
363
        $classes = preg_grep(
364
            '/'.preg_quote("\\{$class}", '\/').'$/i',
365
            ClassLoader::instance()->getManifest()->getClassNames()
366
        );
367
368
        // Find all modules for candidate classes
369
        $modules = array_unique(array_map(function ($class) {
370
            return i18n::get_owner_module($class);
371
        }, $classes));
372
373
        if (count($modules) === 1) {
374
            return reset($modules);
375
        }
376
377
        // Couldn't find it! Exists in none, or multiple modules.
378
        return null;
379
    }
380
381
    /**
382
     * Merge all entities with existing strings
383
     *
384
     * @param array $entitiesByModule
385
     * @return array
386
     */
387
    protected function mergeWithExisting($entitiesByModule)
388
    {
389
        // For each module do a simple merge of the default yml with these strings
390
        foreach ($entitiesByModule as $module => $messages) {
391
            // Load existing localisations
392
            $masterFile = "{$this->basePath}/{$module}/lang/{$this->defaultLocale}.yml";
393
            $existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile);
394
395
            // Merge
396
            if ($existingMessages) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $existingMessages 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...
397
                $entitiesByModule[$module] = array_merge(
398
                    $existingMessages,
399
                    $messages
400
                );
401
            }
402
        }
403
        return $entitiesByModule;
404
    }
405
406
    /**
407
     * Collect all entities grouped by module
408
     *
409
     * @return array
410
     */
411
    protected function getEntitiesByModule()
412
    {
413
        // A master string tables array (one mst per module)
414
        $entitiesByModule = array();
415
        $modules = $this->getModules($this->basePath);
416
        foreach ($modules as $module) {
417
            // we store the master string tables
418
            $processedEntities = $this->processModule($module);
419
            if (isset($entitiesByModule[$module])) {
420
                $entitiesByModule[$module] = array_merge_recursive($entitiesByModule[$module], $processedEntities);
421
            } else {
422
                $entitiesByModule[$module] = $processedEntities;
423
            }
424
425
            // Extract all entities for "foreign" modules ('module' key in array form)
426
            // @see CMSMenu::provideI18nEntities for an example usage
427
            foreach ($entitiesByModule[$module] as $fullName => $spec) {
428
                $specModule = $module;
429
430
                // Rewrite spec if module is specified
431
                if (is_array($spec) && isset($spec['module'])) {
432
                    $specModule = $spec['module'];
433
                    unset($spec['module']);
434
435
                    // If only element is defalt, simplify
436
                    if (count($spec) === 1 && !empty($spec['default'])) {
437
                        $spec = $spec['default'];
438
                    }
439
                }
440
441
                // Remove from source module
442
                if ($specModule !== $module) {
443
                    unset($entitiesByModule[$module][$fullName]);
444
                }
445
446
                // Write to target module
447
                if (!isset($entitiesByModule[$specModule])) {
448
                    $entitiesByModule[$specModule] = [];
449
                }
450
                $entitiesByModule[$specModule][$fullName] = $spec;
451
            }
452
        }
453
        return $entitiesByModule;
454
    }
455
456
457
    public function write($module, $entities)
458
    {
459
        $this->getWriter()->write($entities, $this->defaultLocale, $this->baseSavePath . '/' . $module);
460
        return $this;
461
    }
462
463
    /**
464
     * Builds a master string table from php and .ss template files for the module passed as the $module param
465
     * @see collectFromCode() and collectFromTemplate()
466
     *
467
     * @param string $module A module's name or just 'themes/<themename>'
468
     * @return array An array of entities found in the files that comprise the module
469
     */
470
    protected function processModule($module)
471
    {
472
        $entities = array();
473
474
        // Search for calls in code files if these exists
475
        $fileList = $this->getFileListForModule($module);
476
        foreach ($fileList as $filePath) {
477
            $extension = pathinfo($filePath, PATHINFO_EXTENSION);
478
            $content = file_get_contents($filePath);
479
            // Filter based on extension
480
            if ($extension === 'php') {
481
                $entities = array_merge(
482
                    $entities,
483
                    $this->collectFromCode($content, $module),
484
                    $this->collectFromEntityProviders($filePath, $module)
485
                );
486
            } elseif ($extension === 'ss') {
487
                // templates use their filename as a namespace
488
                $namespace = basename($filePath);
489
                $entities = array_merge(
490
                    $entities,
491
                    $this->collectFromTemplate($content, $module, $namespace)
492
                );
493
            }
494
        }
495
496
        // sort for easier lookup and comparison with translated files
497
        ksort($entities);
498
499
        return $entities;
500
    }
501
502
    /**
503
     * Retrieves the list of files for this module
504
     *
505
     * @param string $module
506
     * @return array List of files to parse
507
     */
508
    protected function getFileListForModule($module)
509
    {
510
        $modulePath = "{$this->basePath}/{$module}";
511
512
        // Search all .ss files in themes
513
        if (stripos($module, 'themes/') === 0) {
514
            return $this->getFilesRecursive($modulePath, null, 'ss');
515
        }
516
517
        // If Framework or non-standard module structure, so we'll scan all subfolders
518
        if ($module === FRAMEWORK_DIR || !is_dir("{$modulePath}/code")) {
519
            return $this->getFilesRecursive($modulePath);
520
        }
521
522
        // Get code files
523
        $files = $this->getFilesRecursive("{$modulePath}/code", null, 'php');
524
525
        // Search for templates in this module
526
        if (is_dir("{$modulePath}/templates")) {
527
            $templateFiles = $this->getFilesRecursive("{$modulePath}/templates", null, 'ss');
528
        } else {
529
            $templateFiles = $this->getFilesRecursive($modulePath, null, 'ss');
530
        }
531
532
        return array_merge($files, $templateFiles);
533
    }
534
535
    /**
536
     * Extracts translatables from .php files.
537
     * Note: Translations without default values are omitted.
538
     *
539
     * @param string $content The text content of a parsed template-file
540
     * @param string $module Module's name or 'themes'. Could also be a namespace
541
     * Generated by templates includes. E.g. 'UploadField.ss'
542
     * @return array Map of localised keys to default values provided for this code
543
     */
544
    public function collectFromCode($content, $module)
545
    {
546
        $entities = array();
547
548
        $tokens = token_get_all("<?php\n" . $content);
549
        $inTransFn = false;
550
        $inConcat = false;
551
        $inArrayClosedBy = false; // Set to the expected closing token, or false if not in array
552
        $currentEntity = array();
553
        foreach ($tokens as $token) {
554
            if (is_array($token)) {
555
                list($id, $text) = $token;
556
557
                // Suppress tokenisation within array
558
                if ($inTransFn && !$inArrayClosedBy && $id == T_ARRAY) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false 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...
559
                    $inArrayClosedBy = ')'; // Array will close with this element
560
                    continue;
561
                }
562
563
                // Start definition
564
                if ($id == T_STRING && $text == '_t') {
565
                    $inTransFn = true;
566
                    continue;
567
                }
568
569
                // Skip rest of processing unless we are in a translation, and not inside a nested array
570
                if (!$inTransFn || $inArrayClosedBy) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false 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...
571
                    continue;
572
                }
573
574
                // If inside this translation, some elements might be unreachable
575
                if (in_array($id, [T_VARIABLE, T_STATIC, T_CLASS_C]) ||
576
                    ($id === T_STRING && in_array($text, ['self', 'static', 'parent']))
577
                ) {
578
                    // Un-collectable strings such as _t(static::class.'.KEY').
579
                    // Should be provided by i18nEntityProvider instead
580
                    $inTransFn = false;
581
                    $inArrayClosedBy = false;
582
                    $inConcat = false;
583
                    $currentEntity = array();
584
                    continue;
585
                }
586
587
                if ($id == T_CONSTANT_ENCAPSED_STRING) {
588
                    // Fixed quoting escapes, and remove leading/trailing quotes
589
                    if (preg_match('/^\'/', $text)) {
590
                        $text = str_replace("\\'", "'", $text);
591
                        $text = preg_replace('/^\'/', '', $text);
592
                        $text = preg_replace('/\'$/', '', $text);
593
                    } else {
594
                        $text = str_replace('\"', '"', $text);
595
                        $text = preg_replace('/^"/', '', $text);
596
                        $text = preg_replace('/"$/', '', $text);
597
                    }
598
599
                    if ($inConcat) {
600
                        // Parser error
601
                        if (empty($currentEntity)) {
602
                            user_error('Error concatenating localisation key', E_USER_WARNING);
603
                        } else {
604
                            $currentEntity[count($currentEntity) - 1] .= $text;
605
                        }
606
                    } else {
607
                        $currentEntity[] = $text;
608
                    }
609
                }
610
                continue; // is_array
611
            }
612
613
            // Test we can close this array
614
            if ($inTransFn && $inArrayClosedBy && ($token === $inArrayClosedBy)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false 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...
615
                $inArrayClosedBy = false;
616
                continue;
617
            }
618
619
            // Continue only if in translation and not in array
620
            if (!$inTransFn || $inArrayClosedBy) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inArrayClosedBy of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false 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...
621
                continue;
622
            }
623
624
            switch ($token) {
625
                case '.':
626
                    $inConcat = true;
627
                    break;
628
                case ',':
629
                    $inConcat = false;
630
                    break;
631
                case '[':
632
                    // Enter array
633
                    $inArrayClosedBy = ']';
634
                    break;
635
                case ')':
636
                    // finalize definition
637
                    $inTransFn = false;
638
                    $inConcat = false;
639
                    // Ensure key is valid before saving
640
                    if (!empty($currentEntity[0])) {
641
                        $key = $currentEntity[0];
642
                        $default = '';
643
                        $comment = '';
644
                        if (!empty($currentEntity[1])) {
645
                            $default = $currentEntity[1];
646
                            if (!empty($currentEntity[2])) {
647
                                $comment = $currentEntity[2];
648
                            }
649
                        }
650
                        // Save in appropriate format
651
                        if ($default) {
652
                            $plurals = i18n::parse_plurals($default);
653
                            // Use array form if either plural or metadata is provided
654
                            if ($plurals) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $plurals 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...
655
                                $entity = $plurals;
656
                            } elseif ($comment) {
657
                                $entity = ['default' => $default];
658
                            } else {
659
                                $entity = $default;
660
                            }
661
                            if ($comment) {
662
                                $entity['comment'] = $comment;
663
                            }
664
                            $entities[$key] = $entity;
665
                        } elseif ($this->getWarnOnEmptyDefault()) {
666
                            trigger_error("Missing localisation default for key " . $currentEntity[0], E_USER_NOTICE);
667
                        }
668
                    }
669
                    $currentEntity = array();
670
                    $inArrayClosedBy = false;
671
                    break;
672
            }
673
        }
674
675
        // Normalise all keys
676
        foreach ($entities as $key => $entity) {
677
            unset($entities[$key]);
678
            $entities[$this->normalizeEntity($key, $module)] = $entity;
679
        }
680
        ksort($entities);
681
682
        return $entities;
683
    }
684
685
    /**
686
     * Extracts translatables from .ss templates (Self referencing)
687
     *
688
     * @param string $content The text content of a parsed template-file
689
     * @param string $fileName The name of a template file when method is used in self-referencing mode
690
     * @param string $module Module's name or 'themes'
691
     * @param array $parsedFiles
692
     * @return array $entities An array of entities representing the extracted template function calls
693
     */
694
    public function collectFromTemplate($content, $fileName, $module, &$parsedFiles = array())
0 ignored issues
show
Unused Code introduced by
The parameter $fileName 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 $parsedFiles 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...
695
    {
696
        // use parser to extract <%t style translatable entities
697
        $entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault());
698
699
        // use the old method of getting _t() style translatable entities
700
        // Collect in actual template
701
        if (preg_match_all('/(_t\([^\)]*?\))/ms', $content, $matches)) {
702
            foreach ($matches[1] as $match) {
703
                $entities = array_merge($entities, $this->collectFromCode($match, $module));
704
            }
705
        }
706
707
        foreach ($entities as $entity => $spec) {
708
            unset($entities[$entity]);
709
            $entities[$this->normalizeEntity($entity, $module)] = $spec;
710
        }
711
        ksort($entities);
712
713
        return $entities;
714
    }
715
716
    /**
717
     * Allows classes which implement i18nEntityProvider to provide
718
     * additional translation strings.
719
     *
720
     * Not all classes can be instanciated without mandatory arguments,
721
     * so entity collection doesn't work for all SilverStripe classes currently
722
     *
723
     * @uses i18nEntityProvider
724
     * @param string $filePath
725
     * @param string $module
726
     * @return array
727
     */
728
    public function collectFromEntityProviders($filePath, $module = null)
0 ignored issues
show
Unused Code introduced by
The parameter $module 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...
729
    {
730
        $entities = array();
731
        $classes = ClassInfo::classes_for_file($filePath);
732
        foreach ($classes as $class) {
733
            // Skip non-implementing classes
734
            if (!class_exists($class) || !is_a($class, i18nEntityProvider::class, true)) {
735
                continue;
736
            }
737
738
            // Skip abstract classes
739
            $reflectionClass = new ReflectionClass($class);
740
            if ($reflectionClass->isAbstract()) {
741
                continue;
742
            }
743
744
            /** @var i18nEntityProvider $obj */
745
            $obj = singleton($class);
746
            $provided = $obj->provideI18nEntities();
747
            // Handle deprecated return syntax
748
            foreach ($provided as $key => $value) {
749
                // Detect non-associative result for any key
750
                if (is_array($value) && $value === array_values($value)) {
751
                    Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated');
752
                    $entity = array_filter([
753
                        'default' => $value[0],
754
                        'comment' => isset($value[1]) ? $value[1] : null,
755
                        'module' => isset($value[2]) ? $value[2] : null,
756
                    ]);
757
                    if (count($entity) === 1) {
758
                        $provided[$key] = $value[0];
759
                    } elseif ($entity) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $entity 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...
760
                        $provided[$key] = $entity;
761
                    } else {
762
                        unset($provided[$key]);
763
                    }
764
                }
765
            }
766
            $entities = array_merge($entities, $provided);
767
        }
768
769
        ksort($entities);
770
        return $entities;
771
    }
772
773
    /**
774
     * Normalizes enitities with namespaces.
775
     *
776
     * @param string $fullName
777
     * @param string $_namespace
778
     * @return string|boolean FALSE
779
     */
780
    protected function normalizeEntity($fullName, $_namespace = null)
781
    {
782
        // split fullname into entity parts
783
        $entityParts = explode('.', $fullName);
784
        if (count($entityParts) > 1) {
785
            // templates don't have a custom namespace
786
            $entity = array_pop($entityParts);
787
            // namespace might contain dots, so we explode
788
            $namespace = implode('.', $entityParts);
789
        } else {
790
            $entity = array_pop($entityParts);
791
            $namespace = $_namespace;
792
        }
793
794
        // If a dollar sign is used in the entity name,
795
        // we can't resolve without running the method,
796
        // and skip the processing. This is mostly used for
797
        // dynamically translating static properties, e.g. looping
798
        // through $db, which are detected by {@link collectFromEntityProviders}.
799
        if ($entity && strpos('$', $entity) !== false) {
800
            return false;
801
        }
802
803
        return "{$namespace}.{$entity}";
804
    }
805
806
807
808
    /**
809
     * Helper function that searches for potential files (templates and code) to be parsed
810
     *
811
     * @param string $folder base directory to scan (will scan recursively)
812
     * @param array $fileList Array to which potential files will be appended
813
     * @param string $type Optional, "php" or "ss" only
814
     * @param string $folderExclude Regular expression matching folder names to exclude
815
     * @return array $fileList An array of files
816
     */
817
    protected function getFilesRecursive($folder, $fileList = array(), $type = null, $folderExclude = '/\/(tests)$/')
818
    {
819
        if (!$fileList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fileList 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...
820
            $fileList = array();
821
        }
822
        // Skip ignored folders
823
        if (is_file("{$folder}/_manifest_exclude") || preg_match($folderExclude, $folder)) {
824
            return $fileList;
825
        }
826
827
        foreach (glob($folder.'/*') as $path) {
828
            // Recurse if directory
829
            if (is_dir($path)) {
830
                $fileList = array_merge(
831
                    $fileList,
832
                    $this->getFilesRecursive($path, $fileList, $type, $folderExclude)
833
                );
834
                continue;
835
            }
836
837
            // Check if this extension is included
838
            $extension = pathinfo($path, PATHINFO_EXTENSION);
839
            if (in_array($extension, $this->fileExtensions)
840
                && (!$type || $type === $extension)
0 ignored issues
show
Bug Best Practice introduced by
The expression $type of type string|null is loosely compared to false; 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...
841
            ) {
842
                $fileList[$path] = $path;
843
            }
844
        }
845
        return $fileList;
846
    }
847
848
    public function getDefaultLocale()
849
    {
850
        return $this->defaultLocale;
851
    }
852
853
    public function setDefaultLocale($locale)
854
    {
855
        $this->defaultLocale = $locale;
856
    }
857
858
    /**
859
     * @return bool
860
     */
861
    public function getWarnOnEmptyDefault()
862
    {
863
        return $this->warnOnEmptyDefault;
864
    }
865
866
    /**
867
     * @param bool $warnOnEmptyDefault
868
     * @return $this
869
     */
870
    public function setWarnOnEmptyDefault($warnOnEmptyDefault)
871
    {
872
        $this->warnOnEmptyDefault = $warnOnEmptyDefault;
873
        return $this;
874
    }
875
}
876