Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.78% |
88 / 90 |
|
87.50% |
14 / 16 |
CRAP | |
0.00% |
0 / 1 |
ExtendedIni | |
97.78% |
88 / 90 |
|
87.50% |
14 / 16 |
43 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
addToPathStack | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
load | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
getLanguageFilename | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
resetLoadedFiles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
checkAndMarkLoadedFile | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
loadLanguageLocale | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
resolveAlias | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
applyAliases | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
resetAliases | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
disableAliases | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
enableAliases | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
normalizeAlias | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
markAndLoadAliases | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
loadLanguageFile | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
8 | |||
loadParentData | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * VuFind Translate Adapter ExtendedIni |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License version 2, |
12 | * as published by the Free Software Foundation. |
13 | * |
14 | * This program is distributed in the hope that it will be useful, |
15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17 | * GNU General Public License for more details. |
18 | * |
19 | * You should have received a copy of the GNU General Public License |
20 | * along with this program; if not, write to the Free Software |
21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
22 | * |
23 | * @category VuFind |
24 | * @package Translator |
25 | * @author Demian Katz <demian.katz@villanova.edu> |
26 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
27 | * @link https://vufind.org Main Site |
28 | */ |
29 | |
30 | namespace VuFind\I18n\Translator\Loader; |
31 | |
32 | use Laminas\I18n\Exception\InvalidArgumentException; |
33 | use Laminas\I18n\Exception\RuntimeException; |
34 | use Laminas\I18n\Translator\Loader\FileLoaderInterface; |
35 | use Laminas\I18n\Translator\TextDomain; |
36 | |
37 | use function count; |
38 | use function dirname; |
39 | use function in_array; |
40 | |
41 | /** |
42 | * Handles the language loading and language file parsing |
43 | * |
44 | * @category VuFind |
45 | * @package Translator |
46 | * @author Demian Katz <demian.katz@villanova.edu> |
47 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
48 | * @link https://vufind.org Main Site |
49 | */ |
50 | class ExtendedIni implements FileLoaderInterface |
51 | { |
52 | /** |
53 | * List of directories to search for language files. |
54 | * |
55 | * @var array |
56 | */ |
57 | protected $pathStack; |
58 | |
59 | /** |
60 | * Fallback locales to use for language strings missing from selected file. |
61 | * |
62 | * @var string[] |
63 | */ |
64 | protected $fallbackLocales; |
65 | |
66 | /** |
67 | * List of files loaded during the current run -- avoids infinite loops and |
68 | * duplicate loading. |
69 | * |
70 | * @var array |
71 | */ |
72 | protected $loadedFiles = []; |
73 | |
74 | /** |
75 | * Helper for reading .ini files from disk. |
76 | * |
77 | * @var ExtendedIniReader |
78 | */ |
79 | protected $reader; |
80 | |
81 | /** |
82 | * Is aliasing enabled? |
83 | * |
84 | * @var bool |
85 | */ |
86 | protected $useAliases = true; |
87 | |
88 | /** |
89 | * Map of translation aliases. |
90 | * |
91 | * @var array |
92 | */ |
93 | protected $aliases = []; |
94 | |
95 | /** |
96 | * List of loaded alias configuration files. |
97 | * |
98 | * @var array |
99 | */ |
100 | protected $loadedAliasFiles = []; |
101 | |
102 | /** |
103 | * Loaded TextDomains used for resolving aliases. |
104 | * |
105 | * @var array |
106 | */ |
107 | protected $aliasDomains = []; |
108 | |
109 | /** |
110 | * Constructor |
111 | * |
112 | * @param array $pathStack List of directories to search for |
113 | * language files. |
114 | * @param string|string[] $fallbackLocales Fallback locale(s) to use for |
115 | * language strings missing from selected file. |
116 | * @param ExtendedIniReader $reader Helper for reading .ini files from |
117 | * disk. |
118 | */ |
119 | public function __construct( |
120 | $pathStack = [], |
121 | $fallbackLocales = null, |
122 | ExtendedIniReader $reader = null |
123 | ) { |
124 | $this->pathStack = $pathStack; |
125 | $this->fallbackLocales = $fallbackLocales ? (array)$fallbackLocales : []; |
126 | $this->reader = $reader ?? new ExtendedIniReader(); |
127 | } |
128 | |
129 | /** |
130 | * Add additional directories to the path stack. |
131 | * |
132 | * @param array|string $pathStack Path stack addition(s). |
133 | * |
134 | * @return void |
135 | */ |
136 | public function addToPathStack($pathStack) |
137 | { |
138 | $this->pathStack = array_merge($this->pathStack, (array)$pathStack); |
139 | } |
140 | |
141 | /** |
142 | * Load method defined by FileLoaderInterface. |
143 | * |
144 | * @param string $locale Locale to read from language file |
145 | * @param string $filename Relative base path for language file (used for |
146 | * loading text domains; optional) |
147 | * |
148 | * @return TextDomain |
149 | * @throws InvalidArgumentException |
150 | */ |
151 | public function load($locale, $filename) |
152 | { |
153 | if ($locale == 'debug') { |
154 | return null; |
155 | } |
156 | |
157 | // Reset loaded aliases: |
158 | $this->resetAliases(); |
159 | |
160 | // Reset the loaded files list: |
161 | $this->resetLoadedFiles(); |
162 | |
163 | // Identify the current TextDomain name: |
164 | $currentDomain = empty($filename) ? 'default' : $filename; |
165 | |
166 | // Load base data: |
167 | $data = $this->loadLanguageLocale($locale, $filename, $this->useAliases); |
168 | |
169 | // Set up a reference to the current domain for use in alias processing: |
170 | $this->aliasDomains[$currentDomain] = $data; |
171 | |
172 | // Apply aliases: |
173 | if ($this->useAliases) { |
174 | $this->applyAliases($data, $locale, $currentDomain); |
175 | } |
176 | |
177 | // Load fallback data, if any: |
178 | foreach ($this->fallbackLocales as $fallbackLocale) { |
179 | $newData = $this->loadLanguageLocale($fallbackLocale, $filename); |
180 | $newData->merge($data); |
181 | $data = $newData; |
182 | } |
183 | |
184 | return $data; |
185 | } |
186 | |
187 | /** |
188 | * Get the language file name for a language and domain |
189 | * |
190 | * @param string $locale Locale name |
191 | * @param string $domain Text domain (if any) |
192 | * |
193 | * @return string |
194 | */ |
195 | public function getLanguageFilename($locale, $domain) |
196 | { |
197 | return empty($domain) |
198 | ? $locale . '.ini' |
199 | : $domain . '/' . $locale . '.ini'; |
200 | } |
201 | |
202 | /** |
203 | * Reset the loaded file list. |
204 | * |
205 | * @return void |
206 | */ |
207 | protected function resetLoadedFiles() |
208 | { |
209 | $this->loadedFiles = []; |
210 | } |
211 | |
212 | /** |
213 | * Check if a file has already been loaded; mark it loaded if it is not already. |
214 | * |
215 | * @param string $filename Name of file to check and mark as loaded. |
216 | * |
217 | * @return bool True if loaded, false if new. |
218 | */ |
219 | protected function checkAndMarkLoadedFile($filename) |
220 | { |
221 | if (isset($this->loadedFiles[$filename])) { |
222 | return true; |
223 | } |
224 | $this->loadedFiles[$filename] = true; |
225 | return false; |
226 | } |
227 | |
228 | /** |
229 | * Load the language file for a given locale and domain. |
230 | * |
231 | * @param string $locale Locale name |
232 | * @param string $domain Text domain (if any) |
233 | * @param bool $processAliases Should we process alias data? |
234 | * |
235 | * @return TextDomain |
236 | */ |
237 | protected function loadLanguageLocale($locale, $domain, $processAliases = false) |
238 | { |
239 | $filename = $this->getLanguageFilename($locale, $domain); |
240 | // Load the language file, and throw a fatal exception if it's missing |
241 | // and we're not dealing with text domains. A missing base file is an |
242 | // unexpected, fatal error; a missing domain-specific file is more likely |
243 | // due to the possibility of incomplete translations. |
244 | return $this->loadLanguageFile( |
245 | $filename, |
246 | empty($domain), |
247 | $processAliases ? (empty($domain) ? 'default' : $domain) : null |
248 | ); |
249 | } |
250 | |
251 | /** |
252 | * Resolve a single alias (or return null if it cannot be resolved) |
253 | * |
254 | * @param array $alias The [domain, key] or [key] alias array |
255 | * @param string $defaultDomain The domain to use if $alias does not specify one |
256 | * @param string $locale The locale currently being loaded |
257 | * @param array $breadcrumbs Previously-resolved aliases (to prevent infinite loops) |
258 | * |
259 | * @return ?string |
260 | * @throws \Exception |
261 | */ |
262 | protected function resolveAlias( |
263 | array $alias, |
264 | string $defaultDomain, |
265 | string $locale, |
266 | array $breadcrumbs = [] |
267 | ): ?string { |
268 | // If the current alias target does not include a TextDomain part, assume it refers |
269 | // to the current active TextDomain: |
270 | if (count($alias) < 2) { |
271 | array_unshift($alias, $defaultDomain); |
272 | } |
273 | [$domain, $key] = $alias; |
274 | |
275 | // If the alias references another TextDomain, we need to load that now. |
276 | if (!isset($this->aliasDomains[$domain])) { |
277 | $this->aliasDomains[$domain] = $this->loadLanguageLocale($locale, $domain, true); |
278 | } |
279 | if ($this->aliasDomains[$domain]->offsetExists($key)) { |
280 | return $this->aliasDomains[$domain]->offsetGet($key); |
281 | } elseif (isset($this->aliases[$domain][$key])) { |
282 | // Circular alias infinite loop prevention: |
283 | $breadcrumbKey = "$domain::$key"; |
284 | if (in_array($breadcrumbKey, $breadcrumbs)) { |
285 | throw new \Exception("Circular alias detected resolving $breadcrumbKey"); |
286 | } |
287 | $breadcrumbs[] = $breadcrumbKey; |
288 | return $this->resolveAlias($this->aliases[$domain][$key], $domain, $locale, $breadcrumbs); |
289 | } |
290 | return null; |
291 | } |
292 | |
293 | /** |
294 | * Apply loaded aliases to the provided TextDomain. |
295 | * |
296 | * @param TextDomain $data Text domain to update |
297 | * @param string $currentLocale The locale currently being loaded |
298 | * @param string $currentDomain The name of the text domain currently being loaded |
299 | * |
300 | * @return void |
301 | */ |
302 | protected function applyAliases(TextDomain $data, string $currentLocale, string $currentDomain): void |
303 | { |
304 | foreach ($this->aliases[$currentDomain] ?? [] as $alias => $target) { |
305 | // Do not overwrite existing values with alias, and do not create aliases |
306 | // when target values are missing. |
307 | if ( |
308 | !$data->offsetExists($alias) |
309 | && $aliasValue = $this->resolveAlias($target, $currentDomain, $currentLocale) |
310 | ) { |
311 | $data->offsetSet($alias, $aliasValue); |
312 | } |
313 | } |
314 | } |
315 | |
316 | /** |
317 | * Reset all collected alias data. |
318 | * |
319 | * @return void |
320 | */ |
321 | protected function resetAliases(): void |
322 | { |
323 | $this->aliases = $this->loadedAliasFiles = []; |
324 | } |
325 | |
326 | /** |
327 | * Disable aliasing functionality. |
328 | * |
329 | * @return void |
330 | */ |
331 | public function disableAliases(): void |
332 | { |
333 | $this->useAliases = false; |
334 | } |
335 | |
336 | /** |
337 | * Enable aliasing functionality. |
338 | * |
339 | * @return void |
340 | */ |
341 | public function enableAliases(): void |
342 | { |
343 | $this->useAliases = true; |
344 | } |
345 | |
346 | /** |
347 | * Expand an alias string into an array (either [textdomain, key] or just [key]). |
348 | * |
349 | * @param string $alias String to parse |
350 | * |
351 | * @return string[] |
352 | */ |
353 | protected function normalizeAlias(string $alias): array |
354 | { |
355 | return explode('::', $alias); |
356 | } |
357 | |
358 | /** |
359 | * Load an alias configuration (if not already loaded) and mark it loaded. |
360 | * |
361 | * @param string $aliasDomain Domain for which aliases are being loaded |
362 | * @param string $filename Filename to load |
363 | * |
364 | * @return void |
365 | */ |
366 | protected function markAndLoadAliases(string $aliasDomain, string $filename): void |
367 | { |
368 | $loadedFiles = $this->loadedAliasFiles[$aliasDomain] ?? []; |
369 | if (!in_array($filename, $loadedFiles)) { |
370 | $this->loadedAliasFiles[$aliasDomain] = array_merge($loadedFiles, [$filename]); |
371 | if (file_exists($filename)) { |
372 | // Parse and normalize the alias configuration: |
373 | $newAliases = array_map( |
374 | [$this, 'normalizeAlias'], |
375 | $this->reader->getTextDomain($filename)->getArrayCopy() |
376 | ); |
377 | // Merge with pre-existing aliases: |
378 | $this->aliases[$aliasDomain] = array_merge($this->aliases[$aliasDomain] ?? [], $newAliases); |
379 | } |
380 | } |
381 | } |
382 | |
383 | /** |
384 | * Search the path stack for language files and merge them together. |
385 | * |
386 | * @param string $filename Name of file to search path stack for. |
387 | * @param bool $failOnError If true, throw an exception when file not found. |
388 | * @param ?string $aliasDomain Name of TextDomain for which we should process aliases |
389 | * (or null to skip alias processing) |
390 | * |
391 | * @throws RuntimeException |
392 | * @throws InvalidArgumentException |
393 | * @return TextDomain |
394 | */ |
395 | protected function loadLanguageFile($filename, $failOnError, ?string $aliasDomain) |
396 | { |
397 | // Don't load a file that has already been loaded: |
398 | if ($this->checkAndMarkLoadedFile($filename)) { |
399 | return new TextDomain(); |
400 | } |
401 | |
402 | $data = false; |
403 | foreach ($this->pathStack as $path) { |
404 | $fileOnPath = $path . '/' . $filename; |
405 | if (file_exists($fileOnPath)) { |
406 | // Load current file with parent data, if necessary: |
407 | $current = $this->loadParentData( |
408 | $this->reader->getTextDomain($fileOnPath), |
409 | $aliasDomain |
410 | ); |
411 | if ($data === false) { |
412 | $data = $current; |
413 | } else { |
414 | $data->merge($current); |
415 | } |
416 | } |
417 | if ($aliasDomain) { |
418 | $this->markAndLoadAliases($aliasDomain, dirname($fileOnPath) . '/aliases.ini'); |
419 | } |
420 | } |
421 | if ($data === false) { |
422 | // Should we throw an exception? If not, return an empty result: |
423 | if ($failOnError) { |
424 | throw new InvalidArgumentException( |
425 | "Ini file '{$filename}' not found" |
426 | ); |
427 | } |
428 | return new TextDomain(); |
429 | } |
430 | |
431 | return $data; |
432 | } |
433 | |
434 | /** |
435 | * Support method for loadLanguageFile: retrieve parent data. |
436 | * |
437 | * @param TextDomain $data TextDomain to populate with parent information. |
438 | * @param ?string $aliasDomain Name of TextDomain for which we should process aliases |
439 | * (or null to skip alias processing) |
440 | * |
441 | * @return TextDomain |
442 | */ |
443 | protected function loadParentData($data, ?string $aliasDomain) |
444 | { |
445 | if (!isset($data['@parent_ini'])) { |
446 | return $data; |
447 | } |
448 | $parent = $this->loadLanguageFile($data['@parent_ini'], true, $aliasDomain); |
449 | $parent->merge($data); |
450 | return $parent; |
451 | } |
452 | } |