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 favoriteActionFilterTag = "favorite.filterTag"
19 favoriteActionFilterUntagged = "favorite.filterUntagged"
20 favoriteActionClearFilters = "favorite.clearFilters"
21 tagActionCreate = "tag.create"
22 tagActionUpdate = "tag.update"
23 tagActionDelete = "tag.delete"
24 tagActionAssign = "tag.assign"
25 tagActionRemove = "tag.remove"
26)
27
28//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
29func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
30 if a.deps.Favorites == nil {
31 return fmt.Errorf("favorites service not configured")
32 }
33 data := event.Data
34 switch event.Action {
35 case favoriteActionCreate:
36 favoriteURL, err := validateFavoriteURL(data["url"])
37 if err != nil {
38 return err
39 }
40 tags, err := optionalTagIDs(data["tags"])
41 if err != nil {
42 return err
43 }
44 favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
45 URL: favoriteURL,
46 Title: strings.TrimSpace(data["title"]),
47 Tags: tags,
48 })
49 if err != nil {
50 return err
51 }
52 a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
53 case favoriteActionUpdate:
54 id, err := parsePositiveInt64(data["id"], "favorite id")
55 if err != nil {
56 return err
57 }
58 shortcut, err := optionalShortcut(data["shortcut_key"])
59 if err != nil {
60 return err
61 }
62 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
63 ID: entity.FavoriteID(id),
64 Title: strings.TrimSpace(data["title"]),
65 FaviconURL: strings.TrimSpace(data["favicon_url"]),
66 ShortcutKey: shortcut,
67 ShortcutKeySet: true,
68 })
69 if err != nil {
70 return err
71 }
72 a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
73 case favoriteActionDelete:
74 id, err := parsePositiveInt64(data["id"], "favorite id")
75 if err != nil {
76 return err
77 }
78 if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
79 return err
80 }
81 a.favoritesNotice = "Deleted favorite"
82 case favoriteActionFilterTag:
83 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
84 if err != nil {
85 return err
86 }
87 id := entity.TagID(tagID)
88 a.favoriteTagFilter = &id
89 a.favoriteUntaggedFilter = false
90 a.favoritesNotice = ""
91 case favoriteActionFilterUntagged:
92 a.favoriteTagFilter = nil
93 a.favoriteUntaggedFilter = true
94 a.favoritesNotice = ""
95 case favoriteActionClearFilters:
96 a.favoriteTagFilter = nil
97 a.favoriteUntaggedFilter = false
98 a.favoritesNotice = ""
99 case tagActionCreate:
100 name := strings.TrimSpace(data["name"])
101 if name == "" {
102 return fmt.Errorf("tag name is required")
103 }
104 tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
105 if err != nil {
106 return err
107 }
108 a.favoritesNotice = "Created tag " + tag.Name
109 case tagActionUpdate:
110 id, err := parsePositiveInt64(data["id"], "tag id")
111 if err != nil {
112 return err
113 }
114 name := strings.TrimSpace(data["name"])
115 if name == "" {
116 return fmt.Errorf("tag name is required")
117 }
118 if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
119 return err
120 }
121 a.favoritesNotice = "Saved tag " + name
122 case tagActionDelete:
123 id, err := parsePositiveInt64(data["id"], "tag id")
124 if err != nil {
125 return err
126 }
127 if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
128 return err
129 }
130 a.favoriteTagFilter = nil
131 a.favoritesNotice = "Deleted tag"
132 case tagActionAssign, tagActionRemove:
133 favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
134 if err != nil {
135 return err
136 }
137 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
138 if err != nil {
139 return err
140 }
141 if event.Action == tagActionAssign {
142 if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
143 return err
144 }
145 a.favoritesNotice = "Assigned tag"
146 } else {
147 if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
148 return err
149 }
150 a.favoritesNotice = "Removed tag"
151 }
152 default:
153 return fmt.Errorf("unknown favorite action: %q", event.Action)
154 }
155 return nil
156}
157
158func validateFavoriteURL(raw string) (string, error) {
159 favoriteURL := strings.TrimSpace(raw)
160 if favoriteURL == "" {
161 return "", fmt.Errorf("favorite URL is required")
162 }
163 parsed, err := url.Parse(favoriteURL)
164 if err != nil || parsed.Scheme == "" {
165 return "", fmt.Errorf("favorite URL must be absolute")
166 }
167 switch strings.ToLower(parsed.Scheme) {
168 case "http", "https":
169 if parsed.Host == "" {
170 return "", fmt.Errorf("favorite URL host is required")
171 }
172 case "dumb":
173 if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
174 return favoriteURL, nil
175 }
176 return "", fmt.Errorf("favorite URL must target a known systemview route")
177 default:
178 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
179 }
180 return favoriteURL, nil
181}
182
183func firstActionValue(data map[string]string, keys ...string) string {
184 for _, key := range keys {
185 if value, ok := data[key]; ok {
186 return value
187 }
188 }
189 return ""
190}
191
192func parsePositiveInt64(raw, label string) (int64, error) {
193 id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
194 if err != nil {
195 return 0, fmt.Errorf("invalid %s: %w", label, err)
196 }
197 if id <= 0 {
198 return 0, fmt.Errorf("%s must be a positive integer", label)
199 }
200 return id, nil
201}
202
203func optionalTagIDs(raw string) ([]entity.TagID, error) {
204 raw = strings.TrimSpace(raw)
205 if raw == "" {
206 return nil, nil
207 }
208 parts := strings.Split(raw, ",")
209 ids := make([]entity.TagID, 0, len(parts))
210 seen := make(map[entity.TagID]struct{}, len(parts))
211 for _, part := range parts {
212 id, err := parsePositiveInt64(part, "tag id")
213 if err != nil {
214 return nil, err
215 }
216 tagID := entity.TagID(id)
217 if _, ok := seen[tagID]; ok {
218 continue
219 }
220 seen[tagID] = struct{}{}
221 ids = append(ids, tagID)
222 }
223 return ids, nil
224}
225
226// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
227func optionalShortcut(raw string) (*int, error) {
228 raw = strings.TrimSpace(raw)
229 if raw == "" {
230 return nil, nil
231 }
232 value, err := strconv.Atoi(raw)
233 if err != nil || value < 1 || value > 9 {
234 return nil, fmt.Errorf("shortcut key must be 1-9")
235 }
236 return &value, nil
237}