Passed
Push — master ( fce103...2380f9 )
by Thomas
15:59 queued 13:32
created

getModulesAndThemesExposed()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 4
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 6
rs 10
1
<?php
2
3
namespace LeKoala\Base\i18n;
4
5
use Exception;
6
use SilverStripe\Dev\Debug;
7
use SilverStripe\View\SSViewer;
8
use SilverStripe\Control\Director;
9
use LeKoala\Multilingual\LangHelper;
10
use SilverStripe\Core\Manifest\Module;
11
use SilverStripe\i18n\Messages\Reader;
12
use SilverStripe\i18n\Messages\Writer;
13
use SilverStripe\Core\Injector\Injector;
14
use SilverStripe\Core\Manifest\ModuleLoader;
15
use SilverStripe\i18n\TextCollection\Parser;
16
use LeKoala\Base\Helpers\GoogleTranslateHelper;
17
use SilverStripe\i18n\TextCollection\i18nTextCollector;
18
use SilverStripe\Core\Path;
19
20
/**
21
 * Improved text collector
22
 */
23
class MultilingualTextCollector extends i18nTextCollector
24
{
25
    /**
26
     * @var boolean
27
     */
28
    protected $debug = false;
29
30
    /**
31
     * @var boolean
32
     */
33
    protected $clearUnused = false;
34
35
    /**
36
     * @var array<string>
37
     */
38
    protected $restrictToModules = [];
39
40
    /**
41
     * @var boolean
42
     */
43
    protected $mergeWithExisting = true;
44
45
    /**
46
     * @var boolean
47
     */
48
    protected $preventWrite = false;
49
50
    /**
51
     * @var boolean
52
     */
53
    protected $autoTranslate = false;
54
55
    /**
56
     * @param ?string $locale
57
     */
58
    public function __construct($locale = null)
59
    {
60
        parent::__construct($locale);
61
62
        // Somehow the injector is confused so we inject ourself
63
        $this->reader = Injector::inst()->create(Reader::class);
64
        $this->writer = Injector::inst()->create(Writer::class);
65
    }
66
67
    /**
68
     * This is the main method to build the master string tables with the
69
     * original strings. It will search for existent modules that use the
70
     * i18n feature, parse the _t() calls and write the resultant files
71
     * in the lang folder of each module.
72
     *
73
     * @param array<string> $restrictToModules
74
     * @param bool $mergeWithExisting Merge new master strings with existing
75
     * ones already defined in language files, rather than replacing them.
76
     * This can be useful for long-term maintenance of translations across
77
     * releases, because it allows "translation backports" to older releases
78
     * without removing strings these older releases still rely on.
79
     * @return array<string,mixed>|null $result
80
     */
81
    public function run($restrictToModules = null, $mergeWithExisting = false)
82
    {
83
        $entitiesByModule = $this->collect($restrictToModules, $mergeWithExisting);
84
        if (empty($entitiesByModule)) {
85
            Debug::message("No entities have been collected");
86
            return null;
87
        }
88
        if ($this->debug) {
89
            Debug::message("Debug mode is enabled and no files have been written");
90
            Debug::dump($entitiesByModule);
91
            return null;
92
        }
93
94
        $modules = $this->getModulesAndThemesExposed();
95
96
        // Write each module language file
97
        foreach ($entitiesByModule as $moduleName => $entities) {
98
            // Skip empty translations
99
            if (empty($entities)) {
100
                continue;
101
            }
102
103
            // Clean sorting prior to writing
104
            ksort($entities);
105
            $module = $modules[$moduleName];
106
            $this->write($module, $entities);
107
        }
108
109
        return $entitiesByModule;
110
    }
111
112
    protected function getModulesAndThemesExposed()
113
    {
114
        $refObject = new \ReflectionObject($this);
115
        $refMethod = $refObject->getMethod('getModulesAndThemes');
116
        $refMethod->setAccessible(true);
117
        return $refMethod->invoke($this);
118
    }
119
120
    protected function getModuleNameExposed($arg1, $arg2)
121
    {
122
        $refObject = new \ReflectionObject($this);
123
        $refMethod = $refObject->getMethod('getModuleName');
124
        $refMethod->setAccessible(true);
125
        return $refMethod->invoke($this, $arg1, $arg2);
126
    }
127
128
    /**
129
     * Extract all strings from modules and return these grouped by module name
130
     *
131
     * @param array<string> $restrictToModules
132
     * @param bool $mergeWithExisting
133
     * @return array<string,mixed>|null
134
     */
135
    public function collect($restrictToModules = null, $mergeWithExisting = null)
136
    {
137
        if ($mergeWithExisting === null) {
138
            $mergeWithExisting = $this->getMergeWithExisting();
139
        } else {
140
            $this->setMergeWithExisting($mergeWithExisting);
141
        }
142
        if ($restrictToModules === null) {
143
            $restrictToModules = $this->getRestrictToModules();
144
        } else {
145
            $this->setRestrictToModules($restrictToModules);
146
        }
147
148
        return parent::collect($restrictToModules, $mergeWithExisting);
149
    }
150
151
    /**
152
     * Collect all entities grouped by module
153
     *
154
     * @return array
155
     */
156
    protected function getEntitiesByModule()
157
    {
158
        $allModules = $this->getModulesAndThemesExposed();
159
        $modules = [];
160
        foreach ($this->restrictToModules as $m) {
161
            if (array_key_exists($m, $allModules)) {
162
                $modules[$m] = $allModules[$m];
163
            }
164
        }
165
166
        // A master string tables array (one mst per module)
167
        $entitiesByModule = [];
168
        foreach ($modules as $moduleName => $module) {
169
            // we store the master string tables
170
            $processedEntities = $this->processModule($module);
171
            $moduleName = $this->getModuleNameExposed($moduleName, $module);
172
            if (isset($entitiesByModule[$moduleName])) {
173
                $entitiesByModule[$moduleName] = array_merge_recursive(
174
                    $entitiesByModule[$moduleName],
175
                    $processedEntities
176
                );
177
            } else {
178
                $entitiesByModule[$moduleName] = $processedEntities;
179
            }
180
181
            // Extract all entities for "foreign" modules ('module' key in array form)
182
            // @see CMSMenu::provideI18nEntities for an example usage
183
            foreach ($entitiesByModule[$moduleName] as $fullName => $spec) {
184
                $specModuleName = $moduleName;
185
186
                // Rewrite spec if module is specified
187
                if (is_array($spec) && isset($spec['module'])) {
188
                    // Normalise name (in case non-composer name is specified)
189
                    $specModule = ModuleLoader::inst()->getManifest()->getModule($spec['module']);
190
                    if ($specModule) {
191
                        $specModuleName = $specModule->getName();
192
                    }
193
                    unset($spec['module']);
194
195
                    // If only element is default, simplify
196
                    if (count($spec ?? []) === 1 && !empty($spec['default'])) {
197
                        $spec = $spec['default'];
198
                    }
199
                }
200
201
                // Remove from source module
202
                if ($specModuleName !== $moduleName) {
203
                    unset($entitiesByModule[$moduleName][$fullName]);
204
                }
205
206
                // Write to target module
207
                if (!isset($entitiesByModule[$specModuleName])) {
208
                    $entitiesByModule[$specModuleName] = [];
209
                }
210
                $entitiesByModule[$specModuleName][$fullName] = $spec;
211
            }
212
        }
213
        return $entitiesByModule;
214
    }
215
216
    /**
217
     * Merge all entities with existing strings
218
     *
219
     * @param array<string,mixed> $entitiesByModule
220
     * @return array<string,mixed>|null
221
     */
222
    protected function mergeWithExisting($entitiesByModule)
223
    {
224
        $modules = $this->getModulesAndThemesExposed();
225
        // For each module do a simple merge of the default yml with these strings
226
        foreach ($entitiesByModule as $module => $messages) {
227
            $masterFile = Path::join($modules[$module]->getPath(), 'lang', $this->defaultLocale . '.yml');
228
229
            // YamlReader fails silently if path is not correct
230
            if (!is_file($masterFile)) {
231
                throw new Exception("File $masterFile does not exist. Please collect without merge first.");
232
            }
233
            $existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile);
234
235
            // Merge
236
            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...
237
                throw new Exception("No existing messages were found in $masterFile. Please collect without merge first.");
238
            }
239
240
            $newMessages = array_diff_key($messages, $existingMessages);
241
242
            // attempt auto translation
243
            if ($this->autoTranslate) {
244
                foreach ($newMessages as $newMessageKey => $newMessageVal) {
245
                    try {
246
                        if (is_array($newMessageVal)) {
247
                            $result = [];
248
                            foreach ($newMessageVal as $newMessageValItem) {
249
                                $result[] = GoogleTranslateHelper::translate($newMessageValItem, $this->defaultLocale);
250
                            }
251
                        } else {
252
                            $result = GoogleTranslateHelper::translate($newMessageVal, $this->defaultLocale);
253
                        }
254
                        sleep(1);
255
                        $messages[$newMessageKey] = $result;
256
                    } catch (Exception $ex) {
257
                        Debug::dump($ex->getMessage());
258
                    }
259
                }
260
            }
261
262
            if ($this->debug) {
263
                Debug::dump($existingMessages);
264
            }
265
            $entitiesByModule[$module] = array_merge(
266
                $messages,
267
                $existingMessages
268
            );
269
270
            // Clear unused
271
            if ($this->getClearUnused()) {
272
                $unusedEntities = array_diff(
273
                    array_keys($existingMessages),
274
                    array_keys($messages)
275
                );
276
                foreach ($unusedEntities as $unusedEntity) {
277
                    // Skip globals
278
                    if (strpos($unusedEntity, LangHelper::GLOBAL_ENTITY . '.') !== false) {
279
                        continue;
280
                    }
281
                    if ($this->debug) {
282
                        Debug::message("Removed $unusedEntity");
283
                    }
284
                    unset($entitiesByModule[$unusedEntity]);
285
                }
286
            }
287
        }
288
        return $entitiesByModule;
289
    }
290
291
    /**
292
     * @param Module $module
293
     * @return array<string,mixed>|null
294
     */
295
    public function collectFromTheme(Module $module)
296
    {
297
        $themeDir = $this->getThemeDir();
298
        $themeFolder = Director::baseFolder() . '/' . $themeDir . '/Templates';
299
300
        $files = $this->getFilesRecursive($themeFolder, [], 'ss');
301
302
        $entities = [];
303
        foreach ($files as $file) {
304
            $fileContent = file_get_contents($file);
305
            if (!$fileContent) {
306
                continue;
307
            }
308
            $fileEntities = $this->collectFromTemplate($fileContent, $file, $module);
309
            if ($fileEntities) {
310
                $entities = array_merge($entities, $fileEntities);
311
            }
312
        }
313
314
        return $entities;
315
    }
316
317
    /**
318
     * Extracts translatables from .ss templates (Self referencing)
319
     *
320
     * @param string $content The text content of a parsed template-file
321
     * @param string $fileName The name of a template file when method is used in self-referencing mode
322
     * @param Module $module Module being collected
323
     * @param array<mixed> $parsedFiles
324
     * @return array<string,mixed>|null $entities An array of entities representing the extracted template function calls
325
     */
326
    public function collectFromTemplate($content, $fileName, Module $module, &$parsedFiles = [])
327
    {
328
        // Get namespace either from $fileName or $module fallback
329
        $namespace = $fileName ? basename($fileName) : $module->getName();
330
331
        // use parser to extract <%t style translatable entities
332
        $entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault());
333
334
        // use the old method of getting _t() style translatable entities is forbidden
335
        if (preg_match_all('/(_t\([^\)]*?\))/ms', $content, $matches)) {
336
            throw new Exception("Old _t calls in $fileName are not allowed in templates. Please use <%t instead.");
337
        }
338
339
        foreach ($entities as $entity => $spec) {
340
            unset($entities[$entity]);
341
            $entities[$this->normalizeEntity($entity, $namespace)] = $spec;
342
        }
343
        ksort($entities);
344
345
        return $entities;
346
    }
347
348
    /**
349
     * Get current theme dir (regardless of current theme set)
350
     * This will work in admin for instance
351
     *
352
     * @return string
353
     */
354
    public function getThemeDir()
355
    {
356
        $themes = SSViewer::config()->themes;
357
        if (!$themes) {
358
            $themes = SSViewer::get_themes();
359
        }
360
        if ($themes) {
361
            do {
362
                $mainTheme = array_shift($themes);
363
            } while (strpos($mainTheme, '$') === 0);
364
365
            return 'themes/' . $mainTheme;
366
        }
367
        return project();
368
    }
369
370
    /**
371
     * @return boolean
372
     */
373
    public function isAdminTheme()
374
    {
375
        $themes = SSViewer::get_themes();
376
        if (empty($themes)) {
377
            return false;
378
        }
379
        $theme = $themes[0];
380
        return strpos($theme, 'silverstripe/admin') === 0;
381
    }
382
383
    /**
384
     * Get the value of clearUnused
385
     *
386
     * @return boolean
387
     */
388
    public function getClearUnused()
389
    {
390
        return $this->clearUnused;
391
    }
392
393
    /**
394
     * Set the value of clearUnused
395
     *
396
     * @param boolean $clearUnused
397
     *
398
     * @return self
399
     */
400
    public function setClearUnused($clearUnused)
401
    {
402
        $this->clearUnused = $clearUnused;
403
        return $this;
404
    }
405
406
    /**
407
     * Get the value of restrictToModules
408
     *
409
     * @return array<string>
410
     */
411
    public function getRestrictToModules()
412
    {
413
        return $this->restrictToModules;
414
    }
415
416
    /**
417
     * Set the value of restrictToModules
418
     *
419
     * @param array<string> $restrictToModules
420
     *
421
     * @return self
422
     */
423
    public function setRestrictToModules($restrictToModules)
424
    {
425
        $this->restrictToModules = $restrictToModules;
426
        return $this;
427
    }
428
429
    /**
430
     * Get the value of mergeWithExisting
431
     *
432
     * @return boolean
433
     */
434
    public function getMergeWithExisting()
435
    {
436
        return $this->mergeWithExisting;
437
    }
438
439
    /**
440
     * Set the value of mergeWithExisting
441
     *
442
     * @param boolean $mergeWithExisting
443
     *
444
     * @return self
445
     */
446
    public function setMergeWithExisting($mergeWithExisting)
447
    {
448
        $this->mergeWithExisting = $mergeWithExisting;
449
        return $this;
450
    }
451
452
    /**
453
     * Get the value of debug
454
     *
455
     * @return boolean
456
     */
457
    public function getDebug()
458
    {
459
        return $this->debug;
460
    }
461
462
    /**
463
     * Set the value of debug
464
     *
465
     * @param boolean $debug
466
     *
467
     * @return self
468
     */
469
    public function setDebug($debug)
470
    {
471
        $this->debug = $debug;
472
        return $this;
473
    }
474
475
    /**
476
     * Get the value of autoTranslate
477
     * @return boolean
478
     */
479
    public function getAutoTranslate()
480
    {
481
        return $this->autoTranslate;
482
    }
483
484
    /**
485
     * Set the value of autoTranslate
486
     *
487
     * @param boolean $autoTranslate
488
     * @return self
489
     */
490
    public function setAutoTranslate($autoTranslate)
491
    {
492
        $this->autoTranslate = $autoTranslate;
493
        return $this;
494
    }
495
}
496