[BUGFIX] Exception in EXT:form due to invalid array lookup
[Packages/TYPO3.CMS.git] / typo3 / sysext / form / Classes / Service / TranslationService.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Form\Service;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use TYPO3\CMS\Core\Localization\Locales;
19 use TYPO3\CMS\Core\Localization\LocalizationFactory;
20 use TYPO3\CMS\Core\SingletonInterface;
21 use TYPO3\CMS\Core\Utility\ArrayUtility;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23 use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
24 use TYPO3\CMS\Extbase\Object\ObjectManager;
25 use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
26 use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
27 use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
28 use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
29 use TYPO3\CMS\Lang\LanguageService;
30
31 /**
32 * Advanced translations
33 * This class is subjected to change.
34 * **Do NOT subclass**
35 *
36 * Scope: frontend / backend
37 * @internal
38 */
39 class TranslationService implements SingletonInterface
40 {
41
42 /**
43 * Local Language content
44 *
45 * @var array
46 */
47 protected $LOCAL_LANG = [];
48
49 /**
50 * Contains those LL keys, which have been set to (empty) in TypoScript.
51 * This is necessary, as we cannot distinguish between a nonexisting
52 * translation and a label that has been cleared by TS.
53 * In both cases ['key'][0]['target'] is "".
54 *
55 * @var array
56 */
57 protected $LOCAL_LANG_UNSET = [];
58
59 /**
60 * Key of the language to use
61 *
62 * @var string
63 */
64 protected $languageKey = null;
65
66 /**
67 * Pointer to alternative fall-back language to use
68 *
69 * @var array
70 */
71 protected $alternativeLanguageKeys = [];
72
73 /**
74 * @var \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface
75 */
76 protected $configurationManager = null;
77
78 /**
79 * Return TranslationService as singleton
80 *
81 * @return TranslationService
82 * @internal
83 */
84 public static function getInstance()
85 {
86 return GeneralUtility::makeInstance(ObjectManager::class)->get(self::class);
87 }
88
89 /**
90 * Returns the localized label of the LOCAL_LANG key, $key.
91 *
92 * @param mixed $key The key from the LOCAL_LANG array for which to return the value.
93 * @param array $arguments the arguments of the extension, being passed over to vsprintf
94 * @param string $locallangPathAndFilename
95 * @param string $language
96 * @param mixed $defaultValue
97 * @return mixed The value from LOCAL_LANG or $defaultValue if no translation was found.
98 * @internal
99 */
100 public function translate(
101 $key,
102 array $arguments = null,
103 string $locallangPathAndFilename = null,
104 string $language = null,
105 $defaultValue = ''
106 ) {
107 $value = null;
108 $key = (string)$key;
109
110 if ($locallangPathAndFilename) {
111 $key = $locallangPathAndFilename . ':' . $key;
112 }
113
114 $keyParts = explode(':', $key);
115 if (GeneralUtility::isFirstPartOfStr($key, 'LLL:')) {
116 $locallangPathAndFilename = $keyParts[1] . ':' . $keyParts[2];
117 $key = $keyParts[3];
118 } elseif (GeneralUtility::isFirstPartOfStr($key, 'EXT:')) {
119 $locallangPathAndFilename = $keyParts[0] . ':' . $keyParts[1];
120 $key = $keyParts[2];
121 } else {
122 if (count($keyParts) === 2) {
123 $locallangPathAndFilename = $keyParts[0];
124 $key = $keyParts[1];
125 }
126 }
127
128 if ($language) {
129 $this->languageKey = $language;
130 }
131
132 $this->initializeLocalization($locallangPathAndFilename);
133
134 // The "from" charset of csConv() is only set for strings from TypoScript via _LOCAL_LANG
135 if (!empty($this->LOCAL_LANG[$this->languageKey][$key][0]['target'])
136 || isset($this->LOCAL_LANG_UNSET[$this->languageKey][$key])
137 ) {
138 // Local language translation for key exists
139 $value = $this->LOCAL_LANG[$this->languageKey][$key][0]['target'];
140 } elseif (!empty($this->alternativeLanguageKeys)) {
141 $languages = array_reverse($this->alternativeLanguageKeys);
142 foreach ($languages as $language) {
143 if (!empty($this->LOCAL_LANG[$language][$key][0]['target'])
144 || isset($this->LOCAL_LANG_UNSET[$language][$key])
145 ) {
146 // Alternative language translation for key exists
147 $value = $this->LOCAL_LANG[$language][$key][0]['target'];
148 break;
149 }
150 }
151 }
152
153 if ($value === null && (!empty($this->LOCAL_LANG['default'][$key][0]['target'])
154 || isset($this->LOCAL_LANG_UNSET['default'][$key]))
155 ) {
156 // Default language translation for key exists
157 // No charset conversion because default is English and thereby ASCII
158 $value = $this->LOCAL_LANG['default'][$key][0]['target'];
159 }
160
161 if (is_array($arguments) && $value !== null) {
162 $value = vsprintf($value, $arguments);
163 } else {
164 if (empty($value)) {
165 $value = $defaultValue;
166 }
167 }
168
169 return $value;
170 }
171
172 /**
173 * Recursively translate values.
174 *
175 * @param array $array
176 * @param string $translationFile
177 * @return array the modified array
178 * @internal
179 */
180 public function translateValuesRecursive(array $array, string $translationFile = null): array
181 {
182 $result = $array;
183 foreach ($result as $key => $value) {
184 if (is_array($value)) {
185 $result[$key] = $this->translateValuesRecursive($value, $translationFile);
186 } else {
187 $result[$key] = $this->translate($value, null, $translationFile, null, $value);
188 }
189 }
190 return $result;
191 }
192
193 /**
194 * @param FormRuntime $formRuntime
195 * @param string $finisherIdentifier
196 * @param string $optionKey
197 * @param string $optionValue
198 * @param array $renderingOptions
199 * @return string
200 * @throws \InvalidArgumentException
201 * @api
202 */
203 public function translateFinisherOption(
204 FormRuntime $formRuntime,
205 string $finisherIdentifier,
206 string $optionKey,
207 string $optionValue,
208 array $renderingOptions = []
209 ): string {
210 if (empty($finisherIdentifier)) {
211 throw new \InvalidArgumentException('The argument "finisherIdentifier" is empty', 1476216059);
212 }
213 if (empty($optionKey)) {
214 throw new \InvalidArgumentException('The argument "optionKey" is empty', 1476216060);
215 }
216
217 $finisherIdentifier = preg_replace('/Finisher$/', '', $finisherIdentifier);
218 $translationFile = $renderingOptions['translationFile'];
219 if (isset($renderingOptions['translatePropertyValueIfEmpty'])) {
220 $translatePropertyValueIfEmpty = (bool)$renderingOptions['translatePropertyValueIfEmpty'];
221 } else {
222 $translatePropertyValueIfEmpty = true;
223 }
224
225 if (empty($optionValue) && !$translatePropertyValueIfEmpty) {
226 return $optionValue;
227 }
228
229 $language = null;
230 if (isset($renderingOptions['language'])) {
231 $language = $renderingOptions['language'];
232 }
233
234 $translationKeyChain = [
235 sprintf('%s:%s.finisher.%s.%s', $translationFile, $formRuntime->getIdentifier(), $finisherIdentifier, $optionKey),
236 sprintf('%s:finisher.%s.%s', $translationFile, $finisherIdentifier, $optionKey)
237 ];
238 $translatedValue = $this->processTranslationChain($translationKeyChain, $language);
239 $translatedValue = (empty($translatedValue)) ? $optionValue : $translatedValue;
240
241 return $translatedValue;
242 }
243
244 /**
245 * @param RootRenderableInterface $element
246 * @param string $property
247 * @param FormRuntime $formRuntime
248 * @return string|array
249 * @throws \InvalidArgumentException
250 * @internal
251 */
252 public function translateFormElementValue(
253 RootRenderableInterface $element,
254 string $property,
255 FormRuntime $formRuntime
256 ) {
257 if (empty($property)) {
258 throw new \InvalidArgumentException('The argument "property" is empty', 1476216007);
259 }
260
261 $propertyType = 'properties';
262 $renderingOptions = $element->getRenderingOptions();
263
264 if ($property === 'label') {
265 $defaultValue = $element->getLabel();
266 } else {
267 if ($element instanceof FormElementInterface) {
268 try {
269 $defaultValue = ArrayUtility::getValueByPath($element->getProperties(), $property, '.');
270 } catch (\RuntimeException $exception) {
271 $defaultValue = null;
272 }
273 } else {
274 $propertyType = 'renderingOptions';
275 try {
276 $defaultValue = ArrayUtility::getValueByPath($renderingOptions, $property, '.');
277 } catch (\RuntimeException $exception) {
278 $defaultValue = null;
279 }
280 }
281 }
282
283 if (isset($renderingOptions['translation']['translatePropertyValueIfEmpty'])) {
284 $translatePropertyValueIfEmpty = $renderingOptions['translation']['translatePropertyValueIfEmpty'];
285 } else {
286 $translatePropertyValueIfEmpty = true;
287 }
288
289 if (empty($defaultValue) && !$translatePropertyValueIfEmpty) {
290 return $defaultValue;
291 }
292
293 $defaultValue = empty($defaultValue) ? '' : $defaultValue;
294 $translationFile = $renderingOptions['translation']['translationFile'];
295
296 $language = null;
297 if (isset($renderingOptions['translation']['language'])) {
298 $language = $renderingOptions['translation']['language'];
299 }
300 $translationKeyChain = [];
301 if ($property === 'options' && is_array($defaultValue)) {
302 foreach ($defaultValue as $optionValue => &$optionLabel) {
303 $translationKeyChain = [
304 sprintf('%s:%s.element.%s.%s.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $propertyType, $property, $optionValue),
305 sprintf('%s:element.%s.%s.%s.%s', $translationFile, $element->getIdentifier(), $propertyType, $property, $optionValue)
306 ];
307 $translatedValue = $this->processTranslationChain($translationKeyChain, $language);
308 $optionLabel = (empty($translatedValue)) ? $optionLabel : $translatedValue;
309 }
310 $translatedValue = $defaultValue;
311 } else {
312 $translationKeyChain = [
313 sprintf('%s:%s.element.%s.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $propertyType, $property),
314 sprintf('%s:element.%s.%s.%s', $translationFile, $element->getIdentifier(), $propertyType, $property),
315 sprintf('%s:element.%s.%s.%s', $translationFile, $element->getType(), $propertyType, $property),
316 ];
317 $translatedValue = $this->processTranslationChain($translationKeyChain, $language);
318 $translatedValue = (empty($translatedValue)) ? $defaultValue : $translatedValue;
319 }
320
321 return $translatedValue;
322 }
323
324 /**
325 * @param string $languageKey
326 * @internal
327 */
328 public function setLanguage(string $languageKey)
329 {
330 $this->languageKey = $languageKey;
331 }
332
333 /**
334 * @return string
335 * @internal
336 */
337 public function getLanguage(): string
338 {
339 return $this->languageKey;
340 }
341
342 /**
343 * @param array $translationKeyChain
344 * @param string $language
345 * @return string|null
346 */
347 protected function processTranslationChain(array $translationKeyChain, string $language = null)
348 {
349 $translatedValue = null;
350 foreach ($translationKeyChain as $translationKey) {
351 $translatedValue = $this->translate($translationKey, null, null, $language);
352 if (!empty($translatedValue)) {
353 break;
354 }
355 }
356 return $translatedValue;
357 }
358
359 /**
360 * @param string $locallangPathAndFilename
361 * @return void
362 */
363 protected function initializeLocalization(string $locallangPathAndFilename)
364 {
365 if (empty($this->languageKey)) {
366 $this->setLanguageKeys();
367 }
368
369 if (!empty($locallangPathAndFilename)) {
370 /** @var $languageFactory LocalizationFactory */
371 $languageFactory = GeneralUtility::makeInstance(LocalizationFactory::class);
372
373 $this->LOCAL_LANG = $languageFactory->getParsedData($locallangPathAndFilename, $this->languageKey, 'utf-8');
374
375 foreach ($this->alternativeLanguageKeys as $language) {
376 $tempLL = $languageFactory->getParsedData($locallangPathAndFilename, $language, 'utf-8');
377 if ($this->languageKey !== 'default' && isset($tempLL[$language])) {
378 $this->LOCAL_LANG[$language] = $tempLL[$language];
379 }
380 }
381 }
382 $this->loadTypoScriptLabels();
383 }
384
385 /**
386 * Sets the currently active language/language_alt keys.
387 * Default values are "default" for language key and "" for language_alt key.
388 *
389 * @return void
390 */
391 protected function setLanguageKeys()
392 {
393 $this->languageKey = 'default';
394
395 $this->alternativeLanguageKeys = [];
396 if (TYPO3_MODE === 'FE') {
397 if (isset($this->getTypoScriptFrontendController()->config['config']['language'])) {
398 $this->languageKey = $this->getTypoScriptFrontendController()->config['config']['language'];
399 if (isset($this->getTypoScriptFrontendController()->config['config']['language_alt'])) {
400 $this->alternativeLanguageKeys[] = $this->getTypoScriptFrontendController()->config['config']['language_alt'];
401 } else {
402 /** @var $locales \TYPO3\CMS\Core\Localization\Locales */
403 $locales = GeneralUtility::makeInstance(Locales::class);
404 if (in_array($this->languageKey, $locales->getLocales(), true)) {
405 foreach ($locales->getLocaleDependencies($this->languageKey) as $language) {
406 $this->alternativeLanguageKeys[] = $language;
407 }
408 }
409 }
410 }
411 } elseif (!empty($GLOBALS['BE_USER']->uc['lang'])) {
412 $this->languageKey = $GLOBALS['BE_USER']->uc['lang'];
413 } elseif (!empty($this->getLanguageService()->lang)) {
414 $this->languageKey = $this->getLanguageService()->lang;
415 }
416 }
417
418 /**
419 * Overwrites labels that are set via TypoScript.
420 * TS locallang labels have to be configured like:
421 * plugin.tx_form._LOCAL_LANG.languageKey.key = value
422 *
423 * @return void
424 */
425 protected function loadTypoScriptLabels()
426 {
427 $frameworkConfiguration = $this->getConfigurationManager()
428 ->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK, 'form');
429
430 if (!is_array($frameworkConfiguration['_LOCAL_LANG'])) {
431 return;
432 }
433 $this->LOCAL_LANG_UNSET = [];
434 foreach ($frameworkConfiguration['_LOCAL_LANG'] as $languageKey => $labels) {
435 if (!(is_array($labels) && isset($this->LOCAL_LANG[$languageKey]))) {
436 continue;
437 }
438 foreach ($labels as $labelKey => $labelValue) {
439 if (is_string($labelValue)) {
440 $this->LOCAL_LANG[$languageKey][$labelKey][0]['target'] = $labelValue;
441 if ($labelValue === '') {
442 $this->LOCAL_LANG_UNSET[$languageKey][$labelKey] = '';
443 }
444 } elseif (is_array($labelValue)) {
445 $labelValue = $this->flattenTypoScriptLabelArray($labelValue, $labelKey);
446 foreach ($labelValue as $key => $value) {
447 $this->LOCAL_LANG[$languageKey][$key][0]['target'] = $value;
448 if ($value === '') {
449 $this->LOCAL_LANG_UNSET[$languageKey][$key] = '';
450 }
451 }
452 }
453 }
454 }
455 }
456
457 /**
458 * Flatten TypoScript label array; converting a hierarchical array into a flat
459 * array with the keys separated by dots.
460 *
461 * Example Input: array('k1' => array('subkey1' => 'val1'))
462 * Example Output: array('k1.subkey1' => 'val1')
463 *
464 * @param array $labelValues Hierarchical array of labels
465 * @param string $parentKey the name of the parent key in the recursion; is only needed for recursion.
466 * @return array flattened array of labels.
467 */
468 protected function flattenTypoScriptLabelArray(array $labelValues, string $parentKey = ''): array
469 {
470 $result = [];
471 foreach ($labelValues as $key => $labelValue) {
472 if (!empty($parentKey)) {
473 $key = $parentKey . '.' . $key;
474 }
475 if (is_array($labelValue)) {
476 $labelValue = $this->flattenTypoScriptLabelArray($labelValue, $key);
477 $result = array_merge($result, $labelValue);
478 } else {
479 $result[$key] = $labelValue;
480 }
481 }
482 return $result;
483 }
484
485 /**
486 * Returns instance of the configuration manager
487 *
488 * @return ConfigurationManagerInterface
489 */
490 protected function getConfigurationManager(): ConfigurationManagerInterface
491 {
492 if ($this->configurationManager !== null) {
493 return $this->configurationManager;
494 }
495
496 $this->configurationManager = GeneralUtility::makeInstance(ObjectManager::class)
497 ->get(ConfigurationManagerInterface::class);
498 return $this->configurationManager;
499 }
500
501 /**
502 * @return LanguageService
503 */
504 protected function getLanguageService(): LanguageService
505 {
506 return $GLOBALS['LANG'];
507 }
508
509 /**
510 * @return TypoScriptFrontendController
511 */
512 protected function getTypoScriptFrontendController(): TypoScriptFrontendController
513 {
514 return $GLOBALS['TSFE'];
515 }
516 }