main 5041353

manage_favorites.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6	"net/url"
7	"sort"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/dumber/internal/application/dto"
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/domain/repository"
16	domainurl "github.com/bnema/dumber/internal/domain/url"
17	"github.com/bnema/dumber/internal/logging"
18)
19
20// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
21var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
23
24// ManageFavoritesUseCase handles favorite and tag operations.
25type ManageFavoritesUseCase struct {
26	favoriteRepo repository.FavoriteRepository
27	tagRepo      repository.TagRepository
28
29	cacheMu           sync.Mutex
30	favoritesCache    []*entity.Favorite
31	favoriteURLsCache map[string]struct{}
32	cacheTime         time.Time
33}
34
35const favoritesCacheTTL = 2 * time.Second
36
37// NewManageFavoritesUseCase creates a new favorites management use case.
38func NewManageFavoritesUseCase(
39	favoriteRepo repository.FavoriteRepository,
40	tagRepo repository.TagRepository,
41) *ManageFavoritesUseCase {
42	return &ManageFavoritesUseCase{
43		favoriteRepo: favoriteRepo,
44		tagRepo:      tagRepo,
45	}
46}
47
48// AddFavoriteInput contains parameters for adding a favorite.
49type AddFavoriteInput struct {
50	URL        string
51	Title      string
52	FaviconURL string
53	Tags       []entity.TagID
54}
55
56// Add creates a new favorite.
57func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
58	log := logging.FromContext(ctx)
59	log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
60
61	// Check if already favorited
62	existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
63	if err != nil {
64		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
65	}
66	if existing != nil {
67		log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
68		return existing, nil
69	}
70
71	return uc.addNewFavorite(ctx, input)
72}
73
74func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
75	log := logging.FromContext(ctx)
76	fav := entity.NewFavorite(input.URL, input.Title)
77	fav.FaviconURL = input.FaviconURL
78
79	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
80		return nil, fmt.Errorf("failed to save favorite: %w", err)
81	}
82
83	// Assign tags
84	for _, tagID := range input.Tags {
85		if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
86			return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
87		}
88	}
89
90	log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
91	uc.invalidateCache()
92	return fav, nil
93}
94
95// AddFavorite creates a favorite from the UI-facing application port.
96func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
97	for _, tagID := range input.Tags {
98		if tagID <= 0 {
99			return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
100		}
101	}
102	favoriteURL, err := normalizeFavoriteURL(input.URL)
103	if err != nil {
104		return nil, err
105	}
106	if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil {
107		return nil, err
108	} else if existing != nil {
109		return existing, nil
110	}
111	tags := append([]entity.TagID(nil), input.Tags...)
112	title := strings.TrimSpace(input.Title)
113	if title == "" {
114		title = favoriteURL
115	}
116	return uc.addNewFavorite(ctx, AddFavoriteInput{
117		URL:        favoriteURL,
118		Title:      title,
119		FaviconURL: strings.TrimSpace(input.FaviconURL),
120		Tags:       tags,
121	})
122}
123
124func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
125	ctx context.Context,
126	canonicalURL string,
127	rawURL string,
128) (*entity.Favorite, error) {
129	existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
130	if err != nil {
131		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
132	}
133	if existing != nil {
134		return existing, nil
135	}
136	rawURL = strings.TrimSpace(rawURL)
137	if rawURL == "" || rawURL == canonicalURL {
138		return nil, nil
139	}
140	existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL)
141	if err != nil {
142		return nil, fmt.Errorf("failed to check legacy favorite: %w", err)
143	}
144	return existing, nil
145}
146
147func normalizeFavoriteURL(raw string) (string, error) {
148	favoriteURL := domainurl.Normalize(strings.TrimSpace(raw))
149	if favoriteURL == "" {
150		return "", fmt.Errorf("favorite URL is required")
151	}
152	parsed, err := url.Parse(favoriteURL)
153	if err != nil || parsed.Scheme == "" {
154		return "", fmt.Errorf("favorite URL must be absolute")
155	}
156	switch strings.ToLower(parsed.Scheme) {
157	case "http", "https":
158		if parsed.Host == "" {
159			return "", fmt.Errorf("favorite URL host is required")
160		}
161	case "dumb":
162		if parsed.Host != "" {
163			return favoriteURL, nil
164		}
165		if parsed.Opaque != "" {
166			return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
167		}
168		return "", fmt.Errorf("favorite URL host is required")
169	default:
170		return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
171	}
172	return favoriteURL, nil
173}
174
175// UpdateFavorite updates editable favorite metadata from the UI-facing application port.
176func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
177	if input.ID <= 0 {
178		return nil, fmt.Errorf("favorite ID is required")
179	}
180	if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
181		return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
182	}
183
184	fav, err := uc.favoriteRepo.FindByID(ctx, input.ID)
185	if err != nil {
186		return nil, fmt.Errorf("failed to find favorite: %w", err)
187	}
188	if fav == nil {
189		return nil, fmt.Errorf("favorite %d not found", input.ID)
190	}
191
192	title := strings.TrimSpace(input.Title)
193	if title == "" {
194		return nil, fmt.Errorf("favorite title is required")
195	}
196	fav.Title = title
197	fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
198	if input.ShortcutKeySet {
199		fav.ShortcutKey = input.ShortcutKey
200	}
201
202	if err := uc.Update(ctx, fav); err != nil {
203		return nil, err
204	}
205	return fav, nil
206}
207
208// DeleteFavorite deletes a favorite from the UI-facing application port.
209func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
210	return uc.Remove(ctx, id)
211}
212
213// Remove deletes a favorite by ID.
214func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
215	log := logging.FromContext(ctx)
216	log.Debug().Int64("id", int64(id)).Msg("removing favorite")
217
218	if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
219		return fmt.Errorf("failed to delete favorite: %w", err)
220	}
221
222	log.Info().Int64("id", int64(id)).Msg("favorite removed")
223	uc.invalidateCache()
224	return nil
225}
226
227// RemoveByURL deletes a favorite by its URL.
228func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
229	log := logging.FromContext(ctx)
230	log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
231
232	fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
233	if err != nil {
234		return fmt.Errorf("failed to find favorite: %w", err)
235	}
236	if fav == nil {
237		log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
238		return nil
239	}
240
241	return uc.Remove(ctx, fav.ID)
242}
243
244// Update modifies a favorite's metadata.
245func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
246	log := logging.FromContext(ctx)
247	log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
248
249	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
250		return fmt.Errorf("failed to update favorite: %w", err)
251	}
252
253	log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
254	uc.invalidateCache()
255	return nil
256}
257
258// SetShortcut assigns or removes a keyboard shortcut (1-9).
259func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
260	log := logging.FromContext(ctx)
261
262	if key != nil && (*key < 1 || *key > 9) {
263		return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
264	}
265
266	log.Debug().
267		Int64("id", int64(id)).
268		Msg("setting favorite shortcut")
269
270	if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
271		return fmt.Errorf("failed to set shortcut: %w", err)
272	}
273
274	uc.invalidateCache()
275	return nil
276}
277
278// GetByShortcut finds the favorite with the given shortcut key.
279func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
280	log := logging.FromContext(ctx)
281	log.Debug().Int("key", key).Msg("getting favorite by shortcut")
282
283	if key < 1 || key > 9 {
284		return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
285	}
286
287	return uc.favoriteRepo.GetByShortcut(ctx, key)
288}
289
290// GetByURL finds a favorite by its URL.
291func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
292	log := logging.FromContext(ctx)
293	log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
294
295	return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
296}
297
298// GetAll retrieves all favorites.
299func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
300	log := logging.FromContext(ctx)
301	log.Debug().Msg("getting all favorites")
302
303	uc.cacheMu.Lock()
304	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
305		cached := uc.favoritesCache
306		uc.cacheMu.Unlock()
307		return cached, nil
308	}
309	uc.cacheMu.Unlock()
310
311	favs, err := uc.favoriteRepo.GetAll(ctx)
312	if err != nil {
313		return nil, fmt.Errorf("failed to get favorites: %w", err)
314	}
315
316	uc.cacheMu.Lock()
317	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
318		cached := uc.favoritesCache
319		uc.cacheMu.Unlock()
320		return cached, nil
321	}
322	uc.favoritesCache = favs
323	uc.favoriteURLsCache = buildFavoriteURLSet(favs)
324	uc.cacheTime = time.Now()
325	uc.cacheMu.Unlock()
326
327	log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
328	return favs, nil
329}
330
331// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
332func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
333	favs, err := uc.GetAll(ctx)
334	if err != nil {
335		return nil, err
336	}
337
338	queryLower := strings.ToLower(strings.TrimSpace(query))
339	if queryLower == "" {
340		return favs, nil
341	}
342
343	filtered := make([]*entity.Favorite, 0, len(favs))
344	for _, fav := range favs {
345		if fav == nil {
346			continue
347		}
348		titleLower := strings.ToLower(fav.Title)
349		urlLower := strings.ToLower(fav.URL)
350		if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
351			continue
352		}
353		filtered = append(filtered, fav)
354	}
355
356	sort.SliceStable(filtered, func(i, j int) bool {
357		leftScore := omniboxFavoriteScore(filtered[i], queryLower)
358		rightScore := omniboxFavoriteScore(filtered[j], queryLower)
359		if leftScore != rightScore {
360			return leftScore > rightScore
361		}
362		if filtered[i].Position != filtered[j].Position {
363			return filtered[i].Position < filtered[j].Position
364		}
365		return filtered[i].URL < filtered[j].URL
366	})
367
368	return filtered, nil
369}
370
371// IsFavorite checks if a URL is favorited.
372func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
373	fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
374	if err != nil {
375		return false, fmt.Errorf("failed to check favorite: %w", err)
376	}
377	return fav != nil, nil
378}
379
380func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
381	if fav == nil || queryLower == "" {
382		return 0
383	}
384
385	const (
386		favoriteExactMatchScore    = 600
387		favoriteHostPrefixScore    = 520
388		favoriteTitlePrefixScore   = 440
389		favoriteTitleContainsScore = 320
390		favoriteURLContainsScore   = 280
391	)
392
393	urlLower := strings.ToLower(fav.URL)
394	strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
395	if strippedURLLower == "" {
396		strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
397	}
398	titleLower := strings.ToLower(fav.Title)
399
400	switch {
401	case titleLower == queryLower || urlLower == queryLower:
402		return favoriteExactMatchScore
403	case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
404		return favoriteHostPrefixScore
405	case strings.HasPrefix(titleLower, queryLower):
406		return favoriteTitlePrefixScore
407	case strings.Contains(titleLower, queryLower):
408		return favoriteTitleContainsScore
409	case strings.Contains(urlLower, queryLower):
410		return favoriteURLContainsScore
411	default:
412		return 0
413	}
414}
415
416// ToggleResult indicates the result of a toggle operation.
417type ToggleResult struct {
418	Added   bool // true if favorite was added, false if removed
419	URL     string
420	Title   string
421	Message string // User-friendly message
422}
423
424// Toggle adds or removes a URL from favorites based on current state.
425// If the URL is already a favorite, it removes it. Otherwise, it adds it.
426func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
427	favoriteURL, err := normalizeFavoriteURL(rawURL)
428	if err != nil {
429		return nil, err
430	}
431	title = strings.TrimSpace(title)
432
433	log := logging.FromContext(ctx)
434	log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
435
436	// Check if already favorited, including legacy rows stored before URL canonicalization.
437	existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
438	if err != nil {
439		return nil, err
440	}
441
442	if existing != nil {
443		// Remove favorite
444		if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
445			return nil, fmt.Errorf("failed to remove favorite: %w", err)
446		}
447		log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
448		uc.invalidateCache()
449		return &ToggleResult{
450			Added:   false,
451			URL:     favoriteURL,
452			Title:   existing.Title,
453			Message: "Favorite removed",
454		}, nil
455	}
456
457	// Add favorite
458	fav := entity.NewFavorite(favoriteURL, title)
459	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
460		return nil, fmt.Errorf("failed to add favorite: %w", err)
461	}
462	log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
463	uc.invalidateCache()
464	return &ToggleResult{
465		Added:   true,
466		URL:     favoriteURL,
467		Title:   title,
468		Message: "Favorite added",
469	}, nil
470}
471
472// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
473func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
474	log := logging.FromContext(ctx)
475	log.Debug().Msg("getting all favorite URLs")
476
477	uc.cacheMu.Lock()
478	if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
479		cached := uc.favoriteURLsCache
480		uc.cacheMu.Unlock()
481		return cached, nil
482	}
483	uc.cacheMu.Unlock()
484
485	favs, err := uc.GetAll(ctx)
486	if err != nil {
487		return nil, fmt.Errorf("failed to get favorites: %w", err)
488	}
489
490	urls := buildFavoriteURLSet(favs)
491
492	log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
493	return urls, nil
494}
495
496func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
497	urls := make(map[string]struct{}, len(favs))
498	for _, fav := range favs {
499		if fav == nil || fav.URL == "" {
500			continue
501		}
502		urls[fav.URL] = struct{}{}
503	}
504	return urls
505}
506
507func (uc *ManageFavoritesUseCase) invalidateCache() {
508	uc.cacheMu.Lock()
509	defer uc.cacheMu.Unlock()
510	uc.favoritesCache = nil
511	uc.favoriteURLsCache = nil
512	uc.cacheTime = time.Time{}
513}
514
515// AddTag creates a new tag.
516func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
517	name = strings.TrimSpace(name)
518	color = strings.TrimSpace(color)
519	if name == "" {
520		return nil, fmt.Errorf("tag name is required")
521	}
522	log := logging.FromContext(ctx)
523	log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
524
525	// Check if tag already exists
526	existing, err := uc.tagRepo.FindByName(ctx, name)
527	if err != nil {
528		return nil, fmt.Errorf("failed to check existing tag: %w", err)
529	}
530	if existing != nil {
531		log.Debug().Str("name", name).Msg("tag already exists")
532		return existing, nil
533	}
534
535	tag := entity.NewTag(name)
536	if color != "" {
537		tag.Color = color
538	}
539
540	if err := uc.tagRepo.Save(ctx, tag); err != nil {
541		return nil, fmt.Errorf("failed to create tag: %w", err)
542	}
543
544	log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
545	return tag, nil
546}
547
548// DeleteTag removes a tag.
549func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
550	log := logging.FromContext(ctx)
551	log.Debug().Int64("id", int64(id)).Msg("deleting tag")
552
553	if err := uc.tagRepo.Delete(ctx, id); err != nil {
554		return fmt.Errorf("failed to delete tag: %w", err)
555	}
556
557	log.Info().Int64("id", int64(id)).Msg("tag deleted")
558	uc.invalidateCache()
559	return nil
560}
561
562// GetAllTags retrieves all tags.
563func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
564	log := logging.FromContext(ctx)
565	log.Debug().Msg("getting all tags")
566
567	return uc.tagRepo.GetAll(ctx)
568}
569
570// TagFavorite assigns a tag to a favorite.
571func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
572	log := logging.FromContext(ctx)
573	log.Debug().
574		Int64("favorite_id", int64(favID)).
575		Int64("tag_id", int64(tagID)).
576		Msg("tagging favorite")
577
578	if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
579		return fmt.Errorf("failed to tag favorite: %w", err)
580	}
581	uc.invalidateCache()
582
583	return nil
584}
585
586// UntagFavorite removes a tag from a favorite.
587func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
588	log := logging.FromContext(ctx)
589	log.Debug().
590		Int64("favorite_id", int64(favID)).
591		Int64("tag_id", int64(tagID)).
592		Msg("untagging favorite")
593
594	if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
595		return fmt.Errorf("failed to untag favorite: %w", err)
596	}
597	uc.invalidateCache()
598
599	return nil
600}
601
602// UpdateFavoriteTags reconciles a favorite's tag assignments with the requested state.
603// Failed mutations are rolled back in reverse order when the repository cannot
604// provide a transaction boundary.
605func (uc *ManageFavoritesUseCase) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error {
606	currentTags, err := uc.tagRepo.GetForFavorite(ctx, favID)
607	if err != nil {
608		return fmt.Errorf("failed to load tags for favorite %d: %w", favID, err)
609	}
610
611	current := make(map[entity.TagID]struct{}, len(currentTags))
612	for _, tag := range currentTags {
613		if tag != nil {
614			current[tag.ID] = struct{}{}
615		}
616	}
617	wanted := make(map[entity.TagID]struct{}, len(tagIDs))
618	for _, id := range tagIDs {
619		wanted[id] = struct{}{}
620	}
621
622	toRemove := make([]entity.TagID, 0, len(current))
623	for id := range current {
624		if _, ok := wanted[id]; !ok {
625			toRemove = append(toRemove, id)
626		}
627	}
628	toAdd := make([]entity.TagID, 0, len(wanted))
629	for id := range wanted {
630		if _, ok := current[id]; !ok {
631			toAdd = append(toAdd, id)
632		}
633	}
634	sort.Slice(toRemove, func(i, j int) bool { return toRemove[i] < toRemove[j] })
635	sort.Slice(toAdd, func(i, j int) bool { return toAdd[i] < toAdd[j] })
636
637	type mutation struct {
638		tagID entity.TagID
639		added bool
640	}
641	applied := make([]mutation, 0, len(toRemove)+len(toAdd))
642	rollbackLog := logging.FromContext(ctx)
643	rollback := func() {
644		for i := len(applied) - 1; i >= 0; i-- {
645			change := applied[i]
646			var rollbackErr error
647			if change.added {
648				rollbackErr = uc.UntagFavorite(ctx, favID, change.tagID)
649			} else {
650				rollbackErr = uc.TagFavorite(ctx, favID, change.tagID)
651			}
652			if rollbackErr != nil {
653				rollbackLog.Warn().
654					Err(rollbackErr).
655					Int64("favorite_id", int64(favID)).
656					Int64("tag_id", int64(change.tagID)).
657					Msg("failed to roll back favorite tag mutation")
658			}
659		}
660	}
661
662	for _, id := range toRemove {
663		if err := uc.UntagFavorite(ctx, favID, id); err != nil {
664			rollback()
665			return fmt.Errorf("failed to remove tag %d from favorite %d: %w", id, favID, err)
666		}
667		applied = append(applied, mutation{tagID: id})
668	}
669	for _, id := range toAdd {
670		if err := uc.TagFavorite(ctx, favID, id); err != nil {
671			rollback()
672			return fmt.Errorf("failed to add tag %d to favorite %d: %w", id, favID, err)
673		}
674		applied = append(applied, mutation{tagID: id, added: true})
675	}
676	return nil
677}
678
679// GetTagsForFavorite retrieves all tags for a favorite.
680func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
681	log := logging.FromContext(ctx)
682	log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
683
684	return uc.tagRepo.GetForFavorite(ctx, favID)
685}
686
687// UpdateTag updates a tag's name and color.
688func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
689	name = strings.TrimSpace(name)
690	color = strings.TrimSpace(color)
691	if name == "" {
692		return fmt.Errorf("tag name is required")
693	}
694	log := logging.FromContext(ctx)
695	log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
696
697	tag, err := uc.tagRepo.FindByID(ctx, id)
698	if err != nil {
699		return fmt.Errorf("failed to find tag: %w", err)
700	}
701	if tag == nil {
702		return fmt.Errorf("tag %d not found", id)
703	}
704
705	tag.Name = name
706	if color != "" {
707		tag.Color = color
708	}
709
710	if err := uc.tagRepo.Save(ctx, tag); err != nil {
711		return fmt.Errorf("failed to update tag: %w", err)
712	}
713
714	log.Info().Int64("id", int64(id)).Msg("tag updated")
715	uc.invalidateCache()
716	return nil
717}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close