v0.21.0 73bf644

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// GetTree builds a complete hierarchical view of folders and favorites.
200func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201	log := logging.FromContext(ctx)
202	log.Debug().Msg("building favorite tree")
203
204	tree := entity.NewFavoriteTree()
205
206	// Get all folders
207	folders, err := uc.folderRepo.GetAll(ctx)
208	if err != nil {
209		return nil, fmt.Errorf("failed to get folders: %w", err)
210	}
211
212	// Build folder map and organize by parent
213	for _, folder := range folders {
214		tree.FolderMap[folder.ID] = folder
215		if folder.ParentID == nil {
216			tree.RootFolders = append(tree.RootFolders, folder)
217		} else {
218			tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder)
219		}
220	}
221
222	// Get all favorites
223	favorites, err := uc.favoriteRepo.GetAll(ctx)
224	if err != nil {
225		return nil, fmt.Errorf("failed to get favorites: %w", err)
226	}
227
228	// Organize favorites by folder
229	for _, fav := range favorites {
230		if fav.FolderID == nil {
231			tree.RootFavorites = append(tree.RootFavorites, fav)
232		} else {
233			tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav)
234		}
235	}
236
237	log.Debug().
238		Int("folders", len(folders)).
239		Int("favorites", len(favorites)).
240		Msg("favorite tree built")
241
242	return tree, nil
243}
244
245// CreateFolder creates a new folder.
246func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
247	log := logging.FromContext(ctx)
248	log.Debug().Str("name", name).Msg("creating folder")
249
250	folder := entity.NewFolder(name)
251	folder.ParentID = parentID
252
253	if err := uc.folderRepo.Save(ctx, folder); err != nil {
254		return nil, fmt.Errorf("failed to create folder: %w", err)
255	}
256
257	log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created")
258	return folder, nil
259}
260
261// DeleteFolder removes a folder. Favorites in the folder are moved to root.
262func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error {
263	log := logging.FromContext(ctx)
264	log.Debug().Int64("id", int64(id)).Msg("deleting folder")
265
266	// Move favorites from this folder to root (or parent)
267	folder, err := uc.folderRepo.FindByID(ctx, id)
268	if err != nil {
269		return fmt.Errorf("failed to find folder: %w", err)
270	}
271	if folder == nil {
272		log.Debug().Int64("id", int64(id)).Msg("folder not found")
273		return nil
274	}
275
276	// Get favorites in this folder
277	favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id)
278	if err != nil {
279		return fmt.Errorf("failed to get folder favorites: %w", err)
280	}
281
282	// Move favorites to parent folder (or root)
283	for _, fav := range favorites {
284		if setErr := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); setErr != nil {
285			log.Warn().
286				Int64("favorite_id", int64(fav.ID)).
287				Err(setErr).
288				Msg("failed to move favorite from deleted folder")
289		}
290	}
291
292	// Get child folders
293	children, err := uc.folderRepo.GetChildren(ctx, &id)
294	if err != nil {
295		return fmt.Errorf("failed to get child folders: %w", err)
296	}
297
298	// Recursively delete child folders
299	for _, child := range children {
300		if err := uc.DeleteFolder(ctx, child.ID); err != nil {
301			log.Warn().
302				Int64("child_id", int64(child.ID)).
303				Err(err).
304				Msg("failed to delete child folder")
305		}
306	}
307
308	// Delete the folder itself
309	if err := uc.folderRepo.Delete(ctx, id); err != nil {
310		return fmt.Errorf("failed to delete folder: %w", err)
311	}
312
313	log.Info().Int64("id", int64(id)).Msg("folder deleted")
314	return nil
315}
316
317// GetAllFolders retrieves all folders.
318func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
319	log := logging.FromContext(ctx)
320	log.Debug().Msg("getting all folders")
321
322	return uc.folderRepo.GetAll(ctx)
323}
324
325// AddTag creates a new tag.
326func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
327	log := logging.FromContext(ctx)
328	log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
329
330	// Check if tag already exists
331	existing, err := uc.tagRepo.FindByName(ctx, name)
332	if err != nil {
333		return nil, fmt.Errorf("failed to check existing tag: %w", err)
334	}
335	if existing != nil {
336		log.Debug().Str("name", name).Msg("tag already exists")
337		return existing, nil
338	}
339
340	tag := entity.NewTag(name)
341	if color != "" {
342		tag.Color = color
343	}
344
345	if err := uc.tagRepo.Save(ctx, tag); err != nil {
346		return nil, fmt.Errorf("failed to create tag: %w", err)
347	}
348
349	log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
350	return tag, nil
351}
352
353// DeleteTag removes a tag.
354func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
355	log := logging.FromContext(ctx)
356	log.Debug().Int64("id", int64(id)).Msg("deleting tag")
357
358	if err := uc.tagRepo.Delete(ctx, id); err != nil {
359		return fmt.Errorf("failed to delete tag: %w", err)
360	}
361
362	log.Info().Int64("id", int64(id)).Msg("tag deleted")
363	return nil
364}
365
366// GetAllTags retrieves all tags.
367func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
368	log := logging.FromContext(ctx)
369	log.Debug().Msg("getting all tags")
370
371	return uc.tagRepo.GetAll(ctx)
372}
373
374// TagFavorite assigns a tag to a favorite.
375func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
376	log := logging.FromContext(ctx)
377	log.Debug().
378		Int64("favorite_id", int64(favID)).
379		Int64("tag_id", int64(tagID)).
380		Msg("tagging favorite")
381
382	if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
383		return fmt.Errorf("failed to tag favorite: %w", err)
384	}
385
386	return nil
387}
388
389// UntagFavorite removes a tag from a favorite.
390func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
391	log := logging.FromContext(ctx)
392	log.Debug().
393		Int64("favorite_id", int64(favID)).
394		Int64("tag_id", int64(tagID)).
395		Msg("untagging favorite")
396
397	if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
398		return fmt.Errorf("failed to untag favorite: %w", err)
399	}
400
401	return nil
402}
403
404// GetTagsForFavorite retrieves all tags for a favorite.
405func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
406	log := logging.FromContext(ctx)
407	log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
408
409	return uc.tagRepo.GetForFavorite(ctx, favID)
410}
411
412// UpdateFolder updates a folder's name and icon.
413func (uc *ManageFavoritesUseCase) UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error {
414	log := logging.FromContext(ctx)
415	log.Debug().Int64("id", int64(id)).Str("name", name).Msg("updating folder")
416
417	folder, err := uc.folderRepo.FindByID(ctx, id)
418	if err != nil {
419		return fmt.Errorf("failed to find folder: %w", err)
420	}
421	if folder == nil {
422		return fmt.Errorf("folder %d not found", id)
423	}
424
425	if name != "" {
426		folder.Name = name
427	}
428	folder.Icon = icon
429
430	if err := uc.folderRepo.Save(ctx, folder); err != nil {
431		return fmt.Errorf("failed to update folder: %w", err)
432	}
433
434	log.Info().Int64("id", int64(id)).Msg("folder updated")
435	return nil
436}
437
438// UpdateTag updates a tag's name and color.
439func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
440	log := logging.FromContext(ctx)
441	log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
442
443	tag, err := uc.tagRepo.FindByID(ctx, id)
444	if err != nil {
445		return fmt.Errorf("failed to find tag: %w", err)
446	}
447	if tag == nil {
448		return fmt.Errorf("tag %d not found", id)
449	}
450
451	if name != "" {
452		tag.Name = name
453	}
454	if color != "" {
455		tag.Color = color
456	}
457
458	if err := uc.tagRepo.Save(ctx, tag); err != nil {
459		return fmt.Errorf("failed to update tag: %w", err)
460	}
461
462	log.Info().Int64("id", int64(id)).Msg("tag updated")
463	return nil
464}

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