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