1<?php
2namespace TYPO3\CMS\Core\Imaging;
3
4/*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17use TYPO3\CMS\Core\Resource\File;
18use TYPO3\CMS\Core\Resource\FolderInterface;
19use TYPO3\CMS\Core\Resource\InaccessibleFolder;
20use TYPO3\CMS\Core\Resource\ResourceInterface;
21use TYPO3\CMS\Core\Type\Icon\IconState;
22use TYPO3\CMS\Core\Utility\GeneralUtility;
23use TYPO3\CMS\Core\Versioning\VersionState;
24use TYPO3\CMS\Extbase\SignalSlot\Dispatcher;
25
26/**
27 * The main factory class, which acts as the entrypoint for generating an Icon object which
28 * is responsible for rendering an icon. Checks for the correct icon provider through the IconRegistry.
29 */
30class IconFactory
31{
32    /**
33     * @var IconRegistry
34     */
35    protected $iconRegistry;
36
37    /**
38     * Mapping of record status to overlays.
39     * $GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['recordStatusMapping']
40     *
41     * @var string[]
42     */
43    protected $recordStatusMapping = [];
44
45    /**
46     * Order of priorities for overlays.
47     * $GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['overlayPriorities']
48     *
49     * @var string[]
50     */
51    protected $overlayPriorities = [];
52
53    /**
54     * Runtime icon cache
55     *
56     * @var array
57     */
58    protected static $iconCache = [];
59
60    /**
61     * @param IconRegistry $iconRegistry
62     */
63    public function __construct(IconRegistry $iconRegistry = null)
64    {
65        $this->iconRegistry = $iconRegistry ? $iconRegistry : GeneralUtility::makeInstance(IconRegistry::class);
66        $this->recordStatusMapping = $GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['recordStatusMapping'];
67        $this->overlayPriorities = $GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['overlayPriorities'];
68    }
69
70    /**
71     * @param string $identifier
72     * @param string $size "large", "small" or "default", see the constants of the Icon class
73     * @param string $overlayIdentifier
74     * @param IconState $state
75     * @return Icon
76     */
77    public function getIcon($identifier, $size = Icon::SIZE_DEFAULT, $overlayIdentifier = null, IconState $state = null)
78    {
79        $cacheIdentifier = md5($identifier . $size . $overlayIdentifier . (string)$state);
80        if (!empty(static::$iconCache[$cacheIdentifier])) {
81            return static::$iconCache[$cacheIdentifier];
82        }
83
84        if (
85            !$this->iconRegistry->isDeprecated($identifier)
86            && !$this->iconRegistry->isRegistered($identifier)
87        ) {
88            // in case icon identifier is neither deprecated nor registered
89            $identifier = $this->iconRegistry->getDefaultIconIdentifier();
90        }
91
92        $iconConfiguration = $this->iconRegistry->getIconConfigurationByIdentifier($identifier);
93        $iconConfiguration['state'] = $state;
94        $icon = $this->createIcon($identifier, $size, $overlayIdentifier, $iconConfiguration);
95
96        /** @var IconProviderInterface $iconProvider */
97        $iconProvider = GeneralUtility::makeInstance($iconConfiguration['provider']);
98        $iconProvider->prepareIconMarkup($icon, $iconConfiguration['options']);
99
100        static::$iconCache[$cacheIdentifier] = $icon;
101
102        return $icon;
103    }
104
105    /**
106     * This method is used throughout the TYPO3 Backend to show icons for a DB record
107     *
108     * @param string $table The TCA table name
109     * @param array $row The DB record of the TCA table
110     * @param string $size "large" "small" or "default", see the constants of the Icon class
111     * @return Icon
112     */
113    public function getIconForRecord($table, array $row, $size = Icon::SIZE_DEFAULT)
114    {
115        $iconIdentifier = $this->mapRecordTypeToIconIdentifier($table, $row);
116        $overlayIdentifier = $this->mapRecordTypeToOverlayIdentifier($table, $row);
117        return $this->getIcon($iconIdentifier, $size, $overlayIdentifier);
118    }
119
120    /**
121     * This helper functions looks up the column that is used for the type of the chosen TCA table and then fetches the
122     * corresponding iconName based on the chosen icon class in this TCA.
123     * The TCA looks up
124     * - [ctrl][typeicon_column]
125     * -
126     * This method solely takes care of the type of this record, not any statuses used for overlays.
127     *
128     * see EXT:core/Configuration/TCA/pages.php for an example with the TCA table "pages"
129     *
130     * @param string $table The TCA table
131     * @param array $row The selected record
132     * @internal
133     * @TODO: make this method protected, after FormEngine doesn't need it anymore.
134     * @return string The icon identifier string for the icon of that DB record
135     */
136    public function mapRecordTypeToIconIdentifier($table, array $row)
137    {
138        $recordType = [];
139        $ref = null;
140
141        if (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_column'])) {
142            $column = $GLOBALS['TCA'][$table]['ctrl']['typeicon_column'];
143            if (isset($row[$column])) {
144                // even if not properly documented the value of the typeicon_column in a record could be
145                // an array (multiselect) in typeicon_classes a key could consist of a comma-separated string "foo,bar"
146                // but mostly it should be only one entry in that array
147                if (is_array($row[$column])) {
148                    $recordType[1] = implode(',', $row[$column]);
149                } else {
150                    $recordType[1] = $row[$column];
151                }
152            } else {
153                $recordType[1] = 'default';
154            }
155            // Workaround to give nav_hide pages a complete different icon
156            // Although it's not a separate doctype
157            // and to give root-pages an own icon
158            if ($table === 'pages') {
159                if ((int)$row['nav_hide'] > 0) {
160                    $recordType[2] = $recordType[1] . '-hideinmenu';
161                }
162                if ((int)$row['is_siteroot'] > 0) {
163                    $recordType[3] = $recordType[1] . '-root';
164                }
165                if (!empty($row['module'])) {
166                    $recordType[4] = 'contains-' . $row['module'];
167                }
168                if ((int)$row['content_from_pid'] > 0) {
169                    if ($row['is_siteroot']) {
170                        $recordType[4] = 'page-contentFromPid-root';
171                    } else {
172                        $recordType[4] = (int)$row['nav_hide'] === 0
173                            ? 'page-contentFromPid' : 'page-contentFromPid-hideinmenu';
174                    }
175                }
176            }
177            if (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'])
178                && is_array($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'])
179            ) {
180                foreach ($recordType as $key => $type) {
181                    if (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'][$type])) {
182                        $recordType[$key] = $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'][$type];
183                    } else {
184                        unset($recordType[$key]);
185                    }
186                }
187                $recordType[0] = $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['default'];
188                if (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['mask'])) {
189                    $recordType[5] = str_replace(
190                        '###TYPE###',
191                        $row[$column],
192                        $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['mask']
193                    );
194                }
195                if (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['userFunc'])) {
196                    $parameters = ['row' => $row];
197                    $recordType[6] = GeneralUtility::callUserFunction(
198                        $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['userFunc'],
199                        $parameters,
200                        $ref
201                    );
202                }
203            } else {
204                foreach ($recordType as &$type) {
205                    $type = 'tcarecords-' . $table . '-' . $type;
206                }
207                unset($type);
208                $recordType[0] = 'tcarecords-' . $table . '-default';
209            }
210        } elseif (isset($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'])
211            && is_array($GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'])
212        ) {
213            $recordType[0] = $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes']['default'];
214        } else {
215            $recordType[0] = 'tcarecords-' . $table . '-default';
216        }
217
218        krsort($recordType);
219        foreach ($recordType as $iconName) {
220            if ($this->iconRegistry->isRegistered($iconName)) {
221                return $iconName;
222            }
223        }
224
225        return $this->iconRegistry->getDefaultIconIdentifier();
226    }
227
228    /**
229     * This helper function checks if the DB record ($row) has any special status based on the TCA settings
230     * like hidden, starttime etc, and then returns a specific icon overlay identifier for the overlay of this DB record
231     * This method solely takes care of the overlay of this record, not any type
232     *
233     * @param string $table The TCA table
234     * @param array $row The selected record
235     * @return string The status with the highest priority
236     */
237    protected function mapRecordTypeToOverlayIdentifier($table, array $row)
238    {
239        $tcaCtrl = $GLOBALS['TCA'][$table]['ctrl'];
240        // Calculate for a given record the actual visibility at the moment
241        $status = [
242            'hidden' => false,
243            'starttime' => false,
244            'endtime' => false,
245            'futureendtime' => false,
246            'fe_group' => false,
247            'deleted' => false,
248            'protectedSection' => false,
249            'nav_hide' => !empty($row['nav_hide']),
250        ];
251        // Icon state based on "enableFields":
252        if (isset($tcaCtrl['enablecolumns']) && is_array($tcaCtrl['enablecolumns'])) {
253            $enableColumns = $tcaCtrl['enablecolumns'];
254            // If "hidden" is enabled:
255            if (isset($enableColumns['disabled']) && !empty($row[$enableColumns['disabled']])) {
256                $status['hidden'] = true;
257            }
258            // If a "starttime" is set and higher than current time:
259            if (!empty($enableColumns['starttime']) && $GLOBALS['EXEC_TIME'] < (int)$row[$enableColumns['starttime']]) {
260                $status['starttime'] = true;
261            }
262            // If an "endtime" is set
263            if (!empty($enableColumns['endtime'])) {
264                if ((int)$row[$enableColumns['endtime']] > 0) {
265                    if ((int)$row[$enableColumns['endtime']] < $GLOBALS['EXEC_TIME']) {
266                        // End-timing applies at this point.
267                        $status['endtime'] = true;
268                    } else {
269                        // End-timing WILL apply in the future for this element.
270                        $status['futureendtime'] = true;
271                    }
272                }
273            }
274            // If a user-group field is set
275            if (!empty($enableColumns['fe_group']) && $row[$enableColumns['fe_group']]) {
276                $status['fe_group'] = true;
277            }
278        }
279        // If "deleted" flag is set (only when listing records which are also deleted!)
280        if (isset($tcaCtrl['delete']) && !empty($row[$tcaCtrl['delete']])) {
281            $status['deleted'] = true;
282        }
283        // Detecting extendToSubpages (for pages only)
284        if ($table === 'pages' && (int)$row['extendToSubpages'] > 0) {
285            $status['protectedSection'] = true;
286        }
287        if (isset($row['t3ver_state'])
288            && VersionState::cast($row['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) {
289            $status['deleted'] = true;
290        }
291
292        // Now only show the status with the highest priority
293        $iconName = '';
294        foreach ($this->overlayPriorities as $priority) {
295            if ($status[$priority]) {
296                $iconName = $this->recordStatusMapping[$priority];
297                break;
298            }
299        }
300
301        // Hook to define an alternative iconName
302        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['overrideIconOverlay'] ?? [] as $className) {
303            $hookObject = GeneralUtility::makeInstance($className);
304            if (method_exists($hookObject, 'postOverlayPriorityLookup')) {
305                $iconName = $hookObject->postOverlayPriorityLookup($table, $row, $status, $iconName);
306            }
307        }
308
309        return $iconName;
310    }
311
312    /**
313     * Get Icon for a file by its extension
314     *
315     * @param string $fileExtension
316     * @param string $size "large" "small" or "default", see the constants of the Icon class
317     * @param string $overlayIdentifier
318     * @return Icon
319     */
320    public function getIconForFileExtension($fileExtension, $size = Icon::SIZE_DEFAULT, $overlayIdentifier = null)
321    {
322        $iconName = $this->iconRegistry->getIconIdentifierForFileExtension($fileExtension);
323        return $this->getIcon($iconName, $size, $overlayIdentifier);
324    }
325
326    /**
327     * This method is used throughout the TYPO3 Backend to show icons for files and folders
328     *
329     * The method takes care of the translation of file extension to proper icon and for folders
330     * it will return the icon depending on the role of the folder.
331     *
332     * If the given resource is a folder there are some additional options that can be used:
333     *  - mount-root => TRUE (to indicate this is the root of a mount)
334     *  - folder-open => TRUE (to indicate that the folder is opened in the file tree)
335     *
336     * There is a hook in place to manipulate the icon name and overlays.
337     *
338     * @param ResourceInterface $resource
339     * @param string $size "large" "small" or "default", see the constants of the Icon class
340     * @param string $overlayIdentifier
341     * @param array $options An associative array with additional options.
342     * @return Icon
343     */
344    public function getIconForResource(
345        ResourceInterface $resource,
346        $size = Icon::SIZE_DEFAULT,
347        $overlayIdentifier = null,
348        array $options = []
349    ) {
350        $iconIdentifier = null;
351
352        // Folder
353        if ($resource instanceof FolderInterface) {
354            // non browsable storage
355            if ($resource->getStorage()->isBrowsable() === false && !empty($options['mount-root'])) {
356                $iconIdentifier = 'apps-filetree-folder-locked';
357            } else {
358                // storage root
359                if ($resource->getStorage()->getRootLevelFolder()->getIdentifier() === $resource->getIdentifier()) {
360                    $iconIdentifier = 'apps-filetree-root';
361                }
362
363                $role = is_callable([$resource, 'getRole']) ? $resource->getRole() : '';
364
365                // user/group mount root
366                if (!empty($options['mount-root'])) {
367                    $iconIdentifier = 'apps-filetree-mount';
368                    if ($role === FolderInterface::ROLE_READONLY_MOUNT) {
369                        $overlayIdentifier = 'overlay-locked';
370                    } elseif ($role === FolderInterface::ROLE_USER_MOUNT) {
371                        $overlayIdentifier = 'overlay-restricted';
372                    }
373                }
374
375                if ($iconIdentifier === null) {
376                    // in folder tree view $options['folder-open'] can define an open folder icon
377                    if (!empty($options['folder-open'])) {
378                        $iconIdentifier = 'apps-filetree-folder-opened';
379                    } else {
380                        $iconIdentifier = 'apps-filetree-folder-default';
381                    }
382
383                    if ($role === FolderInterface::ROLE_TEMPORARY) {
384                        $iconIdentifier = 'apps-filetree-folder-temp';
385                    } elseif ($role === FolderInterface::ROLE_RECYCLER) {
386                        $iconIdentifier = 'apps-filetree-folder-recycler';
387                    }
388                }
389
390                // if locked add overlay
391                if ($resource instanceof InaccessibleFolder ||
392                    !$resource->getStorage()->isBrowsable() ||
393                    !$resource->getStorage()->checkFolderActionPermission('add', $resource)
394                ) {
395                    $overlayIdentifier = 'overlay-locked';
396                }
397            }
398        } elseif ($resource instanceof File) {
399            $mimeTypeIcon = $this->iconRegistry->getIconIdentifierForMimeType($resource->getMimeType());
400
401            // Check if we find a exact matching mime type
402            if ($mimeTypeIcon !== null) {
403                $iconIdentifier = $mimeTypeIcon;
404            } else {
405                $fileExtensionIcon = $this->iconRegistry->getIconIdentifierForFileExtension($resource->getExtension());
406                if ($fileExtensionIcon !== 'mimetypes-other-other') {
407                    // Fallback 1: icon by file extension
408                    $iconIdentifier = $fileExtensionIcon;
409                } else {
410                    // Fallback 2: icon by mime type with subtype replaced by *
411                    $mimeTypeParts = explode('/', $resource->getMimeType());
412                    $mimeTypeIcon = $this->iconRegistry->getIconIdentifierForMimeType($mimeTypeParts[0] . '/*');
413                    if ($mimeTypeIcon !== null) {
414                        $iconIdentifier = $mimeTypeIcon;
415                    } else {
416                        // Fallback 3: use 'mimetypes-other-other'
417                        $iconIdentifier = $fileExtensionIcon;
418                    }
419                }
420            }
421            if ($resource->isMissing()) {
422                $overlayIdentifier = 'overlay-missing';
423            }
424        }
425
426        unset($options['mount-root']);
427        unset($options['folder-open']);
428        list($iconIdentifier, $overlayIdentifier) =
429            $this->emitBuildIconForResourceSignal($resource, $size, $options, $iconIdentifier, $overlayIdentifier);
430        return $this->getIcon($iconIdentifier, $size, $overlayIdentifier);
431    }
432
433    /**
434     * Creates an icon object
435     *
436     * @param string $identifier
437     * @param string $size "large", "small" or "default", see the constants of the Icon class
438     * @param string $overlayIdentifier
439     * @param array $iconConfiguration the icon configuration array
440     * @return Icon
441     */
442    protected function createIcon($identifier, $size, $overlayIdentifier = null, array $iconConfiguration = [])
443    {
444        $icon = GeneralUtility::makeInstance(Icon::class);
445        $icon->setIdentifier($identifier);
446        $icon->setSize($size);
447        $icon->setState($iconConfiguration['state'] ?: new IconState());
448        if (!empty($overlayIdentifier)) {
449            $icon->setOverlayIcon($this->getIcon($overlayIdentifier, Icon::SIZE_OVERLAY));
450        }
451        if (!empty($iconConfiguration['options']['spinning'])) {
452            $icon->setSpinning(true);
453        }
454
455        return $icon;
456    }
457
458    /**
459     * Emits a signal right after the identifiers are built.
460     *
461     * @param ResourceInterface $resource
462     * @param string $size
463     * @param array $options
464     * @param string $iconIdentifier
465     * @param string $overlayIdentifier
466     * @return mixed
467     * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException
468     * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException
469     */
470    protected function emitBuildIconForResourceSignal(
471        ResourceInterface $resource,
472        $size,
473        array $options,
474        $iconIdentifier,
475        $overlayIdentifier
476    ) {
477        $result = $this->getSignalSlotDispatcher()->dispatch(
478            self::class,
479            'buildIconForResourceSignal',
480            [$resource, $size, $options, $iconIdentifier, $overlayIdentifier]
481        );
482        $iconIdentifier = $result[3];
483        $overlayIdentifier = $result[4];
484        return [$iconIdentifier, $overlayIdentifier];
485    }
486
487    /**
488     * Get the SignalSlot dispatcher
489     *
490     * @return \TYPO3\CMS\Extbase\SignalSlot\Dispatcher
491     */
492    protected function getSignalSlotDispatcher()
493    {
494        return GeneralUtility::makeInstance(Dispatcher::class);
495    }
496
497    /**
498     * clear icon cache
499     */
500    public function clearIconCache()
501    {
502        static::$iconCache = [];
503    }
504}
505