Issues (17)

src/MultilingualTextCollector.php (1 issue)

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