1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Backend\Form\FormDataProvider;
17
18use Doctrine\DBAL\Connection;
19use TYPO3\CMS\Backend\Form\FormDataCompiler;
20use TYPO3\CMS\Backend\Form\FormDataGroup\TcaInputPlaceholderRecord;
21use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
22use TYPO3\CMS\Core\Database\ConnectionPool;
23use TYPO3\CMS\Core\Localization\LanguageService;
24use TYPO3\CMS\Core\Utility\GeneralUtility;
25
26/**
27 * Resolve placeholders for fields of type input or text. The placeholder value
28 * in the processedTca section of the result will be replaced with the resolved
29 * value.
30 */
31class TcaInputPlaceholders implements FormDataProviderInterface
32{
33    /**
34     * Resolve placeholders for input/text fields. Placeholders that are simple
35     * strings will be returned unmodified. Placeholders beginning with __row are
36     * being resolved, possibly traversing multiple tables.
37     *
38     * @param array $result
39     * @return array
40     */
41    public function addData(array $result)
42    {
43        foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) {
44            // Placeholders are only valid for input and text type fields
45            if (
46                ($fieldConfig['config']['type'] !== 'input' && $fieldConfig['config']['type'] !== 'text')
47                || !isset($fieldConfig['config']['placeholder'])
48            ) {
49                continue;
50            }
51
52            // Resolve __row|field type placeholders
53            if (strpos($fieldConfig['config']['placeholder'], '__row|') === 0) {
54                // split field names into array and remove the __row indicator
55                $fieldNameArray = array_slice(
56                    GeneralUtility::trimExplode('|', $fieldConfig['config']['placeholder'], true),
57                    1
58                );
59                $result['processedTca']['columns'][$fieldName]['config']['placeholder'] = $this->getPlaceholderValue($fieldNameArray, $result);
60            }
61
62            // Resolve placeholders from language files
63            if (strpos($fieldConfig['config']['placeholder'], 'LLL:') === 0) {
64                $result['processedTca']['columns'][$fieldName]['config']['placeholder'] = $this->getLanguageService()->sL($fieldConfig['config']['placeholder']);
65            }
66
67            // Remove empty placeholders
68            if (empty($result['processedTca']['columns'][$fieldName]['config']['placeholder'])) {
69                unset($result['processedTca']['columns'][$fieldName]['config']['placeholder']);
70            }
71        }
72
73        return $result;
74    }
75
76    /**
77     * Recursively resolve the placeholder value. A placeholder string with a
78     * syntax of __row|field1|field2|field3 will be recursively resolved to a
79     * final value.
80     *
81     * @param array $fieldNameArray
82     * @param array $result
83     * @param int $recursionLevel
84     * @return string
85     */
86    protected function getPlaceholderValue($fieldNameArray, $result, $recursionLevel = 0)
87    {
88        if ($recursionLevel > 99) {
89            // This should not happen, treat as misconfiguration
90            return '';
91        }
92
93        $fieldName = array_shift($fieldNameArray);
94
95        // Skip if a defined field was actually not present in the database row
96        // Using array_key_exists here, since NULL values are valid as well.
97        if (!array_key_exists($fieldName, $result['databaseRow'])) {
98            return '';
99        }
100
101        $value = $result['databaseRow'][$fieldName];
102
103        if (!isset($result['processedTca']['columns'][$fieldName]['config'])
104            || !is_array($result['processedTca']['columns'][$fieldName]['config'])
105        ) {
106            return (string)$value;
107        }
108
109        $fieldConfig = $result['processedTca']['columns'][$fieldName]['config'];
110
111        switch ($fieldConfig['type']) {
112            case 'select':
113                // The FormDataProviders already resolved the select items to an array of uids,
114                // filter out empty values that occur when no related record has been selected.
115                $possibleUids = array_filter($value);
116                $foreignTableName = $fieldConfig['foreign_table'];
117                break;
118            case 'group':
119                $possibleUids = $this->getRelatedGroupFieldUids($fieldConfig, $value);
120                $foreignTableName = $this->getAllowedTableForGroupField($fieldConfig);
121                break;
122            case 'inline':
123                $possibleUids = array_filter(GeneralUtility::trimExplode(',', $value, true));
124                $foreignTableName = $fieldConfig['foreign_table'];
125                break;
126            default:
127                $possibleUids = [];
128                $foreignTableName = '';
129        }
130
131        if (!empty($possibleUids) && !empty($fieldNameArray)) {
132            if (count($possibleUids) > 1
133                && !empty($GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'])
134                && isset($result['currentSysLanguage'])
135            ) {
136                $possibleUids = $this->getPossibleUidsByCurrentSysLanguage($possibleUids, $foreignTableName, $result['currentSysLanguage']);
137            }
138            $relatedFormData = $this->getRelatedFormData($foreignTableName, $possibleUids[0], $fieldNameArray[0]);
139            if (!empty($GLOBALS['TCA'][$result['tableName']]['ctrl']['languageField'])
140                && isset($result['databaseRow'][$GLOBALS['TCA'][$result['tableName']]['ctrl']['languageField']])
141            ) {
142                $relatedFormData['currentSysLanguage'] = $result['databaseRow'][$GLOBALS['TCA'][$result['tableName']]['ctrl']['languageField']][0];
143            }
144            $value = $this->getPlaceholderValue($fieldNameArray, $relatedFormData, $recursionLevel + 1);
145        }
146
147        if ($recursionLevel === 0 && is_array($value)) {
148            $value = implode(', ', $value);
149        }
150        return (string)$value;
151    }
152
153    /**
154     * Compile a formdata result set based on the tablename and record uid.
155     *
156     * @param string $tableName Name of the table for which to compile formdata
157     * @param int $uid UID of the record for which to compile the formdata
158     * @param string $columnToProcess The column that is required from the record
159     * @return array The compiled formdata
160     */
161    protected function getRelatedFormData($tableName, $uid, $columnToProcess)
162    {
163        $fakeDataInput = [
164            'command' => 'edit',
165            'vanillaUid' => (int)$uid,
166            'tableName' => $tableName,
167            'inlineCompileExistingChildren' => false,
168            'columnsToProcess' => [$columnToProcess],
169        ];
170        /** @var TcaInputPlaceholderRecord $formDataGroup */
171        $formDataGroup = GeneralUtility::makeInstance(TcaInputPlaceholderRecord::class);
172        /** @var FormDataCompiler $formDataCompiler */
173        $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
174        $compilerResult = $formDataCompiler->compile($fakeDataInput);
175        return $compilerResult;
176    }
177
178    /**
179     * Return uids of related records for group type fields. Uids consisting of
180     * multiple parts like [table]_[uid]|[title] will be reduced to integers and
181     * validated against the allowed table. Uids without a table prefix are
182     * accepted in any case.
183     *
184     * @param array $fieldConfig TCA "config" section for the group type field.
185     * @param string $value A comma separated list of records
186     * @return array
187     */
188    protected function getRelatedGroupFieldUids(array $fieldConfig, $value)
189    {
190        $relatedUids = [];
191        $allowedTable = $this->getAllowedTableForGroupField($fieldConfig);
192
193        // Skip if it's not a database relation with a resolvable foreign table
194        if (($fieldConfig['internal_type'] !== 'db') || ($allowedTable === false)) {
195            return $relatedUids;
196        }
197
198        // Related group values have been prepared by TcaGroup data provider, an array is expected here
199        foreach ($value as $singleValue) {
200            $relatedUids[] = $singleValue['uid'];
201        }
202
203        return $relatedUids;
204    }
205
206    /**
207     * Will read the "allowed" value from the given field configuration
208     * and returns FALSE if none or more than one has been defined.
209     * Otherwise the name of the allowed table will be returned.
210     *
211     * @param array $fieldConfig TCA "config" section for the group type field.
212     * @return bool|string
213     */
214    protected function getAllowedTableForGroupField(array $fieldConfig)
215    {
216        $allowedTable = false;
217
218        $allowedTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed'], true);
219        if (count($allowedTables) === 1) {
220            $allowedTable = $allowedTables[0];
221        }
222
223        return $allowedTable;
224    }
225
226    /**
227     * E.g. sys_file is not translatable, thus the uid of the translation of it's metadata has to be retrieved here.
228     *
229     * Get the uid of e.g. a file metadata entry for a given sys_language_uid and the possible translated data.
230     * If there is no translation available, return the uid of default language.
231     * If there is no value at all, return the "possible uids".
232     *
233     * @param array $possibleUids
234     * @param string $foreignTableName
235     * @param int $currentLanguage
236     * @return array
237     */
238    protected function getPossibleUidsByCurrentSysLanguage(array $possibleUids, $foreignTableName, $currentLanguage)
239    {
240        $languageField = $GLOBALS['TCA'][$foreignTableName]['ctrl']['languageField'];
241        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($foreignTableName);
242        $possibleRecords = $queryBuilder->select('uid', $languageField)
243            ->from($foreignTableName)
244            ->where(
245                $queryBuilder->expr()->in(
246                    'uid',
247                    $queryBuilder->createNamedParameter($possibleUids, Connection::PARAM_INT_ARRAY)
248                ),
249                $queryBuilder->expr()->in(
250                    $languageField,
251                    $queryBuilder->createNamedParameter([$currentLanguage, 0], Connection::PARAM_INT_ARRAY)
252                )
253            )
254            ->groupBy($languageField, 'uid')
255            ->execute()
256            ->fetchAll();
257
258        if (!empty($possibleRecords)) {
259            // Either only one record or first record matches language
260            if (count($possibleRecords) === 1
261                || (int)$possibleRecords[0][$languageField] === (int)$currentLanguage
262            ) {
263                return [$possibleRecords[0]['uid']];
264            }
265
266            // Language of second record matches language
267            return [$possibleRecords[1]['uid']];
268        }
269
270        return $possibleUids;
271    }
272
273    /**
274     * @return LanguageService
275     */
276    protected function getLanguageService()
277    {
278        return $GLOBALS['LANG'];
279    }
280}
281