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 TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
19use TYPO3\CMS\Backend\Form\FormDataCompiler;
20use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
21use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
22use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
23use TYPO3\CMS\Backend\Form\InlineStackProcessor;
24use TYPO3\CMS\Backend\Utility\BackendUtility;
25use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
26use TYPO3\CMS\Core\Database\RelationHandler;
27use TYPO3\CMS\Core\Localization\LanguageService;
28use TYPO3\CMS\Core\Messaging\FlashMessage;
29use TYPO3\CMS\Core\Messaging\FlashMessageService;
30use TYPO3\CMS\Core\Utility\GeneralUtility;
31use TYPO3\CMS\Core\Utility\MathUtility;
32use TYPO3\CMS\Core\Versioning\VersionState;
33
34/**
35 * Resolve and prepare inline data.
36 */
37class TcaInline extends AbstractDatabaseRecordProvider implements FormDataProviderInterface
38{
39    /**
40     * Resolve inline fields
41     *
42     * @param array $result
43     * @return array
44     */
45    public function addData(array $result)
46    {
47        $result = $this->addInlineFirstPid($result);
48
49        foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) {
50            if (!$this->isInlineField($fieldConfig)) {
51                continue;
52            }
53            $result['processedTca']['columns'][$fieldName]['children'] = [];
54            if (!$this->isUserAllowedToModify($fieldConfig)) {
55                continue;
56            }
57            if ($result['inlineResolveExistingChildren']) {
58                $result = $this->resolveRelatedRecords($result, $fieldName);
59                $result = $this->addForeignSelectorAndUniquePossibleRecords($result, $fieldName);
60            }
61        }
62
63        return $result;
64    }
65
66    /**
67     * Is column of type "inline"
68     *
69     * @param array $fieldConfig
70     * @return bool
71     */
72    protected function isInlineField($fieldConfig)
73    {
74        return !empty($fieldConfig['config']['type']) && $fieldConfig['config']['type'] === 'inline';
75    }
76
77    /**
78     * Is user allowed to modify child elements
79     *
80     * @param array $fieldConfig
81     * @return bool
82     */
83    protected function isUserAllowedToModify($fieldConfig)
84    {
85        return $this->getBackendUser()->check('tables_modify', $fieldConfig['config']['foreign_table']);
86    }
87
88    /**
89     * The "entry" pid for inline records. Nested inline records can potentially hang around on different
90     * pid's, but the entry pid is needed for AJAX calls, so that they would know where the action takes place on the page structure.
91     *
92     * @param array $result Incoming result
93     * @return array Modified result
94     * @todo: Find out when and if this is different from 'effectivePid'
95     */
96    protected function addInlineFirstPid(array $result)
97    {
98        if ($result['inlineFirstPid'] === null) {
99            $table = $result['tableName'];
100            $row = $result['databaseRow'];
101            // If the parent is a page, use the uid(!) of the (new?) page as pid for the child records:
102            if ($table === 'pages') {
103                $liveVersionId = BackendUtility::getLiveVersionIdOfRecord('pages', $row['uid']);
104                $pid = $liveVersionId ?? $row['uid'];
105            } elseif (($row['pid'] ?? 0) < 0) {
106                $prevRec = BackendUtility::getRecord($table, (int)abs($row['pid']));
107                $pid = $prevRec['pid'];
108            } else {
109                $pid = $row['pid'] ?? 0;
110            }
111            if (MathUtility::canBeInterpretedAsInteger($pid)) {
112                $pageRecord = BackendUtility::getRecord('pages', (int)$pid);
113                if ((int)$pageRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] > 0) {
114                    $pid = (int)$pageRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
115                }
116            } elseif (strpos($pid, 'NEW') !== 0) {
117                throw new \RuntimeException(
118                    'inlineFirstPid should either be an integer or a "NEW..." string',
119                    1521220142
120                );
121            }
122            $result['inlineFirstPid'] = $pid;
123        }
124        return $result;
125    }
126
127    /**
128     * Substitute the value in databaseRow of this inline field with an array
129     * that contains the databaseRows of currently connected records and some meta information.
130     *
131     * @param array $result Result array
132     * @param string $fieldName Current handle field name
133     * @return array Modified item array
134     */
135    protected function resolveRelatedRecordsOverlays(array $result, $fieldName)
136    {
137        $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table'];
138
139        $connectedUidsOfLocalizedOverlay = [];
140        if ($result['command'] === 'edit') {
141            $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids(
142                $result['processedTca']['columns'][$fieldName]['config'],
143                $result['tableName'],
144                $result['databaseRow']['uid'],
145                $result['databaseRow'][$fieldName]
146            );
147        }
148        $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay);
149        $connectedUidsOfLocalizedOverlay = $this->getWorkspacedUids($connectedUidsOfLocalizedOverlay, $childTableName);
150        if ($result['inlineCompileExistingChildren']) {
151            $tableNameWithDefaultRecords = $result['tableName'];
152            $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids(
153                $result['processedTca']['columns'][$fieldName]['config'],
154                $tableNameWithDefaultRecords,
155                $result['defaultLanguageRow']['uid'],
156                $result['defaultLanguageRow'][$fieldName]
157            );
158            $connectedUidsOfDefaultLanguageRecord = $this->getWorkspacedUids($connectedUidsOfDefaultLanguageRecord, $childTableName);
159
160            $showPossible = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords'];
161
162            // Find which records are localized, which records are not localized and which are
163            // localized but miss default language record
164            $fieldNameWithDefaultLanguageUid = $GLOBALS['TCA'][$childTableName]['ctrl']['transOrigPointerField'];
165            foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) {
166                try {
167                    $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid);
168                } catch (DatabaseRecordException $e) {
169                    // The child could not be compiled, probably it was deleted and a dangling mm record exists
170                    $this->logger->warning(
171                        $e->getMessage(),
172                        [
173                            'table' => $childTableName,
174                            'uid' => $localizedUid,
175                            'exception' => $e
176                        ]
177                    );
178                    continue;
179                }
180                $uidOfDefaultLanguageRecord = $localizedRecord[$fieldNameWithDefaultLanguageUid];
181                if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord)) {
182                    // This localized child has a default language record. Remove this record from list of default language records
183                    $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]);
184                }
185                // Compile localized record
186                $compiledChild = $this->compileChild($result, $fieldName, $localizedUid);
187                $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
188            }
189            if ($showPossible) {
190                foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) {
191                    // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that
192                    // exist in default language, but are not localized yet. Compile and mark those
193                    try {
194                        $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid);
195                    } catch (DatabaseRecordException $e) {
196                        // The child could not be compiled, probably it was deleted and a dangling mm record exists
197                        $this->logger->warning(
198                            $e->getMessage(),
199                            [
200                                'table' => $childTableName,
201                                'uid' => $defaultLanguageUid,
202                                'exception' => $e
203                            ]
204                        );
205                        continue;
206                    }
207                    $compiledChild['isInlineDefaultLanguageRecordInLocalizedParentContext'] = true;
208                    $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
209                }
210            }
211        }
212
213        return $result;
214    }
215
216    /**
217     * Substitute the value in databaseRow of this inline field with an array
218     * that contains the databaseRows of currently connected records and some meta information.
219     *
220     * @param array $result Result array
221     * @param string $fieldName Current handle field name
222     * @return array Modified item array
223     */
224    protected function resolveRelatedRecords(array $result, $fieldName)
225    {
226        if ($result['defaultLanguageRow'] !== null) {
227            return $this->resolveRelatedRecordsOverlays($result, $fieldName);
228        }
229
230        $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table'];
231        $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids(
232            $result['processedTca']['columns'][$fieldName]['config'],
233            $result['tableName'],
234            $result['databaseRow']['uid'],
235            $result['databaseRow'][$fieldName]
236        );
237        $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfDefaultLanguageRecord);
238
239        $connectedUidsOfDefaultLanguageRecord = $this->getWorkspacedUids($connectedUidsOfDefaultLanguageRecord, $childTableName);
240
241        if ($result['inlineCompileExistingChildren']) {
242            foreach ($connectedUidsOfDefaultLanguageRecord as $uid) {
243                try {
244                    $compiledChild = $this->compileChild($result, $fieldName, $uid);
245                    $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
246                } catch (DatabaseRecordException $e) {
247                    // Nothing to do here, missing child is just not being rendered.
248                }
249            }
250        }
251        return $result;
252    }
253
254    /**
255     * If there is a foreign_selector or foreign_unique configuration, fetch
256     * the list of possible records that can be connected and attach the to the
257     * inline configuration.
258     *
259     * @param array $result Result array
260     * @param string $fieldName Current handle field name
261     * @return array Modified item array
262     */
263    protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName)
264    {
265        if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'])) {
266            return $result;
267        }
268
269        $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'];
270        $foreignFieldName = $selectorOrUniqueConfiguration['fieldName'];
271        $selectorOrUniquePossibleRecords = [];
272
273        if ($selectorOrUniqueConfiguration['config']['type'] === 'select') {
274            // Compile child table data for this field only
275            $selectDataInput = [
276                'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'],
277                'command' => 'new',
278                // Since there is no existing record that may have a type, it does not make sense to
279                // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is
280                'pageTsConfig' => $result['pageTsConfig'],
281                'userTsConfig' => $result['userTsConfig'],
282                'databaseRow' => $result['databaseRow'],
283                'processedTca' => [
284                    'ctrl' => [],
285                    'columns' => [
286                        $foreignFieldName => [
287                            'config' => $selectorOrUniqueConfiguration['config'],
288                        ],
289                    ],
290                ],
291                'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
292            ];
293            /** @var OnTheFly $formDataGroup */
294            $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
295            $formDataGroup->setProviderList([TcaSelectItems::class]);
296            /** @var FormDataCompiler $formDataCompiler */
297            $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
298            $compilerResult = $formDataCompiler->compile($selectDataInput);
299            $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items'];
300        }
301
302        $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords;
303
304        return $result;
305    }
306
307    /**
308     * Compile a full child record
309     *
310     * @param array $result Result array of parent
311     * @param string $parentFieldName Name of parent field
312     * @param int $childUid Uid of child to compile
313     * @return array Full result array
314     */
315    protected function compileChild(array $result, $parentFieldName, $childUid)
316    {
317        $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config'];
318        $childTableName = $parentConfig['foreign_table'];
319
320        /** @var InlineStackProcessor $inlineStackProcessor */
321        $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
322        $inlineStackProcessor->initializeByGivenStructure($result['inlineStructure']);
323        $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
324
325        /** @var TcaDatabaseRecord $formDataGroup */
326        $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
327        /** @var FormDataCompiler $formDataCompiler */
328        $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
329        $formDataCompilerInput = [
330            'command' => 'edit',
331            'tableName' => $childTableName,
332            'vanillaUid' => (int)$childUid,
333            // Give incoming returnUrl down to children so they generate a returnUrl back to
334            // the originally opening record, also see "originalReturnUrl" in inline container
335            // and FormInlineAjaxController
336            'returnUrl' => $result['returnUrl'],
337            'isInlineChild' => true,
338            'inlineStructure' => $result['inlineStructure'],
339            'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
340            'inlineFirstPid' => $result['inlineFirstPid'],
341            'inlineParentConfig' => $parentConfig,
342
343            // values of the current parent element
344            // it is always a string either an id or new...
345            'inlineParentUid' => $result['databaseRow']['uid'],
346            'inlineParentTableName' => $result['tableName'],
347            'inlineParentFieldName' => $parentFieldName,
348
349            // values of the top most parent element set on first level and not overridden on following levels
350            'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
351            'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
352            'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
353        ];
354
355        // For foreign_selector with useCombination $mainChild is the mm record
356        // and $combinationChild is the child-child. For 1:n "normal" relations,
357        // $mainChild is just the normal child record and $combinationChild is empty.
358        $mainChild = $formDataCompiler->compile($formDataCompilerInput);
359        if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
360            try {
361                $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig);
362            } catch (DatabaseRecordException $e) {
363                // The child could not be compiled, probably it was deleted and a dangling mm record
364                // exists. This is a data inconsistency, we catch this exception and create a flash message
365                $message = vsprintf(
366                    $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'),
367                    [$e->getTableName(), $e->getUid(), $childTableName, (int)$childUid]
368                );
369                $flashMessage = GeneralUtility::makeInstance(
370                    FlashMessage::class,
371                    $message,
372                    '',
373                    FlashMessage::ERROR
374                );
375                GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier()->enqueue($flashMessage);
376            }
377        }
378        return $mainChild;
379    }
380
381    /**
382     * With useCombination set, not only content of the intermediate table, but also
383     * the connected child should be rendered in one go. Prepare this here.
384     *
385     * @param array $child Full data array of "mm" record
386     * @param array $parentConfig TCA configuration of "parent"
387     * @return array Full data array of child
388     */
389    protected function compileChildChild(array $child, array $parentConfig)
390    {
391        // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
392        $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
393        // child-child table name is set in child tca "the selector field" foreign_table
394        $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'];
395        /** @var TcaDatabaseRecord $formDataGroup */
396        $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
397        /** @var FormDataCompiler $formDataCompiler */
398        $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
399
400        $formDataCompilerInput = [
401            'command' => 'edit',
402            'tableName' => $childChildTableName,
403            'vanillaUid' => (int)$childChildUid,
404            'isInlineChild' => true,
405            'isInlineChildExpanded' => $child['isInlineChildExpanded'],
406            // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too?
407            'inlineStructure' => $child['inlineStructure'],
408            'inlineFirstPid' => $child['inlineFirstPid'],
409            // values of the top most parent element set on first level and not overridden on following levels
410            'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
411            'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
412            'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
413        ];
414        $childChild = $formDataCompiler->compile($formDataCompilerInput);
415        return $childChild;
416    }
417
418    /**
419     * Substitute given list of uids in child table with workspace uid if needed
420     *
421     * @param array $connectedUids List of connected uids
422     * @param string $childTableName Name of child table
423     * @return array List of uids in workspace
424     */
425    protected function getWorkspacedUids(array $connectedUids, $childTableName)
426    {
427        $backendUser = $this->getBackendUser();
428        $newConnectedUids = [];
429        foreach ($connectedUids as $uid) {
430            // Fetch workspace version of a record (if any):
431            // @todo: Needs handling
432            if ($backendUser->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($childTableName)) {
433                $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state');
434                if (!empty($workspaceVersion)) {
435                    $versionState = VersionState::cast($workspaceVersion['t3ver_state']);
436                    if ($versionState->equals(VersionState::DELETE_PLACEHOLDER)) {
437                        continue;
438                    }
439                    $uid = $workspaceVersion['uid'];
440                }
441            }
442            $newConnectedUids[] = $uid;
443        }
444        return $newConnectedUids;
445    }
446
447    /**
448     * Use RelationHandler to resolve connected uids.
449     *
450     * @param array $parentConfig TCA config section of parent
451     * @param string $parentTableName Name of parent table
452     * @param int $parentUid Uid of parent record
453     * @param string $parentFieldValue Database value of parent record of this inline field
454     * @return array Array with connected uids
455     * @todo: Cover with unit tests
456     */
457    protected function resolveConnectedRecordUids(array $parentConfig, $parentTableName, $parentUid, $parentFieldValue)
458    {
459        $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue);
460        if (empty($parentConfig['MM'])) {
461            $parentUid = $this->getLiveDefaultId($parentTableName, $parentUid);
462        }
463        /** @var RelationHandler $relationHandler */
464        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
465        $relationHandler->registerNonTableValues = (bool)$parentConfig['allowedIdValues'];
466        $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'], $parentConfig['MM'], $parentUid, $parentTableName, $parentConfig);
467        $foreignRecordUids = $relationHandler->getValueArray();
468        $resolvedForeignRecordUids = [];
469        foreach ($foreignRecordUids as $aForeignRecordUid) {
470            if ($parentConfig['MM'] || $parentConfig['foreign_field']) {
471                $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
472            } else {
473                foreach ($directlyConnectedIds as $id) {
474                    if ((int)$aForeignRecordUid === (int)$id) {
475                        $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
476                    }
477                }
478            }
479        }
480        return $resolvedForeignRecordUids;
481    }
482
483    /**
484     * Gets the record uid of the live default record. If already
485     * pointing to the live record, the submitted record uid is returned.
486     *
487     * @param string $tableName
488     * @param int $uid
489     * @return int
490     * @todo: the workspace mess still must be resolved somehow
491     */
492    protected function getLiveDefaultId($tableName, $uid)
493    {
494        $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $uid);
495        if ($liveDefaultId === null) {
496            $liveDefaultId = $uid;
497        }
498        return $liveDefaultId;
499    }
500
501    /**
502     * @return BackendUserAuthentication
503     */
504    protected function getBackendUser()
505    {
506        return $GLOBALS['BE_USER'];
507    }
508
509    /**
510     * @return LanguageService
511     */
512    protected function getLanguageService()
513    {
514        return $GLOBALS['LANG'];
515    }
516}
517