router; } public function isEnabled(): bool { return (bool)($this->getBackendUser()->getTSConfig()['options.']['enableBookmarks'] ?? false); } /** * @return Bookmark[] */ public function getBookmarks(): array { $backendUser = $this->getBackendUser(); $userId = (int)$backendUser->user['uid']; $rows = $this->bookmarkRepository->findByUser($userId); // Build list of valid group IDs from existing groups $groups = $this->getGroups(); $validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups); $bookmarks = []; foreach ($rows as $row) { // Skip bookmarks the user cannot read (e.g., global bookmarks they can't navigate to) if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) { continue; } $row = $this->migrateBookmarkGroup($row, $validGroupIds); $bookmark = $this->createBookmarkFromRow($row, $backendUser); if ($bookmark !== null) { $bookmarks[] = $bookmark; } } return $bookmarks; } /** * Ensures bookmark is assigned to a valid group for output. */ private function migrateBookmarkGroup(array $row, array $validGroupIds): array { $groupUuid = $row['group_uuid'] ?? null; $scGroup = (int)($row['sc_group'] ?? 0); $groupId = $groupUuid !== null ? $groupUuid : $scGroup; if (in_array($groupId, $validGroupIds, true)) { return $row; } $row['group_uuid'] = null; $row['sc_group'] = $scGroup < 0 ? self::GROUP_SUPERGLOBAL : self::GROUP_DEFAULT; return $row; } /** * @return BookmarkGroup[] */ public function getGroups(): array { $groups = []; $backendUser = $this->getBackendUser(); $languageService = $this->getLanguageService(); $globalPrefix = $languageService->sL('core.bookmarks:global'); // User-created groups $userId = (int)$backendUser->user['uid']; $userGroupRows = $this->bookmarkRepository->findGroupsByUser($userId); foreach ($userGroupRows as $index => $row) { $groups[] = $this->createBookmarkGroup( $row['uuid'], $row['label'] ?? '', BookmarkGroupType::USER, (int)($row['sorting'] ?? $index), $backendUser, isset($row['userid']) ? (int)$row['userid'] : null, ); } // TSconfig groups and their global counterparts $configGroups = $backendUser->getTSConfig()['options.']['bookmarkGroups.'] ?? []; $tsconfigIndex = 0; if (is_array($configGroups)) { foreach ($configGroups as $groupId => $configLabel) { $groupId = (int)$groupId; if ($groupId <= 0 || $groupId === 100 || $configLabel === '' || $configLabel === null) { continue; } $label = $languageService->sL((string)$configLabel); $sorting = $tsconfigIndex++; $groups[] = $this->createBookmarkGroup( $groupId, $label, BookmarkGroupType::SYSTEM, $sorting, $backendUser, ); $groups[] = $this->createBookmarkGroup( -$groupId, $globalPrefix . ': ' . $label, BookmarkGroupType::GLOBAL, $sorting, $backendUser, ); } } // Superglobal group $groups[] = $this->createBookmarkGroup( self::GROUP_SUPERGLOBAL, $globalPrefix . ': ' . $languageService->sL('core.bookmarks:all'), BookmarkGroupType::GLOBAL, 0, $backendUser, ); // Default group $groups[] = $this->createBookmarkGroup( self::GROUP_DEFAULT, $languageService->sL('core.bookmarks:group_default'), BookmarkGroupType::SYSTEM, 100, $backendUser, ); // Sort by type priority, then by sorting value $priorityKeys = array_map(static fn(BookmarkGroup $g) => $g->type->getPriority(), $groups); $sortingKeys = array_map(static fn(BookmarkGroup $g) => $g->sorting, $groups); array_multisort($priorityKeys, SORT_ASC, SORT_NUMERIC, $sortingKeys, SORT_ASC, SORT_NUMERIC, $groups); return $groups; } public function getBookmark(int $id): ?Bookmark { $row = $this->bookmarkRepository->findById($id); if ($row === null) { return null; } $backendUser = $this->getBackendUser(); if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) { return null; } $groups = $this->getGroups(); $validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups); $row = $this->migrateBookmarkGroup($row, $validGroupIds); return $this->createBookmarkFromRow($row, $backendUser); } public function hasBookmark(string $routeIdentifier, string $arguments): bool { $userId = (int)$this->getBackendUser()->user['uid']; return $this->bookmarkRepository->exists($userId, $routeIdentifier, $arguments); } /** * @return int|false The ID of the newly created bookmark, or false on failure */ public function createBookmark(string $routeIdentifier, string $arguments = '', string $title = ''): int|false { $backendUser = $this->getBackendUser(); if (!$this->bookmarkVoter->vote(BookmarkVoter::CREATE, [], $backendUser)) { return false; } if (!$this->router->hasRoute($routeIdentifier)) { return false; } if ($arguments !== '' && !json_validate($arguments)) { return false; } $userId = (int)$backendUser->user['uid']; $result = $this->bookmarkRepository->insert($userId, $routeIdentifier, $arguments, $title); return $result; } /** * @return array{success: bool, error?: string} */ public function updateBookmark(int $id, string $title, int|string $groupId): array { $backendUser = $this->getBackendUser(); // Check if bookmark exists $bookmark = $this->bookmarkRepository->findById($id); if ($bookmark === null) { return $this->errorResponse( 'core.bookmarks:error.notFound.message' ); } // Check edit permission if (!$this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) { return $this->errorResponse( 'core.bookmarks:error.accessDenied.message' ); } // Check if user can use global groups if (is_int($groupId) && $groupId < 0 && !$backendUser->isAdmin()) { return $this->errorResponse( 'core.bookmarks:error.globalGroupNotAllowed.message' ); } $userId = $backendUser->isAdmin() ? null : (int)$backendUser->user['uid']; $allowGlobalGroups = $backendUser->isAdmin(); $this->bookmarkRepository->update( $id, $userId, $title, $groupId, $allowGlobalGroups ); return ['success' => true]; } /** * @return array{success: false, error: string} */ private function errorResponse(string $label): array { return [ 'success' => false, 'error' => $this->getLanguageService()->sL($label), ]; } /** * @return array{success: bool, error?: string} */ public function deleteBookmark(int $id): array { $backendUser = $this->getBackendUser(); $bookmark = $this->bookmarkRepository->findById($id); if ($bookmark === null) { return $this->errorResponse( 'core.bookmarks:error.notFound.message' ); } if (!$this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) { return $this->errorResponse( 'core.bookmarks:error.accessDenied.message' ); } $affectedRows = $this->bookmarkRepository->delete($id); if ($affectedRows !== 1) { return $this->errorResponse( 'core.bookmarks:error.deleteFailed.message' ); } return ['success' => true]; } /** * @param int[] $bookmarkIds */ public function reorderBookmarks(array $bookmarkIds): bool { $backendUser = $this->getBackendUser(); $userId = (int)$backendUser->user['uid']; // Fetch all bookmarks in one query $bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds); $sorting = 0; foreach ($bookmarkIds as $bookmarkId) { $bookmark = $bookmarks[$bookmarkId] ?? null; if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) { $this->bookmarkRepository->updateSorting($bookmarkId, $userId, $sorting++); } } return true; } /** * @param int[] $bookmarkIds */ public function deleteBookmarks(array $bookmarkIds): bool { $backendUser = $this->getBackendUser(); $userId = (int)$backendUser->user['uid']; // Fetch all bookmarks in one query $bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds); $manageableIds = []; foreach ($bookmarkIds as $bookmarkId) { $bookmark = $bookmarks[$bookmarkId] ?? null; if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) { $manageableIds[] = $bookmarkId; } } if ($manageableIds !== []) { $this->bookmarkRepository->deleteMultiple($manageableIds, $userId); } return true; } /** * @param int[] $bookmarkIds */ public function moveBookmarks(array $bookmarkIds, int|string $groupId): bool { $backendUser = $this->getBackendUser(); $userId = (int)$backendUser->user['uid']; $allowGlobalGroups = $backendUser->isAdmin(); // Validate target group access for user-created groups if (is_string($groupId)) { $group = $this->bookmarkRepository->findGroupByUuid($groupId); if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::READ, $group, $backendUser)) { return false; } } // Fetch all bookmarks in one query and filter to those user can edit $bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds); $manageableIds = []; foreach ($bookmarkIds as $bookmarkId) { $bookmark = $bookmarks[$bookmarkId] ?? null; if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) { $manageableIds[] = $bookmarkId; } } if ($manageableIds === []) { return false; } $this->bookmarkRepository->moveToGroup( $manageableIds, $userId, $groupId, $allowGlobalGroups ); return true; } public function createGroup(string $label): ?BookmarkGroup { $backendUser = $this->getBackendUser(); if (!$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::CREATE, [], $backendUser)) { return null; } $userId = (int)$backendUser->user['uid']; $uuid = $this->bookmarkRepository->createGroup($userId, $label); if ($uuid === null) { return null; } $row = $this->bookmarkRepository->findGroupByUuid($uuid); if ($row === null) { return null; } return $this->createBookmarkGroup( $row['uuid'], $row['label'] ?? '', BookmarkGroupType::USER, 0, $backendUser, isset($row['userid']) ? (int)$row['userid'] : null, ); } public function updateGroup(string $uuid, string $label): bool { $backendUser = $this->getBackendUser(); $group = $this->bookmarkRepository->findGroupByUuid($uuid); if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) { return false; } $this->bookmarkRepository->updateGroup($uuid, $label); return true; } public function deleteGroup(string $uuid): bool { $backendUser = $this->getBackendUser(); $group = $this->bookmarkRepository->findGroupByUuid($uuid); if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::DELETE, $group, $backendUser)) { return false; } $userId = (int)$backendUser->user['uid']; $this->bookmarkRepository->moveBookmarksFromGroupToDefault($uuid, $userId); $affectedRows = $this->bookmarkRepository->deleteGroup($uuid); return $affectedRows > 0; } /** * @param string[] $uuids */ public function reorderGroups(array $uuids): bool { $backendUser = $this->getBackendUser(); $userId = (int)$backendUser->user['uid']; // Get all user's groups in one query $userGroups = $this->bookmarkRepository->findGroupsByUser($userId); $groupsByUuid = []; foreach ($userGroups as $group) { $groupsByUuid[$group['uuid']] = $group; } // Verify all provided UUIDs can be edited by the user foreach ($uuids as $uuid) { $group = $groupsByUuid[$uuid] ?? null; if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) { return false; } } $this->bookmarkRepository->reorderGroups($uuids, $userId); return true; } private function createBookmarkFromRow(array $row, BackendUserAuthentication $user): ?Bookmark { $routeIdentifier = $row['route'] ?? ''; try { $arguments = json_decode($row['arguments'] ?? '', true, 64, JSON_THROW_ON_ERROR); } catch (\JsonException) { return null; } if (!is_array($arguments)) { return null; } $moduleName = $this->getModuleNameFromRouteIdentifier($routeIdentifier); if ($moduleName === '') { return null; } $accessible = $this->bookmarkVoter->vote(BookmarkVoter::NAVIGATE, $row, $user); $editable = $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $row, $user); $groupUuid = $row['group_uuid'] ?? null; $groupId = $groupUuid !== null ? $groupUuid : (int)$row['sc_group']; $bookmarkData = $this->parseRecordEditData($routeIdentifier, $arguments); $iconData = $this->resolveBookmarkIcon($routeIdentifier, $moduleName, $bookmarkData); $href = $accessible ? (string)$this->uriBuilder->buildUriFromRoute($routeIdentifier, $arguments) : ''; return new Bookmark( id: (int)$row['uid'], route: $routeIdentifier, arguments: $row['arguments'] ?? '', title: ($row['description'] ?? false) ?: 'Bookmark', groupId: $groupId, iconIdentifier: $iconData['identifier'], iconOverlayIdentifier: $iconData['overlay'], module: $moduleName, href: $href, editable: $editable, accessible: $accessible, ); } /** * @return array{identifier: string, overlay: string} */ private function resolveBookmarkIcon(string $routeIdentifier, string $moduleName, array $bookmarkData): array { $identifier = ''; $overlay = ''; switch ($routeIdentifier) { case 'record_edit': $table = $bookmarkData['table'] ?? ''; $recordid = $bookmarkData['recordid'] ?? 0; $action = $bookmarkData['action'] ?? ''; if ($action === 'edit') { $row = BackendUtility::getRecordWSOL($table, (int)$recordid) ?? []; $icon = $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL); } elseif ($action === 'new') { $icon = $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL); } else { $icon = $this->iconFactory->getIcon('empty-empty', IconSize::SMALL); } $identifier = $icon->getIdentifier(); $overlay = $icon->getOverlayIcon()?->getIdentifier() ?? ''; break; case 'file_edit': $identifier = 'mimetypes-text-html'; break; default: $iconIdentifier = ''; if ($module = $this->moduleProvider->getModule($moduleName, null, false)) { $iconIdentifier = $module->getIconIdentifier(); if ($iconIdentifier === '' && $module->getParentModule()) { $iconIdentifier = $module->getParentModule()->getIconIdentifier(); } } if ($iconIdentifier === '') { $iconIdentifier = 'empty-empty'; } $identifier = $iconIdentifier; } return [ 'identifier' => $identifier, 'overlay' => $overlay, ]; } private function createBookmarkGroup( int|string $id, string $label, BookmarkGroupType $type, int $sorting, BackendUserAuthentication $user, ?int $userid = null ): BookmarkGroup { $voterData = ['id' => $id, 'userid' => $userid]; $editable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $voterData, $user); $selectable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::SELECT, $voterData, $user); return new BookmarkGroup( id: $id, label: $label, type: $type, sorting: $sorting, editable: $editable, selectable: $selectable, ); } private function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } private function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } }