package usecase import ( "context" "fmt" "net/url" "sort" "strings" "sync" "time" "github.com/bnema/dumber/internal/application/dto" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/dumber/internal/domain/repository" domainurl "github.com/bnema/dumber/internal/domain/url" "github.com/bnema/dumber/internal/logging" ) // Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports. var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil) var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil) // ManageFavoritesUseCase handles favorite and tag operations. type ManageFavoritesUseCase struct { favoriteRepo repository.FavoriteRepository tagRepo repository.TagRepository cacheMu sync.Mutex favoritesCache []*entity.Favorite favoriteURLsCache map[string]struct{} cacheTime time.Time } const favoritesCacheTTL = 2 * time.Second // NewManageFavoritesUseCase creates a new favorites management use case. func NewManageFavoritesUseCase( favoriteRepo repository.FavoriteRepository, tagRepo repository.TagRepository, ) *ManageFavoritesUseCase { return &ManageFavoritesUseCase{ favoriteRepo: favoriteRepo, tagRepo: tagRepo, } } // AddFavoriteInput contains parameters for adding a favorite. type AddFavoriteInput struct { URL string Title string FaviconURL string Tags []entity.TagID } // Add creates a new favorite. func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { log := logging.FromContext(ctx) log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite") // Check if already favorited existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL) if err != nil { return nil, fmt.Errorf("failed to check existing favorite: %w", err) } if existing != nil { log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited") return existing, nil } return uc.addNewFavorite(ctx, input) } func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { log := logging.FromContext(ctx) fav := entity.NewFavorite(input.URL, input.Title) fav.FaviconURL = input.FaviconURL if err := uc.favoriteRepo.Save(ctx, fav); err != nil { return nil, fmt.Errorf("failed to save favorite: %w", err) } // Assign tags for _, tagID := range input.Tags { if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil { return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err) } } log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added") uc.invalidateCache() return fav, nil } // AddFavorite creates a favorite from the UI-facing application port. func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { for _, tagID := range input.Tags { if tagID <= 0 { return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID) } } favoriteURL, err := normalizeFavoriteURL(input.URL) if err != nil { return nil, err } if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil { return nil, err } else if existing != nil { return existing, nil } tags := append([]entity.TagID(nil), input.Tags...) title := strings.TrimSpace(input.Title) if title == "" { title = favoriteURL } return uc.addNewFavorite(ctx, AddFavoriteInput{ URL: favoriteURL, Title: title, FaviconURL: strings.TrimSpace(input.FaviconURL), Tags: tags, }) } func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL( ctx context.Context, canonicalURL string, rawURL string, ) (*entity.Favorite, error) { existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) if err != nil { return nil, fmt.Errorf("failed to check existing favorite: %w", err) } if existing != nil { return existing, nil } rawURL = strings.TrimSpace(rawURL) if rawURL == "" || rawURL == canonicalURL { return nil, nil } existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL) if err != nil { return nil, fmt.Errorf("failed to check legacy favorite: %w", err) } return existing, nil } func normalizeFavoriteURL(raw string) (string, error) { favoriteURL := domainurl.Normalize(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 parsed.Host != "" { return favoriteURL, nil } if parsed.Opaque != "" { return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil } return "", fmt.Errorf("favorite URL host is required") default: return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") } return favoriteURL, nil } // UpdateFavorite updates editable favorite metadata from the UI-facing application port. func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { if input.ID <= 0 { return nil, fmt.Errorf("favorite ID is required") } if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) } fav, err := uc.favoriteRepo.FindByID(ctx, input.ID) if err != nil { return nil, fmt.Errorf("failed to find favorite: %w", err) } if fav == nil { return nil, fmt.Errorf("favorite %d not found", input.ID) } title := strings.TrimSpace(input.Title) if title == "" { return nil, fmt.Errorf("favorite title is required") } fav.Title = title fav.FaviconURL = strings.TrimSpace(input.FaviconURL) if input.ShortcutKeySet { fav.ShortcutKey = input.ShortcutKey } if err := uc.Update(ctx, fav); err != nil { return nil, err } return fav, nil } // DeleteFavorite deletes a favorite from the UI-facing application port. func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { return uc.Remove(ctx, id) } // Remove deletes a favorite by ID. func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { log := logging.FromContext(ctx) log.Debug().Int64("id", int64(id)).Msg("removing favorite") if err := uc.favoriteRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete favorite: %w", err) } log.Info().Int64("id", int64(id)).Msg("favorite removed") uc.invalidateCache() return nil } // RemoveByURL deletes a favorite by its URL. func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { log := logging.FromContext(ctx) log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL") fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) if err != nil { return fmt.Errorf("failed to find favorite: %w", err) } if fav == nil { log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found") return nil } return uc.Remove(ctx, fav.ID) } // Update modifies a favorite's metadata. func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error { log := logging.FromContext(ctx) log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite") if err := uc.favoriteRepo.Save(ctx, fav); err != nil { return fmt.Errorf("failed to update favorite: %w", err) } log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated") uc.invalidateCache() return nil } // SetShortcut assigns or removes a keyboard shortcut (1-9). func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { log := logging.FromContext(ctx) if key != nil && (*key < 1 || *key > 9) { return fmt.Errorf("shortcut key must be 1-9, got %d", *key) } log.Debug(). Int64("id", int64(id)). Msg("setting favorite shortcut") if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil { return fmt.Errorf("failed to set shortcut: %w", err) } uc.invalidateCache() return nil } // GetByShortcut finds the favorite with the given shortcut key. func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { log := logging.FromContext(ctx) log.Debug().Int("key", key).Msg("getting favorite by shortcut") if key < 1 || key > 9 { return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key) } return uc.favoriteRepo.GetByShortcut(ctx, key) } // GetByURL finds a favorite by its URL. func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { log := logging.FromContext(ctx) log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL") return uc.favoriteRepo.FindByURL(ctx, favoriteURL) } // GetAll retrieves all favorites. func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) { log := logging.FromContext(ctx) log.Debug().Msg("getting all favorites") uc.cacheMu.Lock() if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil { cached := uc.favoritesCache uc.cacheMu.Unlock() return cached, nil } uc.cacheMu.Unlock() favs, err := uc.favoriteRepo.GetAll(ctx) if err != nil { return nil, fmt.Errorf("failed to get favorites: %w", err) } uc.cacheMu.Lock() if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil { cached := uc.favoritesCache uc.cacheMu.Unlock() return cached, nil } uc.favoritesCache = favs uc.favoriteURLsCache = buildFavoriteURLSet(favs) uc.cacheTime = time.Now() uc.cacheMu.Unlock() log.Debug().Int("count", len(favs)).Msg("retrieved favorites") return favs, nil } // FilterForOmnibox returns favorites filtered and ranked for omnibox display. func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) { favs, err := uc.GetAll(ctx) if err != nil { return nil, err } queryLower := strings.ToLower(strings.TrimSpace(query)) if queryLower == "" { return favs, nil } filtered := make([]*entity.Favorite, 0, len(favs)) for _, fav := range favs { if fav == nil { continue } titleLower := strings.ToLower(fav.Title) urlLower := strings.ToLower(fav.URL) if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) { continue } filtered = append(filtered, fav) } sort.SliceStable(filtered, func(i, j int) bool { leftScore := omniboxFavoriteScore(filtered[i], queryLower) rightScore := omniboxFavoriteScore(filtered[j], queryLower) if leftScore != rightScore { return leftScore > rightScore } if filtered[i].Position != filtered[j].Position { return filtered[i].Position < filtered[j].Position } return filtered[i].URL < filtered[j].URL }) return filtered, nil } // IsFavorite checks if a URL is favorited. func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) { fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) if err != nil { return false, fmt.Errorf("failed to check favorite: %w", err) } return fav != nil, nil } func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int { if fav == nil || queryLower == "" { return 0 } const ( favoriteExactMatchScore = 600 favoriteHostPrefixScore = 520 favoriteTitlePrefixScore = 440 favoriteTitleContainsScore = 320 favoriteURLContainsScore = 280 ) urlLower := strings.ToLower(fav.URL) strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www.")) if strippedURLLower == "" { strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://")) } titleLower := strings.ToLower(fav.Title) switch { case titleLower == queryLower || urlLower == queryLower: return favoriteExactMatchScore case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower): return favoriteHostPrefixScore case strings.HasPrefix(titleLower, queryLower): return favoriteTitlePrefixScore case strings.Contains(titleLower, queryLower): return favoriteTitleContainsScore case strings.Contains(urlLower, queryLower): return favoriteURLContainsScore default: return 0 } } // ToggleResult indicates the result of a toggle operation. type ToggleResult struct { Added bool // true if favorite was added, false if removed URL string Title string Message string // User-friendly message } // Toggle adds or removes a URL from favorites based on current state. // If the URL is already a favorite, it removes it. Otherwise, it adds it. func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) { favoriteURL, err := normalizeFavoriteURL(rawURL) if err != nil { return nil, err } title = strings.TrimSpace(title) log := logging.FromContext(ctx) log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite") // Check if already favorited, including legacy rows stored before URL canonicalization. existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) if err != nil { return nil, err } if existing != nil { // Remove favorite if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { return nil, fmt.Errorf("failed to remove favorite: %w", err) } log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") uc.invalidateCache() return &ToggleResult{ Added: false, URL: favoriteURL, Title: existing.Title, Message: "Favorite removed", }, nil } // Add favorite fav := entity.NewFavorite(favoriteURL, title) if err := uc.favoriteRepo.Save(ctx, fav); err != nil { return nil, fmt.Errorf("failed to add favorite: %w", err) } log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") uc.invalidateCache() return &ToggleResult{ Added: true, URL: favoriteURL, Title: title, Message: "Favorite added", }, nil } // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { log := logging.FromContext(ctx) log.Debug().Msg("getting all favorite URLs") uc.cacheMu.Lock() if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil { cached := uc.favoriteURLsCache uc.cacheMu.Unlock() return cached, nil } uc.cacheMu.Unlock() favs, err := uc.GetAll(ctx) if err != nil { return nil, fmt.Errorf("failed to get favorites: %w", err) } urls := buildFavoriteURLSet(favs) log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") return urls, nil } func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} { urls := make(map[string]struct{}, len(favs)) for _, fav := range favs { if fav == nil || fav.URL == "" { continue } urls[fav.URL] = struct{}{} } return urls } func (uc *ManageFavoritesUseCase) invalidateCache() { uc.cacheMu.Lock() defer uc.cacheMu.Unlock() uc.favoritesCache = nil uc.favoriteURLsCache = nil uc.cacheTime = time.Time{} } // AddTag creates a new tag. func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) { name = strings.TrimSpace(name) color = strings.TrimSpace(color) if name == "" { return nil, fmt.Errorf("tag name is required") } log := logging.FromContext(ctx) log.Debug().Str("name", name).Str("color", color).Msg("creating tag") // Check if tag already exists existing, err := uc.tagRepo.FindByName(ctx, name) if err != nil { return nil, fmt.Errorf("failed to check existing tag: %w", err) } if existing != nil { log.Debug().Str("name", name).Msg("tag already exists") return existing, nil } tag := entity.NewTag(name) if color != "" { tag.Color = color } if err := uc.tagRepo.Save(ctx, tag); err != nil { return nil, fmt.Errorf("failed to create tag: %w", err) } log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created") return tag, nil } // DeleteTag removes a tag. func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error { log := logging.FromContext(ctx) log.Debug().Int64("id", int64(id)).Msg("deleting tag") if err := uc.tagRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete tag: %w", err) } log.Info().Int64("id", int64(id)).Msg("tag deleted") uc.invalidateCache() return nil } // GetAllTags retrieves all tags. func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) { log := logging.FromContext(ctx) log.Debug().Msg("getting all tags") return uc.tagRepo.GetAll(ctx) } // TagFavorite assigns a tag to a favorite. func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error { log := logging.FromContext(ctx) log.Debug(). Int64("favorite_id", int64(favID)). Int64("tag_id", int64(tagID)). Msg("tagging favorite") if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { return fmt.Errorf("failed to tag favorite: %w", err) } uc.invalidateCache() return nil } // UntagFavorite removes a tag from a favorite. func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error { log := logging.FromContext(ctx) log.Debug(). Int64("favorite_id", int64(favID)). Int64("tag_id", int64(tagID)). Msg("untagging favorite") if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { return fmt.Errorf("failed to untag favorite: %w", err) } uc.invalidateCache() return nil } // UpdateFavoriteTags reconciles a favorite's tag assignments with the requested state. // Failed mutations are rolled back in reverse order when the repository cannot // provide a transaction boundary. func (uc *ManageFavoritesUseCase) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error { currentTags, err := uc.tagRepo.GetForFavorite(ctx, favID) if err != nil { return fmt.Errorf("failed to load tags for favorite %d: %w", favID, err) } current := make(map[entity.TagID]struct{}, len(currentTags)) for _, tag := range currentTags { if tag != nil { current[tag.ID] = struct{}{} } } wanted := make(map[entity.TagID]struct{}, len(tagIDs)) for _, id := range tagIDs { wanted[id] = struct{}{} } toRemove := make([]entity.TagID, 0, len(current)) for id := range current { if _, ok := wanted[id]; !ok { toRemove = append(toRemove, id) } } toAdd := make([]entity.TagID, 0, len(wanted)) for id := range wanted { if _, ok := current[id]; !ok { toAdd = append(toAdd, id) } } sort.Slice(toRemove, func(i, j int) bool { return toRemove[i] < toRemove[j] }) sort.Slice(toAdd, func(i, j int) bool { return toAdd[i] < toAdd[j] }) type mutation struct { tagID entity.TagID added bool } applied := make([]mutation, 0, len(toRemove)+len(toAdd)) rollbackLog := logging.FromContext(ctx) rollback := func() { for i := len(applied) - 1; i >= 0; i-- { change := applied[i] var rollbackErr error if change.added { rollbackErr = uc.UntagFavorite(ctx, favID, change.tagID) } else { rollbackErr = uc.TagFavorite(ctx, favID, change.tagID) } if rollbackErr != nil { rollbackLog.Warn(). Err(rollbackErr). Int64("favorite_id", int64(favID)). Int64("tag_id", int64(change.tagID)). Msg("failed to roll back favorite tag mutation") } } } for _, id := range toRemove { if err := uc.UntagFavorite(ctx, favID, id); err != nil { rollback() return fmt.Errorf("failed to remove tag %d from favorite %d: %w", id, favID, err) } applied = append(applied, mutation{tagID: id}) } for _, id := range toAdd { if err := uc.TagFavorite(ctx, favID, id); err != nil { rollback() return fmt.Errorf("failed to add tag %d to favorite %d: %w", id, favID, err) } applied = append(applied, mutation{tagID: id, added: true}) } return nil } // GetTagsForFavorite retrieves all tags for a favorite. func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) { log := logging.FromContext(ctx) log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite") return uc.tagRepo.GetForFavorite(ctx, favID) } // UpdateTag updates a tag's name and color. func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error { name = strings.TrimSpace(name) color = strings.TrimSpace(color) if name == "" { return fmt.Errorf("tag name is required") } log := logging.FromContext(ctx) log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag") tag, err := uc.tagRepo.FindByID(ctx, id) if err != nil { return fmt.Errorf("failed to find tag: %w", err) } if tag == nil { return fmt.Errorf("tag %d not found", id) } tag.Name = name if color != "" { tag.Color = color } if err := uc.tagRepo.Save(ctx, tag); err != nil { return fmt.Errorf("failed to update tag: %w", err) } log.Info().Int64("id", int64(id)).Msg("tag updated") uc.invalidateCache() return nil }