1<?php
2
3declare(strict_types=1);
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
18namespace TYPO3\CMS\Backend\Form\FormDataProvider;
19
20use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
21use TYPO3\CMS\Core\Utility\GeneralUtility;
22use TYPO3\CMS\Core\Utility\MathUtility;
23
24/**
25 * Class EvaluateDisplayConditions implements the TCA 'displayCond' option.
26 * The display condition is a colon separated string which describes
27 * the condition to decide whether a form field should be displayed.
28 */
29class EvaluateDisplayConditions implements FormDataProviderInterface
30{
31    /**
32     * Remove fields from processedTca columns that should not be displayed.
33     *
34     * Strategy of the parser is to first find all displayCond in given tca
35     * and within all type=flex fields to parse them into an array. This condition
36     * array contains all information to evaluate that condition in a second
37     * step that - depending on evaluation result - then throws away or keeps the field.
38     *
39     * @param array $result
40     * @return array
41     */
42    public function addData(array $result): array
43    {
44        $result = $this->parseDisplayConditions($result);
45        $result = $this->evaluateConditions($result);
46        return $result;
47    }
48
49    /**
50     * Find all 'displayCond' in TCA and flex forms and substitute them with an
51     * array representation that contains all relevant data to
52     * evaluate the condition later. For "FIELD" conditions the helper methods
53     * findFieldValue() is used to find the value of the referenced field to put
54     * that value into the returned array, too. This is important since the referenced
55     * field is "relative" to the position of the field that has the display condition.
56     * For instance, "FIELD:aField:=:foo" within a flex form field references a field
57     * value from the same sheet, and there are many more complex scenarios to resolve.
58     *
59     * @param array $result Incoming result array
60     * @throws \RuntimeException
61     * @return array Modified result array with all displayCond parsed into arrays
62     */
63    protected function parseDisplayConditions(array $result): array
64    {
65        $flexColumns = [];
66        foreach ($result['processedTca']['columns'] as $columnName => $columnConfiguration) {
67            if (isset($columnConfiguration['config']['type']) && $columnConfiguration['config']['type'] === 'flex') {
68                $flexColumns[$columnName] = $columnConfiguration;
69            }
70            if (!isset($columnConfiguration['displayCond'])) {
71                continue;
72            }
73            $result['processedTca']['columns'][$columnName]['displayCond'] = $this->parseConditionRecursive(
74                $columnConfiguration['displayCond'],
75                $result['databaseRow']
76            );
77        }
78
79        foreach ($flexColumns as $columnName => $flexColumn) {
80            $sheetNameFieldNames = [];
81            foreach ($flexColumn['config']['ds']['sheets'] as $sheetName => $sheetConfiguration) {
82                // Create a list of all sheet names with field names combinations for later 'sheetName.fieldName' lookups
83                // 'one.sheet.one.field' as key, with array of "sheetName" and "fieldName" as value
84                if (isset($sheetConfiguration['ROOT']['el']) && is_array($sheetConfiguration['ROOT']['el'])) {
85                    foreach ($sheetConfiguration['ROOT']['el'] as $flexElementName => $flexElementConfiguration) {
86                        // section container have no value in its own
87                        if (isset($flexElementConfiguration['type']) && $flexElementConfiguration['type'] === 'array'
88                            && isset($flexElementConfiguration['section']) && $flexElementConfiguration['section'] == 1
89                        ) {
90                            continue;
91                        }
92                        $combinedKey = $sheetName . '.' . $flexElementName;
93                        if (array_key_exists($combinedKey, $sheetNameFieldNames)) {
94                            throw new \RuntimeException(
95                                'Ambiguous sheet name and field name combination: Sheet "' . $sheetNameFieldNames[$combinedKey]['sheetName']
96                                . '" with field name "' . $sheetNameFieldNames[$combinedKey]['fieldName'] . '" overlaps with sheet "'
97                                . $sheetName . '" and field name "' . $flexElementName . '". Do not do that.',
98                                1481483061
99                            );
100                        }
101                        $sheetNameFieldNames[$combinedKey] = [
102                            'sheetName' => $sheetName,
103                            'fieldName' => $flexElementName,
104                        ];
105                    }
106                }
107            }
108            foreach ($flexColumn['config']['ds']['sheets'] as $sheetName => $sheetConfiguration) {
109                if (isset($sheetConfiguration['ROOT']['displayCond'])) {
110                    // Condition on a flex sheet
111                    $flexContext = [
112                        'context' => 'flexSheet',
113                        'sheetNameFieldNames' => $sheetNameFieldNames,
114                        'currentSheetName' => $sheetName,
115                        'flexFormRowData' => $result['databaseRow'][$columnName] ?? null,
116                    ];
117                    $parsedDisplayCondition = $this->parseConditionRecursive(
118                        $sheetConfiguration['ROOT']['displayCond'],
119                        $result['databaseRow'],
120                        $flexContext
121                    );
122                    $result['processedTca']['columns'][$columnName]['config']['ds']
123                        ['sheets'][$sheetName]['ROOT']['displayCond']
124                        = $parsedDisplayCondition;
125                }
126                if (isset($sheetConfiguration['ROOT']['el']) && is_array($sheetConfiguration['ROOT']['el'])) {
127                    foreach ($sheetConfiguration['ROOT']['el'] as $flexElementName => $flexElementConfiguration) {
128                        if (isset($flexElementConfiguration['displayCond'])) {
129                            // Condition on a flex element
130                            $flexContext = [
131                                'context' => 'flexField',
132                                'sheetNameFieldNames' => $sheetNameFieldNames,
133                                'currentSheetName' => $sheetName,
134                                'currentFieldName' => $flexElementName,
135                                'flexFormDataStructure' => $result['processedTca']['columns'][$columnName]['config']['ds'],
136                                'flexFormRowData' => $result['databaseRow'][$columnName] ?? null,
137                            ];
138                            $parsedDisplayCondition = $this->parseConditionRecursive(
139                                $flexElementConfiguration['displayCond'],
140                                $result['databaseRow'],
141                                $flexContext
142                            );
143                            $result['processedTca']['columns'][$columnName]['config']['ds']
144                                ['sheets'][$sheetName]['ROOT']
145                                ['el'][$flexElementName]['displayCond']
146                                = $parsedDisplayCondition;
147                        }
148                        if (isset($flexElementConfiguration['type']) && $flexElementConfiguration['type'] === 'array'
149                            && isset($flexElementConfiguration['section']) && $flexElementConfiguration['section'] == 1
150                            && isset($flexElementConfiguration['children']) && is_array($flexElementConfiguration['children'])
151                        ) {
152                            // Conditions on flex container section elements
153                            foreach ($flexElementConfiguration['children'] as $containerIdentifier => $containerElements) {
154                                if (isset($containerElements['el']) && is_array($containerElements['el'])) {
155                                    foreach ($containerElements['el'] as $containerElementName => $containerElementConfiguration) {
156                                        if (isset($containerElementConfiguration['displayCond'])) {
157                                            $flexContext = [
158                                                'context' => 'flexContainerElement',
159                                                'sheetNameFieldNames' => $sheetNameFieldNames,
160                                                'currentSheetName' => $sheetName,
161                                                'currentFieldName' => $flexElementName,
162                                                'currentContainerIdentifier' => $containerIdentifier,
163                                                'currentContainerElementName' => $containerElementName,
164                                                'flexFormDataStructure' => $result['processedTca']['columns'][$columnName]['config']['ds'],
165                                                'flexFormRowData' => $result['databaseRow'][$columnName],
166                                            ];
167                                            $parsedDisplayCondition = $this->parseConditionRecursive(
168                                                $containerElementConfiguration['displayCond'],
169                                                $result['databaseRow'],
170                                                $flexContext
171                                            );
172                                            $result['processedTca']['columns'][$columnName]['config']['ds']
173                                                ['sheets'][$sheetName]['ROOT']
174                                                ['el'][$flexElementName]
175                                                ['children'][$containerIdentifier]
176                                                ['el'][$containerElementName]['displayCond']
177                                                = $parsedDisplayCondition;
178                                        }
179                                    }
180                                }
181                            }
182                        }
183                    }
184                }
185            }
186        }
187        return $result;
188    }
189
190    /**
191     * Parse a condition into an array representation and validate syntax. Handles nested conditions combined with AND and OR.
192     * Calls itself recursive for nesting and logically combined conditions.
193     *
194     * @param mixed $condition Either an array with multiple conditions combined with AND or OR, or a single condition string
195     * @param array $databaseRow Incoming full database row
196     * @param array $flexContext Detailed flex context if display condition is within a flex field, needed to determine field value for "FIELD" conditions
197     * @throws \RuntimeException
198     * @return array Array representation of that condition, see unit tests for details on syntax
199     */
200    protected function parseConditionRecursive($condition, array $databaseRow, array $flexContext = []): array
201    {
202        $conditionArray = [];
203        if (is_string($condition)) {
204            $conditionArray = $this->parseSingleConditionString($condition, $databaseRow, $flexContext);
205        } elseif (is_array($condition)) {
206            foreach ($condition as $logicalOperator => $groupedDisplayConditions) {
207                $logicalOperator = strtoupper(is_string($logicalOperator) ? $logicalOperator : '');
208                if (($logicalOperator !== 'AND' && $logicalOperator !== 'OR') || !is_array($groupedDisplayConditions)) {
209                    throw new \RuntimeException(
210                        'Multiple conditions must have boolean operator "OR" or "AND", "' . $logicalOperator . '" given.',
211                        1481380393
212                    );
213                }
214                $conditionArray = [
215                    'type' => $logicalOperator,
216                    'subConditions' => [],
217                ];
218                foreach ($groupedDisplayConditions as $key => $singleDisplayCondition) {
219                    $key = strtoupper((string)$key);
220                    if (($key === 'AND' || $key === 'OR') && is_array($singleDisplayCondition)) {
221                        // Recursion statement: condition is 'AND' or 'OR' and is pointing to an array (should be conditions again)
222                        $conditionArray['subConditions'][] = $this->parseConditionRecursive(
223                            [$key => $singleDisplayCondition],
224                            $databaseRow,
225                            $flexContext
226                        );
227                    } else {
228                        $conditionArray['subConditions'][] = $this->parseConditionRecursive(
229                            $singleDisplayCondition,
230                            $databaseRow,
231                            $flexContext
232                        );
233                    }
234                }
235            }
236        } else {
237            throw new \RuntimeException(
238                'Condition must be either an array with sub conditions or a single condition string, type ' . gettype($condition) . ' given.',
239                1481381058
240            );
241        }
242        return $conditionArray;
243    }
244
245    /**
246     * Parse a single condition string into pieces, validate them and return
247     * an array representation.
248     *
249     * @param string $conditionString Given condition string like "VERSION:IS:true"
250     * @param array $databaseRow Incoming full database row
251     * @param array $flexContext Detailed flex context if display condition is within a flex field, needed to determine field value for "FIELD" conditions
252     * @return array Validated name array, example: [ type="VERSION", isVersion="true" ]
253     * @throws \RuntimeException
254     */
255    protected function parseSingleConditionString(string $conditionString, array $databaseRow, array $flexContext = []): array
256    {
257        $conditionArray = GeneralUtility::trimExplode(':', $conditionString, false, 4);
258        $namedConditionArray = [
259            'type' => $conditionArray[0],
260        ];
261        switch ($namedConditionArray['type']) {
262            case 'FIELD':
263                if (empty($conditionArray[1])) {
264                    throw new \RuntimeException(
265                        'Field condition "' . $conditionString . '" must have a field name as second part, none given.'
266                        . 'Example: "FIELD:myField:=:myValue"',
267                        1481385695
268                    );
269                }
270                $fieldName = $conditionArray[1];
271                $allowedOperators = ['REQ', '>', '<', '>=', '<=', '-', '!-', '=', '!=', 'IN', '!IN', 'BIT', '!BIT'];
272                if (empty($conditionArray[2]) || !in_array($conditionArray[2], $allowedOperators)) {
273                    throw new \RuntimeException(
274                        'Field condition "' . $conditionString . '" must have a valid operator as third part, non or invalid one given.'
275                        . ' Valid operators are: "' . implode('", "', $allowedOperators) . '".'
276                        . ' Example: "FIELD:myField:=:4"',
277                        1481386239
278                    );
279                }
280                $namedConditionArray['operator'] = $conditionArray[2];
281                if (!isset($conditionArray[3])) {
282                    throw new \RuntimeException(
283                        'Field condition "' . $conditionString . '" must have an operand as fourth part, none given.'
284                        . ' Example: "FIELD:myField:=:4"',
285                        1481401543
286                    );
287                }
288                $operand = $conditionArray[3];
289                if ($namedConditionArray['operator'] === 'REQ') {
290                    $operand = strtolower($operand);
291                    if ($operand === 'true') {
292                        $namedConditionArray['operand'] = true;
293                    } elseif ($operand === 'false') {
294                        $namedConditionArray['operand'] = false;
295                    } else {
296                        throw new \RuntimeException(
297                            'Field condition "' . $conditionString . '" must have "true" or "false" as fourth part.'
298                            . ' Example: "FIELD:myField:REQ:true',
299                            1481401892
300                        );
301                    }
302                } elseif (in_array($namedConditionArray['operator'], ['>', '<', '>=', '<=', 'BIT', '!BIT'])) {
303                    if (!MathUtility::canBeInterpretedAsInteger($operand)) {
304                        throw new \RuntimeException(
305                            'Field condition "' . $conditionString . '" with comparison operator ' . $namedConditionArray['operator']
306                            . ' must have a number as fourth part, ' . $operand . ' given. Example: "FIELD:myField:>:42"',
307                            1481456806
308                        );
309                    }
310                    $namedConditionArray['operand'] = (int)$operand;
311                } elseif ($namedConditionArray['operator'] === '-' || $namedConditionArray['operator'] === '!-') {
312                    [$minimum, $maximum] = GeneralUtility::trimExplode('-', $operand);
313                    if (!MathUtility::canBeInterpretedAsInteger($minimum) || !MathUtility::canBeInterpretedAsInteger($maximum)) {
314                        throw new \RuntimeException(
315                            'Field condition "' . $conditionString . '" with comparison operator ' . $namedConditionArray['operator']
316                            . ' must have two numbers as fourth part, separated by dash, ' . $operand . ' given. Example: "FIELD:myField:-:1-3"',
317                            1481457277
318                        );
319                    }
320                    $namedConditionArray['operand'] = '';
321                    $namedConditionArray['min'] = (int)$minimum;
322                    $namedConditionArray['max'] = (int)$maximum;
323                } elseif ($namedConditionArray['operator'] === 'IN' || $namedConditionArray['operator'] === '!IN'
324                    || $namedConditionArray['operator'] === '=' || $namedConditionArray['operator'] === '!='
325                ) {
326                    $namedConditionArray['operand'] = $operand;
327                }
328                $namedConditionArray['fieldValue'] = $this->findFieldValue($fieldName, $databaseRow, $flexContext);
329                break;
330            case 'HIDE_FOR_NON_ADMINS':
331                break;
332            case 'REC':
333                if (empty($conditionArray[1]) || $conditionArray[1] !== 'NEW') {
334                    throw new \RuntimeException(
335                        'Record condition "' . $conditionString . '" must contain "NEW" keyword: either "REC:NEW:true" or "REC:NEW:false"',
336                        1481384784
337                    );
338                }
339                if (empty($conditionArray[2])) {
340                    throw new \RuntimeException(
341                        'Record condition "' . $conditionString . '" must have an operand "true" or "false", none given. Example: "REC:NEW:true"',
342                        1481384947
343                    );
344                }
345                $operand = strtolower($conditionArray[2]);
346                if ($operand === 'true') {
347                    $namedConditionArray['isNew'] = true;
348                } elseif ($operand === 'false') {
349                    $namedConditionArray['isNew'] = false;
350                } else {
351                    throw new \RuntimeException(
352                        'Record condition "' . $conditionString . '" must have an operand "true" or "false, example "REC:NEW:true", given: ' . $operand,
353                        1481385173
354                    );
355                }
356                // Programming error: There must be a uid available, other data providers should have taken care of that already
357                if (!array_key_exists('uid', $databaseRow)) {
358                    throw new \RuntimeException(
359                        'Required [\'databaseRow\'][\'uid\'] not found in data array',
360                        1481467208
361                    );
362                }
363                // May contain "NEW123..."
364                $namedConditionArray['uid'] = $databaseRow['uid'];
365                break;
366            case 'VERSION':
367                if (empty($conditionArray[1]) || $conditionArray[1] !== 'IS') {
368                    throw new \RuntimeException(
369                        'Version condition "' . $conditionString . '" must contain "IS" keyword: either "VERSION:IS:false" or "VERSION:IS:true"',
370                        1481383660
371                    );
372                }
373                if (empty($conditionArray[2])) {
374                    throw new \RuntimeException(
375                        'Version condition "' . $conditionString . '" must have an operand "true" or "false", none given. Example: "VERSION:IS:true',
376                        1481383888
377                    );
378                }
379                $operand = strtolower($conditionArray[2]);
380                if ($operand === 'true') {
381                    $namedConditionArray['isVersion'] = true;
382                } elseif ($operand === 'false') {
383                    $namedConditionArray['isVersion'] = false;
384                } else {
385                    throw new \RuntimeException(
386                        'Version condition "' . $conditionString . '" must have a "true" or "false" operand, example "VERSION:IS:true", given: ' . $operand,
387                        1481384123
388                    );
389                }
390                // Programming error: There must be a uid available, other data providers should have taken care of that already
391                if (!array_key_exists('uid', $databaseRow)) {
392                    throw new \RuntimeException(
393                        'Required [\'databaseRow\'][\'uid\'] not found in data array',
394                        1481469854
395                    );
396                }
397                $namedConditionArray['uid'] = $databaseRow['uid'];
398                if (array_key_exists('t3ver_oid', $databaseRow)) {
399                    $namedConditionArray['t3ver_oid'] = $databaseRow['t3ver_oid'];
400                }
401                if (array_key_exists('pid', $databaseRow)) {
402                    $namedConditionArray['pid'] = $databaseRow['pid'];
403                }
404                if (array_key_exists('_ORIG_pid', $databaseRow)) {
405                    $namedConditionArray['_ORIG_pid'] = $databaseRow['_ORIG_pid'];
406                }
407                break;
408            case 'USER':
409                if (empty($conditionArray[1])) {
410                    throw new \RuntimeException(
411                        'User function condition "' . $conditionString . '" must have a user function defined a second part, none given.'
412                        . ' Correct format is USER:\My\User\Func->match:more:arguments,'
413                        . ' given: ' . $conditionString,
414                        1481382954
415                    );
416                }
417                $namedConditionArray['function'] = $conditionArray[1];
418                array_shift($conditionArray);
419                array_shift($conditionArray);
420                $parameters = count($conditionArray) < 2
421                    ? $conditionArray
422                    : array_merge(
423                        [$conditionArray[0]],
424                        GeneralUtility::trimExplode(':', $conditionArray[1])
425                    );
426                $namedConditionArray['parameters'] = $parameters;
427                $namedConditionArray['record'] = $databaseRow;
428                $namedConditionArray['flexContext'] = $flexContext;
429                break;
430            default:
431                throw new \RuntimeException(
432                    'Unknown condition rule type "' . $namedConditionArray['type'] . '" with display condition "' . $conditionString . '".',
433                    1481381950
434                );
435        }
436        return $namedConditionArray;
437    }
438
439    /**
440     * Find field value the condition refers to for "FIELD:" conditions.  For "normal" TCA fields this is the value of
441     * a "neighbor" field, but in flex form context it can be prepended with a sheet name. The method sorts out the
442     * details and returns the current field value.
443     *
444     * @param string $givenFieldName The full name used in displayCond. Can have sheet names included in flex context
445     * @param array $databaseRow Incoming database row values
446     * @param array $flexContext Detailed flex context if display condition is within a flex field, needed to determine field value for "FIELD" conditions
447     * @throws \RuntimeException
448     * @return mixed The current field value from database row or a deeper flex form structure field.
449     */
450    protected function findFieldValue(string $givenFieldName, array $databaseRow, array $flexContext = [])
451    {
452        $fieldValue = null;
453
454        // Early return for "normal" tca fields
455        if (empty($flexContext)) {
456            if (array_key_exists($givenFieldName, $databaseRow)) {
457                $fieldValue = $databaseRow[$givenFieldName];
458            }
459            return $fieldValue;
460        }
461        if ($flexContext['context'] === 'flexSheet') {
462            // A display condition on a flex form sheet. Relatively simple: fieldName is either
463            // "parentRec.fieldName" pointing to a databaseRow field name, or "sheetName.fieldName" pointing
464            // to a field value from a neighbor field.
465            if (strpos($givenFieldName, 'parentRec.') === 0) {
466                $fieldName = substr($givenFieldName, 10);
467                if (array_key_exists($fieldName, $databaseRow)) {
468                    $fieldValue = $databaseRow[$fieldName];
469                }
470            } else {
471                if (array_key_exists($givenFieldName, $flexContext['sheetNameFieldNames'])) {
472                    if ($flexContext['currentSheetName'] === $flexContext['sheetNameFieldNames'][$givenFieldName]['sheetName']) {
473                        throw new \RuntimeException(
474                            'Configuring displayCond to "' . $givenFieldName . '" on flex form sheet "'
475                            . $flexContext['currentSheetName'] . '" referencing a value from the same sheet does not make sense.',
476                            1481485705
477                        );
478                    }
479                }
480                $sheetName = $flexContext['sheetNameFieldNames'][$givenFieldName]['sheetName'] ?? null;
481                $fieldName = $flexContext['sheetNameFieldNames'][$givenFieldName]['fieldName'] ?? null;
482                if (!isset($flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$fieldName]['vDEF'])) {
483                    throw new \RuntimeException(
484                        'Flex form displayCond on sheet "' . $flexContext['currentSheetName'] . '" references field "' . $fieldName
485                        . '" of sheet "' . $sheetName . '", but that field does not exist in current data structure',
486                        1481488492
487                    );
488                }
489                $fieldValue = $flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$fieldName]['vDEF'];
490            }
491        } elseif ($flexContext['context'] === 'flexField') {
492            // A display condition on a flex field. Handle "parentRec." similar to sheet conditions,
493            // get a list of "local" field names and see if they are used as reference, else see if a
494            // "sheetName.fieldName" field reference is given
495            if (strpos($givenFieldName, 'parentRec.') === 0) {
496                $fieldName = substr($givenFieldName, 10);
497                if (array_key_exists($fieldName, $databaseRow)) {
498                    $fieldValue = $databaseRow[$fieldName];
499                }
500            } else {
501                $listOfLocalFlexFieldNames = array_keys(
502                    $flexContext['flexFormDataStructure']['sheets'][$flexContext['currentSheetName']]['ROOT']['el']
503                );
504                if (in_array($givenFieldName, $listOfLocalFlexFieldNames, true)) {
505                    // Condition references field name of the same sheet
506                    $sheetName = $flexContext['currentSheetName'];
507                    if (!isset($flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$givenFieldName]['vDEF'])) {
508                        throw new \RuntimeException(
509                            'Flex form displayCond on field "' . $flexContext['currentFieldName'] . '" on flex form sheet "'
510                            . $flexContext['currentSheetName'] . '" references field "' . $givenFieldName . '", but a field value'
511                            . ' does not exist in this sheet',
512                            1481492953
513                        );
514                    }
515                    $fieldValue = $flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$givenFieldName]['vDEF'];
516                } elseif (in_array($givenFieldName, array_keys($flexContext['sheetNameFieldNames'], true))) {
517                    // Condition references field name including a sheet name
518                    $sheetName = $flexContext['sheetNameFieldNames'][$givenFieldName]['sheetName'];
519                    $fieldName = $flexContext['sheetNameFieldNames'][$givenFieldName]['fieldName'];
520                    $fieldValue = $flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$fieldName]['vDEF'];
521                } else {
522                    throw new \RuntimeException(
523                        'Flex form displayCond on field "' . $flexContext['currentFieldName'] . '" on flex form sheet "'
524                        . $flexContext['currentSheetName'] . '" references a field or field / sheet combination "'
525                        . $givenFieldName . '" that might be defined in given data structure but is not found in data values.',
526                        1481496170
527                    );
528                }
529            }
530        } elseif ($flexContext['context'] === 'flexContainerElement') {
531            // A display condition on a flex form section container element. Handle "parentRec.", compare to a
532            // list of local field names, compare to a list of field names from same sheet, compare to a list
533            // of sheet fields from other sheets.
534            if (strpos($givenFieldName, 'parentRec.') === 0) {
535                $fieldName = substr($givenFieldName, 10);
536                if (array_key_exists($fieldName, $databaseRow)) {
537                    $fieldValue = $databaseRow[$fieldName];
538                }
539            } else {
540                $currentSheetName = $flexContext['currentSheetName'];
541                $currentFieldName = $flexContext['currentFieldName'];
542                $currentContainerIdentifier = $flexContext['currentContainerIdentifier'];
543                $currentContainerElementName = $flexContext['currentContainerElementName'];
544                $listOfLocalContainerElementNames = array_keys(
545                    $flexContext['flexFormDataStructure']['sheets'][$currentSheetName]['ROOT']
546                        ['el'][$currentFieldName]
547                        ['children'][$currentContainerIdentifier]
548                        ['el']
549                );
550                $listOfLocalContainerElementNamesWithSheetName = [];
551                foreach ($listOfLocalContainerElementNames as $aContainerElementName) {
552                    $listOfLocalContainerElementNamesWithSheetName[$currentSheetName . '.' . $aContainerElementName] = [
553                        'containerElementName' => $aContainerElementName,
554                    ];
555                }
556                $listOfLocalFlexFieldNames = array_keys(
557                    $flexContext['flexFormDataStructure']['sheets'][$currentSheetName]['ROOT']['el']
558                );
559                if (in_array($givenFieldName, $listOfLocalContainerElementNames, true)) {
560                    // Condition references field of same container instance
561                    $containerType = current(array_keys(
562                        $flexContext['flexFormRowData']['data'][$currentSheetName]
563                            ['lDEF'][$currentFieldName]
564                            ['el'][$currentContainerIdentifier]
565                    ));
566                    $fieldValue = $flexContext['flexFormRowData']['data'][$currentSheetName]
567                        ['lDEF'][$currentFieldName]
568                        ['el'][$currentContainerIdentifier]
569                        [$containerType]
570                        ['el'][$givenFieldName]['vDEF'];
571                } elseif (in_array($givenFieldName, array_keys($listOfLocalContainerElementNamesWithSheetName, true))) {
572                    // Condition references field name of same container instance and has sheet name included
573                    $containerType = current(array_keys(
574                        $flexContext['flexFormRowData']['data'][$currentSheetName]
575                        ['lDEF'][$currentFieldName]
576                        ['el'][$currentContainerIdentifier]
577                    ));
578                    $fieldName = $listOfLocalContainerElementNamesWithSheetName[$givenFieldName]['containerElementName'];
579                    $fieldValue = $flexContext['flexFormRowData']['data'][$currentSheetName]
580                        ['lDEF'][$currentFieldName]
581                        ['el'][$currentContainerIdentifier]
582                        [$containerType]
583                        ['el'][$fieldName]['vDEF'];
584                } elseif (in_array($givenFieldName, $listOfLocalFlexFieldNames, true)) {
585                    // Condition reference field name of sheet this section container is in
586                    $fieldValue = $flexContext['flexFormRowData']['data'][$currentSheetName]
587                        ['lDEF'][$givenFieldName]['vDEF'];
588                } elseif (in_array($givenFieldName, array_keys($flexContext['sheetNameFieldNames'], true))) {
589                    $sheetName = $flexContext['sheetNameFieldNames'][$givenFieldName]['sheetName'];
590                    $fieldName = $flexContext['sheetNameFieldNames'][$givenFieldName]['fieldName'];
591                    $fieldValue = $flexContext['flexFormRowData']['data'][$sheetName]['lDEF'][$fieldName]['vDEF'];
592                } else {
593                    $containerType = current(array_keys(
594                        $flexContext['flexFormRowData']['data'][$currentSheetName]
595                        ['lDEF'][$currentFieldName]
596                        ['el'][$currentContainerIdentifier]
597                    ));
598                    throw new \RuntimeException(
599                        'Flex form displayCond on section container field "' . $currentContainerElementName . '" of container type "'
600                        . $containerType . '" on flex form sheet "'
601                        . $flexContext['currentSheetName'] . '" references a field or field / sheet combination "'
602                        . $givenFieldName . '" that might be defined in given data structure but is not found in data values.',
603                        1481634649
604                    );
605                }
606            }
607        }
608
609        return $fieldValue;
610    }
611
612    /**
613     * Loop through TCA, find prepared conditions and evaluate them. Delete either the
614     * field itself if the condition did not match, or the 'displayCond' in TCA.
615     *
616     * @param array $result
617     * @return array
618     */
619    protected function evaluateConditions(array $result): array
620    {
621        // Evaluate normal tca fields first
622        $listOfFlexFieldNames = [];
623        foreach ($result['processedTca']['columns'] as $columnName => $columnConfiguration) {
624            $conditionResult = true;
625            if (isset($columnConfiguration['displayCond'])) {
626                $conditionResult = $this->evaluateConditionRecursive($columnConfiguration['displayCond']);
627                if (!$conditionResult) {
628                    unset($result['processedTca']['columns'][$columnName]);
629                } else {
630                    // Always unset the whole parsed display condition to save some memory, we're done with them
631                    unset($result['processedTca']['columns'][$columnName]['displayCond']);
632                }
633            }
634            // If field was not removed and if it is a flex field, add to list of flex fields to scan
635            if ($conditionResult && $columnConfiguration['config']['type'] === 'flex') {
636                $listOfFlexFieldNames[] = $columnName;
637            }
638        }
639
640        // Search for flex fields and evaluate sheet conditions throwing them away if needed
641        foreach ($listOfFlexFieldNames as $columnName) {
642            $columnConfiguration = $result['processedTca']['columns'][$columnName];
643            foreach ($columnConfiguration['config']['ds']['sheets'] as $sheetName => $sheetConfiguration) {
644                if (isset($sheetConfiguration['ROOT']['displayCond']) && is_array($sheetConfiguration['ROOT']['displayCond'])) {
645                    if (!$this->evaluateConditionRecursive($sheetConfiguration['ROOT']['displayCond'])) {
646                        unset($result['processedTca']['columns'][$columnName]['config']['ds']['sheets'][$sheetName]);
647                    } else {
648                        unset($result['processedTca']['columns'][$columnName]['config']['ds']['sheets'][$sheetName]['ROOT']['displayCond']);
649                    }
650                }
651            }
652        }
653
654        // With full sheets gone we loop over display conditions of single fields in flex to throw fields away if needed
655        $listOfFlexSectionContainers = [];
656        foreach ($listOfFlexFieldNames as $columnName) {
657            $columnConfiguration = $result['processedTca']['columns'][$columnName];
658            if (is_array($columnConfiguration['config']['ds']['sheets'])) {
659                foreach ($columnConfiguration['config']['ds']['sheets'] as $sheetName => $sheetConfiguration) {
660                    if (isset($sheetConfiguration['ROOT']['el']) && is_array($sheetConfiguration['ROOT']['el'])) {
661                        foreach ($sheetConfiguration['ROOT']['el'] as $flexField => $flexConfiguration) {
662                            $conditionResult = true;
663                            if (isset($flexConfiguration['displayCond']) && is_array($flexConfiguration['displayCond'])) {
664                                $conditionResult = $this->evaluateConditionRecursive($flexConfiguration['displayCond']);
665                                if (!$conditionResult) {
666                                    unset(
667                                        $result['processedTca']['columns'][$columnName]['config']['ds']
668                                            ['sheets'][$sheetName]['ROOT']
669                                            ['el'][$flexField]
670                                    );
671                                } else {
672                                    unset(
673                                        $result['processedTca']['columns'][$columnName]['config']['ds']
674                                            ['sheets'][$sheetName]['ROOT']
675                                            ['el'][$flexField]['displayCond']
676                                    );
677                                }
678                            }
679                            // If it was not removed and if the field is a section container, add it to the section container list
680                            if ($conditionResult
681                                && isset($flexConfiguration['type']) && $flexConfiguration['type'] === 'array'
682                                && isset($flexConfiguration['section']) && $flexConfiguration['section'] == 1
683                                && isset($flexConfiguration['children']) && is_array($flexConfiguration['children'])
684                            ) {
685                                $listOfFlexSectionContainers[] = [
686                                    'columnName' => $columnName,
687                                    'sheetName' => $sheetName,
688                                    'flexField' => $flexField,
689                                ];
690                            }
691                        }
692                    }
693                }
694            }
695        }
696
697        // Loop over found section container elements and evaluate their conditions
698        foreach ($listOfFlexSectionContainers as $flexSectionContainerPosition) {
699            $columnName = $flexSectionContainerPosition['columnName'];
700            $sheetName = $flexSectionContainerPosition['sheetName'];
701            $flexField = $flexSectionContainerPosition['flexField'];
702            $sectionElement = $result['processedTca']['columns'][$columnName]['config']['ds']
703                ['sheets'][$sheetName]['ROOT']
704                ['el'][$flexField];
705            foreach ($sectionElement['children'] as $containerInstanceName => $containerDataStructure) {
706                if (isset($containerDataStructure['el']) && is_array($containerDataStructure['el'])) {
707                    foreach ($containerDataStructure['el'] as $containerElementName => $containerElementConfiguration) {
708                        if (isset($containerElementConfiguration['displayCond']) && is_array($containerElementConfiguration['displayCond'])) {
709                            if (!$this->evaluateConditionRecursive($containerElementConfiguration['displayCond'])) {
710                                unset(
711                                    $result['processedTca']['columns'][$columnName]['config']['ds']
712                                        ['sheets'][$sheetName]['ROOT']
713                                        ['el'][$flexField]
714                                        ['children'][$containerInstanceName]
715                                        ['el'][$containerElementName]
716                                );
717                            } else {
718                                unset(
719                                    $result['processedTca']['columns'][$columnName]['config']['ds']
720                                        ['sheets'][$sheetName]['ROOT']
721                                        ['el'][$flexField]
722                                        ['children'][$containerInstanceName]
723                                        ['el'][$containerElementName]['displayCond']
724                                );
725                            }
726                        }
727                    }
728                }
729            }
730        }
731
732        return $result;
733    }
734
735    /**
736     * Evaluate a condition recursive by evaluating the single condition type
737     *
738     * @param array $conditionArray The condition to evaluate, possibly with subConditions for AND and OR types
739     * @return bool true if the condition matched
740     */
741    protected function evaluateConditionRecursive(array $conditionArray): bool
742    {
743        switch ($conditionArray['type']) {
744            case 'AND':
745                $result = true;
746                foreach ($conditionArray['subConditions'] as $subCondition) {
747                    $result = $result && $this->evaluateConditionRecursive($subCondition);
748                }
749                return $result;
750            case 'OR':
751                $result = false;
752                foreach ($conditionArray['subConditions'] as $subCondition) {
753                    $result = $result || $this->evaluateConditionRecursive($subCondition);
754                }
755                return $result;
756            case 'FIELD':
757                return $this->matchFieldCondition($conditionArray);
758            case 'HIDE_FOR_NON_ADMINS':
759                return (bool)$this->getBackendUser()->isAdmin();
760            case 'REC':
761                return $this->matchRecordCondition($conditionArray);
762            case 'VERSION':
763                return $this->matchVersionCondition($conditionArray);
764            case 'USER':
765                return $this->matchUserCondition($conditionArray);
766        }
767        return false;
768    }
769
770    /**
771     * Evaluates conditions concerning a field of the current record.
772     *
773     * Example:
774     * "FIELD:sys_language_uid:>:0" => TRUE, if the field 'sys_language_uid' is greater than 0
775     *
776     * @param array $condition Condition array
777     * @return bool
778     */
779    protected function matchFieldCondition(array $condition): bool
780    {
781        $operator = $condition['operator'];
782        $operand = $condition['operand'];
783        $fieldValue = $condition['fieldValue'];
784        $result = false;
785        switch ($operator) {
786            case 'REQ':
787                if (is_array($fieldValue) && count($fieldValue) <= 1) {
788                    $fieldValue = array_shift($fieldValue);
789                }
790                if ($operand) {
791                    $result = (bool)$fieldValue;
792                } else {
793                    $result = !$fieldValue;
794                }
795                break;
796            case '>':
797                if (is_array($fieldValue) && count($fieldValue) <= 1) {
798                    $fieldValue = array_shift($fieldValue);
799                }
800                $result = $fieldValue > $operand;
801                break;
802            case '<':
803                if (is_array($fieldValue) && count($fieldValue) <= 1) {
804                    $fieldValue = array_shift($fieldValue);
805                }
806                $result = $fieldValue < $operand;
807                break;
808            case '>=':
809                if (is_array($fieldValue) && count($fieldValue) <= 1) {
810                    $fieldValue = array_shift($fieldValue);
811                }
812                if ($fieldValue === null) {
813                    // If field value is null, this is NOT greater than or equal 0
814                    // See test set "Field is not greater than or equal to zero if empty array given"
815                    $result = false;
816                } else {
817                    $result = $fieldValue >= $operand;
818                }
819                break;
820            case '<=':
821                if (is_array($fieldValue) && count($fieldValue) <= 1) {
822                    $fieldValue = array_shift($fieldValue);
823                }
824                $result = $fieldValue <= $operand;
825                break;
826            case '-':
827            case '!-':
828                if (is_array($fieldValue) && count($fieldValue) <= 1) {
829                    $fieldValue = array_shift($fieldValue);
830                }
831                $min = $condition['min'];
832                $max = $condition['max'];
833                $result = $fieldValue >= $min && $fieldValue <= $max;
834                if ($operator[0] === '!') {
835                    $result = !$result;
836                }
837                break;
838            case '=':
839            case '!=':
840                if (is_array($fieldValue) && count($fieldValue) <= 1) {
841                    $fieldValue = array_shift($fieldValue);
842                }
843                $result = $fieldValue == $operand;
844                if ($operator[0] === '!') {
845                    $result = !$result;
846                }
847                break;
848            case 'IN':
849            case '!IN':
850                if (is_array($fieldValue)) {
851                    $result = count(array_intersect($fieldValue, GeneralUtility::trimExplode(',', $operand))) > 0;
852                } else {
853                    $result = GeneralUtility::inList($operand, $fieldValue);
854                }
855                if ($operator[0] === '!') {
856                    $result = !$result;
857                }
858                break;
859            case 'BIT':
860            case '!BIT':
861                $result = (bool)((int)$fieldValue & $operand);
862                if ($operator[0] === '!') {
863                    $result = !$result;
864                }
865                break;
866        }
867        return $result;
868    }
869
870    /**
871     * Evaluates conditions concerning the status of the current record.
872     *
873     * Example:
874     * "REC:NEW:FALSE" => TRUE, if the record is already persisted (has a uid > 0)
875     *
876     * @param array $condition Condition array
877     * @return bool
878     */
879    protected function matchRecordCondition(array $condition): bool
880    {
881        if ($condition['isNew']) {
882            return !((int)$condition['uid'] > 0);
883        }
884        return (int)$condition['uid'] > 0;
885    }
886
887    /**
888     * Evaluates whether the current record is versioned.
889     *
890     * @param array $condition Condition array
891     * @return bool
892     */
893    protected function matchVersionCondition(array $condition): bool
894    {
895        $isNewRecord = !((int)$condition['uid'] > 0);
896        // Detection of version can be done by detecting the workspace of the user
897        $isUserInWorkspace = $this->getBackendUser()->workspace > 0;
898        if ((int)($condition['t3ver_oid'] ?? 0) > 0) {
899            $isRecordDetectedAsVersion = true;
900        } else {
901            $isRecordDetectedAsVersion = false;
902        }
903        // New records in a workspace are not handled as a version record
904        // if it's no new version, we detect versions like this:
905        // * if user is in workspace: always TRUE
906        // * if editor is in live ws: only TRUE if t3ver_oid > 0
907        $result = ($isUserInWorkspace || $isRecordDetectedAsVersion) && !$isNewRecord;
908        if (!$condition['isVersion']) {
909            $result = !$result;
910        }
911        return $result;
912    }
913
914    /**
915     * Evaluates via the referenced user-defined method
916     *
917     * @param array $condition Condition array
918     * @return bool
919     */
920    protected function matchUserCondition(array $condition): bool
921    {
922        $parameter = [
923            'record' => $condition['record'],
924            'flexContext' => $condition['flexContext'],
925            'flexformValueKey' => 'vDEF',
926            'conditionParameters' => $condition['parameters'],
927        ];
928        return (bool)GeneralUtility::callUserFunction($condition['function'], $parameter, $this);
929    }
930
931    /**
932     * Get current backend user
933     *
934     * @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
935     */
936    protected function getBackendUser()
937    {
938        return $GLOBALS['BE_USER'];
939    }
940}
941