[FEATURE] EXT:form - support translation arguments
[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\LanguageService;
19 use TYPO3\CMS\Core\Localization\Locales;
20 use TYPO3\CMS\Core\Localization\LocalizationFactory;
21 use TYPO3\CMS\Core\SingletonInterface;
22 use TYPO3\CMS\Core\Utility\ArrayUtility;
23 use TYPO3\CMS\Core\Utility\GeneralUtility;
24 use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
25 use TYPO3\CMS\Extbase\Object\ObjectManager;
26 use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
27 use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
28 use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
29 use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
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) && !empty($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 array|string|null $translationFile
177 * @return array the modified array
178 * @internal
179 */
180 public function translateValuesRecursive(array $array, $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 $translationFiles = null;
188 if (is_string($translationFile)) {
189 $translationFiles = [$translationFile];
190 } elseif (is_array($translationFile)) {
191 $translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFile);
192 }
193
194 if ($translationFiles) {
195 foreach ($translationFiles as $_translationFile) {
196 $translatedValue = $this->translate($value, null, $_translationFile, null);
197 if (!empty($translatedValue)) {
198 $result[$key] = $translatedValue;
199 break;
200 }
201 }
202 } else {
203 $result[$key] = $this->translate($value, null, $translationFile, null, $value);
204 }
205 }
206 }
207 return $result;
208 }
209
210 /**
211 * @param FormRuntime $formRuntime
212 * @param string $finisherIdentifier
213 * @param string $optionKey
214 * @param string $optionValue
215 * @param array $renderingOptions
216 * @return string
217 * @throws \InvalidArgumentException
218 * @api
219 */
220 public function translateFinisherOption(
221 FormRuntime $formRuntime,
222 string $finisherIdentifier,
223 string $optionKey,
224 string $optionValue,
225 array $renderingOptions = []
226 ): string {
227 if (empty($finisherIdentifier)) {
228 throw new \InvalidArgumentException('The argument "finisherIdentifier" is empty', 1476216059);
229 }
230 if (empty($optionKey)) {
231 throw new \InvalidArgumentException('The argument "optionKey" is empty', 1476216060);
232 }
233
234 $finisherIdentifier = preg_replace('/Finisher$/', '', $finisherIdentifier);
235 $translationFile = $renderingOptions['translationFile'];
236 if (empty($translationFile)) {
237 $translationFile = $formRuntime->getRenderingOptions()['translation']['translationFile'];
238 }
239
240 if (is_string($translationFile)) {
241 $translationFiles = [$translationFile];
242 } else {
243 $translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFile);
244 }
245
246 if (isset($renderingOptions['translatePropertyValueIfEmpty'])) {
247 $translatePropertyValueIfEmpty = (bool)$renderingOptions['translatePropertyValueIfEmpty'];
248 } else {
249 $translatePropertyValueIfEmpty = true;
250 }
251
252 if (empty($optionValue) && !$translatePropertyValueIfEmpty) {
253 return $optionValue;
254 }
255
256 $language = null;
257 if (isset($renderingOptions['language'])) {
258 $language = $renderingOptions['language'];
259 }
260
261 try {
262 $arguments = ArrayUtility::getValueByPath($renderingOptions['arguments'] ?? [], $optionKey, '.');
263 } catch (\RuntimeException $e) {
264 $arguments = [];
265 }
266
267 $translationKeyChain = [];
268 foreach ($translationFiles as $translationFile) {
269 $translationKeyChain[] = sprintf('%s:%s.finisher.%s.%s', $translationFile, $formRuntime->getIdentifier(), $finisherIdentifier, $optionKey);
270 $translationKeyChain[] = sprintf('%s:finisher.%s.%s', $translationFile, $finisherIdentifier, $optionKey);
271 }
272
273 $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments);
274 $translatedValue = (empty($translatedValue)) ? $optionValue : $translatedValue;
275
276 return $translatedValue;
277 }
278
279 /**
280 * @param RootRenderableInterface $element
281 * @param array $propertyParts
282 * @param FormRuntime $formRuntime
283 * @return string|array
284 * @throws \InvalidArgumentException
285 * @internal
286 */
287 public function translateFormElementValue(
288 RootRenderableInterface $element,
289 array $propertyParts,
290 FormRuntime $formRuntime
291 ) {
292 if (empty($propertyParts)) {
293 throw new \InvalidArgumentException('The argument "propertyParts" is empty', 1476216007);
294 }
295
296 $propertyType = 'properties';
297 $property = implode('.', $propertyParts);
298 $renderingOptions = $element->getRenderingOptions();
299
300 if ($property === 'label') {
301 $defaultValue = $element->getLabel();
302 } else {
303 if ($element instanceof FormElementInterface) {
304 try {
305 $defaultValue = ArrayUtility::getValueByPath($element->getProperties(), $propertyParts, '.');
306 } catch (\RuntimeException $exception) {
307 $defaultValue = null;
308 }
309 } else {
310 $propertyType = 'renderingOptions';
311 try {
312 $defaultValue = ArrayUtility::getValueByPath($renderingOptions, $propertyParts, '.');
313 } catch (\RuntimeException $exception) {
314 $defaultValue = null;
315 }
316 }
317 }
318
319 if (isset($renderingOptions['translation']['translatePropertyValueIfEmpty'])) {
320 $translatePropertyValueIfEmpty = $renderingOptions['translation']['translatePropertyValueIfEmpty'];
321 } else {
322 $translatePropertyValueIfEmpty = true;
323 }
324
325 if (empty($defaultValue) && !$translatePropertyValueIfEmpty) {
326 return $defaultValue;
327 }
328
329 $defaultValue = empty($defaultValue) ? '' : $defaultValue;
330 $translationFile = $renderingOptions['translation']['translationFile'];
331 if (empty($translationFile)) {
332 $translationFile = $formRuntime->getRenderingOptions()['translation']['translationFile'];
333 }
334
335 if (is_string($translationFile)) {
336 $translationFiles = [$translationFile];
337 } else {
338 $translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFile);
339 }
340
341 $language = null;
342 if (isset($renderingOptions['translation']['language'])) {
343 $language = $renderingOptions['translation']['language'];
344 }
345
346 try {
347 $arguments = ArrayUtility::getValueByPath($renderingOptions['translation']['arguments'] ?? [], $propertyParts, '.');
348 } catch (\RuntimeException $e) {
349 $arguments = [];
350 }
351
352 if ($property === 'options' && is_array($defaultValue)) {
353 foreach ($defaultValue as $optionValue => &$optionLabel) {
354 $translationKeyChain = [];
355 foreach ($translationFiles as $translationFile) {
356 $translationKeyChain[] = sprintf('%s:%s.element.%s.%s.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $propertyType, $property, $optionValue);
357 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s.%s', $translationFile, $element->getIdentifier(), $propertyType, $property, $optionValue);
358 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s.%s', $translationFile, $element->getType(), $propertyType, $property, $optionValue);
359 }
360
361 $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments);
362 $optionLabel = (empty($translatedValue)) ? $optionLabel : $translatedValue;
363 }
364 $translatedValue = $defaultValue;
365 } elseif ($property === 'fluidAdditionalAttributes' && is_array($defaultValue)) {
366 foreach ($defaultValue as $propertyName => &$propertyValue) {
367 $translationKeyChain = [];
368 foreach ($translationFiles as $translationFile) {
369 $translationKeyChain[] = sprintf('%s:%s.element.%s.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $propertyType, $propertyName);
370 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s', $translationFile, $element->getIdentifier(), $propertyType, $propertyName);
371 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s', $translationFile, $element->getType(), $propertyType, $propertyName);
372 }
373
374 $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments);
375 $propertyValue = (empty($translatedValue)) ? $propertyValue : $translatedValue;
376 }
377 $translatedValue = $defaultValue;
378 } else {
379 $translationKeyChain = [];
380 foreach ($translationFiles as $translationFile) {
381 $translationKeyChain[] = sprintf('%s:%s.element.%s.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $propertyType, $property);
382 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s', $translationFile, $element->getIdentifier(), $propertyType, $property);
383 $translationKeyChain[] = sprintf('%s:element.%s.%s.%s', $translationFile, $element->getType(), $propertyType, $property);
384 }
385
386 $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments);
387 $translatedValue = (empty($translatedValue)) ? $defaultValue : $translatedValue;
388 }
389
390 return $translatedValue;
391 }
392
393 /**
394 * @param RootRenderableInterface $element
395 * @param int $code
396 * @param string $defaultValue
397 * @param array $arguments
398 * @param FormRuntime $formRuntime
399 * @return string
400 * @throws \InvalidArgumentException
401 * @internal
402 */
403 public function translateFormElementError(
404 RootRenderableInterface $element,
405 int $code,
406 array $arguments,
407 string $defaultValue = '',
408 FormRuntime $formRuntime
409 ): string {
410 if (empty($code)) {
411 throw new \InvalidArgumentException('The argument "code" is empty', 1489272978);
412 }
413
414 $validationErrors = $element->getProperties()['validationErrorMessages'];
415 if (is_array($validationErrors)) {
416 foreach ($validationErrors as $validationError) {
417 if ((int)$validationError['code'] === $code) {
418 return sprintf($validationError['message'], $arguments);
419 }
420 }
421 }
422
423 $renderingOptions = $element->getRenderingOptions();
424 $translationFile = $renderingOptions['translation']['translationFile'];
425 if (empty($translationFile)) {
426 $translationFile = $formRuntime->getRenderingOptions()['translation']['translationFile'];
427 }
428
429 if (is_string($translationFile)) {
430 $translationFiles = [$translationFile];
431 } else {
432 $translationFiles = $this->sortArrayWithIntegerKeysDescending($translationFile);
433 }
434
435 $language = null;
436 if (isset($renderingOptions['language'])) {
437 $language = $renderingOptions['language'];
438 }
439
440 $translationKeyChain = [];
441 foreach ($translationFiles as $translationFile) {
442 $translationKeyChain[] = sprintf('%s:%s.validation.error.%s.%s', $translationFile, $formRuntime->getIdentifier(), $element->getIdentifier(), $code);
443 $translationKeyChain[] = sprintf('%s:%s.validation.error.%s', $translationFile, $formRuntime->getIdentifier(), $code);
444 $translationKeyChain[] = sprintf('%s:validation.error.%s.%s', $translationFile, $element->getIdentifier(), $code);
445 $translationKeyChain[] = sprintf('%s:validation.error.%s', $translationFile, $code);
446 }
447
448 $translatedValue = $this->processTranslationChain($translationKeyChain, $language, $arguments);
449 $translatedValue = (empty($translatedValue)) ? $defaultValue : $translatedValue;
450 return $translatedValue;
451 }
452
453 /**
454 * @param string $languageKey
455 * @internal
456 */
457 public function setLanguage(string $languageKey)
458 {
459 $this->languageKey = $languageKey;
460 }
461
462 /**
463 * @return string
464 * @internal
465 */
466 public function getLanguage(): string
467 {
468 return $this->languageKey;
469 }
470
471 /**
472 * @param array $translationKeyChain
473 * @param string $language
474 * @param array $arguments
475 * @return string|null
476 */
477 protected function processTranslationChain(
478 array $translationKeyChain,
479 string $language = null,
480 array $arguments = null
481 ) {
482 $translatedValue = null;
483 foreach ($translationKeyChain as $translationKey) {
484 $translatedValue = $this->translate($translationKey, $arguments, null, $language);
485 if (!empty($translatedValue)) {
486 break;
487 }
488 }
489 return $translatedValue;
490 }
491
492 /**
493 * @param string $locallangPathAndFilename
494 */
495 protected function initializeLocalization(string $locallangPathAndFilename)
496 {
497 if (empty($this->languageKey)) {
498 $this->setLanguageKeys();
499 }
500
501 if (!empty($locallangPathAndFilename)) {
502 /** @var $languageFactory LocalizationFactory */
503 $languageFactory = GeneralUtility::makeInstance(LocalizationFactory::class);
504 $this->LOCAL_LANG = $languageFactory->getParsedData($locallangPathAndFilename, $this->languageKey);
505
506 foreach ($this->alternativeLanguageKeys as $language) {
507 $tempLL = $languageFactory->getParsedData($locallangPathAndFilename, $language);
508 if ($this->languageKey !== 'default' && isset($tempLL[$language])) {
509 $this->LOCAL_LANG[$language] = $tempLL[$language];
510 }
511 }
512 }
513 $this->loadTypoScriptLabels();
514 }
515
516 /**
517 * Sets the currently active language/language_alt keys.
518 * Default values are "default" for language key and "" for language_alt key.
519 */
520 protected function setLanguageKeys()
521 {
522 $this->languageKey = 'default';
523
524 $this->alternativeLanguageKeys = [];
525 if (TYPO3_MODE === 'FE') {
526 if (isset($this->getTypoScriptFrontendController()->config['config']['language'])) {
527 $this->languageKey = $this->getTypoScriptFrontendController()->config['config']['language'];
528 if (isset($this->getTypoScriptFrontendController()->config['config']['language_alt'])) {
529 $this->alternativeLanguageKeys[] = $this->getTypoScriptFrontendController()->config['config']['language_alt'];
530 } else {
531 /** @var $locales \TYPO3\CMS\Core\Localization\Locales */
532 $locales = GeneralUtility::makeInstance(Locales::class);
533 if (in_array($this->languageKey, $locales->getLocales(), true)) {
534 foreach ($locales->getLocaleDependencies($this->languageKey) as $language) {
535 $this->alternativeLanguageKeys[] = $language;
536 }
537 }
538 }
539 }
540 } elseif (!empty($GLOBALS['BE_USER']->uc['lang'])) {
541 $this->languageKey = $GLOBALS['BE_USER']->uc['lang'];
542 } elseif (!empty($this->getLanguageService()->lang)) {
543 $this->languageKey = $this->getLanguageService()->lang;
544 }
545 }
546
547 /**
548 * Overwrites labels that are set via TypoScript.
549 * TS locallang labels have to be configured like:
550 * plugin.tx_form._LOCAL_LANG.languageKey.key = value
551 */
552 protected function loadTypoScriptLabels()
553 {
554 $frameworkConfiguration = $this->getConfigurationManager()
555 ->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK, 'form');
556
557 if (!is_array($frameworkConfiguration['_LOCAL_LANG'])) {
558 return;
559 }
560 $this->LOCAL_LANG_UNSET = [];
561 foreach ($frameworkConfiguration['_LOCAL_LANG'] as $languageKey => $labels) {
562 if (!(is_array($labels) && isset($this->LOCAL_LANG[$languageKey]))) {
563 continue;
564 }
565 foreach ($labels as $labelKey => $labelValue) {
566 if (is_string($labelValue)) {
567 $this->LOCAL_LANG[$languageKey][$labelKey][0]['target'] = $labelValue;
568 if ($labelValue === '') {
569 $this->LOCAL_LANG_UNSET[$languageKey][$labelKey] = '';
570 }
571 } elseif (is_array($labelValue)) {
572 $labelValue = $this->flattenTypoScriptLabelArray($labelValue, $labelKey);
573 foreach ($labelValue as $key => $value) {
574 $this->LOCAL_LANG[$languageKey][$key][0]['target'] = $value;
575 if ($value === '') {
576 $this->LOCAL_LANG_UNSET[$languageKey][$key] = '';
577 }
578 }
579 }
580 }
581 }
582 }
583
584 /**
585 * Flatten TypoScript label array; converting a hierarchical array into a flat
586 * array with the keys separated by dots.
587 *
588 * Example Input: array('k1' => array('subkey1' => 'val1'))
589 * Example Output: array('k1.subkey1' => 'val1')
590 *
591 * @param array $labelValues Hierarchical array of labels
592 * @param string $parentKey the name of the parent key in the recursion; is only needed for recursion.
593 * @return array flattened array of labels.
594 */
595 protected function flattenTypoScriptLabelArray(array $labelValues, string $parentKey = ''): array
596 {
597 $result = [];
598 foreach ($labelValues as $key => $labelValue) {
599 if (!empty($parentKey)) {
600 $key = $parentKey . '.' . $key;
601 }
602 if (is_array($labelValue)) {
603 $labelValue = $this->flattenTypoScriptLabelArray($labelValue, $key);
604 $result = array_merge($result, $labelValue);
605 } else {
606 $result[$key] = $labelValue;
607 }
608 }
609 return $result;
610 }
611
612 /**
613 * If the array contains numerical keys only, sort it in descending order
614 *
615 * @param array $array
616 * @return array
617 */
618 protected function sortArrayWithIntegerKeysDescending(array $array)
619 {
620 if (count(array_filter(array_keys($array), 'is_string')) === 0) {
621 krsort($array);
622 }
623 return $array;
624 }
625
626 /**
627 * Returns instance of the configuration manager
628 *
629 * @return ConfigurationManagerInterface
630 */
631 protected function getConfigurationManager(): ConfigurationManagerInterface
632 {
633 if ($this->configurationManager !== null) {
634 return $this->configurationManager;
635 }
636
637 $this->configurationManager = GeneralUtility::makeInstance(ObjectManager::class)
638 ->get(ConfigurationManagerInterface::class);
639 return $this->configurationManager;
640 }
641
642 /**
643 * @return LanguageService
644 */
645 protected function getLanguageService(): LanguageService
646 {
647 return $GLOBALS['LANG'];
648 }
649
650 /**
651 * @return TypoScriptFrontendController
652 */
653 protected function getTypoScriptFrontendController(): TypoScriptFrontendController
654 {
655 return $GLOBALS['TSFE'];
656 }
657 }