[!!!][FEATURE] Refactor and streamline click menu / context menu
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / ContextMenu / ItemProviders / RecordProvider.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Backend\ContextMenu\ItemProviders;
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
18 use TYPO3\CMS\Backend\Utility\BackendUtility;
19 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
20 use TYPO3\CMS\Core\Type\Bitmask\Permission;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Versioning\VersionState;
23
24 /**
25 * Class responsible for providing click menu items for db records which don't have custom provider (as e.g. pages)
26 *
27 */
28 class RecordProvider extends AbstractProvider
29 {
30 /**
31 * Database record
32 *
33 * @var array
34 */
35 protected $record = [];
36
37 /**
38 * Database record of the page $this->record is placed on
39 *
40 * @var array
41 */
42 protected $pageRecord = [];
43
44 /**
45 * Local cache for the result of BackendUserAuthentication::calcPerms()
46 *
47 * @var int
48 */
49 protected $pagePermissions = 0;
50
51 /**
52 * @var array
53 */
54 protected $itemsConfiguration = [
55 'view' => [
56 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.view',
57 'iconIdentifier' => 'actions-document-view',
58 'callbackAction' => 'viewRecord'
59 ],
60 'edit' => [
61 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.edit',
62 'iconIdentifier' => 'actions-open',
63 'callbackAction' => 'editRecord'
64 ],
65 'new' => [
66 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.new',
67 'iconIdentifier' => 'actions-document-new',
68 'callbackAction' => 'newRecord'
69 ],
70 'info' => [
71 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.info',
72 'iconIdentifier' => 'actions-document-info',
73 'callbackAction' => 'openInfoPopUp'
74 ],
75 'divider1' => [
76 'type' => 'divider'
77 ],
78 'copy' => [
79 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
80 'iconIdentifier' => 'actions-edit-copy',
81 'callbackAction' => 'copy'
82 ],
83 'copyRelease' => [
84 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
85 'iconIdentifier' => 'actions-edit-copy-release',
86 'callbackAction' => 'clipboardRelease'
87 ],
88 'cut' => [
89 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cut',
90 'iconIdentifier' => 'actions-edit-cut',
91 'callbackAction' => 'cut'
92 ],
93 'cutRelease' => [
94 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cut',
95 'iconIdentifier' => 'actions-edit-cut-release',
96 'callbackAction' => 'clipboardRelease'
97 ],
98 'pasteAfter' => [
99 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.pasteafter',
100 'iconIdentifier' => 'actions-document-paste-after',
101 'callbackAction' => 'pasteAfter'
102 ],
103 'divider2' => [
104 'type' => 'divider'
105 ],
106 'more' => [
107 'type' => 'submenu',
108 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.more',
109 'iconIdentifier' => '',
110 'callbackAction' => 'openSubmenu',
111 'childItems' => [
112 'newWizard' => [
113 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_newWizard',
114 'iconIdentifier' => 'actions-document-new',
115 'callbackAction' => 'newContentWizard',
116 ],
117 'openListModule' => [
118 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_db_list',
119 'iconIdentifier' => 'actions-system-list-open',
120 'callbackAction' => 'openListModule',
121 ],
122 ],
123 ],
124 'divider3' => [
125 'type' => 'divider'
126 ],
127 'enable' => [
128 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:enable',
129 'iconIdentifier' => 'actions-edit-unhide',
130 'callbackAction' => 'enableRecord',
131 ],
132 'disable' => [
133 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:disable',
134 'iconIdentifier' => 'actions-edit-hide',
135 'callbackAction' => 'disableRecord',
136 ],
137 'delete' => [
138 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.delete',
139 'iconIdentifier' => 'actions-edit-delete',
140 'callbackAction' => 'deleteRecord',
141 ],
142 'history' => [
143 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_history',
144 'iconIdentifier' => 'actions-document-history-open',
145 'callbackAction' => 'openHistoryPopUp',
146 ],
147 ];
148
149 /**
150 * Whether this provider should kick in
151 *
152 * @return bool
153 */
154 public function canHandle(): bool
155 {
156 if (in_array($this->table, ['sys_file', 'sys_filemounts', 'sys_file_storage', 'pages'], true)
157 || strpos($this->table, '-drag') !== false) {
158 return false;
159 }
160 return isset($GLOBALS['TCA'][$this->table]);
161 }
162
163 /**
164 * Initialize db record
165 */
166 protected function initialize()
167 {
168 parent::initialize();
169 $this->record = BackendUtility::getRecordWSOL($this->table, $this->identifier);
170 }
171
172 /**
173 * Priority is set to lower then default value, in order to skip this provider if there is less generic provider available.
174 *
175 * @return int
176 */
177 public function getPriority(): int
178 {
179 return 60;
180 }
181
182 /**
183 * This provider works as a fallback if there is no provider dedicated for certain table, thus it's only kicking in when $items are empty.
184 *
185 * @param array $items
186 * @return array
187 */
188 public function addItems(array $items): array
189 {
190 if (!empty($items)) {
191 return $items;
192 }
193 $this->initialize();
194 return $this->prepareItems($this->itemsConfiguration);
195 }
196
197 /**
198 * Whether a given item can be rendered (e.g. user has enough permissions)
199 *
200 * @param string $itemName
201 * @param string $type
202 * @return bool
203 */
204 protected function canRender(string $itemName, string $type): bool
205 {
206 if (in_array($type, ['divider', 'submenu'], true)) {
207 return true;
208 }
209 if (in_array($itemName, $this->disabledItems, true)) {
210 return false;
211 }
212 $canRender = false;
213 switch ($itemName) {
214 case 'view':
215 $canRender = $this->canBeViewed();
216 break;
217 case 'edit':
218 case 'new':
219 $canRender = $this->canBeEdited();
220 break;
221 case 'newWizard':
222 $canRender = $this->canOpenNewCEWizard();
223 break;
224 case 'info':
225 $canRender = $this->canShowInfo();
226 break;
227 case 'enable':
228 $canRender = $this->canBeEnabled();
229 break;
230 case 'disable':
231 $canRender = $this->canBeDisabled();
232 break;
233 case 'delete':
234 $canRender = $this->canBeDeleted();
235 break;
236 case 'history':
237 $canRender = $this->canShowHistory();
238 break;
239 case 'openListModule':
240 $canRender = $this->canOpenListModule();
241 break;
242 case 'copy':
243 $canRender = $this->canBeCopied();
244 break;
245 case 'copyRelease':
246 $canRender = $this->isRecordInClipboard('copy');
247 break;
248 case 'cut':
249 $canRender = $this->canBeCut();
250 break;
251 case 'cutRelease':
252 $canRender = $this->isRecordInClipboard('cut');
253 break;
254 case 'pasteAfter':
255 $canRender = $this->canBePastedAfter();
256 break;
257 }
258 return $canRender;
259 }
260
261 /**
262 * Saves calculated permissions for a page containing given record, to speed things up
263 */
264 protected function initPermissions()
265 {
266 $this->pageRecord = BackendUtility::getRecord('pages', $this->record['pid']);
267 $this->pagePermissions = $this->backendUser->calcPerms($this->pageRecord);
268 }
269
270 /**
271 * Returns true if a current user have access to given permission
272 *
273 * @see BackendUserAuthentication::doesUserHaveAccess()
274 * @param int $permission
275 * @return bool
276 */
277 protected function hasPagePermission(int $permission): bool
278 {
279 return $this->backendUser->isAdmin() || ($this->pagePermissions & $permission) == $permission;
280 }
281
282 /**
283 * Additional attributes for JS
284 *
285 * @param string $itemName
286 * @return array
287 */
288 protected function getAdditionalAttributes(string $itemName): array
289 {
290 $attributes = [];
291 if ($itemName === 'view') {
292 $attributes += $this->getViewAdditionalAttributes();
293 }
294 if ($itemName === 'newWizard' && $this->table === 'tt_content') {
295 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod');
296 $moduleName = isset($tsConfig['properties']['newContentElementWizard.']['override'])
297 ? $tsConfig['properties']['newContentElementWizard.']['override']
298 : 'new_content_element';
299 $urlParameters = [
300 'id' => $this->record['pid'],
301 'sys_language_uid' => $this->record['sys_language_uid'],
302 'colPos' => $this->record['colPos'],
303 'uid_pid' => -$this->record['uid']
304 ];
305 $url = BackendUtility::getModuleUrl($moduleName, $urlParameters);
306 $attributes += [
307 'data-new-wizard-url' => htmlspecialchars($url)
308 ];
309 }
310 if ($itemName === 'delete') {
311 $attributes += $this->getDeleteAdditionalAttributes();
312 }
313 if ($itemName === 'openListModule') {
314 $attributes += [
315 'data-page-uid' => $this->record['pid']
316 ];
317 }
318 if ($itemName === 'pasteAfter') {
319 $attributes += $this->getPasteAdditionalAttributes('after');
320 }
321 return $attributes;
322 }
323
324 /**
325 * Additional attributes for the 'view' item
326 *
327 * @return array
328 */
329 protected function getViewAdditionalAttributes(): array
330 {
331 $attributes = [];
332 $viewLink = $this->getViewLink();
333 if ($viewLink) {
334 $attributes += [
335 'data-preview-url' => htmlspecialchars($viewLink),
336 ];
337 }
338 return $attributes;
339 }
340
341 /**
342 * Additional attributes for the pasteInto and pasteAfter items
343 *
344 * @param string $type "after" or "into"
345 * @return array
346 */
347 protected function getPasteAdditionalAttributes(string $type): array
348 {
349 $attributes = [];
350 if ($this->backendUser->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
351 $selItem = $this->clipboard->getSelectedRecord();
352 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste');
353
354 $confirmMessage = sprintf(
355 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.'
356 . ($this->clipboard->currentMode() === 'copy' ? 'copy' : 'move') . '_' . $type),
357 GeneralUtility::fixed_lgd_cs($selItem['_RECORD_TITLE'], $this->backendUser->uc['titleLen']),
358 GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen'])
359 );
360 $attributes += [
361 'data-title' => htmlspecialchars($title),
362 'data-message' => htmlspecialchars($confirmMessage)
363 ];
364 }
365 return $attributes;
366 }
367
368 /**
369 * Additional data for a "delete" action (confirmation modal title and message)
370 *
371 * @return array
372 */
373 protected function getDeleteAdditionalAttributes(): array
374 {
375 $attributes = [];
376 if ($this->backendUser->jsConfirmation(JsConfirmation::DELETE)) {
377 $recordTitle = GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen']);
378
379 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:delete');
380 $confirmMessage = sprintf(
381 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.delete'),
382 $recordTitle
383 );
384 $confirmMessage .= BackendUtility::referenceCount(
385 $this->table,
386 $this->record['uid'],
387 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord')
388 );
389 $confirmMessage .= BackendUtility::translationCount(
390 $this->table,
391 $this->record['uid'],
392 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord')
393 );
394 $attributes += [
395 'data-title' => htmlspecialchars($title),
396 'data-message' => htmlspecialchars($confirmMessage)
397 ];
398 }
399 return $attributes;
400 }
401
402 /**
403 * Returns id of the Page used for preview
404 *
405 * @return int
406 */
407 protected function getPreviewPid(): int
408 {
409 return (int)$this->record['pid'];
410 }
411
412 /**
413 * Returns the view link
414 *
415 * @return string
416 */
417 protected function getViewLink(): string
418 {
419 $javascriptLink = BackendUtility::viewOnClick($this->getPreviewPid());
420 $extractedLink = '';
421 if (preg_match('/window\\.open\\(\'([^\']+)\'/i', $javascriptLink, $match)) {
422 // Clean JSON-serialized ampersands ('&')
423 // @see GeneralUtility::quoteJSvalue()
424 $extractedLink = json_decode('"' . trim($match[1], '"') . '"');
425 }
426 return $extractedLink;
427 }
428
429 /**
430 * Checks if the page is allowed to show info
431 *
432 * @return bool
433 */
434 protected function canShowInfo(): bool
435 {
436 return true;
437 }
438
439 /**
440 * Checks if the page is allowed to show info
441 *
442 * @return bool
443 */
444 protected function canShowHistory(): bool
445 {
446 return true;
447 }
448
449 /**
450 * Checks if the record can be previewed in frontend
451 *
452 * @return bool
453 */
454 protected function canBeViewed(): bool
455 {
456 return $this->table === 'tt_content';
457 }
458
459 /**
460 * Whether a record can be edited
461 *
462 * @return bool
463 */
464 protected function canBeEdited(): bool
465 {
466 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) {
467 return false;
468 }
469 if ($this->backendUser->isAdmin()) {
470 return true;
471 }
472 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) {
473 return false;
474 }
475
476 $access = !$this->isRecordLocked()
477 && $this->backendUser->check('tables_modify', $this->table)
478 && $this->hasPagePermission(Permission::CONTENT_EDIT);
479 return $access;
480 }
481
482 /**
483 * Checks if the user has the right to delete the page
484 *
485 * @return bool
486 */
487 protected function canBeDeleted(): bool
488 {
489 $disableDeleteTS = $this->backendUser->getTSConfig('options.disableDelete');
490 $disableDelete = (bool) trim(isset($disableDeleteTS['properties'][$this->table]) ? $disableDeleteTS['properties'][$this->table] : (string)$disableDeleteTS['value']);
491 return !$disableDelete && $this->canBeEdited();
492 }
493
494 /**
495 * Returns true if current record can be unhidden/enabled
496 *
497 * @return bool
498 */
499 protected function canBeEnabled(): bool
500 {
501 return $this->hasDisableColumnWithValue(1) && $this->canBeEdited();
502 }
503
504 /**
505 * Returns true if current record can be hidden
506 *
507 * @return bool
508 */
509 protected function canBeDisabled(): bool
510 {
511 return $this->hasDisableColumnWithValue(0) && $this->canBeEdited();
512 }
513
514 /**
515 * Returns true new content element wizard can be shown
516 *
517 * @return bool
518 */
519 protected function canOpenNewCEWizard(): bool
520 {
521 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod.web_layout');
522 $wizardEnabled = true;
523 if (isset($tsConfig['properties']['disableNewContentElementWizard'])) {
524 $wizardEnabled = false;
525 }
526 return $this->table === 'tt_content' && $wizardEnabled && $this->canBeEdited();
527 }
528
529 /**
530 * @return bool
531 */
532 protected function canOpenListModule(): bool
533 {
534 return $this->backendUser->check('modules', 'web_list');
535 }
536
537 /**
538 * @return bool
539 */
540 protected function canBeCopied(): bool
541 {
542 return !$this->isRecordInClipboard('copy')
543 && !$this->isRecordATranslation();
544 }
545
546 /**
547 * @return bool
548 */
549 protected function canBeCut(): bool
550 {
551 return !$this->isRecordInClipboard('cut')
552 && $this->canBeEdited()
553 && !$this->isRecordATranslation();
554 }
555
556 /**
557 * Paste after is only shown for records from the same table (comparing record in clipboard and record clicked)
558 *
559 * @return bool
560 */
561 protected function canBePastedAfter(): bool
562 {
563 $clipboardElementCount = count($this->clipboard->elFromTable($this->table));
564
565 return $clipboardElementCount
566 && $this->backendUser->check('tables_modify', $this->table)
567 && $this->hasPagePermission(Permission::CONTENT_EDIT);
568 }
569
570 /**
571 * Checks if table have "disable" column (e.g. "hidden"), if user has access to this column
572 * and if it contains given value
573 *
574 * @param int $value
575 * @return bool
576 */
577 protected function hasDisableColumnWithValue(int $value): bool
578 {
579 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'])) {
580 $hiddenFieldName = $GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'];
581 if (
582 $hiddenFieldName !== '' && !empty($GLOBALS['TCA'][$this->table]['columns'][$hiddenFieldName]['exclude'])
583 && $this->backendUser->check('non_exclude_fields', $this->table . ':' . $hiddenFieldName)
584 ) {
585 return (int)$this->record[$hiddenFieldName] === (int)$value;
586 }
587 }
588 return false;
589 }
590
591 /**
592 * Record is locked if page is locked or page is not locked but record is
593 *
594 * @return bool
595 */
596 protected function isRecordLocked(): bool
597 {
598 return (int)$this->pageRecord['editlock'] === 1
599 || isset($GLOBALS['TCA'][$this->table]['ctrl']['editlock'])
600 && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['editlock']] === 1;
601 }
602
603 /**
604 * Returns true is a current record is a delete placeholder
605 *
606 * @return bool
607 */
608 protected function isDeletePlaceholder(): bool
609 {
610 if (!isset($this->record['t3ver_state'])) {
611 return false;
612 }
613 return VersionState::cast($this->record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER);
614 }
615
616 /**
617 * Checks if current record is in the "normal" pad of the clipboard
618 *
619 * @param string $mode "copy", "cut" or '' for any mode
620 * @return bool
621 */
622 protected function isRecordInClipboard(string $mode = ''): bool
623 {
624 $isSelected = '';
625 if ($this->clipboard->current === 'normal') {
626 $isSelected = $this->clipboard->isSelected($this->table, $this->record['uid']);
627 }
628 return $mode === '' ? !empty($isSelected) : $isSelected === $mode;
629 }
630
631 /**
632 * Returns true is a record ia a translation
633 *
634 * @return bool
635 */
636 protected function isRecordATranslation(): bool
637 {
638 return BackendUtility::isTableLocalizable($this->table) && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['transOrigPointerField']] !== 0;
639 }
640
641 /**
642 * @return string
643 */
644 protected function getIdentifier(): string
645 {
646 return $this->record['uid'];
647 }
648 }