*/ public static function allowedGroupIcons(): array { return self::ALLOWED_GROUP_ICONS; } /** * @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->findExistingByCanonicalUrl($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 { if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) { return false; } 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); } 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; } /** @return array|false */ private function findExistingByCanonicalUrl(int $userId, string $canonicalUrl): array|false { $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $canonicalUrl); if ($existing) { return $existing; } $rows = $this->bookmarkRepository->listByUser($userId); foreach ($rows as $row) { $rowUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($row['url'] ?? '')); if ($rowUrl === $canonicalUrl) { return $row; } } return false; } }