v0.30.0 eb3068f

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

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