package systemviews import ( "context" "fmt" "net/url" "strconv" "strings" "github.com/bnema/dumber/internal/application/dto" "github.com/bnema/dumber/internal/domain/entity" ) const ( favoriteActionCreate = "favorite.create" favoriteActionUpdate = "favorite.update" favoriteActionDelete = "favorite.delete" favoriteActionFilterTag = "favorite.filterTag" favoriteActionFilterUntagged = "favorite.filterUntagged" favoriteActionClearFilters = "favorite.clearFilters" tagActionCreate = "tag.create" tagActionUpdate = "tag.update" tagActionDelete = "tag.delete" tagActionAssign = "tag.assign" tagActionRemove = "tag.remove" ) //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { if a.deps.Favorites == nil { return fmt.Errorf("favorites service not configured") } data := event.Data switch event.Action { case favoriteActionCreate: favoriteURL, err := validateFavoriteURL(data["url"]) if err != nil { return err } tags, err := optionalTagIDs(data["tags"]) if err != nil { return err } favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{ URL: favoriteURL, Title: strings.TrimSpace(data["title"]), Tags: tags, }) if err != nil { return err } a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite) case favoriteActionUpdate: id, err := parsePositiveInt64(data["id"], "favorite id") if err != nil { return err } shortcut, err := optionalShortcut(data["shortcut_key"]) if err != nil { return err } favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ ID: entity.FavoriteID(id), Title: strings.TrimSpace(data["title"]), FaviconURL: strings.TrimSpace(data["favicon_url"]), ShortcutKey: shortcut, ShortcutKeySet: true, }) if err != nil { return err } a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite) case favoriteActionDelete: id, err := parsePositiveInt64(data["id"], "favorite id") if err != nil { return err } if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil { return err } a.favoritesNotice = "Deleted favorite" case favoriteActionFilterTag: tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") if err != nil { return err } id := entity.TagID(tagID) a.favoriteTagFilter = &id a.favoriteUntaggedFilter = false a.favoritesNotice = "" case favoriteActionFilterUntagged: a.favoriteTagFilter = nil a.favoriteUntaggedFilter = true a.favoritesNotice = "" case favoriteActionClearFilters: a.favoriteTagFilter = nil a.favoriteUntaggedFilter = false a.favoritesNotice = "" case tagActionCreate: name := strings.TrimSpace(data["name"]) if name == "" { return fmt.Errorf("tag name is required") } tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"])) if err != nil { return err } a.favoritesNotice = "Created tag " + tag.Name case tagActionUpdate: id, err := parsePositiveInt64(data["id"], "tag id") if err != nil { return err } name := strings.TrimSpace(data["name"]) if name == "" { return fmt.Errorf("tag name is required") } if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil { return err } a.favoritesNotice = "Saved tag " + name case tagActionDelete: id, err := parsePositiveInt64(data["id"], "tag id") if err != nil { return err } if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil { return err } a.favoriteTagFilter = nil a.favoritesNotice = "Deleted tag" case tagActionAssign, tagActionRemove: favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id") if err != nil { return err } tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") if err != nil { return err } if event.Action == tagActionAssign { if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil { return err } a.favoritesNotice = "Assigned tag" } else { if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil { return err } a.favoritesNotice = "Removed tag" } default: return fmt.Errorf("unknown favorite action: %q", event.Action) } return nil } func validateFavoriteURL(raw string) (string, error) { favoriteURL := strings.TrimSpace(raw) if favoriteURL == "" { return "", fmt.Errorf("favorite URL is required") } parsed, err := url.Parse(favoriteURL) if err != nil || parsed.Scheme == "" { return "", fmt.Errorf("favorite URL must be absolute") } switch strings.ToLower(parsed.Scheme) { case "http", "https": if parsed.Host == "" { return "", fmt.Errorf("favorite URL host is required") } case "dumb": if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown { return favoriteURL, nil } return "", fmt.Errorf("favorite URL must target a known systemview route") default: return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") } return favoriteURL, nil } func firstActionValue(data map[string]string, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { return value } } return "" } func parsePositiveInt64(raw, label string) (int64, error) { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { return 0, fmt.Errorf("invalid %s: %w", label, err) } if id <= 0 { return 0, fmt.Errorf("%s must be a positive integer", label) } return id, nil } func optionalTagIDs(raw string) ([]entity.TagID, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } parts := strings.Split(raw, ",") ids := make([]entity.TagID, 0, len(parts)) seen := make(map[entity.TagID]struct{}, len(parts)) for _, part := range parts { id, err := parsePositiveInt64(part, "tag id") if err != nil { return nil, err } tagID := entity.TagID(id) if _, ok := seen[tagID]; ok { continue } seen[tagID] = struct{}{} ids = append(ids, tagID) } return ids, nil } // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. func optionalShortcut(raw string) (*int, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } value, err := strconv.Atoi(raw) if err != nil || value < 1 || value > 9 { return nil, fmt.Errorf("shortcut key must be 1-9") } return &value, nil }