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\Element;
17
18use TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeTrait;
19use TYPO3\CMS\Backend\Utility\BackendUtility;
20use TYPO3\CMS\Core\Imaging\Icon;
21use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
22use TYPO3\CMS\Core\LinkHandling\LinkService;
23use TYPO3\CMS\Core\Localization\LanguageService;
24use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
25use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
26use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
27use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
28use TYPO3\CMS\Core\Resource\File;
29use TYPO3\CMS\Core\Resource\Folder;
30use TYPO3\CMS\Core\Utility\GeneralUtility;
31use TYPO3\CMS\Core\Utility\MathUtility;
32use TYPO3\CMS\Core\Utility\StringUtility;
33use TYPO3\CMS\Frontend\Service\TypoLinkCodecService;
34
35/**
36 * Link input element.
37 *
38 * Shows current link and the link popup.
39 */
40class InputLinkElement extends AbstractFormElement
41{
42    use OnFieldChangeTrait;
43
44    /**
45     * Default field information enabled for this element.
46     *
47     * @var array
48     */
49    protected $defaultFieldInformation = [
50        'tcaDescription' => [
51            'renderType' => 'tcaDescription',
52        ],
53    ];
54
55    /**
56     * Default field controls render the link icon
57     *
58     * @var array
59     */
60    protected $defaultFieldControl = [
61        'linkPopup' => [
62            'renderType' => 'linkPopup',
63            'options' => [],
64        ],
65    ];
66
67    /**
68     * Default field wizards enabled for this element.
69     *
70     * @var array
71     */
72    protected $defaultFieldWizard = [
73        'localizationStateSelector' => [
74            'renderType' => 'localizationStateSelector',
75        ],
76        'otherLanguageContent' => [
77            'renderType' => 'otherLanguageContent',
78            'after' => [
79                'localizationStateSelector',
80            ],
81        ],
82        'defaultLanguageDifferences' => [
83            'renderType' => 'defaultLanguageDifferences',
84            'after' => [
85                'otherLanguageContent',
86            ],
87        ],
88    ];
89
90    /**
91     * This will render a single-line input form field, possibly with various control/validation features
92     *
93     * @return array As defined in initializeResultArray() of AbstractNode
94     */
95    public function render()
96    {
97        $languageService = $this->getLanguageService();
98
99        $table = $this->data['tableName'];
100        $fieldName = $this->data['fieldName'];
101        $row = $this->data['databaseRow'];
102        $parameterArray = $this->data['parameterArray'];
103        $resultArray = $this->initializeResultArray();
104        $config = $parameterArray['fieldConf']['config'];
105
106        $itemValue = $parameterArray['itemFormElValue'];
107        $evalList = GeneralUtility::trimExplode(',', $config['eval'] ?? '', true);
108        $size = MathUtility::forceIntegerInRange($config['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth);
109        $width = (int)$this->formMaxWidth($size);
110        $nullControlNameEscaped = htmlspecialchars('control[active][' . $table . '][' . $row['uid'] . '][' . $fieldName . ']');
111
112        $fieldInformationResult = $this->renderFieldInformation();
113        $fieldInformationHtml = $fieldInformationResult['html'];
114        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
115
116        if ($config['readOnly'] ?? false) {
117            // Early return for read only fields
118            $html = [];
119            $html[] = '<div class="formengine-field-item t3js-formengine-field-item">';
120            $html[] =   $fieldInformationHtml;
121            $html[] =   '<div class="form-wizards-wrap">';
122            $html[] =       '<div class="form-wizards-element">';
123            $html[] =           '<div class="form-control-wrap" style="max-width: ' . $width . 'px">';
124            $html[] =               '<input class="form-control" value="' . htmlspecialchars($itemValue) . '" type="text" disabled>';
125            $html[] =           '</div>';
126            $html[] =       '</div>';
127            $html[] =   '</div>';
128            $html[] = '</div>';
129            $resultArray['html'] = implode(LF, $html);
130            return $resultArray;
131        }
132
133        // @todo: The whole eval handling is a mess and needs refactoring
134        foreach ($evalList as $func) {
135            // @todo: This is ugly: The code should find out on it's own whether an eval definition is a
136            // @todo: keyword like "date", or a class reference. The global registration could be dropped then
137            // Pair hook to the one in \TYPO3\CMS\Core\DataHandling\DataHandler::checkValue_input_Eval()
138            if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
139                if (class_exists($func)) {
140                    $evalObj = GeneralUtility::makeInstance($func);
141                    if (method_exists($evalObj, 'deevaluateFieldValue')) {
142                        $_params = [
143                            'value' => $itemValue,
144                        ];
145                        $itemValue = $evalObj->deevaluateFieldValue($_params);
146                    }
147                    $resultArray = $this->resolveJavaScriptEvaluation($resultArray, $func, $evalObj);
148                }
149            }
150        }
151
152        $fieldId = StringUtility::getUniqueId('formengine-input-');
153
154        $attributes = [
155            'value' => '',
156            'id' => $fieldId,
157            'class' => implode(' ', [
158                'form-control',
159                't3js-clearable',
160                't3js-form-field-inputlink-input',
161                'hidden',
162                'hasDefaultValue',
163            ]),
164            'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config),
165            'data-formengine-input-params' => (string)json_encode([
166                'field' => $parameterArray['itemFormElName'],
167                'evalList' => implode(',', $evalList),
168            ]),
169            'data-formengine-input-name' => (string)($parameterArray['itemFormElName'] ?? ''),
170        ];
171
172        $maxLength = $config['max'] ?? 0;
173        if ((int)$maxLength > 0) {
174            $attributes['maxlength'] = (string)(int)$maxLength;
175        }
176        if (!empty($config['placeholder'])) {
177            $attributes['placeholder'] = trim($config['placeholder']);
178        }
179        if (isset($config['autocomplete'])) {
180            $attributes['autocomplete'] = empty($config['autocomplete']) ? 'new-' . $fieldName : 'on';
181        }
182
183        $valuePickerHtml = [];
184        if (isset($config['valuePicker']['items']) && is_array($config['valuePicker']['items'])) {
185            $valuePickerConfiguration = [
186                'mode' => $config['valuePicker']['mode'] ?? 'replace',
187                'linked-field' => '[data-formengine-input-name="' . $parameterArray['itemFormElName'] . '"]',
188            ];
189            $valuePickerAttributes = array_merge(
190                [
191                    'class' => 'form-select form-control-adapt',
192                ],
193                $this->getOnFieldChangeAttrs('change', $parameterArray['fieldChangeFunc'] ?? [])
194            );
195
196            $valuePickerHtml[] = '<typo3-formengine-valuepicker ' . GeneralUtility::implodeAttributes($valuePickerConfiguration, true) . '>';
197            $valuePickerHtml[] = '<select ' . GeneralUtility::implodeAttributes($valuePickerAttributes, true) . '>';
198            $valuePickerHtml[] = '<option></option>';
199            foreach ($config['valuePicker']['items'] as $item) {
200                $valuePickerHtml[] = '<option value="' . htmlspecialchars($item[1]) . '">' . htmlspecialchars($languageService->sL($item[0])) . '</option>';
201            }
202            $valuePickerHtml[] = '</select>';
203            $valuePickerHtml[] = '</typo3-formengine-valuepicker>';
204
205            $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/FormEngine/FieldWizard/ValuePicker');
206        }
207
208        $fieldWizardResult = $this->renderFieldWizard();
209        $fieldWizardHtml = $fieldWizardResult['html'];
210        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
211
212        $fieldControlResult = $this->renderFieldControl();
213        $fieldControlHtml = $fieldControlResult['html'];
214        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
215
216        $linkExplanation = $this->getLinkExplanation($itemValue ?: '');
217        $explanation = htmlspecialchars($linkExplanation['text'] ?? '');
218        $toggleButtonTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.toggleLinkExplanation');
219
220        $expansionHtml = [];
221        $expansionHtml[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">';
222        $expansionHtml[] =  '<div class="form-wizards-wrap">';
223        $expansionHtml[] =      '<div class="form-wizards-element">';
224        $expansionHtml[] =          '<div class="input-group t3js-form-field-inputlink">';
225        $expansionHtml[] =              '<span class="t3js-form-field-inputlink-icon input-group-addon">' . ($linkExplanation['icon'] ?? '') . '</span>';
226        $expansionHtml[] =              '<input class="form-control t3js-form-field-inputlink-explanation" data-bs-toggle="tooltip" title="' . $explanation . '" value="' . $explanation . '" readonly>';
227        $expansionHtml[] =              '<input type="text" ' . GeneralUtility::implodeAttributes($attributes, true) . ' />';
228        $expansionHtml[] =              '<button class="btn btn-default t3js-form-field-inputlink-explanation-toggle" type="button" title="' . htmlspecialchars($toggleButtonTitle) . '">';
229        $expansionHtml[] =                  $this->iconFactory->getIcon('actions-version-workspaces-preview-link', Icon::SIZE_SMALL)->render();
230        $expansionHtml[] =              '</button>';
231        $expansionHtml[] =              '<input type="hidden" name="' . $parameterArray['itemFormElName'] . '" value="' . htmlspecialchars($itemValue) . '" />';
232        $expansionHtml[] =          '</div>';
233        $expansionHtml[] =      '</div>';
234        if (!empty($valuePickerHtml) || !empty($fieldControlHtml)) {
235            $expansionHtml[] =      '<div class="form-wizards-items-aside form-wizards-items-aside--field-control">';
236            $expansionHtml[] =          '<div class="btn-group">';
237            $expansionHtml[] =              implode(LF, $valuePickerHtml);
238            $expansionHtml[] =              $fieldControlHtml;
239            $expansionHtml[] =          '</div>';
240            $expansionHtml[] =      '</div>';
241        }
242        $expansionHtml[] =      '<div class="form-wizards-items-bottom">';
243        $expansionHtml[] =          $linkExplanation['additionalAttributes'] ?? '';
244        $expansionHtml[] =          $fieldWizardHtml;
245        $expansionHtml[] =      '</div>';
246        $expansionHtml[] =  '</div>';
247        $expansionHtml[] = '</div>';
248        $expansionHtml = implode(LF, $expansionHtml);
249
250        $fullElement = $expansionHtml;
251        if ($this->hasNullCheckboxButNoPlaceholder()) {
252            $checked = $itemValue !== null ? ' checked="checked"' : '';
253            $fullElement = [];
254            $fullElement[] = '<div class="t3-form-field-disable"></div>';
255            $fullElement[] = '<div class="form-check t3-form-field-eval-null-checkbox">';
256            $fullElement[] =     '<input type="hidden" name="' . $nullControlNameEscaped . '" value="0" />';
257            $fullElement[] =     '<input type="checkbox" class="form-check-input" name="' . $nullControlNameEscaped . '" id="' . $nullControlNameEscaped . '" value="1"' . $checked . ' />';
258            $fullElement[] =     '<label class="form-check-label" for="' . $nullControlNameEscaped . '">';
259            $fullElement[] =         $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.nullCheckbox');
260            $fullElement[] =     '</label>';
261            $fullElement[] = '</div>';
262            $fullElement[] = $expansionHtml;
263            $fullElement = implode(LF, $fullElement);
264        } elseif ($this->hasNullCheckboxWithPlaceholder()) {
265            $checked = $itemValue !== null ? ' checked="checked"' : '';
266            $placeholder = $shortenedPlaceholder = $config['placeholder'] ?? '';
267            $disabled = '';
268            $fallbackValue = 0;
269            if (strlen($placeholder) > 0) {
270                $shortenedPlaceholder = GeneralUtility::fixed_lgd_cs($placeholder, 20);
271                if ($placeholder !== $shortenedPlaceholder) {
272                    $overrideLabel = sprintf(
273                        $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'),
274                        '<span title="' . htmlspecialchars($placeholder) . '">' . htmlspecialchars($shortenedPlaceholder) . '</span>'
275                    );
276                } else {
277                    $overrideLabel = sprintf(
278                        $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'),
279                        htmlspecialchars($placeholder)
280                    );
281                }
282            } else {
283                $overrideLabel = $languageService->sL(
284                    'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override_not_available'
285                );
286            }
287            $fullElement = [];
288            $fullElement[] = '<div class="form-check t3js-form-field-eval-null-placeholder-checkbox">';
289            $fullElement[] =     '<input type="hidden" name="' . $nullControlNameEscaped . '" value="' . $fallbackValue . '" />';
290            $fullElement[] =     '<input type="checkbox" class="form-check-input" name="' . $nullControlNameEscaped . '" id="' . $nullControlNameEscaped . '" value="1"' . $checked . $disabled . ' />';
291            $fullElement[] =     '<label class="form-check-label" for="' . $nullControlNameEscaped . '">';
292            $fullElement[] =         $overrideLabel;
293            $fullElement[] =     '</label>';
294            $fullElement[] = '</div>';
295            $fullElement[] = '<div class="t3js-formengine-placeholder-placeholder">';
296            $fullElement[] =    '<div class="form-control-wrap" style="max-width:' . $width . 'px">';
297            $fullElement[] =        '<input type="text" class="form-control" disabled="disabled" value="' . htmlspecialchars($shortenedPlaceholder) . '" />';
298            $fullElement[] =    '</div>';
299            $fullElement[] = '</div>';
300            $fullElement[] = '<div class="t3js-formengine-placeholder-formfield">';
301            $fullElement[] =    $expansionHtml;
302            $fullElement[] = '</div>';
303            $fullElement = implode(LF, $fullElement);
304        }
305
306        $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS(
307            'TYPO3/CMS/Backend/FormEngine/Element/InputLinkElement'
308        )->instance($fieldId);
309        $resultArray['html'] = '<div class="formengine-field-item t3js-formengine-field-item">' . $fieldInformationHtml . $fullElement . '</div>';
310        return $resultArray;
311    }
312
313    /**
314     * @param string $itemValue
315     * @return array
316     */
317    protected function getLinkExplanation(string $itemValue): array
318    {
319        if (empty($itemValue)) {
320            return [];
321        }
322        $data = ['text' => '', 'icon' => ''];
323        $typolinkService = GeneralUtility::makeInstance(TypoLinkCodecService::class);
324        $linkParts = $typolinkService->decode($itemValue);
325        $linkService = GeneralUtility::makeInstance(LinkService::class);
326
327        try {
328            $linkData = $linkService->resolve($linkParts['url']);
329        } catch (FileDoesNotExistException|FolderDoesNotExistException|UnknownLinkHandlerException|InvalidPathException $e) {
330            return $data;
331        }
332
333        // Resolving the TypoLink parts (class, title, params)
334        $additionalAttributes = [];
335        foreach ($linkParts as $key => $value) {
336            if ($key === 'url') {
337                continue;
338            }
339            if ($value) {
340                switch ($key) {
341                    case 'class':
342                        $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:class');
343                        break;
344                    case 'title':
345                        $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:title');
346                        break;
347                    case 'additionalParams':
348                        $label = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_browse_links.xlf:params');
349                        break;
350                    default:
351                        $label = (string)$key;
352                }
353
354                $additionalAttributes[] = '<span><strong>' . htmlspecialchars($label) . ': </strong> ' . htmlspecialchars($value) . '</span>';
355            }
356        }
357
358        // Resolve the actual link
359        switch ($linkData['type']) {
360            case LinkService::TYPE_PAGE:
361                $pageRecord = BackendUtility::readPageAccess($linkData['pageuid'], '1=1');
362                // Is this a real page
363                if ($pageRecord['uid'] ?? 0) {
364                    $fragmentTitle = '';
365                    if (isset($linkData['fragment'])) {
366                        if (MathUtility::canBeInterpretedAsInteger($linkData['fragment'])) {
367                            $contentElement = BackendUtility::getRecord('tt_content', (int)$linkData['fragment'], '*', 'pid=' . $pageRecord['uid']);
368                            if ($contentElement) {
369                                $fragmentTitle = BackendUtility::getRecordTitle('tt_content', $contentElement, false, false);
370                            }
371                        }
372                        $fragmentTitle = ' #' . ($fragmentTitle ?: $linkData['fragment']);
373                    }
374                    $data = [
375                        'text' => $pageRecord['_thePathFull'] . '[' . $pageRecord['uid'] . ']' . $fragmentTitle,
376                        'icon' => $this->iconFactory->getIconForRecord('pages', $pageRecord, Icon::SIZE_SMALL)->render(),
377                    ];
378                }
379                break;
380            case LinkService::TYPE_EMAIL:
381                $data = [
382                    'text' => $linkData['email'],
383                    'icon' => $this->iconFactory->getIcon('content-elements-mailform', Icon::SIZE_SMALL)->render(),
384                ];
385                break;
386            case LinkService::TYPE_URL:
387                $data = [
388                    'text' => $linkData['url'],
389                    'icon' => $this->iconFactory->getIcon('apps-pagetree-page-shortcut-external', Icon::SIZE_SMALL)->render(),
390
391                ];
392                break;
393            case LinkService::TYPE_FILE:
394                /** @var File $file */
395                $file = $linkData['file'];
396                if ($file) {
397                    $data = [
398                        'text' => $file->getPublicUrl(),
399                        'icon' => $this->iconFactory->getIconForFileExtension($file->getExtension(), Icon::SIZE_SMALL)->render(),
400                    ];
401                }
402                break;
403            case LinkService::TYPE_FOLDER:
404                /** @var Folder $folder */
405                $folder = $linkData['folder'];
406                if ($folder) {
407                    $data = [
408                        'text' => $folder->getPublicUrl(),
409                        'icon' => $this->iconFactory->getIcon('apps-filetree-folder-default', Icon::SIZE_SMALL)->render(),
410                    ];
411                }
412                break;
413            case LinkService::TYPE_RECORD:
414                $table = $this->data['pageTsConfig']['TCEMAIN.']['linkHandler.'][$linkData['identifier'] . '.']['configuration.']['table'] ?? '';
415                $record = BackendUtility::getRecord($table, $linkData['uid']);
416                if ($record) {
417                    $recordTitle = BackendUtility::getRecordTitle($table, $record);
418                    $tableTitle = $this->getLanguageService()->sL($GLOBALS['TCA'][$table]['ctrl']['title']);
419                    $data = [
420                        'text' => sprintf('%s [%s:%d]', $recordTitle, $tableTitle, $linkData['uid']),
421                        'icon' => $this->iconFactory->getIconForRecord($table, $record, Icon::SIZE_SMALL)->render(),
422                    ];
423                } else {
424                    $data = [
425                        'text' => sprintf('%s', $linkData['uid']),
426                        'icon' => $this->iconFactory->getIcon('tcarecords-' . $table . '-default', Icon::SIZE_SMALL, 'overlay-missing')->render(),
427                    ];
428                }
429                break;
430            case LinkService::TYPE_TELEPHONE:
431                $telephone = $linkData['telephone'];
432                if ($telephone) {
433                    $data = [
434                        'text' => $telephone,
435                        'icon' => $this->iconFactory->getIcon('actions-device-mobile', Icon::SIZE_SMALL)->render(),
436                    ];
437                }
438                break;
439            default:
440                // Please note that this hook is preliminary and might change, as this element could become its own
441                // TCA type in the future
442                if (isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler'][$linkData['type']])) {
443                    $linkBuilder = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler'][$linkData['type']]);
444                    $data = $linkBuilder->getFormData($linkData, $linkParts, $this->data, $this);
445                } elseif ($linkData['type'] === LinkService::TYPE_UNKNOWN) {
446                    $data = [
447                        'text' => $linkData['file'],
448                        'icon' => $this->iconFactory->getIcon('actions-link', Icon::SIZE_SMALL)->render(),
449                    ];
450                } else {
451                    $data = [
452                        'text' => 'not implemented type ' . $linkData['type'],
453                        'icon' => '',
454                    ];
455                }
456        }
457
458        $data['additionalAttributes'] = '<div class="help-block">' . implode(' - ', $additionalAttributes) . '</div>';
459        return $data;
460    }
461
462    /**
463     * @return LanguageService
464     */
465    protected function getLanguageService()
466    {
467        return $GLOBALS['LANG'];
468    }
469}
470