v0.23.2 bfd5eb4

manage_favorites.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/dumber/internal/domain/repository"
9	"github.com/bnema/dumber/internal/logging"
10)
11
12// ManageFavoritesUseCase handles favorite, folder, and tag operations.
13type ManageFavoritesUseCase struct {
14	favoriteRepo repository.FavoriteRepository
15	folderRepo   repository.FolderRepository
16	tagRepo      repository.TagRepository
17}
18
19// NewManageFavoritesUseCase creates a new favorites management use case.
20func NewManageFavoritesUseCase(
21	favoriteRepo repository.FavoriteRepository,
22	folderRepo repository.FolderRepository,
23	tagRepo repository.TagRepository,
24) *ManageFavoritesUseCase {
25	return &ManageFavoritesUseCase{
26		favoriteRepo: favoriteRepo,
27		folderRepo:   folderRepo,
28		tagRepo:      tagRepo,
29	}
30}
31
32// AddFavoriteInput contains parameters for adding a favorite.
33type AddFavoriteInput struct {
34	URL        string
35	Title      string
36	FaviconURL string
37	FolderID   *entity.FolderID
38	Tags       []entity.TagID
39}
40
41// Add creates a new favorite.
42func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
43	log := logging.FromContext(ctx)
44	log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
45
46	// Check if already favorited
47	existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
48	if err != nil {
49		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
50	}
51	if existing != nil {
52		log.Debug().Str("url", input.URL).Msg("URL already favorited")
53		return existing, nil
54	}
55
56	fav := entity.NewFavorite(input.URL, input.Title)
57	fav.FaviconURL = input.FaviconURL
58	fav.FolderID = input.FolderID
59
60	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
61		return nil, fmt.Errorf("failed to save favorite: %w", err)
62	}
63
64	// Assign tags
65	for _, tagID := range input.Tags {
66		if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
67			log.Warn().
68				Int64("tag_id", int64(tagID)).
69				Int64("favorite_id", int64(fav.ID)).
70				Err(err).
71				Msg("failed to assign tag")
72		}
73	}
74
75	log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
76	return fav, nil
77}
78
79// Remove deletes a favorite by ID.
80func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
81	log := logging.FromContext(ctx)
82	log.Debug().Int64("id", int64(id)).Msg("removing favorite")
83
84	if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
85		return fmt.Errorf("failed to delete favorite: %w", err)
86	}
87
88	log.Info().Int64("id", int64(id)).Msg("favorite removed")
89	return nil
90}
91
92// RemoveByURL deletes a favorite by its URL.
93func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
94	log := logging.FromContext(ctx)
95	log.Debug().Str("url", url).Msg("removing favorite by URL")
96
97	fav, err := uc.favoriteRepo.FindByURL(ctx, url)
98	if err != nil {
99		return fmt.Errorf("failed to find favorite: %w", err)
100	}
101	if fav == nil {
102		log.Debug().Str("url", url).Msg("favorite not found")
103		return nil
104	}
105
106	return uc.Remove(ctx, fav.ID)
107}
108
109// Update modifies a favorite's metadata.
110func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
111	log := logging.FromContext(ctx)
112	log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
113
114	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
115		return fmt.Errorf("failed to update favorite: %w", err)
116	}
117
118	log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
119	return nil
120}
121
122// Move changes a favorite's folder.
123func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
124	log := logging.FromContext(ctx)
125	log.Debug().
126		Int64("favorite_id", int64(id)).
127		Msg("moving favorite to folder")
128
129	if err := uc.favoriteRepo.SetFolder(ctx, id, folderID); err != nil {
130		return fmt.Errorf("failed to move favorite: %w", err)
131	}
132
133	log.Info().Int64("id", int64(id)).Msg("favorite moved")
134	return nil
135}
136
137// SetShortcut assigns or removes a keyboard shortcut (1-9).
138func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
139	log := logging.FromContext(ctx)
140
141	if key != nil && (*key < 1 || *key > 9) {
142		return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
143	}
144
145	log.Debug().
146		Int64("id", int64(id)).
147		Msg("setting favorite shortcut")
148
149	if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
150		return fmt.Errorf("failed to set shortcut: %w", err)
151	}
152
153	return nil
154}
155
156// GetByShortcut finds the favorite with the given shortcut key.
157func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
158	log := logging.FromContext(ctx)
159	log.Debug().Int("key", key).Msg("getting favorite by shortcut")
160
161	if key < 1 || key > 9 {
162		return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
163	}
164
165	return uc.favoriteRepo.GetByShortcut(ctx, key)
166}
167
168// GetByURL finds a favorite by its URL.
169func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
170	log := logging.FromContext(ctx)
171	log.Debug().Str("url", url).Msg("getting favorite by URL")
172
173	return uc.favoriteRepo.FindByURL(ctx, url)
174}
175
176// GetAll retrieves all favorites.
177func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
178	log := logging.FromContext(ctx)
179	log.Debug().Msg("getting all favorites")
180
181	favs, err := uc.favoriteRepo.GetAll(ctx)
182	if err != nil {
183		return nil, fmt.Errorf("failed to get favorites: %w", err)
184	}
185
186	log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
187	return favs, nil
188}
189
190// IsFavorite checks if a URL is favorited.
191func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
192	fav, err := uc.favoriteRepo.FindByURL(ctx, url)
193	if err != nil {
194		return false, fmt.Errorf("failed to check favorite: %w", err)
195	}
196	return fav != nil, nil
197}
198
199// ToggleResult indicates the result of a toggle operation.
200type ToggleResult struct {
201	Added   bool // true if favorite was added, false if removed
202	URL     string
203	Title   string
204	Message string // User-friendly message
205}
206
207// Toggle adds or removes a URL from favorites based on current state.
208// If the URL is already a favorite, it removes it. Otherwise, it adds it.
209func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
210	log := logging.FromContext(ctx)
211	log.Debug().Str("url", url).Msg("toggling favorite")
212
213	if url == "" {
214		return nil, fmt.Errorf("URL cannot be empty")
215	}
216
217	// Check if already favorited
218	existing, err := uc.favoriteRepo.FindByURL(ctx, url)
219	if err != nil {
220		return nil, fmt.Errorf("failed to check existing favorite: %w", err)
221	}
222
223	if existing != nil {
224		// Remove favorite
225		if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
226			return nil, fmt.Errorf("failed to remove favorite: %w", err)
227		}
228		log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
229		return &ToggleResult{
230			Added:   false,
231			URL:     url,
232			Title:   existing.Title,
233			Message: "Favorite removed",
234		}, nil
235	}
236
237	// Add favorite
238	fav := entity.NewFavorite(url, title)
239	if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
240		return nil, fmt.Errorf("failed to add favorite: %w", err)
241	}
242	log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
243	return &ToggleResult{
244		Added:   true,
245		URL:     url,
246		Title:   title,
247		Message: "Favorite added",
248	}, nil
249}
250
251// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
252func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
253	log := logging.FromContext(ctx)
254	log.Debug().Msg("getting all favorite URLs")
255
256	favs, err := uc.favoriteRepo.GetAll(ctx)
257	if err != nil {
258		return nil, fmt.Errorf("failed to get favorites: %w", err)
259	}
260
261	urls := make(map[string]struct{}, len(favs))
262	for _, fav := range favs {
263		urls[fav.URL] = struct{}{}
264	}
265
266	log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267	return urls, nil
268}
269
270// GetTree builds a complete hierarchical view of folders and favorites.
271func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
272	log := logging.FromContext(ctx)
273	log.Debug().Msg("building favorite tree")
274
275	tree := entity.NewFavoriteTree()
276
277	// Get all folders
278	folders, err := uc.folderRepo.GetAll(ctx)
279	if err != nil {
280		return nil, fmt.Errorf("failed to get folders: %w", err)
281	}
282
283	// Build folder map and organize by parent
284	for _, folder := range folders {
285		tree.FolderMap[folder.ID] = folder
286		if folder.ParentID == nil {
287			tree.RootFolders = append(tree.RootFolders, folder)
288		} else {
289			tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder)
290		}
291	}
292
293	// Get all favorites
294	favorites, err := uc.favoriteRepo.GetAll(ctx)
295	if err != nil {
296		return nil, fmt.Errorf("failed to get favorites: %w", err)
297	}
298
299	// Organize favorites by folder
300	for _, fav := range favorites {
301		if fav.FolderID == nil {
302			tree.RootFavorites = append(tree.RootFavorites, fav)
303		} else {
304			tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav)
305		}
306	}
307
308	log.Debug().
309		Int("folders", len(folders)).
310		Int("favorites", len(favorites)).
311		Msg("favorite tree built")
312
313	return tree, nil
314}
315
316// CreateFolder creates a new folder.
317func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
318	log := logging.FromContext(ctx)
319	log.Debug().Str("name", name).Msg("creating folder")
320
321	folder := entity.NewFolder(name)
322	folder.ParentID = parentID
323
324	if err := uc.folderRepo.Save(ctx, folder); err != nil {
325		return nil, fmt.Errorf("failed to create folder: %w", err)
326	}
327
328	log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created")
329	return folder, nil
330}
331
332// DeleteFolder removes a folder. Favorites in the folder are moved to root.
333func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error {
334	log := logging.FromContext(ctx)
335	log.Debug().Int64("id", int64(id)).Msg("deleting folder")
336
337	// Move favorites from this folder to root (or parent)
338	folder, err := uc.folderRepo.FindByID(ctx, id)
339	if err != nil {
340		return fmt.Errorf("failed to find folder: %w", err)
341	}
342	if folder == nil {
343		log.Debug().Int64("id", int64(id)).Msg("folder not found")
344		return nil
345	}
346
347	// Get favorites in this folder
348	favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id)
349	if err != nil {
350		return fmt.Errorf("failed to get folder favorites: %w", err)
351	}
352
353	// Move favorites to parent folder (or root)
354	for _, fav := range favorites {
355		if setErr := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); setErr != nil {
356			log.Warn().
357				Int64("favorite_id", int64(fav.ID)).
358				Err(setErr).
359				Msg("failed to move favorite from deleted folder")
360		}
361	}
362
363	// Get child folders
364	children, err := uc.folderRepo.GetChildren(ctx, &id)
365	if err != nil {
366		return fmt.Errorf("failed to get child folders: %w", err)
367	}
368
369	// Recursively delete child folders
370	for _, child := range children {
371		if err := uc.DeleteFolder(ctx, child.ID); err != nil {
372			log.Warn().
373				Int64("child_id", int64(child.ID)).
374				Err(err).
375				Msg("failed to delete child folder")
376		}
377	}
378
379	// Delete the folder itself
380	if err := uc.folderRepo.Delete(ctx, id); err != nil {
381		return fmt.Errorf("failed to delete folder: %w", err)
382	}
383
384	log.Info().Int64("id", int64(id)).Msg("folder deleted")
385	return nil
386}
387
388// GetAllFolders retrieves all folders.
389func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
390	log := logging.FromContext(ctx)
391	log.Debug().Msg("getting all folders")
392
393	return uc.folderRepo.GetAll(ctx)
394}
395
396// AddTag creates a new tag.
397func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
398	log := logging.FromContext(ctx)
399	log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
400
401	// Check if tag already exists
402	existing, err := uc.tagRepo.FindByName(ctx, name)
403	if err != nil {
404		return nil, fmt.Errorf("failed to check existing tag: %w", err)
405	}
406	if existing != nil {
407		log.Debug().Str("name", name).Msg("tag already exists")
408		return existing, nil
409	}
410
411	tag := entity.NewTag(name)
412	if color != "" {
413		tag.Color = color
414	}
415
416	if err := uc.tagRepo.Save(ctx, tag); err != nil {
417		return nil, fmt.Errorf("failed to create tag: %w", err)
418	}
419
420	log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
421	return tag, nil
422}
423
424// DeleteTag removes a tag.
425func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
426	log := logging.FromContext(ctx)
427	log.Debug().Int64("id", int64(id)).Msg("deleting tag")
428
429	if err := uc.tagRepo.Delete(ctx, id); err != nil {
430		return fmt.Errorf("failed to delete tag: %w", err)
431	}
432
433	log.Info().Int64("id", int64(id)).Msg("tag deleted")
434	return nil
435}
436
437// GetAllTags retrieves all tags.
438func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
439	log := logging.FromContext(ctx)
440	log.Debug().Msg("getting all tags")
441
442	return uc.tagRepo.GetAll(ctx)
443}
444
445// TagFavorite assigns a tag to a favorite.
446func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
447	log := logging.FromContext(ctx)
448	log.Debug().
449		Int64("favorite_id", int64(favID)).
450		Int64("tag_id", int64(tagID)).
451		Msg("tagging favorite")
452
453	if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
454		return fmt.Errorf("failed to tag favorite: %w", err)
455	}
456
457	return nil
458}
459
460// UntagFavorite removes a tag from a favorite.
461func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
462	log := logging.FromContext(ctx)
463	log.Debug().
464		Int64("favorite_id", int64(favID)).
465		Int64("tag_id", int64(tagID)).
466		Msg("untagging favorite")
467
468	if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
469		return fmt.Errorf("failed to untag favorite: %w", err)
470	}
471
472	return nil
473}
474
475// GetTagsForFavorite retrieves all tags for a favorite.
476func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
477	log := logging.FromContext(ctx)
478	log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
479
480	return uc.tagRepo.GetForFavorite(ctx, favID)
481}
482
483// UpdateFolder updates a folder's name and icon.
484func (uc *ManageFavoritesUseCase) UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error {
485	log := logging.FromContext(ctx)
486	log.Debug().Int64("id", int64(id)).Str("name", name).Msg("updating folder")
487
488	folder, err := uc.folderRepo.FindByID(ctx, id)
489	if err != nil {
490		return fmt.Errorf("failed to find folder: %w", err)
491	}
492	if folder == nil {
493		return fmt.Errorf("folder %d not found", id)
494	}
495
496	if name != "" {
497		folder.Name = name
498	}
499	folder.Icon = icon
500
501	if err := uc.folderRepo.Save(ctx, folder); err != nil {
502		return fmt.Errorf("failed to update folder: %w", err)
503	}
504
505	log.Info().Int64("id", int64(id)).Msg("folder updated")
506	return nil
507}
508
509// UpdateTag updates a tag's name and color.
510func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
511	log := logging.FromContext(ctx)
512	log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
513
514	tag, err := uc.tagRepo.FindByID(ctx, id)
515	if err != nil {
516		return fmt.Errorf("failed to find tag: %w", err)
517	}
518	if tag == nil {
519		return fmt.Errorf("tag %d not found", id)
520	}
521
522	if name != "" {
523		tag.Name = name
524	}
525	if color != "" {
526		tag.Color = color
527	}
528
529	if err := uc.tagRepo.Save(ctx, tag); err != nil {
530		return fmt.Errorf("failed to update tag: %w", err)
531	}
532
533	log.Info().Int64("id", int64(id)).Msg("tag updated")
534	return nil
535}

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