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
|
|||
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 |
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.