*/ public static function allowedGroupIcons(): array { return self::ALLOWED_GROUP_ICONS; } /** @return array icon class => translated label */ public static function allowedGroupIconLabels(): array { return [ 'bi-house' => t('Icon: Home'), 'bi-people' => t('Icon: People'), 'bi-person' => t('Icon: Person'), 'bi-folder' => t('Icon: Folder'), 'bi-file-text' => t('Icon: Document'), 'bi-gear' => t('Icon: Settings'), 'bi-shield-lock' => t('Icon: Security'), 'bi-bar-chart' => t('Icon: Chart'), 'bi-envelope' => t('Icon: Mail'), 'bi-calendar' => t('Icon: Calendar'), 'bi-clock' => t('Icon: Clock'), 'bi-star' => t('Icon: Star'), 'bi-heart' => t('Icon: Heart'), 'bi-flag' => t('Icon: Flag'), 'bi-bookmark' => t('Icon: Bookmark'), 'bi-pin-map' => t('Icon: Location'), 'bi-lightning' => t('Icon: Lightning'), 'bi-database' => t('Icon: Database'), 'bi-globe' => t('Icon: Globe'), 'bi-code-slash' => t('Icon: Code'), ]; } /** * @return array{groups: list>, ungrouped: list>} */ public function listGroupedForUser(int $userId): array { $groups = $this->groupRepository->listByUser($userId); $bookmarks = $this->bookmarkRepository->listByUser($userId); $grouped = []; foreach ($groups as $group) { $gid = (int) ($group['id'] ?? 0); $grouped[$gid] = [ 'id' => $gid, 'name' => (string) ($group['name'] ?? ''), 'icon' => (string) ($group['icon'] ?? 'bi-folder'), 'sort_order' => (int) ($group['sort_order'] ?? 0), 'bookmarks' => [], ]; } $ungrouped = []; foreach ($bookmarks as $bm) { $item = [ 'id' => (int) ($bm['id'] ?? 0), 'name' => (string) ($bm['name'] ?? ''), 'url' => BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? '')), 'group_id' => isset($bm['group_id']) ? (int) $bm['group_id'] : null, 'sort_order' => (int) ($bm['sort_order'] ?? 0), ]; $gid = $item['group_id']; if ($gid !== null && $gid > 0 && isset($grouped[$gid])) { $grouped[$gid]['bookmarks'][] = $item; } else { $ungrouped[] = $item; } } return [ 'groups' => array_values($grouped), 'ungrouped' => $ungrouped, ]; } /** * @return array{ok: bool, mode?: 'created'|'updated', error?: string, bookmark?: array} */ public function saveBookmark(int $userId, string $name, string $url, ?int $groupId): array { $name = $this->truncate(trim($name), self::NAME_MAX); if ($name === '') { return ['ok' => false, 'error' => 'name_required']; } $url = $this->sanitizeUrl($url); if ($url === '') { return ['ok' => false, 'error' => 'url_required']; } $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $url); if ($groupId !== null && $groupId > 0) { $group = $this->groupRepository->findById($groupId, $userId); if (!$group) { return ['ok' => false, 'error' => 'group_not_found']; } } else { $groupId = null; } if ($existing) { $bookmarkId = (int) ($existing['id'] ?? 0); if ($bookmarkId <= 0) { return ['ok' => false, 'error' => 'not_found']; } $existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0 ? (int) $existing['group_id'] : null; $update = [ 'name' => $name, 'group_id' => $groupId, ]; if ($existingGroupId !== $groupId) { $update['sort_order'] = $groupId === null ? $this->navigationRepository->nextRootSortOrder($userId) : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId); } $existingUrl = trim((string) ($existing['url'] ?? '')); if ($existingUrl !== $url) { $update['url'] = $url; } if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) { return ['ok' => false, 'error' => 'update_failed']; } return [ 'ok' => true, 'mode' => 'updated', 'bookmark' => [ 'id' => $bookmarkId, 'name' => $name, 'url' => $url, 'group_id' => $groupId, ], ]; } if ($this->bookmarkRepository->countByUser($userId) >= self::MAX_BOOKMARKS) { return ['ok' => false, 'error' => 'max_reached']; } $id = $this->bookmarkRepository->create([ 'user_id' => $userId, 'group_id' => $groupId, 'name' => $name, 'url' => $url, 'sort_order' => $groupId === null ? $this->navigationRepository->nextRootSortOrder($userId) : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId), ]); if ($id === false) { $raceExisting = $this->bookmarkRepository->findByUserAndUrl($userId, $url); if ($raceExisting) { $bookmarkId = (int) ($raceExisting['id'] ?? 0); $raceGroupId = isset($raceExisting['group_id']) && (int) $raceExisting['group_id'] > 0 ? (int) $raceExisting['group_id'] : null; $raceUpdate = [ 'name' => $name, 'group_id' => $groupId, ]; if ($raceGroupId !== $groupId) { $raceUpdate['sort_order'] = $groupId === null ? $this->navigationRepository->nextRootSortOrder($userId) : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId); } if ($bookmarkId > 0 && $this->bookmarkRepository->update($bookmarkId, $userId, $raceUpdate)) { return [ 'ok' => true, 'mode' => 'updated', 'bookmark' => [ 'id' => $bookmarkId, 'name' => $name, 'url' => $url, 'group_id' => $groupId, ], ]; } } return ['ok' => false, 'error' => 'create_failed']; } return [ 'ok' => true, 'mode' => 'created', 'bookmark' => [ 'id' => $id, 'name' => $name, 'url' => $url, 'group_id' => $groupId, ], ]; } /** * @param array $data * @return array{ok: bool, error?: string} */ public function updateBookmark(int $userId, int $bookmarkId, array $data): array { $existing = $this->bookmarkRepository->findById($bookmarkId, $userId); if (!$existing) { return ['ok' => false, 'error' => 'not_found']; } $update = []; if (array_key_exists('name', $data)) { $name = $this->truncate(trim((string) $data['name']), self::NAME_MAX); if ($name === '') { return ['ok' => false, 'error' => 'name_required']; } $update['name'] = $name; } if (array_key_exists('group_id', $data)) { $groupId = $data['group_id']; $targetGroupId = null; if ($groupId !== null && $groupId !== '' && (int) $groupId > 0) { $group = $this->groupRepository->findById((int) $groupId, $userId); if (!$group) { return ['ok' => false, 'error' => 'group_not_found']; } $targetGroupId = (int) $groupId; $update['group_id'] = $targetGroupId; } else { $update['group_id'] = null; } $existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0 ? (int) $existing['group_id'] : null; if ($existingGroupId !== $targetGroupId) { $update['sort_order'] = $targetGroupId === null ? $this->navigationRepository->nextRootSortOrder($userId) : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $targetGroupId); } } if ($update === []) { return ['ok' => true]; } if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) { return ['ok' => false, 'error' => 'update_failed']; } return ['ok' => true]; } public function deleteBookmark(int $userId, int $bookmarkId): bool { return $this->bookmarkRepository->delete($bookmarkId, $userId); } /** @param list $idOrderPairs */ public function reorderBookmarks(int $userId, array $idOrderPairs): bool { return $this->bookmarkRepository->updateSortOrders($userId, $idOrderPairs); } /** * @return array{ok: bool, mode?: 'created'|'updated', error?: string, group?: array} */ public function saveGroup(int $userId, string $name, string $icon, ?int $groupId = null): array { $name = $this->truncate(trim($name), self::GROUP_NAME_MAX); if ($name === '') { return ['ok' => false, 'error' => 'name_required']; } $icon = $this->validateGroupIcon($icon); if ($groupId !== null && $groupId > 0) { $existing = $this->groupRepository->findById($groupId, $userId); if (!$existing) { return ['ok' => false, 'error' => 'not_found']; } if (!$this->groupRepository->update($groupId, $userId, ['name' => $name, 'icon' => $icon])) { return ['ok' => false, 'error' => 'update_failed']; } return ['ok' => true, 'mode' => 'updated', 'group' => ['id' => $groupId, 'name' => $name, 'icon' => $icon]]; } if ($this->groupRepository->countByUser($userId) >= self::MAX_GROUPS) { return ['ok' => false, 'error' => 'max_reached']; } $id = $this->groupRepository->create([ 'user_id' => $userId, 'name' => $name, 'icon' => $icon, 'sort_order' => $this->navigationRepository->nextRootSortOrder($userId), ]); if ($id === false) { return ['ok' => false, 'error' => 'create_failed']; } return ['ok' => true, 'mode' => 'created', 'group' => ['id' => $id, 'name' => $name, 'icon' => $icon]]; } public function deleteGroup(int $userId, int $groupId): bool { $bookmarks = $this->bookmarkRepository->listByGroup($userId, $groupId); $nextSort = $this->navigationRepository->nextRootSortOrder($userId); foreach ($bookmarks as $bookmark) { $bookmarkId = (int) ($bookmark['id'] ?? 0); if ($bookmarkId <= 0) { continue; } if (!$this->bookmarkRepository->unsetGroupId($bookmarkId, $userId, $nextSort)) { return false; } $nextSort++; } return $this->groupRepository->delete($groupId, $userId); } /** @param list $idOrderPairs */ public function reorderGroups(int $userId, array $idOrderPairs): bool { return $this->groupRepository->updateSortOrders($userId, $idOrderPairs); } /** @param list $rootOrderPairs */ public function reorderRoot(int $userId, array $rootOrderPairs): bool { return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs); } /** * @param list $items * @return array{ok: bool, error?: string} */ public function reorderFromItems(int $userId, string $type, array $items): array { $allowedTypes = ['groups', 'bookmarks']; if (!in_array($type, $allowedTypes, true)) { return ['ok' => false, 'error' => 'invalid_type']; } if ($items === []) { return ['ok' => false, 'error' => 'invalid_items']; } $ok = $type === 'groups' ? $this->reorderGroups($userId, $items) : $this->reorderBookmarks($userId, $items); return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed']; } /** * @param list $rootPairs * @return array{ok: bool, error?: string} */ public function reorderRootFromItems(int $userId, array $rootPairs): array { if ($rootPairs === []) { return ['ok' => false, 'error' => 'invalid_items']; } $ok = $this->reorderRoot($userId, $rootPairs); return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed']; } private function sanitizeUrl(string $url): string { $url = BookmarkUrlNormalizer::canonicalizeRelative($url); if ($url === '') { return ''; } if (mb_strlen($url) > self::URL_MAX) { $url = mb_substr($url, 0, self::URL_MAX); } return $url; } private function validateGroupIcon(string $icon): string { $icon = trim($icon); if (in_array($icon, self::ALLOWED_GROUP_ICONS, true)) { return $icon; } return 'bi-folder'; } private function truncate(string $value, int $maxLength): string { if (mb_strlen($value) > $maxLength) { return mb_substr($value, 0, $maxLength); } return $value; } }