|
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\Core\Manifest\Module; |
|
9
|
|
|
use SilverStripe\Core\Manifest\ModuleLoader; |
|
10
|
|
|
use SilverStripe\Dev\Debug; |
|
11
|
|
|
use SilverStripe\Control\Director; |
|
12
|
|
|
use ReflectionClass; |
|
13
|
|
|
use SilverStripe\Dev\Deprecation; |
|
14
|
|
|
use SilverStripe\i18n\i18n; |
|
15
|
|
|
use SilverStripe\i18n\i18nEntityProvider; |
|
16
|
|
|
use SilverStripe\i18n\Messages\Reader; |
|
17
|
|
|
use SilverStripe\i18n\Messages\Writer; |
|
18
|
|
|
|
|
19
|
|
|
/** |
|
20
|
|
|
* SilverStripe-variant of the "gettext" tool: |
|
21
|
|
|
* Parses the string content of all PHP-files and SilverStripe templates |
|
22
|
|
|
* for ocurrences of the _t() translation method. Also uses the {@link i18nEntityProvider} |
|
23
|
|
|
* interface to get dynamically defined entities by executing the |
|
24
|
|
|
* {@link provideI18nEntities()} method on all implementors of this interface. |
|
25
|
|
|
* |
|
26
|
|
|
* Collects all found entities (and their natural language text for the default locale) |
|
27
|
|
|
* into language-files for each module in an array notation. Creates or overwrites these files, |
|
28
|
|
|
* e.g. framework/lang/en.yml. |
|
29
|
|
|
* |
|
30
|
|
|
* The collector needs to be run whenever you make new translatable |
|
31
|
|
|
* entities available. Please don't alter the arrays in language tables manually. |
|
32
|
|
|
* |
|
33
|
|
|
* Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask |
|
34
|
|
|
* Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule |
|
35
|
|
|
* Usage on CLI: sake dev/tasks/i18nTextCollectorTask |
|
36
|
|
|
* Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule |
|
37
|
|
|
* |
|
38
|
|
|
* @author Bernat Foj Capell <[email protected]> |
|
39
|
|
|
* @author Ingo Schommer <[email protected]> |
|
40
|
|
|
* @uses i18nEntityProvider |
|
41
|
|
|
* @uses i18n |
|
42
|
|
|
*/ |
|
43
|
|
|
class i18nTextCollector |
|
44
|
|
|
{ |
|
45
|
|
|
use Injectable; |
|
46
|
|
|
|
|
47
|
|
|
/** |
|
48
|
|
|
* Default (master) locale |
|
49
|
|
|
* |
|
50
|
|
|
* @var string |
|
51
|
|
|
*/ |
|
52
|
|
|
protected $defaultLocale; |
|
53
|
|
|
|
|
54
|
|
|
/** |
|
55
|
|
|
* Trigger if warnings should be shown if default is omitted |
|
56
|
|
|
* |
|
57
|
|
|
* @var bool |
|
58
|
|
|
*/ |
|
59
|
|
|
protected $warnOnEmptyDefault = false; |
|
60
|
|
|
|
|
61
|
|
|
/** |
|
62
|
|
|
* The directory base on which the collector should act. |
|
63
|
|
|
* Usually the webroot set through {@link Director::baseFolder()}. |
|
64
|
|
|
* |
|
65
|
|
|
* @todo Fully support changing of basePath through {@link SSViewer} and {@link ManifestBuilder} |
|
66
|
|
|
* |
|
67
|
|
|
* @var string |
|
68
|
|
|
*/ |
|
69
|
|
|
public $basePath; |
|
70
|
|
|
|
|
71
|
|
|
/** |
|
72
|
|
|
* Save path |
|
73
|
|
|
* |
|
74
|
|
|
* @var string |
|
75
|
|
|
*/ |
|
76
|
|
|
public $baseSavePath; |
|
77
|
|
|
|
|
78
|
|
|
/** |
|
79
|
|
|
* @var Writer |
|
80
|
|
|
*/ |
|
81
|
|
|
protected $writer; |
|
82
|
|
|
|
|
83
|
|
|
/** |
|
84
|
|
|
* Translation reader |
|
85
|
|
|
* |
|
86
|
|
|
* @var Reader |
|
87
|
|
|
*/ |
|
88
|
|
|
protected $reader; |
|
89
|
|
|
|
|
90
|
|
|
/** |
|
91
|
|
|
* List of file extensions to parse |
|
92
|
|
|
* |
|
93
|
|
|
* @var array |
|
94
|
|
|
*/ |
|
95
|
|
|
protected $fileExtensions = array('php', 'ss'); |
|
96
|
|
|
|
|
97
|
|
|
/** |
|
98
|
|
|
* @param $locale |
|
99
|
|
|
*/ |
|
100
|
|
|
public function __construct($locale = null) |
|
101
|
|
|
{ |
|
102
|
|
|
$this->defaultLocale = $locale |
|
103
|
|
|
? $locale |
|
104
|
|
|
: i18n::getData()->langFromLocale(i18n::config()->uninherited('default_locale')); |
|
105
|
|
|
$this->basePath = Director::baseFolder(); |
|
106
|
|
|
$this->baseSavePath = Director::baseFolder(); |
|
107
|
|
|
$this->setWarnOnEmptyDefault(i18n::config()->uninherited('missing_default_warning')); |
|
108
|
|
|
} |
|
109
|
|
|
|
|
110
|
|
|
/** |
|
111
|
|
|
* Assign a writer |
|
112
|
|
|
* |
|
113
|
|
|
* @param Writer $writer |
|
114
|
|
|
* @return $this |
|
115
|
|
|
*/ |
|
116
|
|
|
public function setWriter($writer) |
|
117
|
|
|
{ |
|
118
|
|
|
$this->writer = $writer; |
|
119
|
|
|
return $this; |
|
120
|
|
|
} |
|
121
|
|
|
|
|
122
|
|
|
/** |
|
123
|
|
|
* Gets the currently assigned writer, or the default if none is specified. |
|
124
|
|
|
* |
|
125
|
|
|
* @return Writer |
|
126
|
|
|
*/ |
|
127
|
|
|
public function getWriter() |
|
128
|
|
|
{ |
|
129
|
|
|
return $this->writer; |
|
130
|
|
|
} |
|
131
|
|
|
|
|
132
|
|
|
/** |
|
133
|
|
|
* Get reader |
|
134
|
|
|
* |
|
135
|
|
|
* @return Reader |
|
136
|
|
|
*/ |
|
137
|
|
|
public function getReader() |
|
138
|
|
|
{ |
|
139
|
|
|
return $this->reader; |
|
140
|
|
|
} |
|
141
|
|
|
|
|
142
|
|
|
/** |
|
143
|
|
|
* Set reader |
|
144
|
|
|
* |
|
145
|
|
|
* @param Reader $reader |
|
146
|
|
|
* @return $this |
|
147
|
|
|
*/ |
|
148
|
|
|
public function setReader(Reader $reader) |
|
149
|
|
|
{ |
|
150
|
|
|
$this->reader = $reader; |
|
151
|
|
|
return $this; |
|
152
|
|
|
} |
|
153
|
|
|
|
|
154
|
|
|
/** |
|
155
|
|
|
* This is the main method to build the master string tables with the |
|
156
|
|
|
* original strings. It will search for existent modules that use the |
|
157
|
|
|
* i18n feature, parse the _t() calls and write the resultant files |
|
158
|
|
|
* in the lang folder of each module. |
|
159
|
|
|
* |
|
160
|
|
|
* @uses DataObject->collectI18nStatics() |
|
161
|
|
|
* |
|
162
|
|
|
* @param array $restrictToModules |
|
163
|
|
|
* @param bool $mergeWithExisting Merge new master strings with existing |
|
164
|
|
|
* ones already defined in language files, rather than replacing them. |
|
165
|
|
|
* This can be useful for long-term maintenance of translations across |
|
166
|
|
|
* releases, because it allows "translation backports" to older releases |
|
167
|
|
|
* without removing strings these older releases still rely on. |
|
168
|
|
|
*/ |
|
169
|
|
|
public function run($restrictToModules = null, $mergeWithExisting = false) |
|
170
|
|
|
{ |
|
171
|
|
|
$entitiesByModule = $this->collect($restrictToModules, $mergeWithExisting); |
|
|
|
|
|
|
172
|
|
|
if (empty($entitiesByModule)) { |
|
173
|
|
|
return; |
|
174
|
|
|
} |
|
175
|
|
|
|
|
176
|
|
|
// Write each module language file |
|
177
|
|
|
foreach ($entitiesByModule as $moduleName => $entities) { |
|
178
|
|
|
// Skip empty translations |
|
179
|
|
|
if (empty($entities)) { |
|
180
|
|
|
continue; |
|
181
|
|
|
} |
|
182
|
|
|
|
|
183
|
|
|
// Clean sorting prior to writing |
|
184
|
|
|
ksort($entities); |
|
185
|
|
|
$module = ModuleLoader::instance()->getManifest()->getModule($moduleName); |
|
186
|
|
|
$this->write($module, $entities); |
|
|
|
|
|
|
187
|
|
|
} |
|
188
|
|
|
} |
|
189
|
|
|
|
|
190
|
|
|
/** |
|
191
|
|
|
* Extract all strings from modules and return these grouped by module name |
|
192
|
|
|
* |
|
193
|
|
|
* @param array $restrictToModules |
|
194
|
|
|
* @param bool $mergeWithExisting |
|
195
|
|
|
* @return array |
|
196
|
|
|
*/ |
|
197
|
|
|
public function collect($restrictToModules = array(), $mergeWithExisting = false) |
|
198
|
|
|
{ |
|
199
|
|
|
$entitiesByModule = $this->getEntitiesByModule(); |
|
200
|
|
|
|
|
201
|
|
|
// Resolve conflicts between duplicate keys across modules |
|
202
|
|
|
$entitiesByModule = $this->resolveDuplicateConflicts($entitiesByModule); |
|
203
|
|
|
|
|
204
|
|
|
// Optionally merge with existing master strings |
|
205
|
|
|
if ($mergeWithExisting) { |
|
206
|
|
|
$entitiesByModule = $this->mergeWithExisting($entitiesByModule); |
|
207
|
|
|
} |
|
208
|
|
|
|
|
209
|
|
|
// Restrict modules we update to just the specified ones (if any passed) |
|
210
|
|
|
if (!empty($restrictToModules)) { |
|
211
|
|
|
// Normalise module names |
|
212
|
|
|
$modules = array_filter(array_map(function ($name) { |
|
213
|
|
|
$module = ModuleLoader::instance()->getManifest()->getModule($name); |
|
214
|
|
|
return $module ? $module->getName() : null; |
|
215
|
|
|
}, $restrictToModules)); |
|
216
|
|
|
// Remove modules |
|
217
|
|
|
foreach (array_diff(array_keys($entitiesByModule), $modules) as $module) { |
|
218
|
|
|
unset($entitiesByModule[$module]); |
|
219
|
|
|
} |
|
220
|
|
|
} |
|
221
|
|
|
return $entitiesByModule; |
|
222
|
|
|
} |
|
223
|
|
|
|
|
224
|
|
|
/** |
|
225
|
|
|
* Resolve conflicts between duplicate keys across modules |
|
226
|
|
|
* |
|
227
|
|
|
* @param array $entitiesByModule List of all modules with keys |
|
228
|
|
|
* @return array Filtered listo of modules with duplicate keys unassigned |
|
229
|
|
|
*/ |
|
230
|
|
|
protected function resolveDuplicateConflicts($entitiesByModule) |
|
231
|
|
|
{ |
|
232
|
|
|
// Find all keys that exist across multiple modules |
|
233
|
|
|
$conflicts = $this->getConflicts($entitiesByModule); |
|
234
|
|
|
foreach ($conflicts as $conflict) { |
|
235
|
|
|
// Determine if we can narrow down the ownership |
|
236
|
|
|
$bestModule = $this->getBestModuleForKey($entitiesByModule, $conflict); |
|
237
|
|
|
if (!$bestModule || !isset($entitiesByModule[$bestModule])) { |
|
238
|
|
|
continue; |
|
239
|
|
|
} |
|
240
|
|
|
|
|
241
|
|
|
// Remove foreign duplicates |
|
242
|
|
|
foreach ($entitiesByModule as $module => $entities) { |
|
243
|
|
|
if ($module !== $bestModule) { |
|
244
|
|
|
unset($entitiesByModule[$module][$conflict]); |
|
245
|
|
|
} |
|
246
|
|
|
} |
|
247
|
|
|
} |
|
248
|
|
|
return $entitiesByModule; |
|
249
|
|
|
} |
|
250
|
|
|
|
|
251
|
|
|
/** |
|
252
|
|
|
* Find all keys in the entity list that are duplicated across modules |
|
253
|
|
|
* |
|
254
|
|
|
* @param array $entitiesByModule |
|
255
|
|
|
* @return array List of keys |
|
256
|
|
|
*/ |
|
257
|
|
|
protected function getConflicts($entitiesByModule) |
|
258
|
|
|
{ |
|
259
|
|
|
$modules = array_keys($entitiesByModule); |
|
260
|
|
|
$allConflicts = array(); |
|
261
|
|
|
// bubble-compare each group of modules |
|
262
|
|
|
for ($i = 0; $i < count($modules) - 1; $i++) { |
|
263
|
|
|
$left = array_keys($entitiesByModule[$modules[$i]]); |
|
264
|
|
|
for ($j = $i+1; $j < count($modules); $j++) { |
|
|
|
|
|
|
265
|
|
|
$right = array_keys($entitiesByModule[$modules[$j]]); |
|
266
|
|
|
$conflicts = array_intersect($left, $right); |
|
267
|
|
|
$allConflicts = array_merge($allConflicts, $conflicts); |
|
268
|
|
|
} |
|
269
|
|
|
} |
|
270
|
|
|
return array_unique($allConflicts); |
|
271
|
|
|
} |
|
272
|
|
|
|
|
273
|
|
|
/** |
|
274
|
|
|
* Map of translation keys => module names |
|
275
|
|
|
* @var array |
|
276
|
|
|
*/ |
|
277
|
|
|
protected $classModuleCache = []; |
|
278
|
|
|
|
|
279
|
|
|
/** |
|
280
|
|
|
* Determine the best module to be given ownership over this key |
|
281
|
|
|
* |
|
282
|
|
|
* @param array $entitiesByModule |
|
283
|
|
|
* @param string $key |
|
284
|
|
|
* @return string Best module, if found |
|
285
|
|
|
*/ |
|
286
|
|
|
protected function getBestModuleForKey($entitiesByModule, $key) |
|
287
|
|
|
{ |
|
288
|
|
|
// Check classes |
|
289
|
|
|
$class = current(explode('.', $key)); |
|
290
|
|
|
if (array_key_exists($class, $this->classModuleCache)) { |
|
291
|
|
|
return $this->classModuleCache[$class]; |
|
292
|
|
|
} |
|
293
|
|
|
$owner = $this->findModuleForClass($class); |
|
294
|
|
|
if ($owner) { |
|
295
|
|
|
$this->classModuleCache[$class] = $owner; |
|
296
|
|
|
return $owner; |
|
297
|
|
|
} |
|
298
|
|
|
|
|
299
|
|
|
// @todo - How to determine ownership of templates? Templates can |
|
300
|
|
|
// exist in multiple locations with the same name. |
|
301
|
|
|
|
|
302
|
|
|
// Display notice if not found |
|
303
|
|
|
Debug::message( |
|
304
|
|
|
"Duplicate key {$key} detected in no / multiple modules with no obvious owner", |
|
305
|
|
|
false |
|
306
|
|
|
); |
|
307
|
|
|
|
|
308
|
|
|
// Fall back to framework then cms modules |
|
309
|
|
|
foreach (array('framework', 'cms') as $module) { |
|
310
|
|
|
if (isset($entitiesByModule[$module][$key])) { |
|
311
|
|
|
$this->classModuleCache[$class] = $module; |
|
312
|
|
|
return $module; |
|
313
|
|
|
} |
|
314
|
|
|
} |
|
315
|
|
|
|
|
316
|
|
|
// Do nothing |
|
317
|
|
|
$this->classModuleCache[$class] = null; |
|
318
|
|
|
return null; |
|
319
|
|
|
} |
|
320
|
|
|
|
|
321
|
|
|
/** |
|
322
|
|
|
* Given a partial class name, attempt to determine the best module to assign strings to. |
|
323
|
|
|
* |
|
324
|
|
|
* @param string $class Either a FQN class name, or a non-qualified class name. |
|
325
|
|
|
* @return string Name of module |
|
326
|
|
|
*/ |
|
327
|
|
|
protected function findModuleForClass($class) |
|
328
|
|
|
{ |
|
329
|
|
|
if (ClassInfo::exists($class)) { |
|
330
|
|
|
$module = ClassLoader::instance() |
|
331
|
|
|
->getManifest() |
|
332
|
|
|
->getOwnerModule($class); |
|
333
|
|
|
if ($module) { |
|
334
|
|
|
return $module->getName(); |
|
335
|
|
|
} |
|
336
|
|
|
} |
|
337
|
|
|
|
|
338
|
|
|
// If we can't find a class, see if it needs to be fully qualified |
|
339
|
|
|
if (strpos($class, '\\') !== false) { |
|
340
|
|
|
return null; |
|
341
|
|
|
} |
|
342
|
|
|
|
|
343
|
|
|
// Find FQN that ends with $class |
|
344
|
|
|
$classes = preg_grep( |
|
345
|
|
|
'/'.preg_quote("\\{$class}", '\/').'$/i', |
|
346
|
|
|
ClassLoader::instance()->getManifest()->getClassNames() |
|
347
|
|
|
); |
|
348
|
|
|
|
|
349
|
|
|
// Find all modules for candidate classes |
|
350
|
|
|
$modules = array_unique(array_map(function ($class) { |
|
351
|
|
|
$module = ClassLoader::instance()->getManifest()->getOwnerModule($class); |
|
352
|
|
|
return $module ? $module->getName() : null; |
|
353
|
|
|
}, $classes)); |
|
354
|
|
|
|
|
355
|
|
|
if (count($modules) === 1) { |
|
356
|
|
|
return reset($modules); |
|
357
|
|
|
} |
|
358
|
|
|
|
|
359
|
|
|
// Couldn't find it! Exists in none, or multiple modules. |
|
360
|
|
|
return null; |
|
361
|
|
|
} |
|
362
|
|
|
|
|
363
|
|
|
/** |
|
364
|
|
|
* Merge all entities with existing strings |
|
365
|
|
|
* |
|
366
|
|
|
* @param array $entitiesByModule |
|
367
|
|
|
* @return array |
|
368
|
|
|
*/ |
|
369
|
|
|
protected function mergeWithExisting($entitiesByModule) |
|
370
|
|
|
{ |
|
371
|
|
|
// For each module do a simple merge of the default yml with these strings |
|
372
|
|
|
foreach ($entitiesByModule as $module => $messages) { |
|
373
|
|
|
// Load existing localisations |
|
374
|
|
|
$masterFile = "{$this->basePath}/{$module}/lang/{$this->defaultLocale}.yml"; |
|
375
|
|
|
$existingMessages = $this->getReader()->read($this->defaultLocale, $masterFile); |
|
376
|
|
|
|
|
377
|
|
|
// Merge |
|
378
|
|
|
if ($existingMessages) { |
|
|
|
|
|
|
379
|
|
|
$entitiesByModule[$module] = array_merge( |
|
380
|
|
|
$existingMessages, |
|
381
|
|
|
$messages |
|
382
|
|
|
); |
|
383
|
|
|
} |
|
384
|
|
|
} |
|
385
|
|
|
return $entitiesByModule; |
|
386
|
|
|
} |
|
387
|
|
|
|
|
388
|
|
|
/** |
|
389
|
|
|
* Collect all entities grouped by module |
|
390
|
|
|
* |
|
391
|
|
|
* @return array |
|
392
|
|
|
*/ |
|
393
|
|
|
protected function getEntitiesByModule() |
|
394
|
|
|
{ |
|
395
|
|
|
// A master string tables array (one mst per module) |
|
396
|
|
|
$entitiesByModule = array(); |
|
397
|
|
|
$modules = ModuleLoader::instance()->getManifest()->getModules(); |
|
398
|
|
|
foreach ($modules as $module) { |
|
399
|
|
|
// we store the master string tables |
|
400
|
|
|
$processedEntities = $this->processModule($module); |
|
401
|
|
|
$moduleName = $module->getName(); |
|
402
|
|
|
if (isset($entitiesByModule[$moduleName])) { |
|
403
|
|
|
$entitiesByModule[$moduleName] = array_merge_recursive( |
|
404
|
|
|
$entitiesByModule[$moduleName], |
|
405
|
|
|
$processedEntities |
|
406
|
|
|
); |
|
407
|
|
|
} else { |
|
408
|
|
|
$entitiesByModule[$moduleName] = $processedEntities; |
|
409
|
|
|
} |
|
410
|
|
|
|
|
411
|
|
|
// Extract all entities for "foreign" modules ('module' key in array form) |
|
412
|
|
|
// @see CMSMenu::provideI18nEntities for an example usage |
|
413
|
|
|
foreach ($entitiesByModule[$moduleName] as $fullName => $spec) { |
|
414
|
|
|
$specModuleName = $moduleName; |
|
415
|
|
|
|
|
416
|
|
|
// Rewrite spec if module is specified |
|
417
|
|
|
if (is_array($spec) && isset($spec['module'])) { |
|
418
|
|
|
// Normalise name (in case non-composer name is specified) |
|
419
|
|
|
$specModule = ModuleLoader::instance()->getManifest()->getModule($spec['module']); |
|
420
|
|
|
if ($specModule) { |
|
421
|
|
|
$specModuleName = $specModule->getName(); |
|
422
|
|
|
} |
|
423
|
|
|
unset($spec['module']); |
|
424
|
|
|
|
|
425
|
|
|
// If only element is defalt, simplify |
|
426
|
|
|
if (count($spec) === 1 && !empty($spec['default'])) { |
|
427
|
|
|
$spec = $spec['default']; |
|
428
|
|
|
} |
|
429
|
|
|
} |
|
430
|
|
|
|
|
431
|
|
|
// Remove from source module |
|
432
|
|
|
if ($specModuleName !== $moduleName) { |
|
433
|
|
|
unset($entitiesByModule[$moduleName][$fullName]); |
|
434
|
|
|
} |
|
435
|
|
|
|
|
436
|
|
|
// Write to target module |
|
437
|
|
|
if (!isset($entitiesByModule[$specModuleName])) { |
|
438
|
|
|
$entitiesByModule[$specModuleName] = []; |
|
439
|
|
|
} |
|
440
|
|
|
$entitiesByModule[$specModuleName][$fullName] = $spec; |
|
441
|
|
|
} |
|
442
|
|
|
} |
|
443
|
|
|
return $entitiesByModule; |
|
444
|
|
|
} |
|
445
|
|
|
|
|
446
|
|
|
/** |
|
447
|
|
|
* Write entities to a module |
|
448
|
|
|
* |
|
449
|
|
|
* @param Module $module |
|
450
|
|
|
* @param array $entities |
|
451
|
|
|
* @return $this |
|
452
|
|
|
*/ |
|
453
|
|
|
public function write(Module $module, $entities) |
|
454
|
|
|
{ |
|
455
|
|
|
$this->getWriter()->write( |
|
456
|
|
|
$entities, |
|
457
|
|
|
$this->defaultLocale, |
|
458
|
|
|
$this->baseSavePath . '/' . $module->getRelativePath() |
|
459
|
|
|
); |
|
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 Module $module Module instance |
|
468
|
|
|
* @return array An array of entities found in the files that comprise the module |
|
469
|
|
|
*/ |
|
470
|
|
|
protected function processModule(Module $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, $filePath, $module), |
|
484
|
|
|
$this->collectFromEntityProviders($filePath, $module) |
|
485
|
|
|
); |
|
486
|
|
|
} elseif ($extension === 'ss') { |
|
487
|
|
|
// templates use their filename as a namespace |
|
488
|
|
|
$entities = array_merge( |
|
489
|
|
|
$entities, |
|
490
|
|
|
$this->collectFromTemplate($content, $filePath, $module) |
|
491
|
|
|
); |
|
492
|
|
|
} |
|
493
|
|
|
} |
|
494
|
|
|
|
|
495
|
|
|
// sort for easier lookup and comparison with translated files |
|
496
|
|
|
ksort($entities); |
|
497
|
|
|
|
|
498
|
|
|
return $entities; |
|
499
|
|
|
} |
|
500
|
|
|
|
|
501
|
|
|
/** |
|
502
|
|
|
* Retrieves the list of files for this module |
|
503
|
|
|
* |
|
504
|
|
|
* @param Module $module Module instance |
|
505
|
|
|
* @return array List of files to parse |
|
506
|
|
|
*/ |
|
507
|
|
|
protected function getFileListForModule(Module $module) |
|
508
|
|
|
{ |
|
509
|
|
|
$modulePath = $module->getPath(); |
|
510
|
|
|
|
|
511
|
|
|
// Search all .ss files in themes |
|
512
|
|
|
if (stripos($module->getRelativePath(), 'themes/') === 0) { |
|
513
|
|
|
return $this->getFilesRecursive($modulePath, null, 'ss'); |
|
514
|
|
|
} |
|
515
|
|
|
|
|
516
|
|
|
// If non-standard module structure, search all root files |
|
517
|
|
|
if (!is_dir("{$modulePath}/code") && !is_dir("{$modulePath}/src")) { |
|
518
|
|
|
return $this->getFilesRecursive($modulePath); |
|
519
|
|
|
} |
|
520
|
|
|
|
|
521
|
|
|
// Get code files |
|
522
|
|
|
if (is_dir("{$modulePath}/src")) { |
|
523
|
|
|
$files = $this->getFilesRecursive("{$modulePath}/src", null, 'php'); |
|
524
|
|
|
} else { |
|
525
|
|
|
$files = $this->getFilesRecursive("{$modulePath}/code", null, 'php'); |
|
526
|
|
|
} |
|
527
|
|
|
|
|
528
|
|
|
// Search for templates in this module |
|
529
|
|
|
if (is_dir("{$modulePath}/templates")) { |
|
530
|
|
|
$templateFiles = $this->getFilesRecursive("{$modulePath}/templates", null, 'ss'); |
|
531
|
|
|
} else { |
|
532
|
|
|
$templateFiles = $this->getFilesRecursive($modulePath, null, 'ss'); |
|
533
|
|
|
} |
|
534
|
|
|
|
|
535
|
|
|
return array_merge($files, $templateFiles); |
|
536
|
|
|
} |
|
537
|
|
|
|
|
538
|
|
|
/** |
|
539
|
|
|
* Extracts translatables from .php files. |
|
540
|
|
|
* Note: Translations without default values are omitted. |
|
541
|
|
|
* |
|
542
|
|
|
* @param string $content The text content of a parsed template-file |
|
543
|
|
|
* @param string $fileName Filename Optional filename |
|
544
|
|
|
* @param Module $module Module being collected |
|
545
|
|
|
* @return array Map of localised keys to default values provided for this code |
|
546
|
|
|
*/ |
|
547
|
|
|
public function collectFromCode($content, $fileName, Module $module) |
|
548
|
|
|
{ |
|
549
|
|
|
// Get namespace either from $fileName or $module fallback |
|
550
|
|
|
$namespace = $fileName ? basename($fileName) : $module->getName(); |
|
551
|
|
|
|
|
552
|
|
|
$entities = array(); |
|
553
|
|
|
|
|
554
|
|
|
$tokens = token_get_all("<?php\n" . $content); |
|
555
|
|
|
$inTransFn = false; |
|
556
|
|
|
$inConcat = false; |
|
557
|
|
|
$inNamespace = false; |
|
558
|
|
|
$inClass = false; |
|
559
|
|
|
$inArrayClosedBy = false; // Set to the expected closing token, or false if not in array |
|
560
|
|
|
$currentEntity = array(); |
|
561
|
|
|
$currentClass = []; // Class components |
|
562
|
|
|
foreach ($tokens as $token) { |
|
563
|
|
|
if (is_array($token)) { |
|
564
|
|
|
list($id, $text) = $token; |
|
565
|
|
|
|
|
566
|
|
|
// Check class |
|
567
|
|
|
if ($id === T_NAMESPACE) { |
|
568
|
|
|
$inNamespace = true; |
|
569
|
|
|
$currentClass = []; |
|
570
|
|
|
continue; |
|
571
|
|
|
} |
|
572
|
|
|
if ($inNamespace && $id === T_STRING) { |
|
573
|
|
|
$currentClass[] = $text; |
|
574
|
|
|
continue; |
|
575
|
|
|
} |
|
576
|
|
|
|
|
577
|
|
|
// Check class |
|
578
|
|
|
if ($id === T_CLASS) { |
|
579
|
|
|
$inClass = true; |
|
580
|
|
|
continue; |
|
581
|
|
|
} |
|
582
|
|
|
if ($inClass && $id === T_STRING) { |
|
583
|
|
|
$currentClass[] = $text; |
|
584
|
|
|
$inClass = false; |
|
585
|
|
|
continue; |
|
586
|
|
|
} |
|
587
|
|
|
|
|
588
|
|
|
// Suppress tokenisation within array |
|
589
|
|
|
if ($inTransFn && !$inArrayClosedBy && $id == T_ARRAY) { |
|
|
|
|
|
|
590
|
|
|
$inArrayClosedBy = ')'; // Array will close with this element |
|
591
|
|
|
continue; |
|
592
|
|
|
} |
|
593
|
|
|
|
|
594
|
|
|
// Start definition |
|
595
|
|
|
if ($id == T_STRING && $text == '_t') { |
|
596
|
|
|
$inTransFn = true; |
|
597
|
|
|
continue; |
|
598
|
|
|
} |
|
599
|
|
|
|
|
600
|
|
|
// Skip rest of processing unless we are in a translation, and not inside a nested array |
|
601
|
|
|
if (!$inTransFn || $inArrayClosedBy) { |
|
|
|
|
|
|
602
|
|
|
continue; |
|
603
|
|
|
} |
|
604
|
|
|
|
|
605
|
|
|
// If inside this translation, some elements might be unreachable |
|
606
|
|
|
if (in_array($id, [T_VARIABLE, T_STATIC]) || |
|
607
|
|
|
($id === T_STRING && in_array($text, ['self', 'static', 'parent'])) |
|
608
|
|
|
) { |
|
609
|
|
|
// Un-collectable strings such as _t(static::class.'.KEY'). |
|
610
|
|
|
// Should be provided by i18nEntityProvider instead |
|
611
|
|
|
$inTransFn = false; |
|
612
|
|
|
$inArrayClosedBy = false; |
|
613
|
|
|
$inConcat = false; |
|
614
|
|
|
$currentEntity = array(); |
|
615
|
|
|
continue; |
|
616
|
|
|
} |
|
617
|
|
|
|
|
618
|
|
|
// Check text |
|
619
|
|
|
if ($id == T_CONSTANT_ENCAPSED_STRING) { |
|
620
|
|
|
// Fixed quoting escapes, and remove leading/trailing quotes |
|
621
|
|
|
if (preg_match('/^\'/', $text)) { |
|
622
|
|
|
$text = str_replace("\\'", "'", $text); |
|
623
|
|
|
$text = preg_replace('/^\'/', '', $text); |
|
624
|
|
|
$text = preg_replace('/\'$/', '', $text); |
|
625
|
|
|
} else { |
|
626
|
|
|
$text = str_replace('\"', '"', $text); |
|
627
|
|
|
$text = preg_replace('/^"/', '', $text); |
|
628
|
|
|
$text = preg_replace('/"$/', '', $text); |
|
629
|
|
|
} |
|
630
|
|
|
} elseif ($id === T_CLASS_C) { |
|
631
|
|
|
// Evaluate __CLASS__ . '.KEY' concatenation |
|
632
|
|
|
$text = implode('\\', $currentClass); |
|
633
|
|
|
} else { |
|
634
|
|
|
continue; |
|
635
|
|
|
} |
|
636
|
|
|
|
|
637
|
|
|
if ($inConcat) { |
|
638
|
|
|
// Parser error |
|
639
|
|
|
if (empty($currentEntity)) { |
|
640
|
|
|
user_error('Error concatenating localisation key', E_USER_WARNING); |
|
641
|
|
|
} else { |
|
642
|
|
|
$currentEntity[count($currentEntity) - 1] .= $text; |
|
643
|
|
|
} |
|
644
|
|
|
} else { |
|
645
|
|
|
$currentEntity[] = $text; |
|
646
|
|
|
} |
|
647
|
|
|
continue; // is_array |
|
648
|
|
|
} |
|
649
|
|
|
|
|
650
|
|
|
// Test we can close this array |
|
651
|
|
|
if ($inTransFn && $inArrayClosedBy && ($token === $inArrayClosedBy)) { |
|
|
|
|
|
|
652
|
|
|
$inArrayClosedBy = false; |
|
653
|
|
|
continue; |
|
654
|
|
|
} |
|
655
|
|
|
|
|
656
|
|
|
// Check if we can close the namespace |
|
657
|
|
|
if ($inNamespace && $token === ';') { |
|
658
|
|
|
$inNamespace = false; |
|
659
|
|
|
continue; |
|
660
|
|
|
} |
|
661
|
|
|
|
|
662
|
|
|
// Continue only if in translation and not in array |
|
663
|
|
|
if (!$inTransFn || $inArrayClosedBy) { |
|
|
|
|
|
|
664
|
|
|
continue; |
|
665
|
|
|
} |
|
666
|
|
|
|
|
667
|
|
|
switch ($token) { |
|
668
|
|
|
case '.': |
|
669
|
|
|
$inConcat = true; |
|
670
|
|
|
break; |
|
671
|
|
|
case ',': |
|
672
|
|
|
$inConcat = false; |
|
673
|
|
|
break; |
|
674
|
|
|
case '[': |
|
675
|
|
|
// Enter array |
|
676
|
|
|
$inArrayClosedBy = ']'; |
|
677
|
|
|
break; |
|
678
|
|
|
case ')': |
|
679
|
|
|
// finalize definition |
|
680
|
|
|
$inTransFn = false; |
|
681
|
|
|
$inConcat = false; |
|
682
|
|
|
// Ensure key is valid before saving |
|
683
|
|
|
if (!empty($currentEntity[0])) { |
|
684
|
|
|
$key = $currentEntity[0]; |
|
685
|
|
|
$default = ''; |
|
686
|
|
|
$comment = ''; |
|
687
|
|
|
if (!empty($currentEntity[1])) { |
|
688
|
|
|
$default = $currentEntity[1]; |
|
689
|
|
|
if (!empty($currentEntity[2])) { |
|
690
|
|
|
$comment = $currentEntity[2]; |
|
691
|
|
|
} |
|
692
|
|
|
} |
|
693
|
|
|
// Save in appropriate format |
|
694
|
|
|
if ($default) { |
|
695
|
|
|
$plurals = i18n::parse_plurals($default); |
|
696
|
|
|
// Use array form if either plural or metadata is provided |
|
697
|
|
|
if ($plurals) { |
|
|
|
|
|
|
698
|
|
|
$entity = $plurals; |
|
699
|
|
|
} elseif ($comment) { |
|
700
|
|
|
$entity = ['default' => $default]; |
|
701
|
|
|
} else { |
|
702
|
|
|
$entity = $default; |
|
703
|
|
|
} |
|
704
|
|
|
if ($comment) { |
|
705
|
|
|
$entity['comment'] = $comment; |
|
706
|
|
|
} |
|
707
|
|
|
$entities[$key] = $entity; |
|
708
|
|
|
} elseif ($this->getWarnOnEmptyDefault()) { |
|
709
|
|
|
trigger_error("Missing localisation default for key " . $currentEntity[0], E_USER_NOTICE); |
|
710
|
|
|
} |
|
711
|
|
|
} |
|
712
|
|
|
$currentEntity = array(); |
|
713
|
|
|
$inArrayClosedBy = false; |
|
714
|
|
|
break; |
|
715
|
|
|
} |
|
716
|
|
|
} |
|
717
|
|
|
|
|
718
|
|
|
// Normalise all keys |
|
719
|
|
|
foreach ($entities as $key => $entity) { |
|
720
|
|
|
unset($entities[$key]); |
|
721
|
|
|
$entities[$this->normalizeEntity($key, $namespace)] = $entity; |
|
722
|
|
|
} |
|
723
|
|
|
ksort($entities); |
|
724
|
|
|
|
|
725
|
|
|
return $entities; |
|
726
|
|
|
} |
|
727
|
|
|
|
|
728
|
|
|
/** |
|
729
|
|
|
* Extracts translatables from .ss templates (Self referencing) |
|
730
|
|
|
* |
|
731
|
|
|
* @param string $content The text content of a parsed template-file |
|
732
|
|
|
* @param string $fileName The name of a template file when method is used in self-referencing mode |
|
733
|
|
|
* @param Module $module Module being collected |
|
734
|
|
|
* @param array $parsedFiles |
|
735
|
|
|
* @return array $entities An array of entities representing the extracted template function calls |
|
736
|
|
|
*/ |
|
737
|
|
|
public function collectFromTemplate($content, $fileName, Module $module, &$parsedFiles = array()) |
|
|
|
|
|
|
738
|
|
|
{ |
|
739
|
|
|
// Get namespace either from $fileName or $module fallback |
|
740
|
|
|
$namespace = $fileName ? basename($fileName) : $module->getName(); |
|
741
|
|
|
|
|
742
|
|
|
// use parser to extract <%t style translatable entities |
|
743
|
|
|
$entities = Parser::getTranslatables($content, $this->getWarnOnEmptyDefault()); |
|
744
|
|
|
|
|
745
|
|
|
// use the old method of getting _t() style translatable entities |
|
746
|
|
|
// Collect in actual template |
|
747
|
|
|
if (preg_match_all('/(_t\([^\)]*?\))/ms', $content, $matches)) { |
|
748
|
|
|
foreach ($matches[1] as $match) { |
|
749
|
|
|
$entities = array_merge($entities, $this->collectFromCode($match, $fileName, $module)); |
|
750
|
|
|
} |
|
751
|
|
|
} |
|
752
|
|
|
|
|
753
|
|
|
foreach ($entities as $entity => $spec) { |
|
754
|
|
|
unset($entities[$entity]); |
|
755
|
|
|
$entities[$this->normalizeEntity($entity, $namespace)] = $spec; |
|
756
|
|
|
} |
|
757
|
|
|
ksort($entities); |
|
758
|
|
|
|
|
759
|
|
|
return $entities; |
|
760
|
|
|
} |
|
761
|
|
|
|
|
762
|
|
|
/** |
|
763
|
|
|
* Allows classes which implement i18nEntityProvider to provide |
|
764
|
|
|
* additional translation strings. |
|
765
|
|
|
* |
|
766
|
|
|
* Not all classes can be instanciated without mandatory arguments, |
|
767
|
|
|
* so entity collection doesn't work for all SilverStripe classes currently |
|
768
|
|
|
* |
|
769
|
|
|
* @uses i18nEntityProvider |
|
770
|
|
|
* @param string $filePath |
|
771
|
|
|
* @param Module $module |
|
772
|
|
|
* @return array |
|
773
|
|
|
*/ |
|
774
|
|
|
public function collectFromEntityProviders($filePath, Module $module = null) |
|
|
|
|
|
|
775
|
|
|
{ |
|
776
|
|
|
$entities = array(); |
|
777
|
|
|
$classes = ClassInfo::classes_for_file($filePath); |
|
778
|
|
|
foreach ($classes as $class) { |
|
779
|
|
|
// Skip non-implementing classes |
|
780
|
|
|
if (!class_exists($class) || !is_a($class, i18nEntityProvider::class, true)) { |
|
781
|
|
|
continue; |
|
782
|
|
|
} |
|
783
|
|
|
|
|
784
|
|
|
// Skip abstract classes |
|
785
|
|
|
$reflectionClass = new ReflectionClass($class); |
|
786
|
|
|
if ($reflectionClass->isAbstract()) { |
|
787
|
|
|
continue; |
|
788
|
|
|
} |
|
789
|
|
|
|
|
790
|
|
|
/** @var i18nEntityProvider $obj */ |
|
791
|
|
|
$obj = singleton($class); |
|
792
|
|
|
$provided = $obj->provideI18nEntities(); |
|
793
|
|
|
// Handle deprecated return syntax |
|
794
|
|
|
foreach ($provided as $key => $value) { |
|
795
|
|
|
// Detect non-associative result for any key |
|
796
|
|
|
if (is_array($value) && $value === array_values($value)) { |
|
797
|
|
|
Deprecation::notice('5.0', 'Non-associative translations from providei18nEntities is deprecated'); |
|
798
|
|
|
$entity = array_filter([ |
|
799
|
|
|
'default' => $value[0], |
|
800
|
|
|
'comment' => isset($value[1]) ? $value[1] : null, |
|
801
|
|
|
'module' => isset($value[2]) ? $value[2] : null, |
|
802
|
|
|
]); |
|
803
|
|
|
if (count($entity) === 1) { |
|
804
|
|
|
$provided[$key] = $value[0]; |
|
805
|
|
|
} elseif ($entity) { |
|
|
|
|
|
|
806
|
|
|
$provided[$key] = $entity; |
|
807
|
|
|
} else { |
|
808
|
|
|
unset($provided[$key]); |
|
809
|
|
|
} |
|
810
|
|
|
} |
|
811
|
|
|
} |
|
812
|
|
|
$entities = array_merge($entities, $provided); |
|
813
|
|
|
} |
|
814
|
|
|
|
|
815
|
|
|
ksort($entities); |
|
816
|
|
|
return $entities; |
|
817
|
|
|
} |
|
818
|
|
|
|
|
819
|
|
|
/** |
|
820
|
|
|
* Normalizes enitities with namespaces. |
|
821
|
|
|
* |
|
822
|
|
|
* @param string $fullName |
|
823
|
|
|
* @param string $_namespace |
|
824
|
|
|
* @return string|boolean FALSE |
|
825
|
|
|
*/ |
|
826
|
|
|
protected function normalizeEntity($fullName, $_namespace = null) |
|
827
|
|
|
{ |
|
828
|
|
|
// split fullname into entity parts |
|
829
|
|
|
$entityParts = explode('.', $fullName); |
|
830
|
|
|
if (count($entityParts) > 1) { |
|
831
|
|
|
// templates don't have a custom namespace |
|
832
|
|
|
$entity = array_pop($entityParts); |
|
833
|
|
|
// namespace might contain dots, so we explode |
|
834
|
|
|
$namespace = implode('.', $entityParts); |
|
835
|
|
|
} else { |
|
836
|
|
|
$entity = array_pop($entityParts); |
|
837
|
|
|
$namespace = $_namespace; |
|
838
|
|
|
} |
|
839
|
|
|
|
|
840
|
|
|
// If a dollar sign is used in the entity name, |
|
841
|
|
|
// we can't resolve without running the method, |
|
842
|
|
|
// and skip the processing. This is mostly used for |
|
843
|
|
|
// dynamically translating static properties, e.g. looping |
|
844
|
|
|
// through $db, which are detected by {@link collectFromEntityProviders}. |
|
845
|
|
|
if ($entity && strpos('$', $entity) !== false) { |
|
846
|
|
|
return false; |
|
847
|
|
|
} |
|
848
|
|
|
|
|
849
|
|
|
return "{$namespace}.{$entity}"; |
|
850
|
|
|
} |
|
851
|
|
|
|
|
852
|
|
|
|
|
853
|
|
|
|
|
854
|
|
|
/** |
|
855
|
|
|
* Helper function that searches for potential files (templates and code) to be parsed |
|
856
|
|
|
* |
|
857
|
|
|
* @param string $folder base directory to scan (will scan recursively) |
|
858
|
|
|
* @param array $fileList Array to which potential files will be appended |
|
859
|
|
|
* @param string $type Optional, "php" or "ss" only |
|
860
|
|
|
* @param string $folderExclude Regular expression matching folder names to exclude |
|
861
|
|
|
* @return array $fileList An array of files |
|
862
|
|
|
*/ |
|
863
|
|
|
protected function getFilesRecursive($folder, $fileList = array(), $type = null, $folderExclude = '/\/(tests)$/') |
|
864
|
|
|
{ |
|
865
|
|
|
if (!$fileList) { |
|
|
|
|
|
|
866
|
|
|
$fileList = array(); |
|
867
|
|
|
} |
|
868
|
|
|
// Skip ignored folders |
|
869
|
|
|
if (is_file("{$folder}/_manifest_exclude") || preg_match($folderExclude, $folder)) { |
|
870
|
|
|
return $fileList; |
|
871
|
|
|
} |
|
872
|
|
|
|
|
873
|
|
|
foreach (glob($folder.'/*') as $path) { |
|
874
|
|
|
// Recurse if directory |
|
875
|
|
|
if (is_dir($path)) { |
|
876
|
|
|
$fileList = array_merge( |
|
877
|
|
|
$fileList, |
|
878
|
|
|
$this->getFilesRecursive($path, $fileList, $type, $folderExclude) |
|
879
|
|
|
); |
|
880
|
|
|
continue; |
|
881
|
|
|
} |
|
882
|
|
|
|
|
883
|
|
|
// Check if this extension is included |
|
884
|
|
|
$extension = pathinfo($path, PATHINFO_EXTENSION); |
|
885
|
|
|
if (in_array($extension, $this->fileExtensions) |
|
886
|
|
|
&& (!$type || $type === $extension) |
|
|
|
|
|
|
887
|
|
|
) { |
|
888
|
|
|
$fileList[$path] = $path; |
|
889
|
|
|
} |
|
890
|
|
|
} |
|
891
|
|
|
return $fileList; |
|
892
|
|
|
} |
|
893
|
|
|
|
|
894
|
|
|
public function getDefaultLocale() |
|
895
|
|
|
{ |
|
896
|
|
|
return $this->defaultLocale; |
|
897
|
|
|
} |
|
898
|
|
|
|
|
899
|
|
|
public function setDefaultLocale($locale) |
|
900
|
|
|
{ |
|
901
|
|
|
$this->defaultLocale = $locale; |
|
902
|
|
|
} |
|
903
|
|
|
|
|
904
|
|
|
/** |
|
905
|
|
|
* @return bool |
|
906
|
|
|
*/ |
|
907
|
|
|
public function getWarnOnEmptyDefault() |
|
908
|
|
|
{ |
|
909
|
|
|
return $this->warnOnEmptyDefault; |
|
910
|
|
|
} |
|
911
|
|
|
|
|
912
|
|
|
/** |
|
913
|
|
|
* @param bool $warnOnEmptyDefault |
|
914
|
|
|
* @return $this |
|
915
|
|
|
*/ |
|
916
|
|
|
public function setWarnOnEmptyDefault($warnOnEmptyDefault) |
|
917
|
|
|
{ |
|
918
|
|
|
$this->warnOnEmptyDefault = $warnOnEmptyDefault; |
|
919
|
|
|
return $this; |
|
920
|
|
|
} |
|
921
|
|
|
} |
|
922
|
|
|
|
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.