detached 4a7ea51

feat(favorites): add sidebar query seam

commit
4a7ea51744157be49a7eb649019b521e57a76d67
author
brice · · 2026-06-28 15:22 UTC
parent
7246c1f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +347 -1
A internal/application/port/favorites_sidebar.go +20 -0
@@ -0,0 +1,20 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// FavoritesSidebarFavorites provides favorite and tag operations needed by the native favorites sidebar.
11+type FavoritesSidebarFavorites interface {
12+ GetAll(ctx context.Context) ([]*entity.Favorite, error)
13+ GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
15+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
16+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
17+ SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
18+ TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
19+ UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
20+}
@@ -0,0 +1,20 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// FavoritesSidebarFavorites provides favorite and tag operations needed by the native favorites sidebar.
11+type FavoritesSidebarFavorites interface {
12+ GetAll(ctx context.Context) ([]*entity.Favorite, error)
13+ GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
15+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
16+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
17+ SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
18+ TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
19+ UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
20+}
A internal/application/usecase/favorite_sidebar_filter.go +167 -0
@@ -0,0 +1,167 @@
1+package usecase
2+
3+import (
4+ "net/url"
5+ "sort"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// FavoriteSidebarQuery describes the pure filtering inputs for the native favorites sidebar.
13+type FavoriteSidebarQuery struct {
14+ Text string
15+ TagIDs []entity.TagID
16+}
17+
18+// FilterFavoritesForSidebar filters, deduplicates, and ranks favorites for the native favorites sidebar.
19+func FilterFavoritesForSidebar(favorites []*entity.Favorite, query FavoriteSidebarQuery) []*entity.Favorite {
20+ text := strings.ToLower(strings.TrimSpace(query.Text))
21+ selectedTagIDs := favoriteSidebarSelectedTagSet(query.TagIDs)
22+
23+ rows := make([]favoriteSidebarFilterRow, 0, len(favorites))
24+ seen := make(map[string]struct{}, len(favorites))
25+ for index, favorite := range favorites {
26+ if favorite == nil {
27+ continue
28+ }
29+ if len(selectedTagIDs) > 0 && !favoriteSidebarHasAnyTag(favorite, selectedTagIDs) {
30+ continue
31+ }
32+
33+ score := 0
34+ if text != "" {
35+ var matched bool
36+ score, matched = favoriteSidebarMatchScore(favorite, text)
37+ if !matched {
38+ continue
39+ }
40+ }
41+
42+ key := favoriteSidebarDedupeKey(favorite)
43+ if key == "" {
44+ continue
45+ }
46+ if _, ok := seen[key]; ok {
47+ continue
48+ }
49+ seen[key] = struct{}{}
50+
51+ rows = append(rows, favoriteSidebarFilterRow{
52+ favorite: favorite,
53+ index: index,
54+ score: score,
55+ })
56+ }
57+
58+ if text != "" || len(selectedTagIDs) > 0 {
59+ sort.SliceStable(rows, func(i, j int) bool {
60+ left, right := rows[i], rows[j]
61+ if left.score != right.score {
62+ return left.score > right.score
63+ }
64+ if left.favorite.Position != right.favorite.Position {
65+ return left.favorite.Position < right.favorite.Position
66+ }
67+ if left.favorite.URL != right.favorite.URL {
68+ return left.favorite.URL < right.favorite.URL
69+ }
70+ return left.index < right.index
71+ })
72+ }
73+
74+ result := make([]*entity.Favorite, 0, len(rows))
75+ for _, row := range rows {
76+ result = append(result, row.favorite)
77+ }
78+ return result
79+}
80+
81+type favoriteSidebarFilterRow struct {
82+ favorite *entity.Favorite
83+ index int
84+ score int
85+}
86+
87+func favoriteSidebarSelectedTagSet(tagIDs []entity.TagID) map[entity.TagID]struct{} {
88+ selectedTagIDs := make(map[entity.TagID]struct{}, len(tagIDs))
89+ for _, tagID := range tagIDs {
90+ selectedTagIDs[tagID] = struct{}{}
91+ }
92+ return selectedTagIDs
93+}
94+
95+func favoriteSidebarHasAnyTag(favorite *entity.Favorite, selectedTagIDs map[entity.TagID]struct{}) bool {
96+ for _, tag := range favorite.Tags {
97+ if _, ok := selectedTagIDs[tag.ID]; ok {
98+ return true
99+ }
100+ }
101+ return false
102+}
103+
104+func favoriteSidebarDedupeKey(favorite *entity.Favorite) string {
105+ if favorite.ID != 0 {
106+ return "id:" + strconv.FormatInt(int64(favorite.ID), 10)
107+ }
108+ if favorite.URL == "" {
109+ return ""
110+ }
111+ return "url:" + favorite.URL
112+}
113+
114+func favoriteSidebarMatchScore(favorite *entity.Favorite, text string) (int, bool) {
115+ title := strings.ToLower(favorite.Title)
116+ favoriteURL := strings.ToLower(favorite.URL)
117+ host := favoriteSidebarURLHost(favorite.URL)
118+
119+ switch {
120+ case title == text || favoriteURL == text:
121+ return 100, true
122+ case strings.HasPrefix(title, text):
123+ return 90, true
124+ case favoriteSidebarHostOrDomainHasPrefix(host, text):
125+ return 80, true
126+ case favoriteSidebarTagNameContains(favorite, text):
127+ return 70, true
128+ case strings.Contains(title, text):
129+ return 60, true
130+ case strings.Contains(favoriteURL, text):
131+ return 50, true
132+ default:
133+ return 0, false
134+ }
135+}
136+
137+func favoriteSidebarTagNameContains(favorite *entity.Favorite, text string) bool {
138+ for _, tag := range favorite.Tags {
139+ if strings.Contains(strings.ToLower(tag.Name), text) {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
146+func favoriteSidebarHostOrDomainHasPrefix(host string, text string) bool {
147+ host = strings.TrimPrefix(host, "www.")
148+ for host != "" {
149+ if strings.HasPrefix(host, text) {
150+ return true
151+ }
152+ dot := strings.IndexByte(host, '.')
153+ if dot < 0 {
154+ return false
155+ }
156+ host = host[dot+1:]
157+ }
158+ return false
159+}
160+
161+func favoriteSidebarURLHost(rawURL string) string {
162+ parsed, err := url.Parse(rawURL)
163+ if err != nil {
164+ return ""
165+ }
166+ return strings.ToLower(parsed.Hostname())
167+}
@@ -0,0 +1,167 @@
1+package usecase
2+
3+import (
4+ "net/url"
5+ "sort"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// FavoriteSidebarQuery describes the pure filtering inputs for the native favorites sidebar.
13+type FavoriteSidebarQuery struct {
14+ Text string
15+ TagIDs []entity.TagID
16+}
17+
18+// FilterFavoritesForSidebar filters, deduplicates, and ranks favorites for the native favorites sidebar.
19+func FilterFavoritesForSidebar(favorites []*entity.Favorite, query FavoriteSidebarQuery) []*entity.Favorite {
20+ text := strings.ToLower(strings.TrimSpace(query.Text))
21+ selectedTagIDs := favoriteSidebarSelectedTagSet(query.TagIDs)
22+
23+ rows := make([]favoriteSidebarFilterRow, 0, len(favorites))
24+ seen := make(map[string]struct{}, len(favorites))
25+ for index, favorite := range favorites {
26+ if favorite == nil {
27+ continue
28+ }
29+ if len(selectedTagIDs) > 0 && !favoriteSidebarHasAnyTag(favorite, selectedTagIDs) {
30+ continue
31+ }
32+
33+ score := 0
34+ if text != "" {
35+ var matched bool
36+ score, matched = favoriteSidebarMatchScore(favorite, text)
37+ if !matched {
38+ continue
39+ }
40+ }
41+
42+ key := favoriteSidebarDedupeKey(favorite)
43+ if key == "" {
44+ continue
45+ }
46+ if _, ok := seen[key]; ok {
47+ continue
48+ }
49+ seen[key] = struct{}{}
50+
51+ rows = append(rows, favoriteSidebarFilterRow{
52+ favorite: favorite,
53+ index: index,
54+ score: score,
55+ })
56+ }
57+
58+ if text != "" || len(selectedTagIDs) > 0 {
59+ sort.SliceStable(rows, func(i, j int) bool {
60+ left, right := rows[i], rows[j]
61+ if left.score != right.score {
62+ return left.score > right.score
63+ }
64+ if left.favorite.Position != right.favorite.Position {
65+ return left.favorite.Position < right.favorite.Position
66+ }
67+ if left.favorite.URL != right.favorite.URL {
68+ return left.favorite.URL < right.favorite.URL
69+ }
70+ return left.index < right.index
71+ })
72+ }
73+
74+ result := make([]*entity.Favorite, 0, len(rows))
75+ for _, row := range rows {
76+ result = append(result, row.favorite)
77+ }
78+ return result
79+}
80+
81+type favoriteSidebarFilterRow struct {
82+ favorite *entity.Favorite
83+ index int
84+ score int
85+}
86+
87+func favoriteSidebarSelectedTagSet(tagIDs []entity.TagID) map[entity.TagID]struct{} {
88+ selectedTagIDs := make(map[entity.TagID]struct{}, len(tagIDs))
89+ for _, tagID := range tagIDs {
90+ selectedTagIDs[tagID] = struct{}{}
91+ }
92+ return selectedTagIDs
93+}
94+
95+func favoriteSidebarHasAnyTag(favorite *entity.Favorite, selectedTagIDs map[entity.TagID]struct{}) bool {
96+ for _, tag := range favorite.Tags {
97+ if _, ok := selectedTagIDs[tag.ID]; ok {
98+ return true
99+ }
100+ }
101+ return false
102+}
103+
104+func favoriteSidebarDedupeKey(favorite *entity.Favorite) string {
105+ if favorite.ID != 0 {
106+ return "id:" + strconv.FormatInt(int64(favorite.ID), 10)
107+ }
108+ if favorite.URL == "" {
109+ return ""
110+ }
111+ return "url:" + favorite.URL
112+}
113+
114+func favoriteSidebarMatchScore(favorite *entity.Favorite, text string) (int, bool) {
115+ title := strings.ToLower(favorite.Title)
116+ favoriteURL := strings.ToLower(favorite.URL)
117+ host := favoriteSidebarURLHost(favorite.URL)
118+
119+ switch {
120+ case title == text || favoriteURL == text:
121+ return 100, true
122+ case strings.HasPrefix(title, text):
123+ return 90, true
124+ case favoriteSidebarHostOrDomainHasPrefix(host, text):
125+ return 80, true
126+ case favoriteSidebarTagNameContains(favorite, text):
127+ return 70, true
128+ case strings.Contains(title, text):
129+ return 60, true
130+ case strings.Contains(favoriteURL, text):
131+ return 50, true
132+ default:
133+ return 0, false
134+ }
135+}
136+
137+func favoriteSidebarTagNameContains(favorite *entity.Favorite, text string) bool {
138+ for _, tag := range favorite.Tags {
139+ if strings.Contains(strings.ToLower(tag.Name), text) {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
146+func favoriteSidebarHostOrDomainHasPrefix(host string, text string) bool {
147+ host = strings.TrimPrefix(host, "www.")
148+ for host != "" {
149+ if strings.HasPrefix(host, text) {
150+ return true
151+ }
152+ dot := strings.IndexByte(host, '.')
153+ if dot < 0 {
154+ return false
155+ }
156+ host = host[dot+1:]
157+ }
158+ return false
159+}
160+
161+func favoriteSidebarURLHost(rawURL string) string {
162+ parsed, err := url.Parse(rawURL)
163+ if err != nil {
164+ return ""
165+ }
166+ return strings.ToLower(parsed.Hostname())
167+}
A internal/application/usecase/favorite_sidebar_filter_test.go +158 -0
@@ -0,0 +1,158 @@
1+package usecase
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestFilterFavoritesForSidebarEmptyQueryReturnsStableOrder(t *testing.T) {
11+ favorites := []*entity.Favorite{
12+ favoriteSidebarTestFavorite(1, "https://third.example", "Third", 30),
13+ favoriteSidebarTestFavorite(2, "https://first.example", "First", 10),
14+ favoriteSidebarTestFavorite(3, "https://second.example", "Second", 20),
15+ }
16+
17+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
18+
19+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 2, 3})
20+ if len(got) > 0 && got[0] != favorites[0] {
21+ t.Fatalf("FilterFavoritesForSidebar should return original favorite pointers in a new slice")
22+ }
23+ if len(favorites) != 3 || favorites[0].ID != 1 || favorites[1].ID != 2 || favorites[2].ID != 3 {
24+ t.Fatalf("FilterFavoritesForSidebar mutated input order: %#v", favorites)
25+ }
26+}
27+
28+func TestFilterFavoritesForSidebarMatchesTitleURLAndTagNames(t *testing.T) {
29+ favorites := []*entity.Favorite{
30+ favoriteSidebarTestFavorite(1, "https://docs.example/go", "Go Docs", 1, favoriteSidebarTestTag(1, "reference")),
31+ favoriteSidebarTestFavorite(2, "https://example.com/articles", "Reading List", 2, favoriteSidebarTestTag(2, "Research")),
32+ favoriteSidebarTestFavorite(3, "https://calendar.example", "Planner", 3, favoriteSidebarTestTag(3, "Work")),
33+ }
34+
35+ t.Run("title", func(t *testing.T) {
36+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
37+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
38+ })
39+
40+ t.Run("url", func(t *testing.T) {
41+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "articles"})
42+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
43+ })
44+
45+ t.Run("tag", func(t *testing.T) {
46+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "research"})
47+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
48+ })
49+}
50+
51+func TestFilterFavoritesForSidebarSelectedTagsMatchAny(t *testing.T) {
52+ favorites := []*entity.Favorite{
53+ favoriteSidebarTestFavorite(1, "https://go.dev", "Go", 1, favoriteSidebarTestTag(1, "dev")),
54+ favoriteSidebarTestFavorite(2, "https://recipes.example", "Recipes", 2, favoriteSidebarTestTag(2, "home")),
55+ favoriteSidebarTestFavorite(3, "https://garden.example", "Garden", 3, favoriteSidebarTestTag(3, "outdoor")),
56+ favoriteSidebarTestFavorite(4, "https://untagged.example", "Untagged", 4),
57+ }
58+
59+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{TagIDs: []entity.TagID{1, 3}})
60+
61+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 3})
62+}
63+
64+func TestFilterFavoritesForSidebarCombinesTextWithinSelectedTags(t *testing.T) {
65+ favorites := []*entity.Favorite{
66+ favoriteSidebarTestFavorite(1, "https://go.dev/doc", "Go Docs", 1, favoriteSidebarTestTag(1, "dev")),
67+ favoriteSidebarTestFavorite(2, "https://go.example/home", "Go Home", 2, favoriteSidebarTestTag(2, "home")),
68+ favoriteSidebarTestFavorite(3, "https://rust.dev", "Rust", 3, favoriteSidebarTestTag(1, "dev")),
69+ }
70+
71+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go", TagIDs: []entity.TagID{1}})
72+
73+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
74+}
75+
76+func TestFilterFavoritesForSidebarRanksExactAndPrefixMatches(t *testing.T) {
77+ favorites := []*entity.Favorite{
78+ favoriteSidebarTestFavorite(1, "https://example.com/go-notes", "My Go Notes", 1),
79+ favoriteSidebarTestFavorite(2, "https://go.example.com", "Other", 2),
80+ favoriteSidebarTestFavorite(3, "https://example.com/other", "Go", 3),
81+ favoriteSidebarTestFavorite(4, "https://example.com/go", "Go Handbook", 4),
82+ }
83+
84+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
85+
86+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 4, 2, 1})
87+}
88+
89+func TestFilterFavoritesForSidebarRanksHostPrefixAndTagNameMatches(t *testing.T) {
90+ favorites := []*entity.Favorite{
91+ favoriteSidebarTestFavorite(1, "https://example.com/golang", "Notes", 1),
92+ favoriteSidebarTestFavorite(2, "https://pkg.go.dev", "Packages", 2),
93+ favoriteSidebarTestFavorite(3, "https://example.org/tools", "Tools", 3, favoriteSidebarTestTag(9, "Go Tools")),
94+ favoriteSidebarTestFavorite(4, "https://example.net/articles", "Long Go Article", 4),
95+ }
96+
97+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
98+
99+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2, 3, 4, 1})
100+}
101+
102+func TestFilterFavoritesForSidebarDeduplicatesRepeatedPointers(t *testing.T) {
103+ favorite := favoriteSidebarTestFavorite(1, "https://example.com/one", "One", 1)
104+ favorites := []*entity.Favorite{
105+ favorite,
106+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "First no ID", 2),
107+ favorite,
108+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "Second no ID", 3),
109+ nil,
110+ }
111+
112+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
113+
114+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 0})
115+ if len(got) != 2 || got[1].Title != "First no ID" {
116+ t.Fatalf("expected duplicate no-ID URL to keep first favorite, got %#v", got)
117+ }
118+}
119+
120+func TestFilterFavoritesForSidebarUsesPositionAndURLTieBreakers(t *testing.T) {
121+ favorites := []*entity.Favorite{
122+ favoriteSidebarTestFavorite(1, "https://z.example/go", "Notes about Go", 2),
123+ favoriteSidebarTestFavorite(2, "https://a.example/go", "More Go Notes", 2),
124+ favoriteSidebarTestFavorite(3, "https://m.example/go", "Go snippets", 1),
125+ }
126+
127+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
128+
129+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 2, 1})
130+}
131+
132+func favoriteSidebarTestFavorite(id entity.FavoriteID, rawURL string, title string, position int, tags ...entity.Tag) *entity.Favorite {
133+ return &entity.Favorite{
134+ ID: id,
135+ URL: rawURL,
136+ Title: title,
137+ Position: position,
138+ Tags: append([]entity.Tag(nil), tags...),
139+ }
140+}
141+
142+func favoriteSidebarTestTag(id entity.TagID, name string) entity.Tag {
143+ return entity.Tag{ID: id, Name: name}
144+}
145+
146+func assertFavoriteSidebarIDs(t *testing.T, favorites []*entity.Favorite, want []entity.FavoriteID) {
147+ t.Helper()
148+ got := make([]entity.FavoriteID, 0, len(favorites))
149+ for _, favorite := range favorites {
150+ if favorite == nil {
151+ t.Fatalf("result contains nil favorite")
152+ }
153+ got = append(got, favorite.ID)
154+ }
155+ if !reflect.DeepEqual(got, want) {
156+ t.Fatalf("favorite IDs = %v, want %v", got, want)
157+ }
158+}
@@ -0,0 +1,158 @@
1+package usecase
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestFilterFavoritesForSidebarEmptyQueryReturnsStableOrder(t *testing.T) {
11+ favorites := []*entity.Favorite{
12+ favoriteSidebarTestFavorite(1, "https://third.example", "Third", 30),
13+ favoriteSidebarTestFavorite(2, "https://first.example", "First", 10),
14+ favoriteSidebarTestFavorite(3, "https://second.example", "Second", 20),
15+ }
16+
17+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
18+
19+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 2, 3})
20+ if len(got) > 0 && got[0] != favorites[0] {
21+ t.Fatalf("FilterFavoritesForSidebar should return original favorite pointers in a new slice")
22+ }
23+ if len(favorites) != 3 || favorites[0].ID != 1 || favorites[1].ID != 2 || favorites[2].ID != 3 {
24+ t.Fatalf("FilterFavoritesForSidebar mutated input order: %#v", favorites)
25+ }
26+}
27+
28+func TestFilterFavoritesForSidebarMatchesTitleURLAndTagNames(t *testing.T) {
29+ favorites := []*entity.Favorite{
30+ favoriteSidebarTestFavorite(1, "https://docs.example/go", "Go Docs", 1, favoriteSidebarTestTag(1, "reference")),
31+ favoriteSidebarTestFavorite(2, "https://example.com/articles", "Reading List", 2, favoriteSidebarTestTag(2, "Research")),
32+ favoriteSidebarTestFavorite(3, "https://calendar.example", "Planner", 3, favoriteSidebarTestTag(3, "Work")),
33+ }
34+
35+ t.Run("title", func(t *testing.T) {
36+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
37+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
38+ })
39+
40+ t.Run("url", func(t *testing.T) {
41+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "articles"})
42+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
43+ })
44+
45+ t.Run("tag", func(t *testing.T) {
46+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "research"})
47+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2})
48+ })
49+}
50+
51+func TestFilterFavoritesForSidebarSelectedTagsMatchAny(t *testing.T) {
52+ favorites := []*entity.Favorite{
53+ favoriteSidebarTestFavorite(1, "https://go.dev", "Go", 1, favoriteSidebarTestTag(1, "dev")),
54+ favoriteSidebarTestFavorite(2, "https://recipes.example", "Recipes", 2, favoriteSidebarTestTag(2, "home")),
55+ favoriteSidebarTestFavorite(3, "https://garden.example", "Garden", 3, favoriteSidebarTestTag(3, "outdoor")),
56+ favoriteSidebarTestFavorite(4, "https://untagged.example", "Untagged", 4),
57+ }
58+
59+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{TagIDs: []entity.TagID{1, 3}})
60+
61+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 3})
62+}
63+
64+func TestFilterFavoritesForSidebarCombinesTextWithinSelectedTags(t *testing.T) {
65+ favorites := []*entity.Favorite{
66+ favoriteSidebarTestFavorite(1, "https://go.dev/doc", "Go Docs", 1, favoriteSidebarTestTag(1, "dev")),
67+ favoriteSidebarTestFavorite(2, "https://go.example/home", "Go Home", 2, favoriteSidebarTestTag(2, "home")),
68+ favoriteSidebarTestFavorite(3, "https://rust.dev", "Rust", 3, favoriteSidebarTestTag(1, "dev")),
69+ }
70+
71+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go", TagIDs: []entity.TagID{1}})
72+
73+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1})
74+}
75+
76+func TestFilterFavoritesForSidebarRanksExactAndPrefixMatches(t *testing.T) {
77+ favorites := []*entity.Favorite{
78+ favoriteSidebarTestFavorite(1, "https://example.com/go-notes", "My Go Notes", 1),
79+ favoriteSidebarTestFavorite(2, "https://go.example.com", "Other", 2),
80+ favoriteSidebarTestFavorite(3, "https://example.com/other", "Go", 3),
81+ favoriteSidebarTestFavorite(4, "https://example.com/go", "Go Handbook", 4),
82+ }
83+
84+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
85+
86+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 4, 2, 1})
87+}
88+
89+func TestFilterFavoritesForSidebarRanksHostPrefixAndTagNameMatches(t *testing.T) {
90+ favorites := []*entity.Favorite{
91+ favoriteSidebarTestFavorite(1, "https://example.com/golang", "Notes", 1),
92+ favoriteSidebarTestFavorite(2, "https://pkg.go.dev", "Packages", 2),
93+ favoriteSidebarTestFavorite(3, "https://example.org/tools", "Tools", 3, favoriteSidebarTestTag(9, "Go Tools")),
94+ favoriteSidebarTestFavorite(4, "https://example.net/articles", "Long Go Article", 4),
95+ }
96+
97+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
98+
99+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{2, 3, 4, 1})
100+}
101+
102+func TestFilterFavoritesForSidebarDeduplicatesRepeatedPointers(t *testing.T) {
103+ favorite := favoriteSidebarTestFavorite(1, "https://example.com/one", "One", 1)
104+ favorites := []*entity.Favorite{
105+ favorite,
106+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "First no ID", 2),
107+ favorite,
108+ favoriteSidebarTestFavorite(0, "https://example.com/no-id", "Second no ID", 3),
109+ nil,
110+ }
111+
112+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{})
113+
114+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{1, 0})
115+ if len(got) != 2 || got[1].Title != "First no ID" {
116+ t.Fatalf("expected duplicate no-ID URL to keep first favorite, got %#v", got)
117+ }
118+}
119+
120+func TestFilterFavoritesForSidebarUsesPositionAndURLTieBreakers(t *testing.T) {
121+ favorites := []*entity.Favorite{
122+ favoriteSidebarTestFavorite(1, "https://z.example/go", "Notes about Go", 2),
123+ favoriteSidebarTestFavorite(2, "https://a.example/go", "More Go Notes", 2),
124+ favoriteSidebarTestFavorite(3, "https://m.example/go", "Go snippets", 1),
125+ }
126+
127+ got := FilterFavoritesForSidebar(favorites, FavoriteSidebarQuery{Text: "go"})
128+
129+ assertFavoriteSidebarIDs(t, got, []entity.FavoriteID{3, 2, 1})
130+}
131+
132+func favoriteSidebarTestFavorite(id entity.FavoriteID, rawURL string, title string, position int, tags ...entity.Tag) *entity.Favorite {
133+ return &entity.Favorite{
134+ ID: id,
135+ URL: rawURL,
136+ Title: title,
137+ Position: position,
138+ Tags: append([]entity.Tag(nil), tags...),
139+ }
140+}
141+
142+func favoriteSidebarTestTag(id entity.TagID, name string) entity.Tag {
143+ return entity.Tag{ID: id, Name: name}
144+}
145+
146+func assertFavoriteSidebarIDs(t *testing.T, favorites []*entity.Favorite, want []entity.FavoriteID) {
147+ t.Helper()
148+ got := make([]entity.FavoriteID, 0, len(favorites))
149+ for _, favorite := range favorites {
150+ if favorite == nil {
151+ t.Fatalf("result contains nil favorite")
152+ }
153+ got = append(got, favorite.ID)
154+ }
155+ if !reflect.DeepEqual(got, want) {
156+ t.Fatalf("favorite IDs = %v, want %v", got, want)
157+ }
158+}
M internal/application/usecase/manage_favorites.go +2 -1
@@ -17,8 +17,9 @@ import (
1717 "github.com/bnema/dumber/internal/logging"
1818 )
1919
20-// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
20+// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
2121 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22+var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
2223
2324 // ManageFavoritesUseCase handles favorite and tag operations.
2425 type ManageFavoritesUseCase struct {
@@ -17,8 +17,9 @@ import (
17 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
18 )18 )
19 19
20-// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.20+// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22+var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
22 23
23 // ManageFavoritesUseCase handles favorite and tag operations.24 // ManageFavoritesUseCase handles favorite and tag operations.
24 type ManageFavoritesUseCase struct {25 type ManageFavoritesUseCase struct {

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