1package systemviews
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strconv"
8 "strings"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/domain/entity"
12)
13
14const (
15 favoriteActionCreate = "favorite.create"
16 favoriteActionUpdate = "favorite.update"
17 favoriteActionDelete = "favorite.delete"
18 favoriteActionFilterFolder = "favorite.filterFolder"
19 favoriteActionFilterTag = "favorite.filterTag"
20 favoriteActionClearFilters = "favorite.clearFilters"
21 folderActionCreate = "folder.create"
22 folderActionUpdate = "folder.update"
23 folderActionDelete = "folder.delete"
24 tagActionCreate = "tag.create"
25 tagActionUpdate = "tag.update"
26 tagActionDelete = "tag.delete"
27 tagActionAssign = "tag.assign"
28 tagActionRemove = "tag.remove"
29)
30
31//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
32func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
33 if a.deps.Favorites == nil {
34 return fmt.Errorf("favorites service not configured")
35 }
36 data := event.Data
37 switch event.Action {
38 case favoriteActionCreate:
39 folderID, err := optionalFolderID(data["folder_id"])
40 if err != nil {
41 return err
42 }
43 favoriteURL, err := validateFavoriteURL(data["url"])
44 if err != nil {
45 return err
46 }
47 tags, err := optionalTagIDs(data["tags"])
48 if err != nil {
49 return err
50 }
51 favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52 URL: favoriteURL,
53 Title: strings.TrimSpace(data["title"]),
54 FolderID: folderID,
55 Tags: tags,
56 })
57 if err != nil {
58 return err
59 }
60 a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
61 case favoriteActionUpdate:
62 id, err := parsePositiveInt64(data["id"], "favorite id")
63 if err != nil {
64 return err
65 }
66 folderID, err := optionalFolderID(data["folder_id"])
67 if err != nil {
68 return err
69 }
70 shortcut, err := optionalShortcut(data["shortcut_key"])
71 if err != nil {
72 return err
73 }
74 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75 ID: entity.FavoriteID(id),
76 Title: strings.TrimSpace(data["title"]),
77 FaviconURL: strings.TrimSpace(data["favicon_url"]),
78 FolderID: folderID,
79 ShortcutKey: shortcut,
80 })
81 if err != nil {
82 return err
83 }
84 a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
85 case favoriteActionDelete:
86 id, err := parsePositiveInt64(data["id"], "favorite id")
87 if err != nil {
88 return err
89 }
90 if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
91 return err
92 }
93 a.favoritesNotice = "Deleted favorite"
94 case favoriteActionFilterFolder:
95 folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
96 if err != nil {
97 return err
98 }
99 a.favoriteFolderFilter = folderID
100 a.favoriteTagFilter = nil
101 a.favoritesNotice = ""
102 case favoriteActionFilterTag:
103 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
104 if err != nil {
105 return err
106 }
107 id := entity.TagID(tagID)
108 a.favoriteTagFilter = &id
109 a.favoriteFolderFilter = nil
110 a.favoritesNotice = ""
111 case favoriteActionClearFilters:
112 a.favoriteFolderFilter = nil
113 a.favoriteTagFilter = nil
114 a.favoritesNotice = ""
115 case folderActionCreate:
116 name := strings.TrimSpace(data["name"])
117 if name == "" {
118 return fmt.Errorf("folder name is required")
119 }
120 folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
121 if err != nil {
122 return err
123 }
124 a.favoritesNotice = "Created folder " + folderDisplayName(folder)
125 case folderActionUpdate:
126 id, err := parsePositiveInt64(data["id"], "folder id")
127 if err != nil {
128 return err
129 }
130 name := strings.TrimSpace(data["name"])
131 if name == "" {
132 return fmt.Errorf("folder name is required")
133 }
134 if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil {
135 return err
136 }
137 a.favoritesNotice = "Saved folder " + name
138 case folderActionDelete:
139 id, err := parsePositiveInt64(data["id"], "folder id")
140 if err != nil {
141 return err
142 }
143 if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil {
144 return err
145 }
146 a.favoriteFolderFilter = nil
147 a.favoritesNotice = "Deleted folder"
148 case tagActionCreate:
149 name := strings.TrimSpace(data["name"])
150 if name == "" {
151 return fmt.Errorf("tag name is required")
152 }
153 tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
154 if err != nil {
155 return err
156 }
157 a.favoritesNotice = "Created tag " + tag.Name
158 case tagActionUpdate:
159 id, err := parsePositiveInt64(data["id"], "tag id")
160 if err != nil {
161 return err
162 }
163 name := strings.TrimSpace(data["name"])
164 if name == "" {
165 return fmt.Errorf("tag name is required")
166 }
167 if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
168 return err
169 }
170 a.favoritesNotice = "Saved tag " + name
171 case tagActionDelete:
172 id, err := parsePositiveInt64(data["id"], "tag id")
173 if err != nil {
174 return err
175 }
176 if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
177 return err
178 }
179 a.favoriteTagFilter = nil
180 a.favoritesNotice = "Deleted tag"
181 case tagActionAssign, tagActionRemove:
182 favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
183 if err != nil {
184 return err
185 }
186 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
187 if err != nil {
188 return err
189 }
190 if event.Action == tagActionAssign {
191 if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
192 return err
193 }
194 a.favoritesNotice = "Assigned tag"
195 } else {
196 if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
197 return err
198 }
199 a.favoritesNotice = "Removed tag"
200 }
201 default:
202 return fmt.Errorf("unknown favorite action: %q", event.Action)
203 }
204 return nil
205}
206
207func validateFavoriteURL(raw string) (string, error) {
208 favoriteURL := strings.TrimSpace(raw)
209 if favoriteURL == "" {
210 return "", fmt.Errorf("favorite URL is required")
211 }
212 parsed, err := url.Parse(favoriteURL)
213 if err != nil || parsed.Scheme == "" {
214 return "", fmt.Errorf("favorite URL must be absolute")
215 }
216 switch strings.ToLower(parsed.Scheme) {
217 case "http", "https":
218 if parsed.Host == "" {
219 return "", fmt.Errorf("favorite URL host is required")
220 }
221 case "dumb":
222 if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223 return favoriteURL, nil
224 }
225 return "", fmt.Errorf("favorite URL must target a known systemview route")
226 default:
227 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
228 }
229 return favoriteURL, nil
230}
231
232func firstActionValue(data map[string]string, keys ...string) string {
233 for _, key := range keys {
234 if value, ok := data[key]; ok {
235 return value
236 }
237 }
238 return ""
239}
240
241func parsePositiveInt64(raw, label string) (int64, error) {
242 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
243 if err != nil {
244 return 0, fmt.Errorf("invalid %s: %w", label, err)
245 }
246 if id <= 0 {
247 return 0, fmt.Errorf("%s must be a positive integer", label)
248 }
249 return id, nil
250}
251
252func optionalTagIDs(raw string) ([]entity.TagID, error) {
253 raw = strings.TrimSpace(raw)
254 if raw == "" {
255 return nil, nil
256 }
257 parts := strings.Split(raw, ",")
258 ids := make([]entity.TagID, 0, len(parts))
259 seen := make(map[entity.TagID]struct{}, len(parts))
260 for _, part := range parts {
261 id, err := parsePositiveInt64(part, "tag id")
262 if err != nil {
263 return nil, err
264 }
265 tagID := entity.TagID(id)
266 if _, ok := seen[tagID]; ok {
267 continue
268 }
269 seen[tagID] = struct{}{}
270 ids = append(ids, tagID)
271 }
272 return ids, nil
273}
274
275// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
276// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
277func optionalFolderID(raw string) (*entity.FolderID, error) {
278 raw = strings.TrimSpace(raw)
279 if raw == "" || raw == "root" {
280 return nil, nil
281 }
282 id, err := parsePositiveInt64(raw, "folder id")
283 if err != nil {
284 return nil, err
285 }
286 folderID := entity.FolderID(id)
287 return &folderID, nil
288}
289
290// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
291// favorites in the root folder; other inputs delegate to optionalFolderID.
292func filterFolderID(raw string) (*entity.FolderID, error) {
293 if strings.TrimSpace(raw) == "root" {
294 root := entity.FolderID(0)
295 return &root, nil
296 }
297 return optionalFolderID(raw)
298}
299
300// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301func optionalShortcut(raw string) (*int, error) {
302 raw = strings.TrimSpace(raw)
303 if raw == "" {
304 return nil, nil
305 }
306 value, err := strconv.Atoi(raw)
307 if err != nil || value < 1 || value > 9 {
308 return nil, fmt.Errorf("shortcut key must be 1-9")
309 }
310 return &value, nil
311}