detached 7246c1f

Merge pull request #332 from bnema/refactor/favorites-tags-first

refactor(favorites): move organization to tags
commit
7246c1f8153c0a011cd5e2a7d359566cd48ee4b6
author
bnema · · 2026-06-28 13:13 UTC
committer
GitHub · · 2026-06-28 13:13 UTC
merge of
eb3068f + 2ddb1b2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
42 files · +1607 -3284
M .mockery.yaml +0 -1
@@ -152,7 +152,6 @@ packages:
152 interfaces:152 interfaces:
153 ContentWhitelistRepository: {}153 ContentWhitelistRepository: {}
154 FavoriteRepository: {}154 FavoriteRepository: {}
155- FolderRepository: {}
156 HistoryRepository: {}155 HistoryRepository: {}
157 SessionRepository: {}156 SessionRepository: {}
158 SessionStateRepository: {}157 SessionStateRepository: {}
M cmd/dumber/main.go +1 -4
@@ -691,7 +691,6 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
691 type repositories struct {691 type repositories struct {
692 history repository.HistoryRepository692 history repository.HistoryRepository
693 favorite repository.FavoriteRepository693 favorite repository.FavoriteRepository
694- folder repository.FolderRepository
695 tag repository.TagRepository694 tag repository.TagRepository
696 zoom repository.ZoomRepository695 zoom repository.ZoomRepository
697 permission port.PermissionRepository696 permission port.PermissionRepository
@@ -704,7 +703,6 @@ func createRepositories(db *sql.DB) *repositories {
704 return &repositories{703 return &repositories{
705 history: sqlite.NewHistoryRepository(db),704 history: sqlite.NewHistoryRepository(db),
706 favorite: sqlite.NewFavoriteRepository(db),705 favorite: sqlite.NewFavoriteRepository(db),
707- folder: sqlite.NewFolderRepository(db),
708 tag: sqlite.NewTagRepository(db),706 tag: sqlite.NewTagRepository(db),
709 zoom: sqlite.NewZoomRepository(db),707 zoom: sqlite.NewZoomRepository(db),
710 permission: sqlite.NewPermissionRepository(db),708 permission: sqlite.NewPermissionRepository(db),
@@ -718,7 +716,6 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
718 return &repositories{716 return &repositories{
719 history: sqlite.NewLazyHistoryRepository(provider),717 history: sqlite.NewLazyHistoryRepository(provider),
720 favorite: sqlite.NewLazyFavoriteRepository(provider),718 favorite: sqlite.NewLazyFavoriteRepository(provider),
721- folder: sqlite.NewLazyFolderRepository(provider),
722 tag: sqlite.NewLazyTagRepository(provider),719 tag: sqlite.NewLazyTagRepository(provider),
723 zoom: sqlite.NewLazyZoomRepository(provider),720 zoom: sqlite.NewLazyZoomRepository(provider),
724 permission: sqlite.NewLazyPermissionRepository(provider),721 permission: sqlite.NewLazyPermissionRepository(provider),
@@ -804,7 +801,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
804 tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths),801 tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths),
805 panes: usecase.NewManagePanesUseCase(idGenerator, localPaths),802 panes: usecase.NewManagePanesUseCase(idGenerator, localPaths),
806 history: historyUC,803 history: historyUC,
807- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),804+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.tag),
808 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),805 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
809 permission: permissionUC,806 permission: permissionUC,
810 navigate: usecase.NewNavigateUseCase(defaultZoom),807 navigate: usecase.NewNavigateUseCase(defaultZoom),
M cmd/systemviews/bootstrap_test.go +0 -16
@@ -64,7 +64,6 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
64 64
65 bridge := &bridgeServiceRecorder{65 bridge := &bridgeServiceRecorder{
66 favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},66 favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
67- folders: []*entity.Folder{{Name: "Read Later"}},
68 tags: []*entity.Tag{{Name: "Go"}},67 tags: []*entity.Tag{{Name: "Go"}},
69 }68 }
70 app := newBridgeApp(&recordingDOM{}, "dumb://favorites", bridge)69 app := newBridgeApp(&recordingDOM{}, "dumb://favorites", bridge)
@@ -141,7 +140,6 @@ type bridgeServiceRecorder struct {
141 140
142 historyEntries []*entity.HistoryEntry141 historyEntries []*entity.HistoryEntry
143 favorites []*entity.Favorite142 favorites []*entity.Favorite
144- folders []*entity.Folder
145 tags []*entity.Tag143 tags []*entity.Tag
146 currentConfig dto.SystemviewConfigPayload144 currentConfig dto.SystemviewConfigPayload
147 keybindings port.KeybindingsConfig145 keybindings port.KeybindingsConfig
@@ -199,26 +197,12 @@ func (*bridgeServiceRecorder) UpdateFavorite(context.Context, dto.FavoriteUpdate
199 197
200 func (*bridgeServiceRecorder) DeleteFavorite(context.Context, int64) error { return nil }198 func (*bridgeServiceRecorder) DeleteFavorite(context.Context, int64) error { return nil }
201 199
202-func (f *bridgeServiceRecorder) ListFolders(context.Context) ([]*entity.Folder, error) {
203- return f.folders, nil
204-}
205-
206 func (f *bridgeServiceRecorder) ListTags(context.Context) ([]*entity.Tag, error) {200 func (f *bridgeServiceRecorder) ListTags(context.Context) ([]*entity.Tag, error) {
207 return f.tags, nil201 return f.tags, nil
208 }202 }
209 203
210 func (*bridgeServiceRecorder) SetShortcut(context.Context, int64, *int) error { return nil }204 func (*bridgeServiceRecorder) SetShortcut(context.Context, int64, *int) error { return nil }
211 205
212-func (*bridgeServiceRecorder) SetFolder(context.Context, int64, *int64) error { return nil }
213-
214-func (*bridgeServiceRecorder) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) {
215- return nil, nil
216-}
217-
218-func (*bridgeServiceRecorder) UpdateFolder(context.Context, int64, string, string) error { return nil }
219-
220-func (*bridgeServiceRecorder) DeleteFolder(context.Context, int64) error { return nil }
221-
222 func (*bridgeServiceRecorder) CreateTag(context.Context, string, string) (*entity.Tag, error) {206 func (*bridgeServiceRecorder) CreateTag(context.Context, string, string) (*entity.Tag, error) {
223 return nil, nil207 return nil, nil
224 }208 }
M docs/systemviews-wasm-crud-plan.md +6 -10
@@ -47,18 +47,14 @@ Preferred direction for new work:
47 | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. |47 | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. |
48 | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. |48 | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. |
49 49
50-### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`)50+### Favorites and tags (`webui/src/pages/homepage/favorites/*`)
51 51
52 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |52 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
53 | --- | --- | --- | --- | --- |53 | --- | --- | --- | --- | --- |
54-| Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`). | Add rich rows/cards, favicons, shortcut badges, folder/tag metadata. |54+| Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`) with tags-first data. | Add rich rows/cards, favicons, shortcut badges, and tag metadata. |
55-| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. |55+| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.AddFavorite`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_create`. | Supported in WASM UI. | Keep validation/error states covered. |
56-| Update favorite | `FavoriteEditor.svelte` locally updates title and uses folder/shortcut/tag handlers. | `favorite_set_shortcut`, `favorite_set_folder`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep folder/shortcut/tag specialized calls or fold into update after design. |56+| Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `ManageFavoritesUseCase.UpdateFavorite`, `favorite_update`, `favorite_set_shortcut`, `tag_assign`, and `tag_remove` are exposed through the ports, handlers, and bridge. | Supported in WASM UI. | Keep shortcut/tag semantics covered by regression tests. |
57-| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |57+| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.DeleteFavorite`/`Remove`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_delete`. | Supported in WASM UI. | Keep destructive confirmation and refresh behavior covered. |
58-| Read folders | `FolderList.svelte` | `folder_list` | Present as static list. | Add counts, selected filter, empty state. |
59-| Create folder | `FolderList.svelte` inline create | `folder_create` | Bridge and handler exist. | Add interactive UI. |
60-| Update folder | Handler exists (`folder_update`). | `folder_update` | Bridge exists. | Add rename/icon UI. |
61-| Delete folder | Handler exists (`folder_delete`). | `folder_delete` | Bridge exists. | Add UI, confirm, refresh favorite folder state. |
62 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |58 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
63 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |59 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
64 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |60 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
@@ -101,7 +97,7 @@ Implementation path:
101 - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates.97 - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates.
102 - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime.98 - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime.
103 - History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation.99 - History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation.
104-- Favorites supports create/update/delete for favorites, folders, and tags; folder/tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation.100+- Favorites supports create/update/delete for favorites and tags; tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation.
105 - Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save.101 - Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save.
106 - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path.102 - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path.
107 - Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states.103 - Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states.
M internal/application/dto/favorites.go +9 -10
@@ -4,18 +4,17 @@ import "github.com/bnema/dumber/internal/domain/entity"
4 4
5 // FavoriteCreateInput contains fields needed to create a favorite from a UI port.5 // FavoriteCreateInput contains fields needed to create a favorite from a UI port.
6 type FavoriteCreateInput struct {6 type FavoriteCreateInput struct {
7- URL string `json:"url"`7+ URL string `json:"url"`
8- Title string `json:"title"`8+ Title string `json:"title"`
9- FaviconURL string `json:"favicon_url"`9+ FaviconURL string `json:"favicon_url"`
10- FolderID *entity.FolderID `json:"folder_id"`10+ Tags []entity.TagID `json:"tags"`
11- Tags []entity.TagID `json:"tags"`
12 }11 }
13 12
14 // FavoriteUpdateInput contains editable favorite metadata.13 // FavoriteUpdateInput contains editable favorite metadata.
15 type FavoriteUpdateInput struct {14 type FavoriteUpdateInput struct {
16- ID entity.FavoriteID `json:"id"`15+ ID entity.FavoriteID `json:"id"`
17- Title string `json:"title"`16+ Title string `json:"title"`
18- FaviconURL string `json:"favicon_url"`17+ FaviconURL string `json:"favicon_url"`
19- FolderID *entity.FolderID `json:"folder_id"`18+ ShortcutKey *int `json:"shortcut_key"`
20- ShortcutKey *int `json:"shortcut_key"`19+ ShortcutKeySet bool `json:"-"`
21 }20 }
M internal/application/port/homepage.go +1 -7
@@ -24,8 +24,7 @@ type HomepageHistory interface {
24 DeleteByDomain(ctx context.Context, domain string) error24 DeleteByDomain(ctx context.Context, domain string) error
25 }25 }
26 26
27-// HomepageFavorites provides favorites/folders/tags operations27+// HomepageFavorites provides favorite and tag operations needed by the WebUI homepage handlers.
28-// needed by the WebUI homepage handlers.
29 type HomepageFavorites interface {28 type HomepageFavorites interface {
30 GetAll(ctx context.Context) ([]*entity.Favorite, error)29 GetAll(ctx context.Context) ([]*entity.Favorite, error)
31 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)30 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
@@ -33,11 +32,6 @@ type HomepageFavorites interface {
33 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error32 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
34 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error33 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
35 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)34 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
36- Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
37- GetAllFolders(ctx context.Context) ([]*entity.Folder, error)
38- CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error)
39- DeleteFolder(ctx context.Context, id entity.FolderID) error
40- UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
41 GetAllTags(ctx context.Context) ([]*entity.Tag, error)35 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
42 AddTag(ctx context.Context, name, color string) (*entity.Tag, error)36 AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
43 DeleteTag(ctx context.Context, id entity.TagID) error37 DeleteTag(ctx context.Context, id entity.TagID) error
M internal/application/port/mocks/homepage.go +0 -331
@@ -982,86 +982,6 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C
982 return _c982 return _c
983 }983 }
984 984
985-// CreateFolder provides a mock function for the type MockHomepageFavorites
986-func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
987- ret := _mock.Called(ctx, name, icon, parentID)
988-
989- if len(ret) == 0 {
990- panic("no return value specified for CreateFolder")
991- }
992-
993- var r0 *entity.Folder
994- var r1 error
995- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok {
996- return returnFunc(ctx, name, icon, parentID)
997- }
998- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok {
999- r0 = returnFunc(ctx, name, icon, parentID)
1000- } else {
1001- if ret.Get(0) != nil {
1002- r0 = ret.Get(0).(*entity.Folder)
1003- }
1004- }
1005- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok {
1006- r1 = returnFunc(ctx, name, icon, parentID)
1007- } else {
1008- r1 = ret.Error(1)
1009- }
1010- return r0, r1
1011-}
1012-
1013-// MockHomepageFavorites_CreateFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFolder'
1014-type MockHomepageFavorites_CreateFolder_Call struct {
1015- *mock.Call
1016-}
1017-
1018-// CreateFolder is a helper method to define mock.On call
1019-// - ctx context.Context
1020-// - name string
1021-// - icon string
1022-// - parentID *entity.FolderID
1023-func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx any, name any, icon any, parentID any) *MockHomepageFavorites_CreateFolder_Call {
1024- return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)}
1025-}
1026-
1027-func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {
1028- _c.Call.Run(func(args mock.Arguments) {
1029- var arg0 context.Context
1030- if args[0] != nil {
1031- arg0 = args[0].(context.Context)
1032- }
1033- var arg1 string
1034- if args[1] != nil {
1035- arg1 = args[1].(string)
1036- }
1037- var arg2 string
1038- if args[2] != nil {
1039- arg2 = args[2].(string)
1040- }
1041- var arg3 *entity.FolderID
1042- if args[3] != nil {
1043- arg3 = args[3].(*entity.FolderID)
1044- }
1045- run(
1046- arg0,
1047- arg1,
1048- arg2,
1049- arg3,
1050- )
1051- })
1052- return _c
1053-}
1054-
1055-func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder, err error) *MockHomepageFavorites_CreateFolder_Call {
1056- _c.Call.Return(folder, err)
1057- return _c
1058-}
1059-
1060-func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {
1061- _c.Call.Return(run)
1062- return _c
1063-}
1064-
1065 // DeleteFavorite provides a mock function for the type MockHomepageFavorites985 // DeleteFavorite provides a mock function for the type MockHomepageFavorites
1066 func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {986 func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
1067 ret := _mock.Called(ctx, id)987 ret := _mock.Called(ctx, id)
@@ -1119,63 +1039,6 @@ func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx c
1119 return _c1039 return _c
1120 }1040 }
1121 1041
1122-// DeleteFolder provides a mock function for the type MockHomepageFavorites
1123-func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error {
1124- ret := _mock.Called(ctx, id)
1125-
1126- if len(ret) == 0 {
1127- panic("no return value specified for DeleteFolder")
1128- }
1129-
1130- var r0 error
1131- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) error); ok {
1132- r0 = returnFunc(ctx, id)
1133- } else {
1134- r0 = ret.Error(0)
1135- }
1136- return r0
1137-}
1138-
1139-// MockHomepageFavorites_DeleteFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFolder'
1140-type MockHomepageFavorites_DeleteFolder_Call struct {
1141- *mock.Call
1142-}
1143-
1144-// DeleteFolder is a helper method to define mock.On call
1145-// - ctx context.Context
1146-// - id entity.FolderID
1147-func (_e *MockHomepageFavorites_Expecter) DeleteFolder(ctx any, id any) *MockHomepageFavorites_DeleteFolder_Call {
1148- return &MockHomepageFavorites_DeleteFolder_Call{Call: _e.mock.On("DeleteFolder", ctx, id)}
1149-}
1150-
1151-func (_c *MockHomepageFavorites_DeleteFolder_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockHomepageFavorites_DeleteFolder_Call {
1152- _c.Call.Run(func(args mock.Arguments) {
1153- var arg0 context.Context
1154- if args[0] != nil {
1155- arg0 = args[0].(context.Context)
1156- }
1157- var arg1 entity.FolderID
1158- if args[1] != nil {
1159- arg1 = args[1].(entity.FolderID)
1160- }
1161- run(
1162- arg0,
1163- arg1,
1164- )
1165- })
1166- return _c
1167-}
1168-
1169-func (_c *MockHomepageFavorites_DeleteFolder_Call) Return(err error) *MockHomepageFavorites_DeleteFolder_Call {
1170- _c.Call.Return(err)
1171- return _c
1172-}
1173-
1174-func (_c *MockHomepageFavorites_DeleteFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) error) *MockHomepageFavorites_DeleteFolder_Call {
1175- _c.Call.Return(run)
1176- return _c
1177-}
1178-
1179 // DeleteTag provides a mock function for the type MockHomepageFavorites1042 // DeleteTag provides a mock function for the type MockHomepageFavorites
1180 func (_mock *MockHomepageFavorites) DeleteTag(ctx context.Context, id entity.TagID) error {1043 func (_mock *MockHomepageFavorites) DeleteTag(ctx context.Context, id entity.TagID) error {
1181 ret := _mock.Called(ctx, id)1044 ret := _mock.Called(ctx, id)
@@ -1295,68 +1158,6 @@ func (_c *MockHomepageFavorites_GetAll_Call) RunAndReturn(run func(ctx context.C
1295 return _c1158 return _c
1296 }1159 }
1297 1160
1298-// GetAllFolders provides a mock function for the type MockHomepageFavorites
1299-func (_mock *MockHomepageFavorites) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) {
1300- ret := _mock.Called(ctx)
1301-
1302- if len(ret) == 0 {
1303- panic("no return value specified for GetAllFolders")
1304- }
1305-
1306- var r0 []*entity.Folder
1307- var r1 error
1308- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.Folder, error)); ok {
1309- return returnFunc(ctx)
1310- }
1311- if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.Folder); ok {
1312- r0 = returnFunc(ctx)
1313- } else {
1314- if ret.Get(0) != nil {
1315- r0 = ret.Get(0).([]*entity.Folder)
1316- }
1317- }
1318- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1319- r1 = returnFunc(ctx)
1320- } else {
1321- r1 = ret.Error(1)
1322- }
1323- return r0, r1
1324-}
1325-
1326-// MockHomepageFavorites_GetAllFolders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllFolders'
1327-type MockHomepageFavorites_GetAllFolders_Call struct {
1328- *mock.Call
1329-}
1330-
1331-// GetAllFolders is a helper method to define mock.On call
1332-// - ctx context.Context
1333-func (_e *MockHomepageFavorites_Expecter) GetAllFolders(ctx any) *MockHomepageFavorites_GetAllFolders_Call {
1334- return &MockHomepageFavorites_GetAllFolders_Call{Call: _e.mock.On("GetAllFolders", ctx)}
1335-}
1336-
1337-func (_c *MockHomepageFavorites_GetAllFolders_Call) Run(run func(ctx context.Context)) *MockHomepageFavorites_GetAllFolders_Call {
1338- _c.Call.Run(func(args mock.Arguments) {
1339- var arg0 context.Context
1340- if args[0] != nil {
1341- arg0 = args[0].(context.Context)
1342- }
1343- run(
1344- arg0,
1345- )
1346- })
1347- return _c
1348-}
1349-
1350-func (_c *MockHomepageFavorites_GetAllFolders_Call) Return(folders []*entity.Folder, err error) *MockHomepageFavorites_GetAllFolders_Call {
1351- _c.Call.Return(folders, err)
1352- return _c
1353-}
1354-
1355-func (_c *MockHomepageFavorites_GetAllFolders_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.Folder, error)) *MockHomepageFavorites_GetAllFolders_Call {
1356- _c.Call.Return(run)
1357- return _c
1358-}
1359-
1360 // GetAllTags provides a mock function for the type MockHomepageFavorites1161 // GetAllTags provides a mock function for the type MockHomepageFavorites
1361 func (_mock *MockHomepageFavorites) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {1162 func (_mock *MockHomepageFavorites) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
1362 ret := _mock.Called(ctx)1163 ret := _mock.Called(ctx)
@@ -1487,69 +1288,6 @@ func (_c *MockHomepageFavorites_GetByShortcut_Call) RunAndReturn(run func(ctx co
1487 return _c1288 return _c
1488 }1289 }
1489 1290
1490-// Move provides a mock function for the type MockHomepageFavorites
1491-func (_mock *MockHomepageFavorites) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
1492- ret := _mock.Called(ctx, id, folderID)
1493-
1494- if len(ret) == 0 {
1495- panic("no return value specified for Move")
1496- }
1497-
1498- var r0 error
1499- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID, *entity.FolderID) error); ok {
1500- r0 = returnFunc(ctx, id, folderID)
1501- } else {
1502- r0 = ret.Error(0)
1503- }
1504- return r0
1505-}
1506-
1507-// MockHomepageFavorites_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
1508-type MockHomepageFavorites_Move_Call struct {
1509- *mock.Call
1510-}
1511-
1512-// Move is a helper method to define mock.On call
1513-// - ctx context.Context
1514-// - id entity.FavoriteID
1515-// - folderID *entity.FolderID
1516-func (_e *MockHomepageFavorites_Expecter) Move(ctx any, id any, folderID any) *MockHomepageFavorites_Move_Call {
1517- return &MockHomepageFavorites_Move_Call{Call: _e.mock.On("Move", ctx, id, folderID)}
1518-}
1519-
1520-func (_c *MockHomepageFavorites_Move_Call) Run(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID)) *MockHomepageFavorites_Move_Call {
1521- _c.Call.Run(func(args mock.Arguments) {
1522- var arg0 context.Context
1523- if args[0] != nil {
1524- arg0 = args[0].(context.Context)
1525- }
1526- var arg1 entity.FavoriteID
1527- if args[1] != nil {
1528- arg1 = args[1].(entity.FavoriteID)
1529- }
1530- var arg2 *entity.FolderID
1531- if args[2] != nil {
1532- arg2 = args[2].(*entity.FolderID)
1533- }
1534- run(
1535- arg0,
1536- arg1,
1537- arg2,
1538- )
1539- })
1540- return _c
1541-}
1542-
1543-func (_c *MockHomepageFavorites_Move_Call) Return(err error) *MockHomepageFavorites_Move_Call {
1544- _c.Call.Return(err)
1545- return _c
1546-}
1547-
1548-func (_c *MockHomepageFavorites_Move_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error) *MockHomepageFavorites_Move_Call {
1549- _c.Call.Return(run)
1550- return _c
1551-}
1552-
1553 // SetShortcut provides a mock function for the type MockHomepageFavorites1291 // SetShortcut provides a mock function for the type MockHomepageFavorites
1554 func (_mock *MockHomepageFavorites) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {1292 func (_mock *MockHomepageFavorites) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
1555 ret := _mock.Called(ctx, id, key)1293 ret := _mock.Called(ctx, id, key)
@@ -1807,75 +1545,6 @@ func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx c
1807 return _c1545 return _c
1808 }1546 }
1809 1547
1810-// UpdateFolder provides a mock function for the type MockHomepageFavorites
1811-func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {
1812- ret := _mock.Called(ctx, id, name, icon)
1813-
1814- if len(ret) == 0 {
1815- panic("no return value specified for UpdateFolder")
1816- }
1817-
1818- var r0 error
1819- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID, string, string) error); ok {
1820- r0 = returnFunc(ctx, id, name, icon)
1821- } else {
1822- r0 = ret.Error(0)
1823- }
1824- return r0
1825-}
1826-
1827-// MockHomepageFavorites_UpdateFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFolder'
1828-type MockHomepageFavorites_UpdateFolder_Call struct {
1829- *mock.Call
1830-}
1831-
1832-// UpdateFolder is a helper method to define mock.On call
1833-// - ctx context.Context
1834-// - id entity.FolderID
1835-// - name string
1836-// - icon string
1837-func (_e *MockHomepageFavorites_Expecter) UpdateFolder(ctx any, id any, name any, icon any) *MockHomepageFavorites_UpdateFolder_Call {
1838- return &MockHomepageFavorites_UpdateFolder_Call{Call: _e.mock.On("UpdateFolder", ctx, id, name, icon)}
1839-}
1840-
1841-func (_c *MockHomepageFavorites_UpdateFolder_Call) Run(run func(ctx context.Context, id entity.FolderID, name string, icon string)) *MockHomepageFavorites_UpdateFolder_Call {
1842- _c.Call.Run(func(args mock.Arguments) {
1843- var arg0 context.Context
1844- if args[0] != nil {
1845- arg0 = args[0].(context.Context)
1846- }
1847- var arg1 entity.FolderID
1848- if args[1] != nil {
1849- arg1 = args[1].(entity.FolderID)
1850- }
1851- var arg2 string
1852- if args[2] != nil {
1853- arg2 = args[2].(string)
1854- }
1855- var arg3 string
1856- if args[3] != nil {
1857- arg3 = args[3].(string)
1858- }
1859- run(
1860- arg0,
1861- arg1,
1862- arg2,
1863- arg3,
1864- )
1865- })
1866- return _c
1867-}
1868-
1869-func (_c *MockHomepageFavorites_UpdateFolder_Call) Return(err error) *MockHomepageFavorites_UpdateFolder_Call {
1870- _c.Call.Return(err)
1871- return _c
1872-}
1873-
1874-func (_c *MockHomepageFavorites_UpdateFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID, name string, icon string) error) *MockHomepageFavorites_UpdateFolder_Call {
1875- _c.Call.Return(run)
1876- return _c
1877-}
1878-
1879 // UpdateTag provides a mock function for the type MockHomepageFavorites1548 // UpdateTag provides a mock function for the type MockHomepageFavorites
1880 func (_mock *MockHomepageFavorites) UpdateTag(ctx context.Context, id entity.TagID, name string, color string) error {1549 func (_mock *MockHomepageFavorites) UpdateTag(ctx context.Context, id entity.TagID, name string, color string) error {
1881 ret := _mock.Called(ctx, id, name, color)1550 ret := _mock.Called(ctx, id, name, color)
M internal/application/port/systemviews.go +1 -6
@@ -22,19 +22,14 @@ type SystemviewHistoryService interface {
22 DeleteDomain(ctx context.Context, domain string) error22 DeleteDomain(ctx context.Context, domain string) error
23 }23 }
24 24
25-// SystemviewFavoritesService exposes favorite, folder, and tag operations.25+// SystemviewFavoritesService exposes favorite and tag operations.
26 type SystemviewFavoritesService interface {26 type SystemviewFavoritesService interface {
27 List(ctx context.Context) ([]*entity.Favorite, error)27 List(ctx context.Context) ([]*entity.Favorite, error)
28 CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)28 CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
29 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)29 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
30 DeleteFavorite(ctx context.Context, id int64) error30 DeleteFavorite(ctx context.Context, id int64) error
31- ListFolders(ctx context.Context) ([]*entity.Folder, error)
32 ListTags(ctx context.Context) ([]*entity.Tag, error)31 ListTags(ctx context.Context) ([]*entity.Tag, error)
33 SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error32 SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
34- SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
35- CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error)
36- UpdateFolder(ctx context.Context, id int64, name, icon string) error
37- DeleteFolder(ctx context.Context, id int64) error
38 CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)33 CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
39 UpdateTag(ctx context.Context, id int64, name, color string) error34 UpdateTag(ctx context.Context, id int64, name, color string) error
40 DeleteTag(ctx context.Context, id int64) error35 DeleteTag(ctx context.Context, id int64) error
M internal/application/usecase/manage_favorites.go +16 -192
@@ -20,10 +20,9 @@ import (
20 // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.20 // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)21 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22 22
23-// ManageFavoritesUseCase handles favorite, folder, and tag operations.23+// ManageFavoritesUseCase handles favorite and tag operations.
24 type ManageFavoritesUseCase struct {24 type ManageFavoritesUseCase struct {
25 favoriteRepo repository.FavoriteRepository25 favoriteRepo repository.FavoriteRepository
26- folderRepo repository.FolderRepository
27 tagRepo repository.TagRepository26 tagRepo repository.TagRepository
28 27
29 cacheMu sync.Mutex28 cacheMu sync.Mutex
@@ -37,12 +36,10 @@ const favoritesCacheTTL = 2 * time.Second
37 // NewManageFavoritesUseCase creates a new favorites management use case.36 // NewManageFavoritesUseCase creates a new favorites management use case.
38 func NewManageFavoritesUseCase(37 func NewManageFavoritesUseCase(
39 favoriteRepo repository.FavoriteRepository,38 favoriteRepo repository.FavoriteRepository,
40- folderRepo repository.FolderRepository,
41 tagRepo repository.TagRepository,39 tagRepo repository.TagRepository,
42 ) *ManageFavoritesUseCase {40 ) *ManageFavoritesUseCase {
43 return &ManageFavoritesUseCase{41 return &ManageFavoritesUseCase{
44 favoriteRepo: favoriteRepo,42 favoriteRepo: favoriteRepo,
45- folderRepo: folderRepo,
46 tagRepo: tagRepo,43 tagRepo: tagRepo,
47 }44 }
48 }45 }
@@ -52,7 +49,6 @@ type AddFavoriteInput struct {
52 URL string49 URL string
53 Title string50 Title string
54 FaviconURL string51 FaviconURL string
55- FolderID *entity.FolderID
56 Tags []entity.TagID52 Tags []entity.TagID
57 }53 }
58 54
@@ -78,7 +74,6 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
78 log := logging.FromContext(ctx)74 log := logging.FromContext(ctx)
79 fav := entity.NewFavorite(input.URL, input.Title)75 fav := entity.NewFavorite(input.URL, input.Title)
80 fav.FaviconURL = input.FaviconURL76 fav.FaviconURL = input.FaviconURL
81- fav.FolderID = input.FolderID
82 77
83 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {78 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
84 return nil, fmt.Errorf("failed to save favorite: %w", err)79 return nil, fmt.Errorf("failed to save favorite: %w", err)
@@ -87,11 +82,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
87 // Assign tags82 // Assign tags
88 for _, tagID := range input.Tags {83 for _, tagID := range input.Tags {
89 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {84 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
90- log.Warn().85+ return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
91- Int64("tag_id", int64(tagID)).
92- Int64("favorite_id", int64(fav.ID)).
93- Err(err).
94- Msg("failed to assign tag")
95 }86 }
96 }87 }
97 88
@@ -125,7 +116,6 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
125 URL: favoriteURL,116 URL: favoriteURL,
126 Title: title,117 Title: title,
127 FaviconURL: strings.TrimSpace(input.FaviconURL),118 FaviconURL: strings.TrimSpace(input.FaviconURL),
128- FolderID: input.FolderID,
129 Tags: tags,119 Tags: tags,
130 })120 })
131 }121 }
@@ -186,7 +176,7 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
186 if input.ID <= 0 {176 if input.ID <= 0 {
187 return nil, fmt.Errorf("favorite ID is required")177 return nil, fmt.Errorf("favorite ID is required")
188 }178 }
189- if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {179+ if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
190 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)180 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
191 }181 }
192 182
@@ -204,8 +194,9 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
204 }194 }
205 fav.Title = title195 fav.Title = title
206 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)196 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
207- fav.FolderID = input.FolderID197+ if input.ShortcutKeySet {
208- fav.ShortcutKey = input.ShortcutKey198+ fav.ShortcutKey = input.ShortcutKey
199+ }
209 200
210 if err := uc.Update(ctx, fav); err != nil {201 if err := uc.Update(ctx, fav); err != nil {
211 return nil, err202 return nil, err
@@ -263,22 +254,6 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori
263 return nil254 return nil
264 }255 }
265 256
266-// Move changes a favorite's folder.
267-func (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).257 // SetShortcut assigns or removes a keyboard shortcut (1-9).
283 func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {258 func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
284 log := logging.FromContext(ctx)259 log := logging.FromContext(ctx)
@@ -536,141 +511,13 @@ func (uc *ManageFavoritesUseCase) invalidateCache() {
536 uc.cacheTime = time.Time{}511 uc.cacheTime = time.Time{}
537 }512 }
538 513
539-// GetTree builds a complete hierarchical view of folders and favorites.514+// AddTag creates a new tag.
540-func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {515+func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, 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.
586-func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
587 name = strings.TrimSpace(name)516 name = strings.TrimSpace(name)
588- icon = strings.TrimSpace(icon)517+ color = strings.TrimSpace(color)
589 if name == "" {518 if name == "" {
590- return nil, fmt.Errorf("folder name is required")519+ return nil, fmt.Errorf("tag 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 }520 }
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.
608-func (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.
665-func (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.
673-func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
674 log := logging.FromContext(ctx)521 log := logging.FromContext(ctx)
675 log.Debug().Str("name", name).Str("color", color).Msg("creating tag")522 log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
676 523
@@ -759,34 +606,13 @@ func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID
759 return uc.tagRepo.GetForFavorite(ctx, favID)606 return uc.tagRepo.GetForFavorite(ctx, favID)
760 }607 }
761 608
762-// UpdateFolder updates a folder's name and icon.
763-func (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.609 // UpdateTag updates a tag's name and color.
789 func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {610 func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
611+ name = strings.TrimSpace(name)
612+ color = strings.TrimSpace(color)
613+ if name == "" {
614+ return fmt.Errorf("tag name is required")
615+ }
790 log := logging.FromContext(ctx)616 log := logging.FromContext(ctx)
791 log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")617 log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
792 618
@@ -798,9 +624,7 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID
798 return fmt.Errorf("tag %d not found", id)624 return fmt.Errorf("tag %d not found", id)
799 }625 }
800 626
801- if name != "" {627+ tag.Name = name
802- tag.Name = name
803- }
804 if color != "" {628 if color != "" {
805 tag.Color = color629 tag.Color = color
806 }630 }
M internal/application/usecase/manage_favorites_cache_test.go +1 -2
@@ -17,7 +17,6 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
17 ctx := logging.WithContext(context.Background(), logger)17 ctx := logging.WithContext(context.Background(), logger)
18 18
19 favoriteRepo := repomocks.NewMockFavoriteRepository(t)19 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20- folderRepo := repomocks.NewMockFolderRepository(t)
21 tagRepo := repomocks.NewMockTagRepository(t)20 tagRepo := repomocks.NewMockTagRepository(t)
22 21
23 fetchedFavorites := []*entity.Favorite{22 fetchedFavorites := []*entity.Favorite{
@@ -27,7 +26,7 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
27 {ID: 2, URL: "https://fresh.example"},26 {ID: 2, URL: "https://fresh.example"},
28 }27 }
29 28
30- uc := NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)29+ uc := NewManageFavoritesUseCase(favoriteRepo, tagRepo)
31 favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) {30 favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) {
32 uc.cacheMu.Lock()31 uc.cacheMu.Lock()
33 uc.favoritesCache = refreshedFavorites32 uc.favoritesCache = refreshedFavorites
M internal/application/usecase/manage_favorites_test.go +125 -76
@@ -1,6 +1,7 @@
1 package usecase_test1 package usecase_test
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "testing"6 "testing"
6 7
@@ -17,7 +18,6 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
17 ctx := testContext()18 ctx := testContext()
18 19
19 favoriteRepo := repomocks.NewMockFavoriteRepository(t)20 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20- folderRepo := repomocks.NewMockFolderRepository(t)
21 tagRepo := repomocks.NewMockTagRepository(t)21 tagRepo := repomocks.NewMockTagRepository(t)
22 22
23 favorites := []*entity.Favorite{23 favorites := []*entity.Favorite{
@@ -28,7 +28,7 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
28 28
29 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)29 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
30 30
31- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)31+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
32 32
33 urls, err := uc.GetAllURLs(ctx)33 urls, err := uc.GetAllURLs(ctx)
34 require.NoError(t, err)34 require.NoError(t, err)
@@ -47,12 +47,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *tes
47 ctx := testContext()47 ctx := testContext()
48 48
49 favoriteRepo := repomocks.NewMockFavoriteRepository(t)49 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
50- folderRepo := repomocks.NewMockFolderRepository(t)
51 tagRepo := repomocks.NewMockTagRepository(t)50 tagRepo := repomocks.NewMockTagRepository(t)
52 51
53 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)52 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
54 53
55- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
56 55
57 urls, err := uc.GetAllURLs(ctx)56 urls, err := uc.GetAllURLs(ctx)
58 require.NoError(t, err)57 require.NoError(t, err)
@@ -64,12 +63,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.
64 ctx := testContext()63 ctx := testContext()
65 64
66 favoriteRepo := repomocks.NewMockFavoriteRepository(t)65 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
67- folderRepo := repomocks.NewMockFolderRepository(t)
68 tagRepo := repomocks.NewMockTagRepository(t)66 tagRepo := repomocks.NewMockTagRepository(t)
69 67
70 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))68 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
71 69
72- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)70+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
73 71
74 urls, err := uc.GetAllURLs(ctx)72 urls, err := uc.GetAllURLs(ctx)
75 require.Error(t, err)73 require.Error(t, err)
@@ -81,7 +79,6 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
81 ctx := testContext()79 ctx := testContext()
82 80
83 favoriteRepo := repomocks.NewMockFavoriteRepository(t)81 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
84- folderRepo := repomocks.NewMockFolderRepository(t)
85 tagRepo := repomocks.NewMockTagRepository(t)82 tagRepo := repomocks.NewMockTagRepository(t)
86 83
87 // URL is not a favorite84 // URL is not a favorite
@@ -90,7 +87,7 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
90 return f.URL == "https://example.com" && f.Title == "Example"87 return f.URL == "https://example.com" && f.Title == "Example"
91 })).Return(nil)88 })).Return(nil)
92 89
93- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)90+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
94 91
95 result, err := uc.Toggle(ctx, "https://example.com", "Example")92 result, err := uc.Toggle(ctx, "https://example.com", "Example")
96 require.NoError(t, err)93 require.NoError(t, err)
@@ -106,7 +103,6 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T)
106 ctx := testContext()103 ctx := testContext()
107 104
108 favoriteRepo := repomocks.NewMockFavoriteRepository(t)105 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
109- folderRepo := repomocks.NewMockFolderRepository(t)
110 tagRepo := repomocks.NewMockTagRepository(t)106 tagRepo := repomocks.NewMockTagRepository(t)
111 107
112 existingFav := &entity.Favorite{108 existingFav := &entity.Favorite{
@@ -119,7 +115,7 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T)
119 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)115 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
120 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)116 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
121 117
122- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)118+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
123 119
124 result, err := uc.Toggle(ctx, "https://example.com", "Example")120 result, err := uc.Toggle(ctx, "https://example.com", "Example")
125 require.NoError(t, err)121 require.NoError(t, err)
@@ -135,10 +131,9 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
135 ctx := testContext()131 ctx := testContext()
136 132
137 favoriteRepo := repomocks.NewMockFavoriteRepository(t)133 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
138- folderRepo := repomocks.NewMockFolderRepository(t)
139 tagRepo := repomocks.NewMockTagRepository(t)134 tagRepo := repomocks.NewMockTagRepository(t)
140 135
141- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)136+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
142 137
143 result, err := uc.Toggle(ctx, "", "Title")138 result, err := uc.Toggle(ctx, "", "Title")
144 require.Error(t, err)139 require.Error(t, err)
@@ -150,7 +145,6 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
150 ctx := testContext()145 ctx := testContext()
151 146
152 favoriteRepo := repomocks.NewMockFavoriteRepository(t)147 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
153- folderRepo := repomocks.NewMockFolderRepository(t)
154 tagRepo := repomocks.NewMockTagRepository(t)148 tagRepo := repomocks.NewMockTagRepository(t)
155 149
156 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()150 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
@@ -159,7 +153,7 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
159 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"153 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
160 })).Return(nil).Once()154 })).Return(nil).Once()
161 155
162- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)156+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
163 157
164 result, err := uc.Toggle(ctx, " example.com ", " Example ")158 result, err := uc.Toggle(ctx, " example.com ", " Example ")
165 require.NoError(t, err)159 require.NoError(t, err)
@@ -173,12 +167,11 @@ func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T)
173 ctx := testContext()167 ctx := testContext()
174 168
175 favoriteRepo := repomocks.NewMockFavoriteRepository(t)169 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
176- folderRepo := repomocks.NewMockFolderRepository(t)
177 tagRepo := repomocks.NewMockTagRepository(t)170 tagRepo := repomocks.NewMockTagRepository(t)
178 171
179 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))172 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
180 173
181- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)174+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
182 175
183 result, err := uc.Toggle(ctx, "https://example.com", "Example")176 result, err := uc.Toggle(ctx, "https://example.com", "Example")
184 require.Error(t, err)177 require.Error(t, err)
@@ -190,13 +183,12 @@ func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
190 ctx := testContext()183 ctx := testContext()
191 184
192 favoriteRepo := repomocks.NewMockFavoriteRepository(t)185 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
193- folderRepo := repomocks.NewMockFolderRepository(t)
194 tagRepo := repomocks.NewMockTagRepository(t)186 tagRepo := repomocks.NewMockTagRepository(t)
195 187
196 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)188 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
197 favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))189 favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
198 190
199- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)191+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
200 192
201 result, err := uc.Toggle(ctx, "https://example.com", "Example")193 result, err := uc.Toggle(ctx, "https://example.com", "Example")
202 require.Error(t, err)194 require.Error(t, err)
@@ -208,14 +200,13 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
208 ctx := testContext()200 ctx := testContext()
209 201
210 favoriteRepo := repomocks.NewMockFavoriteRepository(t)202 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
211- folderRepo := repomocks.NewMockFolderRepository(t)
212 tagRepo := repomocks.NewMockTagRepository(t)203 tagRepo := repomocks.NewMockTagRepository(t)
213 204
214 existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}205 existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
215 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)206 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
216 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))207 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
217 208
218- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)209+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
219 210
220 result, err := uc.Toggle(ctx, "https://example.com", "Example")211 result, err := uc.Toggle(ctx, "https://example.com", "Example")
221 require.Error(t, err)212 require.Error(t, err)
@@ -223,11 +214,64 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
223 assert.Contains(t, err.Error(), "failed to remove favorite")214 assert.Contains(t, err.Error(), "failed to remove favorite")
224 }215 }
225 216
217+func TestManageFavoritesUseCase_UpdateFavoritePreservesShortcutWhenOmitted(t *testing.T) {
218+ ctx := testContext()
219+ shortcut := 3
220+ existing := &entity.Favorite{
221+ ID: 42,
222+ URL: "https://example.com",
223+ Title: "Example",
224+ ShortcutKey: &shortcut,
225+ }
226+
227+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
228+ tagRepo := repomocks.NewMockTagRepository(t)
229+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
230+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
231+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey != nil && *fav.ShortcutKey == 3
232+ })).Return(nil)
233+
234+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
235+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
236+ ID: 42,
237+ Title: "Updated",
238+ })
239+ require.NoError(t, err)
240+ require.NotNil(t, updated.ShortcutKey)
241+ assert.Equal(t, 3, *updated.ShortcutKey)
242+}
243+
244+func TestManageFavoritesUseCase_UpdateFavoriteClearsShortcutWhenExplicit(t *testing.T) {
245+ ctx := testContext()
246+ shortcut := 3
247+ existing := &entity.Favorite{
248+ ID: 42,
249+ URL: "https://example.com",
250+ Title: "Example",
251+ ShortcutKey: &shortcut,
252+ }
253+
254+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
255+ tagRepo := repomocks.NewMockTagRepository(t)
256+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
257+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
258+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey == nil
259+ })).Return(nil)
260+
261+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
262+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
263+ ID: 42,
264+ Title: "Updated",
265+ ShortcutKeySet: true,
266+ })
267+ require.NoError(t, err)
268+ assert.Nil(t, updated.ShortcutKey)
269+}
270+
226 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {271 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
227 ctx := testContext()272 ctx := testContext()
228 273
229 favoriteRepo := repomocks.NewMockFavoriteRepository(t)274 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
230- folderRepo := repomocks.NewMockFolderRepository(t)
231 tagRepo := repomocks.NewMockTagRepository(t)275 tagRepo := repomocks.NewMockTagRepository(t)
232 276
233 favorites := []*entity.Favorite{277 favorites := []*entity.Favorite{
@@ -237,7 +281,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *te
237 }281 }
238 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)282 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
239 283
240- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)284+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
241 285
242 results, err := uc.FilterForOmnibox(ctx, "git")286 results, err := uc.FilterForOmnibox(ctx, "git")
243 require.NoError(t, err)287 require.NoError(t, err)
@@ -251,7 +295,6 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(
251 ctx := testContext()295 ctx := testContext()
252 296
253 favoriteRepo := repomocks.NewMockFavoriteRepository(t)297 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
254- folderRepo := repomocks.NewMockFolderRepository(t)
255 tagRepo := repomocks.NewMockTagRepository(t)298 tagRepo := repomocks.NewMockTagRepository(t)
256 299
257 favorites := []*entity.Favorite{300 favorites := []*entity.Favorite{
@@ -260,7 +303,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(
260 }303 }
261 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)304 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
262 305
263- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)306+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
264 307
265 results, err := uc.FilterForOmnibox(ctx, "")308 results, err := uc.FilterForOmnibox(ctx, "")
266 require.NoError(t, err)309 require.NoError(t, err)
@@ -273,7 +316,6 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
273 ctx := testContext()316 ctx := testContext()
274 317
275 favoriteRepo := repomocks.NewMockFavoriteRepository(t)318 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
276- folderRepo := repomocks.NewMockFolderRepository(t)
277 tagRepo := repomocks.NewMockTagRepository(t)319 tagRepo := repomocks.NewMockTagRepository(t)
278 320
279 favorites := []*entity.Favorite{321 favorites := []*entity.Favorite{
@@ -281,7 +323,7 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
281 }323 }
282 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()324 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
283 325
284- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)326+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
285 327
286 urls, err := uc.GetAllURLs(ctx)328 urls, err := uc.GetAllURLs(ctx)
287 require.NoError(t, err)329 require.NoError(t, err)
@@ -296,7 +338,6 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
296 ctx := testContext()338 ctx := testContext()
297 339
298 favoriteRepo := repomocks.NewMockFavoriteRepository(t)340 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
299- folderRepo := repomocks.NewMockFolderRepository(t)
300 tagRepo := repomocks.NewMockTagRepository(t)341 tagRepo := repomocks.NewMockTagRepository(t)
301 342
302 initialFavorites := []*entity.Favorite{343 initialFavorites := []*entity.Favorite{
@@ -313,7 +354,7 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
313 }354 }
314 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()355 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
315 356
316- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)357+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
317 358
318 urls, err := uc.GetAllURLs(ctx)359 urls, err := uc.GetAllURLs(ctx)
319 require.NoError(t, err)360 require.NoError(t, err)
@@ -327,51 +368,10 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
327 require.Contains(t, urls, "https://new.example")368 require.Contains(t, urls, "https://new.example")
328 }369 }
329 370
330-func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testing.T) {
331- ctx := testContext()
332-
333- favoriteRepo := repomocks.NewMockFavoriteRepository(t)
334- folderRepo := repomocks.NewMockFolderRepository(t)
335- tagRepo := repomocks.NewMockTagRepository(t)
336-
337- folderID := entity.FolderID(9)
338- initialFolderID := folderID
339- cachedFavorites := []*entity.Favorite{
340- {ID: 1, URL: "https://example.com", FolderID: &initialFolderID},
341- }
342- updatedFavorites := []*entity.Favorite{
343- {ID: 1, URL: "https://example.com", FolderID: nil},
344- }
345-
346- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(cachedFavorites, nil).Once()
347- folderRepo.EXPECT().FindByID(mock.Anything, folderID).Return(&entity.Folder{ID: folderID}, nil).Once()
348- favoriteRepo.EXPECT().GetByFolder(mock.Anything, &folderID).Return(cachedFavorites, nil).Once()
349- favoriteRepo.EXPECT().SetFolder(mock.Anything, entity.FavoriteID(1), (*entity.FolderID)(nil)).Return(nil).Once()
350- folderRepo.EXPECT().GetChildren(mock.Anything, &folderID).Return([]*entity.Folder{}, nil).Once()
351- folderRepo.EXPECT().Delete(mock.Anything, folderID).Return(nil).Once()
352- favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
353-
354- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
355-
356- favoritesBefore, err := uc.GetAll(ctx)
357- require.NoError(t, err)
358- require.Len(t, favoritesBefore, 1)
359- require.NotNil(t, favoritesBefore[0].FolderID)
360-
361- err = uc.DeleteFolder(ctx, folderID)
362- require.NoError(t, err)
363-
364- favoritesAfter, err := uc.GetAll(ctx)
365- require.NoError(t, err)
366- require.Len(t, favoritesAfter, 1)
367- assert.Nil(t, favoritesAfter[0].FolderID)
368-}
369-
370 func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {371 func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
371 ctx := testContext()372 ctx := testContext()
372 373
373 favoriteRepo := repomocks.NewMockFavoriteRepository(t)374 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
374- folderRepo := repomocks.NewMockFolderRepository(t)
375 tagRepo := repomocks.NewMockTagRepository(t)375 tagRepo := repomocks.NewMockTagRepository(t)
376 376
377 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()377 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
@@ -380,7 +380,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
380 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"380 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381 })).Return(nil).Once()381 })).Return(nil).Once()
382 382
383- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)383+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
384 384
385 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})385 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386 require.NoError(t, err)386 require.NoError(t, err)
@@ -393,15 +393,66 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393 ctx := testContext()393 ctx := testContext()
394 394
395 favoriteRepo := repomocks.NewMockFavoriteRepository(t)395 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396- folderRepo := repomocks.NewMockFolderRepository(t)
397 tagRepo := repomocks.NewMockTagRepository(t)396 tagRepo := repomocks.NewMockTagRepository(t)
398- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)397+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
399 398
400 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})399 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401 require.Error(t, err)400 require.Error(t, err)
402 assert.Contains(t, err.Error(), "favorite tag id must be positive")401 assert.Contains(t, err.Error(), "favorite tag id must be positive")
403 }402 }
404 403
404+func TestManageFavoritesUseCase_AddFavoriteAssignsTagsWithoutFolder(t *testing.T) {
405+ ctx := testContext()
406+
407+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
408+ tagRepo := repomocks.NewMockTagRepository(t)
409+
410+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
411+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
412+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" && fav.FaviconURL == "https://example.com/favicon.ico"
413+ })).Run(func(_ context.Context, fav *entity.Favorite) {
414+ fav.ID = entity.FavoriteID(42)
415+ }).Return(nil).Once()
416+ tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(nil).Once()
417+ tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(8), entity.FavoriteID(42)).Return(nil).Once()
418+
419+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
420+
421+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
422+ URL: "https://example.com",
423+ Title: "Example",
424+ FaviconURL: " https://example.com/favicon.ico ",
425+ Tags: []entity.TagID{7, 8},
426+ })
427+ require.NoError(t, err)
428+ require.NotNil(t, fav)
429+ assert.Equal(t, entity.FavoriteID(42), fav.ID)
430+}
431+
432+func TestManageFavoritesUseCase_AddFavoriteReturnsAssignTagError(t *testing.T) {
433+ ctx := testContext()
434+
435+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
436+ tagRepo := repomocks.NewMockTagRepository(t)
437+
438+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
439+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Run(func(_ context.Context, fav *entity.Favorite) {
440+ fav.ID = entity.FavoriteID(42)
441+ }).Return(nil).Once()
442+ tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(errors.New("tag missing")).Once()
443+
444+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
445+
446+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
447+ URL: "https://example.com",
448+ Title: "Example",
449+ Tags: []entity.TagID{7},
450+ })
451+ require.Error(t, err)
452+ assert.Nil(t, fav)
453+ assert.Contains(t, err.Error(), "failed to assign tag 7 to favorite 42")
454+}
455+
405 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {456 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
406 ctx := testContext()457 ctx := testContext()
407 458
@@ -416,9 +467,8 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testin
416 for _, raw := range tests {467 for _, raw := range tests {
417 t.Run(raw, func(t *testing.T) {468 t.Run(raw, func(t *testing.T) {
418 favoriteRepo := repomocks.NewMockFavoriteRepository(t)469 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
419- folderRepo := repomocks.NewMockFolderRepository(t)
420 tagRepo := repomocks.NewMockTagRepository(t)470 tagRepo := repomocks.NewMockTagRepository(t)
421- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)471+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
422 472
423 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})473 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
424 require.Error(t, err)474 require.Error(t, err)
@@ -430,7 +480,6 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
430 ctx := testContext()480 ctx := testContext()
431 481
432 favoriteRepo := repomocks.NewMockFavoriteRepository(t)482 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
433- folderRepo := repomocks.NewMockFolderRepository(t)
434 tagRepo := repomocks.NewMockTagRepository(t)483 tagRepo := repomocks.NewMockTagRepository(t)
435 484
436 favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()485 favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
@@ -439,7 +488,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
439 return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"488 return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
440 })).Return(nil).Once()489 })).Return(nil).Once()
441 490
442- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)491+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
443 492
444 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})493 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
445 require.NoError(t, err)494 require.NoError(t, err)
M internal/domain/entity/favorite.go +6 -55
@@ -1,13 +1,13 @@
1 package entity1 package entity
2 2
3-import "time"3+import (
4+ "strings"
5+ "time"
6+)
4 7
5 // FavoriteID uniquely identifies a favorite/bookmark.8 // FavoriteID uniquely identifies a favorite/bookmark.
6 type FavoriteID int649 type FavoriteID int64
7 10
8-// FolderID uniquely identifies a bookmark folder.
9-type FolderID int64
10-
11 // TagID uniquely identifies a tag.11 // TagID uniquely identifies a tag.
12 type TagID int6412 type TagID int64
13 13
@@ -17,9 +17,8 @@ type Favorite struct {
17 URL string `json:"url"`17 URL string `json:"url"`
18 Title string `json:"title"`18 Title string `json:"title"`
19 FaviconURL string `json:"favicon_url"`19 FaviconURL string `json:"favicon_url"`
20- FolderID *FolderID `json:"folder_id"` // nil = root level
21 ShortcutKey *int `json:"shortcut_key"` // 1-9 for quick access (Alt+1 through Alt+9)20 ShortcutKey *int `json:"shortcut_key"` // 1-9 for quick access (Alt+1 through Alt+9)
22- Position int `json:"position"` // Order within folder21+ Position int `json:"position"` // Order within tag or default list
23 Tags []Tag `json:"tags,omitempty"`22 Tags []Tag `json:"tags,omitempty"`
24 CreatedAt time.Time `json:"created_at"`23 CreatedAt time.Time `json:"created_at"`
25 UpdatedAt time.Time `json:"updated_at"`24 UpdatedAt time.Time `json:"updated_at"`
@@ -41,11 +40,6 @@ func (f *Favorite) HasShortcut() bool {
41 return f.ShortcutKey != nil && *f.ShortcutKey >= 1 && *f.ShortcutKey <= 940 return f.ShortcutKey != nil && *f.ShortcutKey >= 1 && *f.ShortcutKey <= 9
42 }41 }
43 42
44-// InFolder returns true if this favorite is in a folder.
45-func (f *Favorite) InFolder() bool {
46- return f.FolderID != nil
47-}
48-
49 // HasTag returns true if this favorite has the given tag.43 // HasTag returns true if this favorite has the given tag.
50 func (f *Favorite) HasTag(tagID TagID) bool {44 func (f *Favorite) HasTag(tagID TagID) bool {
51 for _, t := range f.Tags {45 for _, t := range f.Tags {
@@ -56,29 +50,6 @@ func (f *Favorite) HasTag(tagID TagID) bool {
56 return false50 return false
57 }51 }
58 52
59-// Folder represents a container for organizing favorites.
60-type Folder struct {
61- ID FolderID `json:"id"`
62- Name string `json:"name"`
63- Icon string `json:"icon"` // Optional icon identifier
64- ParentID *FolderID `json:"parent_id"` // nil = root level
65- Position int `json:"position"` // Order within parent
66- CreatedAt time.Time `json:"created_at"`
67-}
68-
69-// NewFolder creates a new folder.
70-func NewFolder(name string) *Folder {
71- return &Folder{
72- Name: name,
73- CreatedAt: time.Now(),
74- }
75-}
76-
77-// IsRoot returns true if this folder is at root level.
78-func (f *Folder) IsRoot() bool {
79- return f.ParentID == nil
80-}
81-
82 // Tag represents a label that can be applied to favorites.53 // Tag represents a label that can be applied to favorites.
83 type Tag struct {54 type Tag struct {
84 ID TagID `json:"id"`55 ID TagID `json:"id"`
@@ -90,28 +61,8 @@ type Tag struct {
90 // NewTag creates a new tag with default color.61 // NewTag creates a new tag with default color.
91 func NewTag(name string) *Tag {62 func NewTag(name string) *Tag {
92 return &Tag{63 return &Tag{
93- Name: name,64+ Name: strings.TrimSpace(name),
94 Color: "#808080", // Default gray65 Color: "#808080", // Default gray
95 CreatedAt: time.Now(),66 CreatedAt: time.Now(),
96 }67 }
97 }68 }
98-
99-// FavoriteTree represents a hierarchical view of folders and favorites.
100-type FavoriteTree struct {
101- RootFolders []*Folder
102- RootFavorites []*Favorite
103- FolderMap map[FolderID]*Folder // Quick lookup
104- ChildFolders map[FolderID][]*Folder // Children of each folder
105- ChildFavorites map[FolderID][]*Favorite // Favorites in each folder
106-}
107-
108-// NewFavoriteTree creates an empty favorite tree.
109-func NewFavoriteTree() *FavoriteTree {
110- return &FavoriteTree{
111- RootFolders: make([]*Folder, 0),
112- RootFavorites: make([]*Favorite, 0),
113- FolderMap: make(map[FolderID]*Folder),
114- ChildFolders: make(map[FolderID][]*Folder),
115- ChildFavorites: make(map[FolderID][]*Favorite),
116- }
117-}
A internal/domain/entity/favorite_test.go +28 -0
@@ -0,0 +1,28 @@
1+package entity
2+
3+import "testing"
4+
5+func TestFavoriteSupportsMultipleTagsAndNoTags(t *testing.T) {
6+ fav := NewFavorite("https://example.com", "Example")
7+ if fav.HasTag(TagID(1)) {
8+ t.Fatal("new favorite unexpectedly has tag")
9+ }
10+
11+ fav.Tags = []Tag{{ID: 1, Name: "Go"}, {ID: 2, Name: "Docs"}}
12+ if !fav.HasTag(1) || !fav.HasTag(2) {
13+ t.Fatalf("favorite tags not detected: %+v", fav.Tags)
14+ }
15+ if fav.HasTag(3) {
16+ t.Fatal("favorite reported missing tag as present")
17+ }
18+}
19+
20+func TestTagStoresTrimmedDisplayNameAndColor(t *testing.T) {
21+ tag := NewTag(" Read Later ")
22+ if tag.Name != "Read Later" {
23+ t.Fatalf("tag name = %q", tag.Name)
24+ }
25+ if tag.Color == "" {
26+ t.Fatal("tag default color is empty")
27+ }
28+}
M internal/domain/repository/favorite.go +3 -30
@@ -20,19 +20,15 @@ type FavoriteRepository interface {
20 // GetAll retrieves all favorites.20 // GetAll retrieves all favorites.
21 GetAll(ctx context.Context) ([]*entity.Favorite, error)21 GetAll(ctx context.Context) ([]*entity.Favorite, error)
22 22
23- // GetByFolder retrieves favorites in a specific folder.23+ // GetByTag retrieves favorites with a specific tag.
24- // Pass nil for root-level favorites.24+ GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error)
25- GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error)
26 25
27 // GetByShortcut retrieves the favorite with the given shortcut key (1-9).26 // GetByShortcut retrieves the favorite with the given shortcut key (1-9).
28 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)27 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
29 28
30- // UpdatePosition updates a favorite's position within its folder.29+ // UpdatePosition updates a favorite's position.
31 UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error30 UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error
32 31
33- // SetFolder moves a favorite to a different folder.
34- SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
35-
36 // SetShortcut assigns a keyboard shortcut (1-9) or nil to remove.32 // SetShortcut assigns a keyboard shortcut (1-9) or nil to remove.
37 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error33 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
38 34
@@ -40,29 +36,6 @@ type FavoriteRepository interface {
40 Delete(ctx context.Context, id entity.FavoriteID) error36 Delete(ctx context.Context, id entity.FavoriteID) error
41 }37 }
42 38
43-// FolderRepository defines operations for bookmark folder persistence.
44-type FolderRepository interface {
45- // Save creates or updates a folder.
46- Save(ctx context.Context, folder *entity.Folder) error
47-
48- // FindByID retrieves a folder by its ID.
49- FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error)
50-
51- // GetAll retrieves all folders.
52- GetAll(ctx context.Context) ([]*entity.Folder, error)
53-
54- // GetChildren retrieves child folders of a parent.
55- // Pass nil for root-level folders.
56- GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error)
57-
58- // UpdatePosition updates a folder's position within its parent.
59- UpdatePosition(ctx context.Context, id entity.FolderID, position int) error
60-
61- // Delete removes a folder by ID.
62- // Favorites in the folder should be moved to root.
63- Delete(ctx context.Context, id entity.FolderID) error
64-}
65-
66 // TagRepository defines operations for tag persistence.39 // TagRepository defines operations for tag persistence.
67 type TagRepository interface {40 type TagRepository interface {
68 // Save creates or updates a tag.41 // Save creates or updates a tag.
M internal/domain/repository/mocks/favorite.go +48 -513
@@ -293,55 +293,55 @@ func (_c *MockFavoriteRepository_GetAll_Call) RunAndReturn(run func(ctx context.
293 return _c293 return _c
294 }294 }
295 295
296-// GetByFolder provides a mock function for the type MockFavoriteRepository296+// GetByShortcut provides a mock function for the type MockFavoriteRepository
297-func (_mock *MockFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {297+func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
298- ret := _mock.Called(ctx, folderID)298+ ret := _mock.Called(ctx, key)
299 299
300 if len(ret) == 0 {300 if len(ret) == 0 {
301- panic("no return value specified for GetByFolder")301+ panic("no return value specified for GetByShortcut")
302 }302 }
303 303
304- var r0 []*entity.Favorite304+ var r0 *entity.Favorite
305 var r1 error305 var r1 error
306- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) ([]*entity.Favorite, error)); ok {306+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok {
307- return returnFunc(ctx, folderID)307+ return returnFunc(ctx, key)
308 }308 }
309- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) []*entity.Favorite); ok {309+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok {
310- r0 = returnFunc(ctx, folderID)310+ r0 = returnFunc(ctx, key)
311 } else {311 } else {
312 if ret.Get(0) != nil {312 if ret.Get(0) != nil {
313- r0 = ret.Get(0).([]*entity.Favorite)313+ r0 = ret.Get(0).(*entity.Favorite)
314 }314 }
315 }315 }
316- if returnFunc, ok := ret.Get(1).(func(context.Context, *entity.FolderID) error); ok {316+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
317- r1 = returnFunc(ctx, folderID)317+ r1 = returnFunc(ctx, key)
318 } else {318 } else {
319 r1 = ret.Error(1)319 r1 = ret.Error(1)
320 }320 }
321 return r0, r1321 return r0, r1
322 }322 }
323 323
324-// MockFavoriteRepository_GetByFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByFolder'324+// MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut'
325-type MockFavoriteRepository_GetByFolder_Call struct {325+type MockFavoriteRepository_GetByShortcut_Call struct {
326 *mock.Call326 *mock.Call
327 }327 }
328 328
329-// GetByFolder is a helper method to define mock.On call329+// GetByShortcut is a helper method to define mock.On call
330 // - ctx context.Context330 // - ctx context.Context
331-// - folderID *entity.FolderID331+// - key int
332-func (_e *MockFavoriteRepository_Expecter) GetByFolder(ctx any, folderID any) *MockFavoriteRepository_GetByFolder_Call {332+func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call {
333- return &MockFavoriteRepository_GetByFolder_Call{Call: _e.mock.On("GetByFolder", ctx, folderID)}333+ return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)}
334 }334 }
335 335
336-func (_c *MockFavoriteRepository_GetByFolder_Call) Run(run func(ctx context.Context, folderID *entity.FolderID)) *MockFavoriteRepository_GetByFolder_Call {336+func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Context, key int)) *MockFavoriteRepository_GetByShortcut_Call {
337 _c.Call.Run(func(args mock.Arguments) {337 _c.Call.Run(func(args mock.Arguments) {
338 var arg0 context.Context338 var arg0 context.Context
339 if args[0] != nil {339 if args[0] != nil {
340 arg0 = args[0].(context.Context)340 arg0 = args[0].(context.Context)
341 }341 }
342- var arg1 *entity.FolderID342+ var arg1 int
343 if args[1] != nil {343 if args[1] != nil {
344- arg1 = args[1].(*entity.FolderID)344+ arg1 = args[1].(int)
345 }345 }
346 run(346 run(
347 arg0,347 arg0,
@@ -351,65 +351,65 @@ func (_c *MockFavoriteRepository_GetByFolder_Call) Run(run func(ctx context.Cont
351 return _c351 return _c
352 }352 }
353 353
354-func (_c *MockFavoriteRepository_GetByFolder_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByFolder_Call {354+func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call {
355- _c.Call.Return(favorites, err)355+ _c.Call.Return(favorite, err)
356 return _c356 return _c
357 }357 }
358 358
359-func (_c *MockFavoriteRepository_GetByFolder_Call) RunAndReturn(run func(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error)) *MockFavoriteRepository_GetByFolder_Call {359+func (_c *MockFavoriteRepository_GetByShortcut_Call) RunAndReturn(run func(ctx context.Context, key int) (*entity.Favorite, error)) *MockFavoriteRepository_GetByShortcut_Call {
360 _c.Call.Return(run)360 _c.Call.Return(run)
361 return _c361 return _c
362 }362 }
363 363
364-// GetByShortcut provides a mock function for the type MockFavoriteRepository364+// GetByTag provides a mock function for the type MockFavoriteRepository
365-func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {365+func (_mock *MockFavoriteRepository) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) {
366- ret := _mock.Called(ctx, key)366+ ret := _mock.Called(ctx, tagID)
367 367
368 if len(ret) == 0 {368 if len(ret) == 0 {
369- panic("no return value specified for GetByShortcut")369+ panic("no return value specified for GetByTag")
370 }370 }
371 371
372- var r0 *entity.Favorite372+ var r0 []*entity.Favorite
373 var r1 error373 var r1 error
374- if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok {374+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) ([]*entity.Favorite, error)); ok {
375- return returnFunc(ctx, key)375+ return returnFunc(ctx, tagID)
376 }376 }
377- if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok {377+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) []*entity.Favorite); ok {
378- r0 = returnFunc(ctx, key)378+ r0 = returnFunc(ctx, tagID)
379 } else {379 } else {
380 if ret.Get(0) != nil {380 if ret.Get(0) != nil {
381- r0 = ret.Get(0).(*entity.Favorite)381+ r0 = ret.Get(0).([]*entity.Favorite)
382 }382 }
383 }383 }
384- if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {384+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.TagID) error); ok {
385- r1 = returnFunc(ctx, key)385+ r1 = returnFunc(ctx, tagID)
386 } else {386 } else {
387 r1 = ret.Error(1)387 r1 = ret.Error(1)
388 }388 }
389 return r0, r1389 return r0, r1
390 }390 }
391 391
392-// MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut'392+// MockFavoriteRepository_GetByTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByTag'
393-type MockFavoriteRepository_GetByShortcut_Call struct {393+type MockFavoriteRepository_GetByTag_Call struct {
394 *mock.Call394 *mock.Call
395 }395 }
396 396
397-// GetByShortcut is a helper method to define mock.On call397+// GetByTag is a helper method to define mock.On call
398 // - ctx context.Context398 // - ctx context.Context
399-// - key int399+// - tagID entity.TagID
400-func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call {400+func (_e *MockFavoriteRepository_Expecter) GetByTag(ctx any, tagID any) *MockFavoriteRepository_GetByTag_Call {
401- return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)}401+ return &MockFavoriteRepository_GetByTag_Call{Call: _e.mock.On("GetByTag", ctx, tagID)}
402 }402 }
403 403
404-func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Context, key int)) *MockFavoriteRepository_GetByShortcut_Call {404+func (_c *MockFavoriteRepository_GetByTag_Call) Run(run func(ctx context.Context, tagID entity.TagID)) *MockFavoriteRepository_GetByTag_Call {
405 _c.Call.Run(func(args mock.Arguments) {405 _c.Call.Run(func(args mock.Arguments) {
406 var arg0 context.Context406 var arg0 context.Context
407 if args[0] != nil {407 if args[0] != nil {
408 arg0 = args[0].(context.Context)408 arg0 = args[0].(context.Context)
409 }409 }
410- var arg1 int410+ var arg1 entity.TagID
411 if args[1] != nil {411 if args[1] != nil {
412- arg1 = args[1].(int)412+ arg1 = args[1].(entity.TagID)
413 }413 }
414 run(414 run(
415 arg0,415 arg0,
@@ -419,12 +419,12 @@ func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Co
419 return _c419 return _c
420 }420 }
421 421
422-func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call {422+func (_c *MockFavoriteRepository_GetByTag_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByTag_Call {
423- _c.Call.Return(favorite, err)423+ _c.Call.Return(favorites, err)
424 return _c424 return _c
425 }425 }
426 426
427-func (_c *MockFavoriteRepository_GetByShortcut_Call) RunAndReturn(run func(ctx context.Context, key int) (*entity.Favorite, error)) *MockFavoriteRepository_GetByShortcut_Call {427+func (_c *MockFavoriteRepository_GetByTag_Call) RunAndReturn(run func(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error)) *MockFavoriteRepository_GetByTag_Call {
428 _c.Call.Return(run)428 _c.Call.Return(run)
429 return _c429 return _c
430 }430 }
@@ -486,69 +486,6 @@ func (_c *MockFavoriteRepository_Save_Call) RunAndReturn(run func(ctx context.Co
486 return _c486 return _c
487 }487 }
488 488
489-// SetFolder provides a mock function for the type MockFavoriteRepository
490-func (_mock *MockFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
491- ret := _mock.Called(ctx, id, folderID)
492-
493- if len(ret) == 0 {
494- panic("no return value specified for SetFolder")
495- }
496-
497- var r0 error
498- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID, *entity.FolderID) error); ok {
499- r0 = returnFunc(ctx, id, folderID)
500- } else {
501- r0 = ret.Error(0)
502- }
503- return r0
504-}
505-
506-// MockFavoriteRepository_SetFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFolder'
507-type MockFavoriteRepository_SetFolder_Call struct {
508- *mock.Call
509-}
510-
511-// SetFolder is a helper method to define mock.On call
512-// - ctx context.Context
513-// - id entity.FavoriteID
514-// - folderID *entity.FolderID
515-func (_e *MockFavoriteRepository_Expecter) SetFolder(ctx any, id any, folderID any) *MockFavoriteRepository_SetFolder_Call {
516- return &MockFavoriteRepository_SetFolder_Call{Call: _e.mock.On("SetFolder", ctx, id, folderID)}
517-}
518-
519-func (_c *MockFavoriteRepository_SetFolder_Call) Run(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID)) *MockFavoriteRepository_SetFolder_Call {
520- _c.Call.Run(func(args mock.Arguments) {
521- var arg0 context.Context
522- if args[0] != nil {
523- arg0 = args[0].(context.Context)
524- }
525- var arg1 entity.FavoriteID
526- if args[1] != nil {
527- arg1 = args[1].(entity.FavoriteID)
528- }
529- var arg2 *entity.FolderID
530- if args[2] != nil {
531- arg2 = args[2].(*entity.FolderID)
532- }
533- run(
534- arg0,
535- arg1,
536- arg2,
537- )
538- })
539- return _c
540-}
541-
542-func (_c *MockFavoriteRepository_SetFolder_Call) Return(err error) *MockFavoriteRepository_SetFolder_Call {
543- _c.Call.Return(err)
544- return _c
545-}
546-
547-func (_c *MockFavoriteRepository_SetFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error) *MockFavoriteRepository_SetFolder_Call {
548- _c.Call.Return(run)
549- return _c
550-}
551-
552 // SetShortcut provides a mock function for the type MockFavoriteRepository489 // SetShortcut provides a mock function for the type MockFavoriteRepository
553 func (_mock *MockFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {490 func (_mock *MockFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
554 ret := _mock.Called(ctx, id, key)491 ret := _mock.Called(ctx, id, key)
@@ -675,408 +612,6 @@ func (_c *MockFavoriteRepository_UpdatePosition_Call) RunAndReturn(run func(ctx
675 return _c612 return _c
676 }613 }
677 614
678-// NewMockFolderRepository creates a new instance of MockFolderRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
679-// The first argument is typically a *testing.T value.
680-func NewMockFolderRepository(t interface {
681- mock.TestingT
682- Cleanup(func())
683-}) *MockFolderRepository {
684- mock := &MockFolderRepository{}
685- mock.Mock.Test(t)
686-
687- t.Cleanup(func() { mock.AssertExpectations(t) })
688-
689- return mock
690-}
691-
692-// MockFolderRepository is an autogenerated mock type for the FolderRepository type
693-type MockFolderRepository struct {
694- mock.Mock
695-}
696-
697-type MockFolderRepository_Expecter struct {
698- mock *mock.Mock
699-}
700-
701-func (_m *MockFolderRepository) EXPECT() *MockFolderRepository_Expecter {
702- return &MockFolderRepository_Expecter{mock: &_m.Mock}
703-}
704-
705-// Delete provides a mock function for the type MockFolderRepository
706-func (_mock *MockFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
707- ret := _mock.Called(ctx, id)
708-
709- if len(ret) == 0 {
710- panic("no return value specified for Delete")
711- }
712-
713- var r0 error
714- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) error); ok {
715- r0 = returnFunc(ctx, id)
716- } else {
717- r0 = ret.Error(0)
718- }
719- return r0
720-}
721-
722-// MockFolderRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
723-type MockFolderRepository_Delete_Call struct {
724- *mock.Call
725-}
726-
727-// Delete is a helper method to define mock.On call
728-// - ctx context.Context
729-// - id entity.FolderID
730-func (_e *MockFolderRepository_Expecter) Delete(ctx any, id any) *MockFolderRepository_Delete_Call {
731- return &MockFolderRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
732-}
733-
734-func (_c *MockFolderRepository_Delete_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockFolderRepository_Delete_Call {
735- _c.Call.Run(func(args mock.Arguments) {
736- var arg0 context.Context
737- if args[0] != nil {
738- arg0 = args[0].(context.Context)
739- }
740- var arg1 entity.FolderID
741- if args[1] != nil {
742- arg1 = args[1].(entity.FolderID)
743- }
744- run(
745- arg0,
746- arg1,
747- )
748- })
749- return _c
750-}
751-
752-func (_c *MockFolderRepository_Delete_Call) Return(err error) *MockFolderRepository_Delete_Call {
753- _c.Call.Return(err)
754- return _c
755-}
756-
757-func (_c *MockFolderRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) error) *MockFolderRepository_Delete_Call {
758- _c.Call.Return(run)
759- return _c
760-}
761-
762-// FindByID provides a mock function for the type MockFolderRepository
763-func (_mock *MockFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
764- ret := _mock.Called(ctx, id)
765-
766- if len(ret) == 0 {
767- panic("no return value specified for FindByID")
768- }
769-
770- var r0 *entity.Folder
771- var r1 error
772- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) (*entity.Folder, error)); ok {
773- return returnFunc(ctx, id)
774- }
775- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) *entity.Folder); ok {
776- r0 = returnFunc(ctx, id)
777- } else {
778- if ret.Get(0) != nil {
779- r0 = ret.Get(0).(*entity.Folder)
780- }
781- }
782- if returnFunc, ok := ret.Get(1).(func(context.Context, entity.FolderID) error); ok {
783- r1 = returnFunc(ctx, id)
784- } else {
785- r1 = ret.Error(1)
786- }
787- return r0, r1
788-}
789-
790-// MockFolderRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'
791-type MockFolderRepository_FindByID_Call struct {
792- *mock.Call
793-}
794-
795-// FindByID is a helper method to define mock.On call
796-// - ctx context.Context
797-// - id entity.FolderID
798-func (_e *MockFolderRepository_Expecter) FindByID(ctx any, id any) *MockFolderRepository_FindByID_Call {
799- return &MockFolderRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}
800-}
801-
802-func (_c *MockFolderRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockFolderRepository_FindByID_Call {
803- _c.Call.Run(func(args mock.Arguments) {
804- var arg0 context.Context
805- if args[0] != nil {
806- arg0 = args[0].(context.Context)
807- }
808- var arg1 entity.FolderID
809- if args[1] != nil {
810- arg1 = args[1].(entity.FolderID)
811- }
812- run(
813- arg0,
814- arg1,
815- )
816- })
817- return _c
818-}
819-
820-func (_c *MockFolderRepository_FindByID_Call) Return(folder *entity.Folder, err error) *MockFolderRepository_FindByID_Call {
821- _c.Call.Return(folder, err)
822- return _c
823-}
824-
825-func (_c *MockFolderRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) (*entity.Folder, error)) *MockFolderRepository_FindByID_Call {
826- _c.Call.Return(run)
827- return _c
828-}
829-
830-// GetAll provides a mock function for the type MockFolderRepository
831-func (_mock *MockFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
832- ret := _mock.Called(ctx)
833-
834- if len(ret) == 0 {
835- panic("no return value specified for GetAll")
836- }
837-
838- var r0 []*entity.Folder
839- var r1 error
840- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.Folder, error)); ok {
841- return returnFunc(ctx)
842- }
843- if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.Folder); ok {
844- r0 = returnFunc(ctx)
845- } else {
846- if ret.Get(0) != nil {
847- r0 = ret.Get(0).([]*entity.Folder)
848- }
849- }
850- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
851- r1 = returnFunc(ctx)
852- } else {
853- r1 = ret.Error(1)
854- }
855- return r0, r1
856-}
857-
858-// MockFolderRepository_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
859-type MockFolderRepository_GetAll_Call struct {
860- *mock.Call
861-}
862-
863-// GetAll is a helper method to define mock.On call
864-// - ctx context.Context
865-func (_e *MockFolderRepository_Expecter) GetAll(ctx any) *MockFolderRepository_GetAll_Call {
866- return &MockFolderRepository_GetAll_Call{Call: _e.mock.On("GetAll", ctx)}
867-}
868-
869-func (_c *MockFolderRepository_GetAll_Call) Run(run func(ctx context.Context)) *MockFolderRepository_GetAll_Call {
870- _c.Call.Run(func(args mock.Arguments) {
871- var arg0 context.Context
872- if args[0] != nil {
873- arg0 = args[0].(context.Context)
874- }
875- run(
876- arg0,
877- )
878- })
879- return _c
880-}
881-
882-func (_c *MockFolderRepository_GetAll_Call) Return(folders []*entity.Folder, err error) *MockFolderRepository_GetAll_Call {
883- _c.Call.Return(folders, err)
884- return _c
885-}
886-
887-func (_c *MockFolderRepository_GetAll_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.Folder, error)) *MockFolderRepository_GetAll_Call {
888- _c.Call.Return(run)
889- return _c
890-}
891-
892-// GetChildren provides a mock function for the type MockFolderRepository
893-func (_mock *MockFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
894- ret := _mock.Called(ctx, parentID)
895-
896- if len(ret) == 0 {
897- panic("no return value specified for GetChildren")
898- }
899-
900- var r0 []*entity.Folder
901- var r1 error
902- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) ([]*entity.Folder, error)); ok {
903- return returnFunc(ctx, parentID)
904- }
905- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) []*entity.Folder); ok {
906- r0 = returnFunc(ctx, parentID)
907- } else {
908- if ret.Get(0) != nil {
909- r0 = ret.Get(0).([]*entity.Folder)
910- }
911- }
912- if returnFunc, ok := ret.Get(1).(func(context.Context, *entity.FolderID) error); ok {
913- r1 = returnFunc(ctx, parentID)
914- } else {
915- r1 = ret.Error(1)
916- }
917- return r0, r1
918-}
919-
920-// MockFolderRepository_GetChildren_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChildren'
921-type MockFolderRepository_GetChildren_Call struct {
922- *mock.Call
923-}
924-
925-// GetChildren is a helper method to define mock.On call
926-// - ctx context.Context
927-// - parentID *entity.FolderID
928-func (_e *MockFolderRepository_Expecter) GetChildren(ctx any, parentID any) *MockFolderRepository_GetChildren_Call {
929- return &MockFolderRepository_GetChildren_Call{Call: _e.mock.On("GetChildren", ctx, parentID)}
930-}
931-
932-func (_c *MockFolderRepository_GetChildren_Call) Run(run func(ctx context.Context, parentID *entity.FolderID)) *MockFolderRepository_GetChildren_Call {
933- _c.Call.Run(func(args mock.Arguments) {
934- var arg0 context.Context
935- if args[0] != nil {
936- arg0 = args[0].(context.Context)
937- }
938- var arg1 *entity.FolderID
939- if args[1] != nil {
940- arg1 = args[1].(*entity.FolderID)
941- }
942- run(
943- arg0,
944- arg1,
945- )
946- })
947- return _c
948-}
949-
950-func (_c *MockFolderRepository_GetChildren_Call) Return(folders []*entity.Folder, err error) *MockFolderRepository_GetChildren_Call {
951- _c.Call.Return(folders, err)
952- return _c
953-}
954-
955-func (_c *MockFolderRepository_GetChildren_Call) RunAndReturn(run func(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error)) *MockFolderRepository_GetChildren_Call {
956- _c.Call.Return(run)
957- return _c
958-}
959-
960-// Save provides a mock function for the type MockFolderRepository
961-func (_mock *MockFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
962- ret := _mock.Called(ctx, folder)
963-
964- if len(ret) == 0 {
965- panic("no return value specified for Save")
966- }
967-
968- var r0 error
969- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.Folder) error); ok {
970- r0 = returnFunc(ctx, folder)
971- } else {
972- r0 = ret.Error(0)
973- }
974- return r0
975-}
976-
977-// MockFolderRepository_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
978-type MockFolderRepository_Save_Call struct {
979- *mock.Call
980-}
981-
982-// Save is a helper method to define mock.On call
983-// - ctx context.Context
984-// - folder *entity.Folder
985-func (_e *MockFolderRepository_Expecter) Save(ctx any, folder any) *MockFolderRepository_Save_Call {
986- return &MockFolderRepository_Save_Call{Call: _e.mock.On("Save", ctx, folder)}
987-}
988-
989-func (_c *MockFolderRepository_Save_Call) Run(run func(ctx context.Context, folder *entity.Folder)) *MockFolderRepository_Save_Call {
990- _c.Call.Run(func(args mock.Arguments) {
991- var arg0 context.Context
992- if args[0] != nil {
993- arg0 = args[0].(context.Context)
994- }
995- var arg1 *entity.Folder
996- if args[1] != nil {
997- arg1 = args[1].(*entity.Folder)
998- }
999- run(
1000- arg0,
1001- arg1,
1002- )
1003- })
1004- return _c
1005-}
1006-
1007-func (_c *MockFolderRepository_Save_Call) Return(err error) *MockFolderRepository_Save_Call {
1008- _c.Call.Return(err)
1009- return _c
1010-}
1011-
1012-func (_c *MockFolderRepository_Save_Call) RunAndReturn(run func(ctx context.Context, folder *entity.Folder) error) *MockFolderRepository_Save_Call {
1013- _c.Call.Return(run)
1014- return _c
1015-}
1016-
1017-// UpdatePosition provides a mock function for the type MockFolderRepository
1018-func (_mock *MockFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
1019- ret := _mock.Called(ctx, id, position)
1020-
1021- if len(ret) == 0 {
1022- panic("no return value specified for UpdatePosition")
1023- }
1024-
1025- var r0 error
1026- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID, int) error); ok {
1027- r0 = returnFunc(ctx, id, position)
1028- } else {
1029- r0 = ret.Error(0)
1030- }
1031- return r0
1032-}
1033-
1034-// MockFolderRepository_UpdatePosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePosition'
1035-type MockFolderRepository_UpdatePosition_Call struct {
1036- *mock.Call
1037-}
1038-
1039-// UpdatePosition is a helper method to define mock.On call
1040-// - ctx context.Context
1041-// - id entity.FolderID
1042-// - position int
1043-func (_e *MockFolderRepository_Expecter) UpdatePosition(ctx any, id any, position any) *MockFolderRepository_UpdatePosition_Call {
1044- return &MockFolderRepository_UpdatePosition_Call{Call: _e.mock.On("UpdatePosition", ctx, id, position)}
1045-}
1046-
1047-func (_c *MockFolderRepository_UpdatePosition_Call) Run(run func(ctx context.Context, id entity.FolderID, position int)) *MockFolderRepository_UpdatePosition_Call {
1048- _c.Call.Run(func(args mock.Arguments) {
1049- var arg0 context.Context
1050- if args[0] != nil {
1051- arg0 = args[0].(context.Context)
1052- }
1053- var arg1 entity.FolderID
1054- if args[1] != nil {
1055- arg1 = args[1].(entity.FolderID)
1056- }
1057- var arg2 int
1058- if args[2] != nil {
1059- arg2 = args[2].(int)
1060- }
1061- run(
1062- arg0,
1063- arg1,
1064- arg2,
1065- )
1066- })
1067- return _c
1068-}
1069-
1070-func (_c *MockFolderRepository_UpdatePosition_Call) Return(err error) *MockFolderRepository_UpdatePosition_Call {
1071- _c.Call.Return(err)
1072- return _c
1073-}
1074-
1075-func (_c *MockFolderRepository_UpdatePosition_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID, position int) error) *MockFolderRepository_UpdatePosition_Call {
1076- _c.Call.Return(run)
1077- return _c
1078-}
1079-
1080 // NewMockTagRepository creates a new instance of MockTagRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.615 // NewMockTagRepository creates a new instance of MockTagRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1081 // The first argument is typically a *testing.T value.616 // The first argument is typically a *testing.T value.
1082 func NewMockTagRepository(t interface {617 func NewMockTagRepository(t interface {
M internal/infrastructure/handlers/homepage/favorites.go +10 -67
@@ -12,17 +12,6 @@ import (
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13 )13 )
14 14
15-func folderIDFromInt64(id *int64) (*entity.FolderID, error) {
16- if id == nil {
17- return nil, nil
18- }
19- if *id <= 0 {
20- return nil, fmt.Errorf("folder id must be positive")
21- }
22- folderID := entity.FolderID(*id)
23- return &folderID, nil
24-}
25-
26 func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {15 func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
27 out := make([]entity.TagID, 0, len(ids))16 out := make([]entity.TagID, 0, len(ids))
28 for _, id := range ids {17 for _, id := range ids {
@@ -69,7 +58,6 @@ type favoriteCreateRequest struct {
69 URL string `json:"url"`58 URL string `json:"url"`
70 Title string `json:"title"`59 Title string `json:"title"`
71 FaviconURL string `json:"favicon_url"`60 FaviconURL string `json:"favicon_url"`
72- FolderID *int64 `json:"folder_id"`
73 Tags []int64 `json:"tags"`61 Tags []int64 `json:"tags"`
74 }62 }
75 63
@@ -91,10 +79,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
91 if trimmedURL == "" {79 if trimmedURL == "" {
92 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil80 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
93 }81 }
94- folderID, err := folderIDFromInt64(req.FolderID)
95- if err != nil {
96- return NewErrorResponse(req.RequestID, err), nil
97- }
98 tags, err := tagIDsFromInt64s(req.Tags)82 tags, err := tagIDsFromInt64s(req.Tags)
99 if err != nil {83 if err != nil {
100 return NewErrorResponse(req.RequestID, err), nil84 return NewErrorResponse(req.RequestID, err), nil
@@ -104,7 +88,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
104 URL: trimmedURL,88 URL: trimmedURL,
105 Title: req.Title,89 Title: req.Title,
106 FaviconURL: req.FaviconURL,90 FaviconURL: req.FaviconURL,
107- FolderID: folderID,
108 Tags: tags,91 Tags: tags,
109 }92 }
110 favorite, err := h.favoritesUC.AddFavorite(ctx, input)93 favorite, err := h.favoritesUC.AddFavorite(ctx, input)
@@ -121,7 +104,6 @@ type favoriteUpdateRequest struct {
121 ID int64 `json:"id"`104 ID int64 `json:"id"`
122 Title string `json:"title"`105 Title string `json:"title"`
123 FaviconURL string `json:"favicon_url"`106 FaviconURL string `json:"favicon_url"`
124- FolderID *int64 `json:"folder_id"`
125 ShortcutKey *int `json:"shortcut_key"`107 ShortcutKey *int `json:"shortcut_key"`
126 }108 }
127 109
@@ -134,6 +116,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
134 if err := json.Unmarshal(payload, &req); err != nil {116 if err := json.Unmarshal(payload, &req); err != nil {
135 return NewErrorResponse("", err), nil117 return NewErrorResponse("", err), nil
136 }118 }
119+ var fields map[string]json.RawMessage
120+ if err := json.Unmarshal(payload, &fields); err != nil {
121+ return NewErrorResponse("", err), nil
122+ }
123+ _, shortcutKeySet := fields["shortcut_key"]
137 124
138 log.Debug().125 log.Debug().
139 Str("request_id", req.RequestID).126 Str("request_id", req.RequestID).
@@ -144,17 +131,12 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
144 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil131 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
145 }132 }
146 133
147- folderID, err := folderIDFromInt64(req.FolderID)
148- if err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151-
152 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
153- ID: entity.FavoriteID(req.ID),135+ ID: entity.FavoriteID(req.ID),
154- Title: req.Title,136+ Title: req.Title,
155- FaviconURL: req.FaviconURL,137+ FaviconURL: req.FaviconURL,
156- FolderID: folderID,138+ ShortcutKey: req.ShortcutKey,
157- ShortcutKey: req.ShortcutKey,139+ ShortcutKeySet: shortcutKeySet,
158 })140 })
159 if err != nil {141 if err != nil {
160 return NewErrorResponse(req.RequestID, err), nil142 return NewErrorResponse(req.RequestID, err), nil
@@ -259,42 +241,3 @@ func (h *FavoritesHandlers) HandleGetByShortcut() port.WebUIMessageHandler {
259 return NewSuccessResponse(req.RequestID, favorite), nil241 return NewSuccessResponse(req.RequestID, favorite), nil
260 })242 })
261 }243 }
262-
263-// setFolderRequest is the payload for favorite_set_folder messages.
264-type setFolderRequest struct {
265- RequestID string `json:"requestId"`
266- FavoriteID int64 `json:"favorite_id"`
267- FolderID *int64 `json:"folder_id"` // null to move to root
268-}
269-
270-// HandleSetFolder handles favorite_set_folder messages.
271-func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
272- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
273- log := logging.FromContext(ctx)
274-
275- var req setFolderRequest
276- if err := json.Unmarshal(payload, &req); err != nil {
277- return NewErrorResponse("", err), nil
278- }
279-
280- log.Debug().
281- Str("request_id", req.RequestID).
282- Int64("favorite_id", req.FavoriteID).
283- Msg("handling favorite_set_folder")
284-
285- if req.FavoriteID <= 0 {
286- return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil
287- }
288-
289- folderID, err := folderIDFromInt64(req.FolderID)
290- if err != nil {
291- return NewErrorResponse(req.RequestID, err), nil
292- }
293-
294- if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
295- return NewErrorResponse(req.RequestID, err), nil
296- }
297-
298- return NewSuccessResponse(req.RequestID, nil), nil
299- })
300-}
A internal/infrastructure/handlers/homepage/favorites_test.go +117 -0
@@ -0,0 +1,117 @@
1+package homepage
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+ "github.com/bnema/dumber/internal/application/port"
10+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestHandleFavoriteCreatePassesTagsOnlyPayload(t *testing.T) {
17+ t.Parallel()
18+
19+ favorites := portmocks.NewMockHomepageFavorites(t)
20+ favorites.EXPECT().
21+ AddFavorite(mock.Anything, dto.FavoriteCreateInput{
22+ URL: "https://example.com",
23+ Title: "Example",
24+ Tags: []entity.TagID{7, 9},
25+ }).
26+ Return(&entity.Favorite{ID: 1, URL: "https://example.com", Title: "Example"}, nil).
27+ Once()
28+
29+ handler := NewFavoritesHandlers(favorites).HandleCreate()
30+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-1","url":" https://example.com ","title":"Example","tags":[7,9]}`))
31+ require.NoError(t, err)
32+
33+ resp, ok := got.(Response)
34+ require.True(t, ok)
35+ require.True(t, resp.Success)
36+}
37+
38+func TestHandleFavoriteCreateRejectsInvalidTagIDs(t *testing.T) {
39+ t.Parallel()
40+
41+ handler := NewFavoritesHandlers(portmocks.NewMockHomepageFavorites(t)).HandleCreate()
42+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-2","url":"https://example.com","tags":[0]}`))
43+ require.NoError(t, err)
44+
45+ resp, ok := got.(Response)
46+ require.True(t, ok)
47+ require.False(t, resp.Success)
48+ require.Contains(t, resp.Error, "tag id must be positive")
49+}
50+
51+func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) {
52+ t.Parallel()
53+
54+ shortcut := 4
55+ favorites := portmocks.NewMockHomepageFavorites(t)
56+ favorites.EXPECT().
57+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
58+ ID: entity.FavoriteID(42),
59+ Title: "Updated",
60+ FaviconURL: "https://example.com/favicon.ico",
61+ ShortcutKey: &shortcut,
62+ ShortcutKeySet: true,
63+ }).
64+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated", ShortcutKey: &shortcut}, nil).
65+ Once()
66+
67+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
68+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-3","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":4}`))
69+ require.NoError(t, err)
70+
71+ resp, ok := got.(Response)
72+ require.True(t, ok)
73+ require.True(t, resp.Success)
74+}
75+func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) {
76+ t.Parallel()
77+
78+ favorites := portmocks.NewMockHomepageFavorites(t)
79+ favorites.EXPECT().
80+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
81+ ID: entity.FavoriteID(42),
82+ Title: "Updated",
83+ FaviconURL: "https://example.com/favicon.ico",
84+ }).
85+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
86+ Once()
87+
88+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
89+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-4","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico"}`))
90+ require.NoError(t, err)
91+
92+ resp, ok := got.(Response)
93+ require.True(t, ok)
94+ require.True(t, resp.Success)
95+}
96+func TestHandleFavoriteUpdateClearsShortcutWhenExplicitNull(t *testing.T) {
97+ t.Parallel()
98+
99+ favorites := portmocks.NewMockHomepageFavorites(t)
100+ favorites.EXPECT().
101+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
102+ ID: entity.FavoriteID(42),
103+ Title: "Updated",
104+ FaviconURL: "https://example.com/favicon.ico",
105+ ShortcutKeySet: true,
106+ }).
107+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
108+ Once()
109+
110+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
111+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-5","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":null}`))
112+ require.NoError(t, err)
113+
114+ resp, ok := got.(Response)
115+ require.True(t, ok)
116+ require.True(t, resp.Success)
117+}
D internal/infrastructure/handlers/homepage/folders.go +0 -146
@@ -1,146 +0,0 @@
1-package homepage
2-
3-import (
4- "context"
5- "encoding/json"
6-
7- "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// FolderHandlers handles folder-related messages from the homepage.
13-type FolderHandlers struct {
14- favoritesUC port.HomepageFavorites
15-}
16-
17-// NewFolderHandlers creates a new FolderHandlers instance.
18-func NewFolderHandlers(favoritesUC port.HomepageFavorites) *FolderHandlers {
19- return &FolderHandlers{favoritesUC: favoritesUC}
20-}
21-
22-// HandleList handles folder_list messages.
23-func (h *FolderHandlers) HandleList() port.WebUIMessageHandler {
24- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
25- log := logging.FromContext(ctx)
26-
27- requestID := ParseRequestID(payload)
28-
29- log.Debug().
30- Str("request_id", requestID).
31- Msg("handling folder_list")
32-
33- folders, err := h.favoritesUC.GetAllFolders(ctx)
34- if err != nil {
35- return NewErrorResponse(requestID, err), nil
36- }
37-
38- return NewSuccessResponse(requestID, folders), nil
39- })
40-}
41-
42-// createFolderRequest is the payload for folder_create messages.
43-type createFolderRequest struct {
44- RequestID string `json:"requestId"`
45- Name string `json:"name"`
46- Icon *string `json:"icon"`
47- ParentID *int64 `json:"parent_id"`
48-}
49-
50-// HandleCreate handles folder_create messages.
51-func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
52- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
53- log := logging.FromContext(ctx)
54-
55- var req createFolderRequest
56- if err := json.Unmarshal(payload, &req); err != nil {
57- return NewErrorResponse("", err), nil
58- }
59-
60- log.Debug().
61- Str("request_id", req.RequestID).
62- Str("name", req.Name).
63- Msg("handling folder_create")
64-
65- parentID, err := folderIDFromInt64(req.ParentID)
66- if err != nil {
67- return NewErrorResponse(req.RequestID, err), nil
68- }
69-
70- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID)
71- if err != nil {
72- return NewErrorResponse(req.RequestID, err), nil
73- }
74-
75- return NewSuccessResponse(req.RequestID, folder), nil
76- })
77-}
78-
79-// deleteFolderRequest is the payload for folder_delete messages.
80-type deleteFolderRequest struct {
81- RequestID string `json:"requestId"`
82- ID int64 `json:"id"`
83-}
84-
85-// HandleDelete handles folder_delete messages.
86-func (h *FolderHandlers) HandleDelete() port.WebUIMessageHandler {
87- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
88- log := logging.FromContext(ctx)
89-
90- var req deleteFolderRequest
91- if err := json.Unmarshal(payload, &req); err != nil {
92- return NewErrorResponse("", err), nil
93- }
94-
95- log.Debug().
96- Str("request_id", req.RequestID).
97- Int64("id", req.ID).
98- Msg("handling folder_delete")
99-
100- if err := h.favoritesUC.DeleteFolder(ctx, entity.FolderID(req.ID)); err != nil {
101- return NewErrorResponse(req.RequestID, err), nil
102- }
103-
104- return NewSuccessResponse(req.RequestID, nil), nil
105- })
106-}
107-
108-// updateFolderRequest is the payload for folder_update messages.
109-type updateFolderRequest struct {
110- RequestID string `json:"requestId"`
111- ID int64 `json:"id"`
112- Name string `json:"name"`
113- Icon *string `json:"icon"`
114-}
115-
116-// HandleUpdate handles folder_update messages.
117-// It delegates to the port.HomepageFavorites interface's UpdateFolder method.
118-func optionalStringValue(value *string) string {
119- if value == nil {
120- return ""
121- }
122- return *value
123-}
124-
125-func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
126- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
127- log := logging.FromContext(ctx)
128-
129- var req updateFolderRequest
130- if err := json.Unmarshal(payload, &req); err != nil {
131- return NewErrorResponse("", err), nil
132- }
133-
134- log.Debug().
135- Str("request_id", req.RequestID).
136- Int64("id", req.ID).
137- Str("name", req.Name).
138- Msg("handling folder_update")
139-
140- if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil {
141- return NewErrorResponse(req.RequestID, err), nil
142- }
143-
144- return NewSuccessResponse(req.RequestID, nil), nil
145- })
146-}
M internal/infrastructure/handlers/homepage/registry.go +0 -9
@@ -48,15 +48,6 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
48 handlers["favorite_delete"] = favoritesHandlers.HandleDelete()48 handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
49 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()49 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
50 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()50 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()
51- handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder()
52-
53- // Folder handlers
54- folderHandlers := NewFolderHandlers(cfg.FavoritesUC)
55- handlers["folder_list"] = folderHandlers.HandleList()
56- handlers["folder_create"] = folderHandlers.HandleCreate()
57- handlers["folder_delete"] = folderHandlers.HandleDelete()
58- handlers["folder_update"] = folderHandlers.HandleUpdate() // Requires UpdateFolder()
59-
60 // Tag handlers51 // Tag handlers
61 tagHandlers := NewTagHandlers(cfg.FavoritesUC)52 tagHandlers := NewTagHandlers(cfg.FavoritesUC)
62 handlers["tag_list"] = tagHandlers.HandleList()53 handlers["tag_list"] = tagHandlers.HandleList()
M internal/infrastructure/persistence/sqlite/favorite_repo.go +120 -179
@@ -13,79 +13,46 @@ import (
13 13
14 // ==================== Favorite Repository ====================14 // ==================== Favorite Repository ====================
15 15
16+const favoriteTagHydrationBatchSize = 900
17+
16 type favoriteRepo struct {18 type favoriteRepo struct {
17- db *sql.DB
18 queries *sqlc.Queries19 queries *sqlc.Queries
19 }20 }
20 21
21 // NewFavoriteRepository creates a new SQLite-backed favorite repository.22 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
22 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {23 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
23- return &favoriteRepo{db: db, queries: sqlc.New(db)}24+ return &favoriteRepo{queries: sqlc.New(db)}
24 }25 }
25 26
26 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {27 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
27 log := logging.FromContext(ctx)28 log := logging.FromContext(ctx)
28 log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite")29 log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite")
29 30
30- var folderID sql.NullInt64
31- if fav.FolderID != nil {
32- folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
33- }
34-
35 if fav.ID > 0 {31 if fav.ID > 0 {
36- return r.updateExisting(ctx, fav, folderID)32+ shortcutKey := sql.NullInt64{}
33+ if fav.ShortcutKey != nil {
34+ shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
35+ }
36+ return r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
37+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
38+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
39+ ShortcutKey: shortcutKey,
40+ ID: int64(fav.ID),
41+ })
37 }42 }
38 43
39 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{44 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
40 Url: fav.URL,45 Url: fav.URL,
41 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},46 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
42 FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},47 FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
43- FolderID: folderID,
44 })48 })
45 if err != nil {49 if err != nil {
46 return err50 return err
47 }51 }
48- fav.ID = entity.FavoriteID(row.ID)52+ favoriteFromRow(favoriteRowFromCreate(row), fav)
49 return nil53 return nil
50 }54 }
51 55
52-func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error {
53- log := logging.FromContext(ctx)
54- tx, err := r.db.BeginTx(ctx, nil)
55- if err != nil {
56- return err
57- }
58- defer func() {
59- if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
60- log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error")
61- }
62- }()
63-
64- shortcutKey := sql.NullInt64{}
65- if fav.ShortcutKey != nil {
66- shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
67- }
68-
69- queries := r.queries.WithTx(tx)
70- if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
71- Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
72- FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
73- ID: int64(fav.ID),
74- }); err != nil {
75- return err
76- }
77- if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
78- return err
79- }
80- if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
81- ShortcutKey: shortcutKey,
82- ID: int64(fav.ID),
83- }); err != nil {
84- return err
85- }
86- return tx.Commit()
87-}
88-
89 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {56 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
90 row, err := r.queries.GetFavoriteByID(ctx, int64(id))57 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
91 if err != nil {58 if err != nil {
@@ -94,7 +61,8 @@ func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*ent
94 }61 }
95 return nil, err62 return nil, err
96 }63 }
97- return favoriteFromRow(row), nil64+ fav := favoriteFromRow(favoriteRowFromGetByID(row), nil)
65+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
98 }66 }
99 67
100 func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {68 func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
@@ -105,7 +73,8 @@ func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favor
105 }73 }
106 return nil, err74 return nil, err
107 }75 }
108- return favoriteFromRow(row), nil76+ fav := favoriteFromRow(favoriteRowFromGetByURL(row), nil)
77+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
109 }78 }
110 79
111 func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {80 func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
@@ -113,22 +82,17 @@ func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
113 if err != nil {82 if err != nil {
114 return nil, err83 return nil, err
115 }84 }
116- return favoritesFromRows(rows), nil85+ favorites := favoritesFromAllRows(rows)
86+ return favorites, r.hydrateTags(ctx, favorites)
117 }87 }
118 88
119-func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {89+func (r *favoriteRepo) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) {
120- var rows []sqlc.Favorite90+ rows, err := r.queries.GetFavoritesByTag(ctx, int64(tagID))
121- var err error
122-
123- if folderID == nil {
124- rows, err = r.queries.GetFavoritesWithoutFolder(ctx)
125- } else {
126- rows, err = r.queries.GetFavoritesByFolder(ctx, sql.NullInt64{Int64: int64(*folderID), Valid: true})
127- }
128 if err != nil {91 if err != nil {
129 return nil, err92 return nil, err
130 }93 }
131- return favoritesFromRows(rows), nil94+ favorites := favoritesFromTagRows(rows)
95+ return favorites, r.hydrateTags(ctx, favorites)
132 }96 }
133 97
134 func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {98 func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
@@ -139,7 +103,8 @@ func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favo
139 }103 }
140 return nil, err104 return nil, err
141 }105 }
142- return favoriteFromRow(row), nil106+ fav := favoriteFromRow(favoriteRowFromGetByShortcut(row), nil)
107+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
143 }108 }
144 109
145 func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {110 func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
@@ -149,17 +114,6 @@ func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID,
149 })114 })
150 }115 }
151 116
152-func (r *favoriteRepo) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
153- var fid sql.NullInt64
154- if folderID != nil {
155- fid = sql.NullInt64{Int64: int64(*folderID), Valid: true}
156- }
157- return r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{
158- FolderID: fid,
159- ID: int64(id),
160- })
161-}
162-
163 func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {117 func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
164 var k sql.NullInt64118 var k sql.NullInt64
165 if key != nil {119 if key != nil {
@@ -175,141 +129,128 @@ func (r *favoriteRepo) Delete(ctx context.Context, id entity.FavoriteID) error {
175 return r.queries.DeleteFavorite(ctx, int64(id))129 return r.queries.DeleteFavorite(ctx, int64(id))
176 }130 }
177 131
178-func favoriteFromRow(row sqlc.Favorite) *entity.Favorite {132+func (r *favoriteRepo) hydrateTags(ctx context.Context, favorites []*entity.Favorite) error {
179- fav := &entity.Favorite{133+ if len(favorites) == 0 {
180- ID: entity.FavoriteID(row.ID),134+ return nil
181- URL: row.Url,
182- Title: row.Title.String,
183- FaviconURL: row.FaviconUrl.String,
184- Position: int(row.Position),
185- CreatedAt: row.CreatedAt.Time,
186- UpdatedAt: row.UpdatedAt.Time,
187 }135 }
188- if row.FolderID.Valid {136+
189- fid := entity.FolderID(row.FolderID.Int64)137+ ids := make([]int64, 0, len(favorites))
190- fav.FolderID = &fid138+ byID := make(map[entity.FavoriteID]*entity.Favorite, len(favorites))
139+ for _, fav := range favorites {
140+ if fav == nil {
141+ continue
142+ }
143+ fav.Tags = []entity.Tag{}
144+ ids = append(ids, int64(fav.ID))
145+ byID[fav.ID] = fav
191 }146 }
192- if row.ShortcutKey.Valid {147+ if len(ids) == 0 {
193- key := int(row.ShortcutKey.Int64)148+ return nil
194- fav.ShortcutKey = &key
195 }149 }
196- return fav
197-}
198 150
199-func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite {151+ for start := 0; start < len(ids); start += favoriteTagHydrationBatchSize {
200- favorites := make([]*entity.Favorite, len(rows))152+ end := start + favoriteTagHydrationBatchSize
201- for i := range rows {153+ if end > len(ids) {
202- favorites[i] = favoriteFromRow(rows[i])154+ end = len(ids)
155+ }
156+ rows, err := r.queries.GetTagsForFavorites(ctx, ids[start:end])
157+ if err != nil {
158+ return err
159+ }
160+ for _, row := range rows {
161+ fav := byID[entity.FavoriteID(row.FavoriteID)]
162+ if fav == nil {
163+ continue
164+ }
165+ fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag))
166+ }
203 }167 }
204- return favorites168+ return nil
205-}
206-
207-// ==================== Folder Repository ====================
208-
209-type folderRepo struct {
210- queries *sqlc.Queries
211 }169 }
212 170
213-// NewFolderRepository creates a new SQLite-backed folder repository.171+type favoriteRow struct {
214-func NewFolderRepository(db *sql.DB) repository.FolderRepository {172+ ID int64
215- return &folderRepo{queries: sqlc.New(db)}173+ URL string
174+ Title sql.NullString
175+ FaviconURL sql.NullString
176+ ShortcutKey sql.NullInt64
177+ Position int64
178+ CreatedAt sql.NullTime
179+ UpdatedAt sql.NullTime
216 }180 }
217 181
218-func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {182+func favoriteFromRow(row favoriteRow, target *entity.Favorite) *entity.Favorite {
219- log := logging.FromContext(ctx)183+ fav := target
220- log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder")184+ if fav == nil {
221-185+ fav = &entity.Favorite{}
222- var parentID sql.NullInt64
223- if folder.ParentID != nil {
224- parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
225- }
226-
227- if folder.ID > 0 {
228- return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{
229- Name: folder.Name,
230- Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
231- ID: int64(folder.ID),
232- })
233 }186 }
234-187+ fav.ID = entity.FavoriteID(row.ID)
235- row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{188+ fav.URL = row.URL
236- Name: folder.Name,189+ fav.Title = row.Title.String
237- Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},190+ fav.FaviconURL = row.FaviconURL.String
238- ParentID: parentID,191+ fav.Position = int(row.Position)
239- })192+ fav.CreatedAt = row.CreatedAt.Time
240- if err != nil {193+ fav.UpdatedAt = row.UpdatedAt.Time
241- return err194+ fav.Tags = nil
195+ if row.ShortcutKey.Valid {
196+ key := int(row.ShortcutKey.Int64)
197+ fav.ShortcutKey = &key
198+ } else {
199+ fav.ShortcutKey = nil
242 }200 }
243- folder.ID = entity.FolderID(row.ID)201+ return fav
244- return nil
245 }202 }
246 203
247-func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {204+func favoriteRowFromCreate(row sqlc.CreateFavoriteRow) favoriteRow {
248- row, err := r.queries.GetFolderByID(ctx, int64(id))205+ return favoriteRow{
249- if err != nil {206+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
250- if errors.Is(err, sql.ErrNoRows) {207+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
251- return nil, nil
252- }
253- return nil, err
254 }208 }
255- return folderFromRow(row), nil
256 }209 }
257 210
258-func (r *folderRepo) GetAll(ctx context.Context) ([]*entity.Folder, error) {211+func favoriteRowFromGetByID(row sqlc.GetFavoriteByIDRow) favoriteRow {
259- rows, err := r.queries.GetAllFolders(ctx)212+ return favoriteRow{
260- if err != nil {213+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
261- return nil, err214+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
262 }215 }
263- return foldersFromRows(rows), nil
264 }216 }
265 217
266-func (r *folderRepo) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {218+func favoriteRowFromGetByURL(row sqlc.GetFavoriteByURLRow) favoriteRow {
267- var rows []sqlc.FavoriteFolder219+ return favoriteRow{
268- var err error220+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
269-221+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
270- if parentID == nil {
271- rows, err = r.queries.GetRootFolders(ctx)
272- } else {
273- rows, err = r.queries.GetChildFolders(ctx, sql.NullInt64{Int64: int64(*parentID), Valid: true})
274- }
275- if err != nil {
276- return nil, err
277 }222 }
278- return foldersFromRows(rows), nil
279-}
280-
281-func (r *folderRepo) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
282- return r.queries.UpdateFolderPosition(ctx, sqlc.UpdateFolderPositionParams{
283- Position: int64(position),
284- ID: int64(id),
285- })
286 }223 }
287 224
288-func (r *folderRepo) Delete(ctx context.Context, id entity.FolderID) error {225+func favoriteRowFromGetByShortcut(row sqlc.GetFavoriteByShortcutRow) favoriteRow {
289- return r.queries.DeleteFolder(ctx, int64(id))226+ return favoriteRow{
227+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
228+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
229+ }
290 }230 }
291 231
292-func folderFromRow(row sqlc.FavoriteFolder) *entity.Folder {232+func favoritesFromAllRows(rows []sqlc.GetAllFavoritesRow) []*entity.Favorite {
293- folder := &entity.Folder{233+ favorites := make([]*entity.Favorite, len(rows))
294- ID: entity.FolderID(row.ID),234+ for i := range rows {
295- Name: row.Name,235+ row := rows[i]
296- Icon: row.Icon.String,236+ favorites[i] = favoriteFromRow(favoriteRow{
297- Position: int(row.Position),237+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
298- CreatedAt: row.CreatedAt.Time,238+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
299- }239+ }, nil)
300- if row.ParentID.Valid {
301- pid := entity.FolderID(row.ParentID.Int64)
302- folder.ParentID = &pid
303 }240 }
304- return folder241+ return favorites
305 }242 }
306 243
307-func foldersFromRows(rows []sqlc.FavoriteFolder) []*entity.Folder {244+func favoritesFromTagRows(rows []sqlc.GetFavoritesByTagRow) []*entity.Favorite {
308- folders := make([]*entity.Folder, len(rows))245+ favorites := make([]*entity.Favorite, len(rows))
309- for i, row := range rows {246+ for i := range rows {
310- folders[i] = folderFromRow(row)247+ row := rows[i]
248+ favorites[i] = favoriteFromRow(favoriteRow{
249+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
250+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
251+ }, nil)
311 }252 }
312- return folders253+ return favorites
313 }254 }
314 255
315 // ==================== Tag Repository ====================256 // ==================== Tag Repository ====================
A internal/infrastructure/persistence/sqlite/favorite_repo_test.go +203 -0
@@ -0,0 +1,203 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ _ "github.com/bnema/purego-sqlite/driver"
11+)
12+
13+func openFavoriteRepoTestDB(t *testing.T) *sql.DB {
14+ t.Helper()
15+ db, err := sql.Open("sqlite3", ":memory:")
16+ if err != nil {
17+ t.Fatalf("open sqlite: %v", err)
18+ }
19+ db.SetMaxOpenConns(1)
20+ t.Cleanup(func() { _ = db.Close() })
21+ if err := RunMigrations(context.Background(), db); err != nil {
22+ t.Fatalf("run migrations: %v", err)
23+ }
24+ return db
25+}
26+
27+func seedFavoriteWithTags(t *testing.T, db *sql.DB) (entity.FavoriteID, entity.TagID) {
28+ t.Helper()
29+ mustExec(t, db, `INSERT INTO favorites(id, url, title, shortcut_key, position) VALUES (1, 'https://one.example', 'One', 1, 1), (2, 'https://two.example', 'Two', NULL, 2)`)
30+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'research', '#111111'), (2, 'engines', '#222222')`)
31+ mustExec(t, db, `INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (1, 1), (1, 2), (2, 1)`)
32+ return 1, 1
33+}
34+
35+func TestFavoriteRepositoryGetAllHydratesTags(t *testing.T) {
36+ db := openFavoriteRepoTestDB(t)
37+ seedFavoriteWithTags(t, db)
38+ repo := NewFavoriteRepository(db)
39+
40+ favorites, err := repo.GetAll(context.Background())
41+ if err != nil {
42+ t.Fatalf("GetAll: %v", err)
43+ }
44+ if len(favorites) != 2 {
45+ t.Fatalf("expected 2 favorites, got %d", len(favorites))
46+ }
47+ assertTagNames(t, favorites[0], "engines", "research")
48+ assertTagNames(t, favorites[1], "research")
49+}
50+
51+func TestFavoriteRepositoryFindByIDHydratesTags(t *testing.T) {
52+ db := openFavoriteRepoTestDB(t)
53+ favID, _ := seedFavoriteWithTags(t, db)
54+ repo := NewFavoriteRepository(db)
55+
56+ fav, err := repo.FindByID(context.Background(), favID)
57+ if err != nil {
58+ t.Fatalf("FindByID: %v", err)
59+ }
60+ assertTagNames(t, fav, "engines", "research")
61+}
62+
63+func TestFavoriteRepositoryFindByURLHydratesTags(t *testing.T) {
64+ db := openFavoriteRepoTestDB(t)
65+ seedFavoriteWithTags(t, db)
66+ repo := NewFavoriteRepository(db)
67+
68+ fav, err := repo.FindByURL(context.Background(), "https://one.example")
69+ if err != nil {
70+ t.Fatalf("FindByURL: %v", err)
71+ }
72+ assertTagNames(t, fav, "engines", "research")
73+}
74+
75+func TestFavoriteRepositoryGetByTagHydratesTags(t *testing.T) {
76+ db := openFavoriteRepoTestDB(t)
77+ _, tagID := seedFavoriteWithTags(t, db)
78+ repo := NewFavoriteRepository(db)
79+
80+ favorites, err := repo.GetByTag(context.Background(), tagID)
81+ if err != nil {
82+ t.Fatalf("GetByTag: %v", err)
83+ }
84+ if len(favorites) != 2 {
85+ t.Fatalf("expected 2 tagged favorites, got %d", len(favorites))
86+ }
87+ assertTagNames(t, favorites[0], "engines", "research")
88+ assertTagNames(t, favorites[1], "research")
89+}
90+
91+func TestFavoriteRepositoryGetByShortcutHydratesTags(t *testing.T) {
92+ db := openFavoriteRepoTestDB(t)
93+ seedFavoriteWithTags(t, db)
94+ repo := NewFavoriteRepository(db)
95+
96+ fav, err := repo.GetByShortcut(context.Background(), 1)
97+ if err != nil {
98+ t.Fatalf("GetByShortcut: %v", err)
99+ }
100+ assertTagNames(t, fav, "engines", "research")
101+}
102+
103+func TestFavoriteRepositoryNoTagsHydratesEmptyTags(t *testing.T) {
104+ db := openFavoriteRepoTestDB(t)
105+ mustExec(t, db, `INSERT INTO favorites(id, url, title, position) VALUES (1, 'https://none.example', 'None', 1)`)
106+ repo := NewFavoriteRepository(db)
107+
108+ fav, err := repo.FindByID(context.Background(), 1)
109+ if err != nil {
110+ t.Fatalf("FindByID: %v", err)
111+ }
112+ if fav == nil {
113+ t.Fatal("expected favorite")
114+ }
115+ if fav.Tags == nil || len(fav.Tags) != 0 {
116+ t.Fatalf("expected non-nil empty Tags, got %#v", fav.Tags)
117+ }
118+}
119+
120+func TestFavoriteRepositorySaveUpdatesShortcutKey(t *testing.T) {
121+ db := openFavoriteRepoTestDB(t)
122+ mustExec(t, db, `INSERT INTO favorites(id, url, title, position) VALUES (1, 'https://shortcut.example', 'Shortcut', 1)`)
123+ repo := NewFavoriteRepository(db)
124+
125+ fav, err := repo.FindByID(context.Background(), 1)
126+ if err != nil {
127+ t.Fatalf("FindByID: %v", err)
128+ }
129+ key := 5
130+ fav.ShortcutKey = &key
131+ err = repo.Save(context.Background(), fav)
132+ if err != nil {
133+ t.Fatalf("Save: %v", err)
134+ }
135+
136+ updated, err := repo.GetByShortcut(context.Background(), 5)
137+ if err != nil {
138+ t.Fatalf("GetByShortcut: %v", err)
139+ }
140+ if updated == nil || updated.ID != fav.ID {
141+ t.Fatalf("expected favorite %d by shortcut, got %#v", fav.ID, updated)
142+ }
143+
144+ updated.ShortcutKey = nil
145+ err = repo.Save(context.Background(), updated)
146+ if err != nil {
147+ t.Fatalf("Save clear shortcut: %v", err)
148+ }
149+ cleared, err := repo.FindByID(context.Background(), updated.ID)
150+ if err != nil {
151+ t.Fatalf("FindByID after clear: %v", err)
152+ }
153+ if cleared.ShortcutKey != nil {
154+ t.Fatalf("expected shortcut cleared, got %d", *cleared.ShortcutKey)
155+ }
156+}
157+
158+func TestFavoriteRepositoryGetAllHydratesTagsInBatches(t *testing.T) {
159+ db := openFavoriteRepoTestDB(t)
160+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'batched', '#111111')`)
161+ for i := int64(1); i <= favoriteTagHydrationBatchSize+5; i++ {
162+ _, err := db.Exec(
163+ `INSERT INTO favorites(id, url, title, position) VALUES (?, ?, ?, ?)`,
164+ i,
165+ fmt.Sprintf("https://batch.example/%d", i),
166+ "Batch",
167+ i,
168+ )
169+ if err != nil {
170+ t.Fatalf("insert favorite %d: %v", i, err)
171+ }
172+ _, err = db.Exec(`INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (?, 1)`, i)
173+ if err != nil {
174+ t.Fatalf("insert assignment %d: %v", i, err)
175+ }
176+ }
177+ repo := NewFavoriteRepository(db)
178+
179+ favorites, err := repo.GetAll(context.Background())
180+ if err != nil {
181+ t.Fatalf("GetAll: %v", err)
182+ }
183+ if len(favorites) != favoriteTagHydrationBatchSize+5 {
184+ t.Fatalf("expected %d favorites, got %d", favoriteTagHydrationBatchSize+5, len(favorites))
185+ }
186+ assertTagNames(t, favorites[0], "batched")
187+ assertTagNames(t, favorites[len(favorites)-1], "batched")
188+}
189+
190+func assertTagNames(t *testing.T, fav *entity.Favorite, names ...string) {
191+ t.Helper()
192+ if fav == nil {
193+ t.Fatal("favorite is nil")
194+ }
195+ if len(fav.Tags) != len(names) {
196+ t.Fatalf("expected %d tags, got %d (%#v)", len(names), len(fav.Tags), fav.Tags)
197+ }
198+ for i, name := range names {
199+ if fav.Tags[i].Name != name {
200+ t.Fatalf("tag %d: expected %q, got %q", i, name, fav.Tags[i].Name)
201+ }
202+ }
203+}
A internal/infrastructure/persistence/sqlite/favorites_migration_test.go +161 -0
@@ -0,0 +1,161 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "testing"
7+
8+ _ "github.com/bnema/purego-sqlite/driver"
9+ "github.com/pressly/goose/v3"
10+)
11+
12+func openMigrationTestDB(t *testing.T) *sql.DB {
13+ t.Helper()
14+ db, err := sql.Open("sqlite3", ":memory:")
15+ if err != nil {
16+ t.Fatalf("open sqlite: %v", err)
17+ }
18+ db.SetMaxOpenConns(1)
19+ t.Cleanup(func() { _ = db.Close() })
20+ return db
21+}
22+
23+func runMigrationsThroughFavoritesSchema(t *testing.T, db *sql.DB) {
24+ t.Helper()
25+ goose.SetBaseFS(embedMigrations)
26+ if err := goose.SetDialect("sqlite3"); err != nil {
27+ t.Fatalf("set dialect: %v", err)
28+ }
29+ goose.SetLogger(goose.NopLogger())
30+ if err := goose.UpTo(db, "migrations", 10); err != nil {
31+ t.Fatalf("migrate through favorites schema: %v", err)
32+ }
33+}
34+
35+func runAllMigrations(t *testing.T, db *sql.DB) {
36+ t.Helper()
37+ if err := RunMigrations(context.Background(), db); err != nil {
38+ t.Fatalf("run migrations: %v", err)
39+ }
40+}
41+
42+func countRows(t *testing.T, db *sql.DB, query string, args ...any) int {
43+ t.Helper()
44+ var count int
45+ if err := db.QueryRow(query, args...).Scan(&count); err != nil {
46+ t.Fatalf("count rows %q: %v", query, err)
47+ }
48+ return count
49+}
50+
51+func TestFavoritesTagsFirstMigrationFolderPathToTag(t *testing.T) {
52+ db := openMigrationTestDB(t)
53+ runMigrationsThroughFavoritesSchema(t, db)
54+
55+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (1, 'Research', NULL), (2, 'Browser Engines', 1)`)
56+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://webkit.org', 'WebKit', 2)`)
57+
58+ runAllMigrations(t, db)
59+
60+ var tagID int64
61+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'research-browser-engines'`).Scan(&tagID); err != nil {
62+ t.Fatalf("expected folder path tag: %v", err)
63+ }
64+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 {
65+ t.Fatalf("expected assignment to folder path tag, got %d", got)
66+ }
67+}
68+
69+func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) {
70+ db := openMigrationTestDB(t)
71+ runMigrationsThroughFavoritesSchema(t, db)
72+
73+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'dev-go', '#111111')`)
74+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (7, 'Dev', NULL), (8, 'Go', 7)`)
75+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://go.dev', 'Go', 8)`)
76+
77+ runAllMigrations(t, db)
78+
79+ var tagID int64
80+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'dev-go-folder-8'`).Scan(&tagID); err != nil {
81+ t.Fatalf("expected suffixed collision tag: %v", err)
82+ }
83+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 {
84+ t.Fatalf("expected assignment to suffixed tag, got %d", got)
85+ }
86+}
87+
88+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) {
89+ db := openMigrationTestDB(t)
90+ runMigrationsThroughFavoritesSchema(t, db)
91+
92+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`)
93+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`)
94+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`)
95+
96+ runAllMigrations(t, db)
97+
98+ var tagID int64
99+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil {
100+ t.Fatalf("expected second-order suffixed collision tag: %v", err)
101+ }
102+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 {
103+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
104+ }
105+}
106+
107+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) {
108+ db := openMigrationTestDB(t)
109+ runMigrationsThroughFavoritesSchema(t, db)
110+
111+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`)
112+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`)
113+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`)
114+
115+ runAllMigrations(t, db)
116+
117+ var folder3TagID int64
118+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil {
119+ t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err)
120+ }
121+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 {
122+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
123+ }
124+
125+ var folder4TagID int64
126+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil {
127+ t.Fatalf("expected natural folder slug tag: %v", err)
128+ }
129+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 {
130+ t.Fatalf("expected assignment to natural slug tag, got %d", got)
131+ }
132+}
133+
134+func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) {
135+ db := openMigrationTestDB(t)
136+ runMigrationsThroughFavoritesSchema(t, db)
137+
138+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, ' Dev-Go ', '#111111'), (2, 'dev-go', '#222222')`)
139+ mustExec(t, db, `INSERT INTO favorites(id, url, title) VALUES (1, 'https://one.example', 'One'), (2, 'https://two.example', 'Two')`)
140+ mustExec(t, db, `INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (1, 1), (2, 2)`)
141+
142+ runAllMigrations(t, db)
143+
144+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tags WHERE lower(trim(name)) = 'dev-go'`); got != 1 {
145+ t.Fatalf("expected one folded dev-go tag, got %d", got)
146+ }
147+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE tag_id = 1`); got != 2 {
148+ t.Fatalf("expected assignments moved to canonical tag 1, got %d", got)
149+ }
150+ mustExec(t, db, `INSERT INTO favorite_tags(name, color) VALUES ('another', '#333333')`)
151+ if _, err := db.Exec(`INSERT INTO favorite_tags(name, color) VALUES (' DEV-GO ', '#444444')`); err == nil {
152+ t.Fatal("expected folded unique index to reject duplicate tag")
153+ }
154+}
155+
156+func mustExec(t *testing.T, db *sql.DB, query string) {
157+ t.Helper()
158+ if _, err := db.Exec(query); err != nil {
159+ t.Fatalf("exec %s: %v", query, err)
160+ }
161+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +2 -78
@@ -357,11 +357,11 @@ func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite
357 return r.repo.GetAll(ctx)357 return r.repo.GetAll(ctx)
358 }358 }
359 359
360-func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {360+func (r *LazyFavoriteRepository) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) {
361 if err := r.init(ctx); err != nil {361 if err := r.init(ctx); err != nil {
362 return nil, err362 return nil, err
363 }363 }
364- return r.repo.GetByFolder(ctx, folderID)364+ return r.repo.GetByTag(ctx, tagID)
365 }365 }
366 366
367 func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {367 func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
@@ -378,13 +378,6 @@ func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.F
378 return r.repo.UpdatePosition(ctx, id, position)378 return r.repo.UpdatePosition(ctx, id, position)
379 }379 }
380 380
381-func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
382- if err := r.init(ctx); err != nil {
383- return err
384- }
385- return r.repo.SetFolder(ctx, id, folderID)
386-}
387-
388 func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {381 func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
389 if err := r.init(ctx); err != nil {382 if err := r.init(ctx); err != nil {
390 return err383 return err
@@ -399,73 +392,6 @@ func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteI
399 return r.repo.Delete(ctx, id)392 return r.repo.Delete(ctx, id)
400 }393 }
401 394
402-// LazyFolderRepository wraps a folder repository with lazy database initialization.
403-type LazyFolderRepository struct {
404- provider port.DatabaseProvider
405- repo repository.FolderRepository
406- once sync.Once
407- initErr error
408-}
409-
410-// NewLazyFolderRepository creates a lazy-loading folder repository.
411-func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
412- return &LazyFolderRepository{provider: provider}
413-}
414-
415-func (r *LazyFolderRepository) init(ctx context.Context) error {
416- r.once.Do(func() {
417- db, err := r.provider.DB(ctx)
418- if err != nil {
419- r.initErr = err
420- return
421- }
422- r.repo = NewFolderRepository(db)
423- })
424- return r.initErr
425-}
426-
427-func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
428- if err := r.init(ctx); err != nil {
429- return err
430- }
431- return r.repo.Save(ctx, folder)
432-}
433-
434-func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
435- if err := r.init(ctx); err != nil {
436- return nil, err
437- }
438- return r.repo.FindByID(ctx, id)
439-}
440-
441-func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
442- if err := r.init(ctx); err != nil {
443- return nil, err
444- }
445- return r.repo.GetAll(ctx)
446-}
447-
448-func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
449- if err := r.init(ctx); err != nil {
450- return nil, err
451- }
452- return r.repo.GetChildren(ctx, parentID)
453-}
454-
455-func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
456- if err := r.init(ctx); err != nil {
457- return err
458- }
459- return r.repo.UpdatePosition(ctx, id, position)
460-}
461-
462-func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
463- if err := r.init(ctx); err != nil {
464- return err
465- }
466- return r.repo.Delete(ctx, id)
467-}
468-
469 // LazyTagRepository wraps a tag repository with lazy database initialization.395 // LazyTagRepository wraps a tag repository with lazy database initialization.
470 type LazyTagRepository struct {396 type LazyTagRepository struct {
471 provider port.DatabaseProvider397 provider port.DatabaseProvider
@@ -798,7 +724,6 @@ func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string,
798 type LazyRepositories struct {724 type LazyRepositories struct {
799 History repository.HistoryRepository725 History repository.HistoryRepository
800 Favorite repository.FavoriteRepository726 Favorite repository.FavoriteRepository
801- Folder repository.FolderRepository
802 Tag repository.TagRepository727 Tag repository.TagRepository
803 Zoom repository.ZoomRepository728 Zoom repository.ZoomRepository
804 Session repository.SessionRepository729 Session repository.SessionRepository
@@ -812,7 +737,6 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
812 return &LazyRepositories{737 return &LazyRepositories{
813 History: NewLazyHistoryRepository(provider),738 History: NewLazyHistoryRepository(provider),
814 Favorite: NewLazyFavoriteRepository(provider),739 Favorite: NewLazyFavoriteRepository(provider),
815- Folder: NewLazyFolderRepository(provider),
816 Tag: NewLazyTagRepository(provider),740 Tag: NewLazyTagRepository(provider),
817 Zoom: NewLazyZoomRepository(provider),741 Zoom: NewLazyZoomRepository(provider),
818 Session: NewLazySessionRepository(provider),742 Session: NewLazySessionRepository(provider),
A internal/infrastructure/persistence/sqlite/migrations/011_favorites_tags_first.sql +135 -0
@@ -0,0 +1,135 @@
1+-- +goose Up
2+-- Convert favorite organization from folders to tags.
3+
4+-- Reconcile duplicate tags by folded name before adding folded uniqueness.
5+CREATE TEMP TABLE _favorite_tag_canonical AS
6+SELECT lower(trim(name)) AS folded_name, MIN(id) AS canonical_id
7+FROM favorite_tags
8+GROUP BY lower(trim(name));
9+
10+INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id)
11+SELECT fta.favorite_id, c.canonical_id
12+FROM favorite_tag_assignments fta
13+JOIN favorite_tags t ON t.id = fta.tag_id
14+JOIN _favorite_tag_canonical c ON c.folded_name = lower(trim(t.name))
15+WHERE fta.tag_id <> c.canonical_id;
16+
17+DELETE FROM favorite_tag_assignments
18+WHERE tag_id NOT IN (SELECT canonical_id FROM _favorite_tag_canonical);
19+
20+DELETE FROM favorite_tags
21+WHERE id NOT IN (SELECT canonical_id FROM _favorite_tag_canonical);
22+
23+DROP TABLE _favorite_tag_canonical;
24+
25+CREATE UNIQUE INDEX IF NOT EXISTS idx_favorite_tags_name_folded ON favorite_tags(lower(trim(name)));
26+
27+-- Build deterministic folder-path slugs. Keep folder tables/columns for compatibility,
28+-- but materialize every current folder assignment as a tag assignment.
29+CREATE TEMP TABLE _folder_tag_map (
30+ folder_id INTEGER PRIMARY KEY,
31+ tag_name TEXT NOT NULL UNIQUE
32+);
33+
34+WITH RECURSIVE folder_paths(id, parent_id, path) AS (
35+ SELECT id, parent_id, trim(name)
36+ FROM favorite_folders
37+ WHERE parent_id IS NULL
38+ UNION ALL
39+ SELECT child.id, child.parent_id, folder_paths.path || '-' || trim(child.name)
40+ FROM favorite_folders child
41+ JOIN folder_paths ON child.parent_id = folder_paths.id
42+), folder_slugs AS (
43+ SELECT
44+ id,
45+ lower(
46+ trim(
47+ replace(replace(replace(replace(replace(path, '/', '-'), '\\', '-'), ' ', '-'), '_', '-'), '.', '-'),
48+ '-'
49+ )
50+ ) AS base_slug
51+ FROM folder_paths
52+), folder_names AS (
53+ SELECT
54+ id,
55+ CASE
56+ WHEN base_slug = '' THEN 'folder-' || id
57+ ELSE base_slug
58+ END AS natural_name
59+ FROM folder_slugs
60+), base_assignments AS (
61+ SELECT id, natural_name AS tag_name
62+ FROM folder_names current_folder
63+ WHERE NOT EXISTS (
64+ SELECT 1 FROM favorite_tags t
65+ WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name))
66+ )
67+ AND NOT EXISTS (
68+ SELECT 1 FROM folder_names earlier
69+ WHERE earlier.id < current_folder.id
70+ AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name))
71+ )
72+), unresolved_folders AS (
73+ SELECT id, natural_name
74+ FROM folder_names
75+ WHERE id NOT IN (SELECT id FROM base_assignments)
76+), candidate_limit(max_attempt) AS (
77+ SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3
78+), candidate_numbers(attempt) AS (
79+ SELECT 1
80+ UNION ALL
81+ SELECT attempt + 1
82+ FROM candidate_numbers, candidate_limit
83+ WHERE attempt < max_attempt
84+), suffix_candidates AS (
85+ SELECT
86+ unresolved_folders.id,
87+ candidate_numbers.attempt,
88+ CASE
89+ WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id
90+ ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt
91+ END AS tag_name
92+ FROM unresolved_folders
93+ CROSS JOIN candidate_numbers
94+), available_suffix_candidates AS (
95+ SELECT id, attempt, tag_name
96+ FROM suffix_candidates candidate
97+ WHERE NOT EXISTS (
98+ SELECT 1 FROM favorite_tags t
99+ WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name))
100+ )
101+ AND NOT EXISTS (
102+ SELECT 1 FROM base_assignments assigned
103+ WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name))
104+ )
105+), suffix_assignments AS (
106+ SELECT id, tag_name
107+ FROM available_suffix_candidates candidate
108+ WHERE attempt = (
109+ SELECT MIN(attempt)
110+ FROM available_suffix_candidates earlier
111+ WHERE earlier.id = candidate.id
112+ )
113+), resolved AS (
114+ SELECT id, tag_name FROM base_assignments
115+ UNION ALL
116+ SELECT id, tag_name FROM suffix_assignments
117+)
118+INSERT INTO _folder_tag_map(folder_id, tag_name)
119+SELECT id, tag_name FROM resolved;
120+
121+INSERT OR IGNORE INTO favorite_tags(name, color)
122+SELECT tag_name, '#808080'
123+FROM _folder_tag_map;
124+
125+INSERT OR IGNORE INTO favorite_tag_assignments(favorite_id, tag_id)
126+SELECT f.id, t.id
127+FROM favorites f
128+JOIN _folder_tag_map ftm ON ftm.folder_id = f.folder_id
129+JOIN favorite_tags t ON lower(trim(t.name)) = lower(trim(ftm.tag_name))
130+WHERE f.folder_id IS NOT NULL;
131+
132+DROP TABLE _folder_tag_map;
133+
134+-- +goose Down
135+DROP INDEX IF EXISTS idx_favorite_tags_name_folded;
M internal/infrastructure/persistence/sqlite/queries/favorites.sql +22 -16
@@ -1,37 +1,43 @@
1 -- name: GetAllFavorites :many1 -- name: GetAllFavorites :many
2-SELECT * FROM favorites ORDER BY position ASC;2+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
3+FROM favorites
4+ORDER BY position ASC;
3 5
4 -- name: GetFavoriteByID :one6 -- name: GetFavoriteByID :one
5-SELECT * FROM favorites WHERE id = ? LIMIT 1;7+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
8+FROM favorites
9+WHERE id = ? LIMIT 1;
6 10
7 -- name: GetFavoriteByURL :one11 -- name: GetFavoriteByURL :one
8-SELECT * FROM favorites WHERE url = ? LIMIT 1;12+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
13+FROM favorites
14+WHERE url = ? LIMIT 1;
9 15
10--- name: GetFavoritesByFolder :many16+-- name: GetFavoritesByTag :many
11-SELECT * FROM favorites WHERE folder_id = ? ORDER BY position ASC;17+SELECT f.id, f.url, f.title, f.favicon_url, f.shortcut_key, f.position, f.created_at, f.updated_at
12-18+FROM favorites f
13--- name: GetFavoritesWithoutFolder :many19+INNER JOIN favorite_tag_assignments fta ON f.id = fta.favorite_id
14-SELECT * FROM favorites WHERE folder_id IS NULL ORDER BY position ASC;20+WHERE fta.tag_id = ?
21+ORDER BY f.position ASC;
15 22
16 -- name: GetFavoriteByShortcut :one23 -- name: GetFavoriteByShortcut :one
17-SELECT * FROM favorites WHERE shortcut_key = ? LIMIT 1;24+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
25+FROM favorites
26+WHERE shortcut_key = ? LIMIT 1;
18 27
19 -- name: CreateFavorite :one28 -- name: CreateFavorite :one
20-INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)29+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
21-VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)30+VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
22-RETURNING *;31+RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at;
23 32
24 -- name: UpdateFavorite :exec33 -- name: UpdateFavorite :exec
25 UPDATE favorites34 UPDATE favorites
26-SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP35+SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP
27 WHERE id = ?;36 WHERE id = ?;
28 37
29 -- name: UpdateFavoritePosition :exec38 -- name: UpdateFavoritePosition :exec
30 UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;39 UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
31 40
32--- name: SetFavoriteFolder :exec
33-UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
34-
35 -- name: SetFavoriteShortcut :exec41 -- name: SetFavoriteShortcut :exec
36 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;42 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
37 43
D internal/infrastructure/persistence/sqlite/queries/folders.sql +0 -25
@@ -1,25 +0,0 @@
1--- name: GetAllFolders :many
2-SELECT * FROM favorite_folders ORDER BY position ASC;
3-
4--- name: GetFolderByID :one
5-SELECT * FROM favorite_folders WHERE id = ? LIMIT 1;
6-
7--- name: GetChildFolders :many
8-SELECT * FROM favorite_folders WHERE parent_id = ? ORDER BY position ASC;
9-
10--- name: GetRootFolders :many
11-SELECT * FROM favorite_folders WHERE parent_id IS NULL ORDER BY position ASC;
12-
13--- name: CreateFolder :one
14-INSERT INTO favorite_folders (name, icon, parent_id, position, created_at)
15-VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorite_folders), 0), CURRENT_TIMESTAMP)
16-RETURNING *;
17-
18--- name: UpdateFolder :exec
19-UPDATE favorite_folders SET name = ?, icon = ? WHERE id = ?;
20-
21--- name: UpdateFolderPosition :exec
22-UPDATE favorite_folders SET position = ? WHERE id = ?;
23-
24--- name: DeleteFolder :exec
25-DELETE FROM favorite_folders WHERE id = ?;
M internal/infrastructure/persistence/sqlite/queries/tags.sql +7 -1
@@ -5,7 +5,7 @@ SELECT * FROM favorite_tags ORDER BY name ASC;
5 SELECT * FROM favorite_tags WHERE id = ? LIMIT 1;5 SELECT * FROM favorite_tags WHERE id = ? LIMIT 1;
6 6
7 -- name: GetTagByName :one7 -- name: GetTagByName :one
8-SELECT * FROM favorite_tags WHERE name = ? LIMIT 1;8+SELECT * FROM favorite_tags WHERE lower(trim(name)) = lower(trim(?)) LIMIT 1;
9 9
10 -- name: CreateTag :one10 -- name: CreateTag :one
11 INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *;11 INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *;
@@ -27,3 +27,9 @@ SELECT t.* FROM favorite_tags t
27 INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id27 INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
28 WHERE fta.favorite_id = ?28 WHERE fta.favorite_id = ?
29 ORDER BY t.name ASC;29 ORDER BY t.name ASC;
30+
31+-- name: GetTagsForFavorites :many
32+SELECT sqlc.embed(t), fta.favorite_id FROM favorite_tags t
33+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
34+WHERE fta.favorite_id IN (sqlc.slice('favorite_ids'))
35+ORDER BY fta.favorite_id ASC, t.name ASC;
M internal/infrastructure/persistence/sqlite/sqlc/favorites.sql.go +112 -91
@@ -11,32 +11,36 @@ import (
11 )11 )
12 12
13 const CreateFavorite = `-- name: CreateFavorite :one13 const CreateFavorite = `-- name: CreateFavorite :one
14-INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)14+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
15-VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)15+VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
16-RETURNING id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at16+RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
17 `17 `
18 18
19 type CreateFavoriteParams struct {19 type CreateFavoriteParams struct {
20 Url string `json:"url"`20 Url string `json:"url"`
21 Title sql.NullString `json:"title"`21 Title sql.NullString `json:"title"`
22 FaviconUrl sql.NullString `json:"favicon_url"`22 FaviconUrl sql.NullString `json:"favicon_url"`
23- FolderID sql.NullInt64 `json:"folder_id"`
24 }23 }
25 24
26-func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) {25+type CreateFavoriteRow struct {
27- row := q.db.QueryRowContext(ctx, CreateFavorite,26+ ID int64 `json:"id"`
28- arg.Url,27+ Url string `json:"url"`
29- arg.Title,28+ Title sql.NullString `json:"title"`
30- arg.FaviconUrl,29+ FaviconUrl sql.NullString `json:"favicon_url"`
31- arg.FolderID,30+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
32- )31+ Position int64 `json:"position"`
33- var i Favorite32+ CreatedAt sql.NullTime `json:"created_at"`
33+ UpdatedAt sql.NullTime `json:"updated_at"`
34+}
35+
36+func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (CreateFavoriteRow, error) {
37+ row := q.db.QueryRowContext(ctx, CreateFavorite, arg.Url, arg.Title, arg.FaviconUrl)
38+ var i CreateFavoriteRow
34 err := row.Scan(39 err := row.Scan(
35 &i.ID,40 &i.ID,
36 &i.Url,41 &i.Url,
37 &i.Title,42 &i.Title,
38 &i.FaviconUrl,43 &i.FaviconUrl,
39- &i.FolderID,
40 &i.ShortcutKey,44 &i.ShortcutKey,
41 &i.Position,45 &i.Position,
42 &i.CreatedAt,46 &i.CreatedAt,
@@ -55,24 +59,36 @@ func (q *Queries) DeleteFavorite(ctx context.Context, id int64) error {
55 }59 }
56 60
57 const GetAllFavorites = `-- name: GetAllFavorites :many61 const GetAllFavorites = `-- name: GetAllFavorites :many
58-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites ORDER BY position ASC62+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
63+FROM favorites
64+ORDER BY position ASC
59 `65 `
60 66
61-func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {67+type GetAllFavoritesRow struct {
68+ ID int64 `json:"id"`
69+ Url string `json:"url"`
70+ Title sql.NullString `json:"title"`
71+ FaviconUrl sql.NullString `json:"favicon_url"`
72+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
73+ Position int64 `json:"position"`
74+ CreatedAt sql.NullTime `json:"created_at"`
75+ UpdatedAt sql.NullTime `json:"updated_at"`
76+}
77+
78+func (q *Queries) GetAllFavorites(ctx context.Context) ([]GetAllFavoritesRow, error) {
62 rows, err := q.db.QueryContext(ctx, GetAllFavorites)79 rows, err := q.db.QueryContext(ctx, GetAllFavorites)
63 if err != nil {80 if err != nil {
64 return nil, err81 return nil, err
65 }82 }
66 defer rows.Close()83 defer rows.Close()
67- items := []Favorite{}84+ items := []GetAllFavoritesRow{}
68 for rows.Next() {85 for rows.Next() {
69- var i Favorite86+ var i GetAllFavoritesRow
70 if err := rows.Scan(87 if err := rows.Scan(
71 &i.ID,88 &i.ID,
72 &i.Url,89 &i.Url,
73 &i.Title,90 &i.Title,
74 &i.FaviconUrl,91 &i.FaviconUrl,
75- &i.FolderID,
76 &i.ShortcutKey,92 &i.ShortcutKey,
77 &i.Position,93 &i.Position,
78 &i.CreatedAt,94 &i.CreatedAt,
@@ -92,18 +108,30 @@ func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
92 }108 }
93 109
94 const GetFavoriteByID = `-- name: GetFavoriteByID :one110 const GetFavoriteByID = `-- name: GetFavoriteByID :one
95-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE id = ? LIMIT 1111+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
112+FROM favorites
113+WHERE id = ? LIMIT 1
96 `114 `
97 115
98-func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) {116+type GetFavoriteByIDRow struct {
117+ ID int64 `json:"id"`
118+ Url string `json:"url"`
119+ Title sql.NullString `json:"title"`
120+ FaviconUrl sql.NullString `json:"favicon_url"`
121+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
122+ Position int64 `json:"position"`
123+ CreatedAt sql.NullTime `json:"created_at"`
124+ UpdatedAt sql.NullTime `json:"updated_at"`
125+}
126+
127+func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (GetFavoriteByIDRow, error) {
99 row := q.db.QueryRowContext(ctx, GetFavoriteByID, id)128 row := q.db.QueryRowContext(ctx, GetFavoriteByID, id)
100- var i Favorite129+ var i GetFavoriteByIDRow
101 err := row.Scan(130 err := row.Scan(
102 &i.ID,131 &i.ID,
103 &i.Url,132 &i.Url,
104 &i.Title,133 &i.Title,
105 &i.FaviconUrl,134 &i.FaviconUrl,
106- &i.FolderID,
107 &i.ShortcutKey,135 &i.ShortcutKey,
108 &i.Position,136 &i.Position,
109 &i.CreatedAt,137 &i.CreatedAt,
@@ -113,18 +141,30 @@ func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, erro
113 }141 }
114 142
115 const GetFavoriteByShortcut = `-- name: GetFavoriteByShortcut :one143 const GetFavoriteByShortcut = `-- name: GetFavoriteByShortcut :one
116-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE shortcut_key = ? LIMIT 1144+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
145+FROM favorites
146+WHERE shortcut_key = ? LIMIT 1
117 `147 `
118 148
119-func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) {149+type GetFavoriteByShortcutRow struct {
150+ ID int64 `json:"id"`
151+ Url string `json:"url"`
152+ Title sql.NullString `json:"title"`
153+ FaviconUrl sql.NullString `json:"favicon_url"`
154+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
155+ Position int64 `json:"position"`
156+ CreatedAt sql.NullTime `json:"created_at"`
157+ UpdatedAt sql.NullTime `json:"updated_at"`
158+}
159+
160+func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (GetFavoriteByShortcutRow, error) {
120 row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey)161 row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey)
121- var i Favorite162+ var i GetFavoriteByShortcutRow
122 err := row.Scan(163 err := row.Scan(
123 &i.ID,164 &i.ID,
124 &i.Url,165 &i.Url,
125 &i.Title,166 &i.Title,
126 &i.FaviconUrl,167 &i.FaviconUrl,
127- &i.FolderID,
128 &i.ShortcutKey,168 &i.ShortcutKey,
129 &i.Position,169 &i.Position,
130 &i.CreatedAt,170 &i.CreatedAt,
@@ -134,18 +174,30 @@ func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.Nul
134 }174 }
135 175
136 const GetFavoriteByURL = `-- name: GetFavoriteByURL :one176 const GetFavoriteByURL = `-- name: GetFavoriteByURL :one
137-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE url = ? LIMIT 1177+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
178+FROM favorites
179+WHERE url = ? LIMIT 1
138 `180 `
139 181
140-func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) {182+type GetFavoriteByURLRow struct {
183+ ID int64 `json:"id"`
184+ Url string `json:"url"`
185+ Title sql.NullString `json:"title"`
186+ FaviconUrl sql.NullString `json:"favicon_url"`
187+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
188+ Position int64 `json:"position"`
189+ CreatedAt sql.NullTime `json:"created_at"`
190+ UpdatedAt sql.NullTime `json:"updated_at"`
191+}
192+
193+func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (GetFavoriteByURLRow, error) {
141 row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)194 row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
142- var i Favorite195+ var i GetFavoriteByURLRow
143 err := row.Scan(196 err := row.Scan(
144 &i.ID,197 &i.ID,
145 &i.Url,198 &i.Url,
146 &i.Title,199 &i.Title,
147 &i.FaviconUrl,200 &i.FaviconUrl,
148- &i.FolderID,
149 &i.ShortcutKey,201 &i.ShortcutKey,
150 &i.Position,202 &i.Position,
151 &i.CreatedAt,203 &i.CreatedAt,
@@ -154,62 +206,39 @@ func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, e
154 return i, err206 return i, err
155 }207 }
156 208
157-const GetFavoritesByFolder = `-- name: GetFavoritesByFolder :many209+const GetFavoritesByTag = `-- name: GetFavoritesByTag :many
158-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id = ? ORDER BY position ASC210+SELECT f.id, f.url, f.title, f.favicon_url, f.shortcut_key, f.position, f.created_at, f.updated_at
211+FROM favorites f
212+INNER JOIN favorite_tag_assignments fta ON f.id = fta.favorite_id
213+WHERE fta.tag_id = ?
214+ORDER BY f.position ASC
159 `215 `
160 216
161-func (q *Queries) GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) {217+type GetFavoritesByTagRow struct {
162- rows, err := q.db.QueryContext(ctx, GetFavoritesByFolder, folderID)218+ ID int64 `json:"id"`
163- if err != nil {219+ Url string `json:"url"`
164- return nil, err220+ Title sql.NullString `json:"title"`
165- }221+ FaviconUrl sql.NullString `json:"favicon_url"`
166- defer rows.Close()222+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
167- items := []Favorite{}223+ Position int64 `json:"position"`
168- for rows.Next() {224+ CreatedAt sql.NullTime `json:"created_at"`
169- var i Favorite225+ UpdatedAt sql.NullTime `json:"updated_at"`
170- if err := rows.Scan(
171- &i.ID,
172- &i.Url,
173- &i.Title,
174- &i.FaviconUrl,
175- &i.FolderID,
176- &i.ShortcutKey,
177- &i.Position,
178- &i.CreatedAt,
179- &i.UpdatedAt,
180- ); err != nil {
181- return nil, err
182- }
183- items = append(items, i)
184- }
185- if err := rows.Close(); err != nil {
186- return nil, err
187- }
188- if err := rows.Err(); err != nil {
189- return nil, err
190- }
191- return items, nil
192 }226 }
193 227
194-const GetFavoritesWithoutFolder = `-- name: GetFavoritesWithoutFolder :many228+func (q *Queries) GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error) {
195-SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id IS NULL ORDER BY position ASC229+ rows, err := q.db.QueryContext(ctx, GetFavoritesByTag, tagID)
196-`
197-
198-func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) {
199- rows, err := q.db.QueryContext(ctx, GetFavoritesWithoutFolder)
200 if err != nil {230 if err != nil {
201 return nil, err231 return nil, err
202 }232 }
203 defer rows.Close()233 defer rows.Close()
204- items := []Favorite{}234+ items := []GetFavoritesByTagRow{}
205 for rows.Next() {235 for rows.Next() {
206- var i Favorite236+ var i GetFavoritesByTagRow
207 if err := rows.Scan(237 if err := rows.Scan(
208 &i.ID,238 &i.ID,
209 &i.Url,239 &i.Url,
210 &i.Title,240 &i.Title,
211 &i.FaviconUrl,241 &i.FaviconUrl,
212- &i.FolderID,
213 &i.ShortcutKey,242 &i.ShortcutKey,
214 &i.Position,243 &i.Position,
215 &i.CreatedAt,244 &i.CreatedAt,
@@ -228,20 +257,6 @@ func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, er
228 return items, nil257 return items, nil
229 }258 }
230 259
231-const SetFavoriteFolder = `-- name: SetFavoriteFolder :exec
232-UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
233-`
234-
235-type SetFavoriteFolderParams struct {
236- FolderID sql.NullInt64 `json:"folder_id"`
237- ID int64 `json:"id"`
238-}
239-
240-func (q *Queries) SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error {
241- _, err := q.db.ExecContext(ctx, SetFavoriteFolder, arg.FolderID, arg.ID)
242- return err
243-}
244-
245 const SetFavoriteShortcut = `-- name: SetFavoriteShortcut :exec260 const SetFavoriteShortcut = `-- name: SetFavoriteShortcut :exec
246 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?261 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
247 `262 `
@@ -258,18 +273,24 @@ func (q *Queries) SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortc
258 273
259 const UpdateFavorite = `-- name: UpdateFavorite :exec274 const UpdateFavorite = `-- name: UpdateFavorite :exec
260 UPDATE favorites275 UPDATE favorites
261-SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP276+SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP
262 WHERE id = ?277 WHERE id = ?
263 `278 `
264 279
265 type UpdateFavoriteParams struct {280 type UpdateFavoriteParams struct {
266- Title sql.NullString `json:"title"`281+ Title sql.NullString `json:"title"`
267- FaviconUrl sql.NullString `json:"favicon_url"`282+ FaviconUrl sql.NullString `json:"favicon_url"`
268- ID int64 `json:"id"`283+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
284+ ID int64 `json:"id"`
269 }285 }
270 286
271 func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error {287 func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error {
272- _, err := q.db.ExecContext(ctx, UpdateFavorite, arg.Title, arg.FaviconUrl, arg.ID)288+ _, err := q.db.ExecContext(ctx, UpdateFavorite,
289+ arg.Title,
290+ arg.FaviconUrl,
291+ arg.ShortcutKey,
292+ arg.ID,
293+ )
273 return err294 return err
274 }295 }
275 296
D internal/infrastructure/persistence/sqlite/sqlc/folders.sql.go +0 -195
@@ -1,195 +0,0 @@
1-// Code generated by sqlc. DO NOT EDIT.
2-// versions:
3-// sqlc v1.30.0
4-// source: folders.sql
5-
6-package sqlc
7-
8-import (
9- "context"
10- "database/sql"
11-)
12-
13-const CreateFolder = `-- name: CreateFolder :one
14-INSERT INTO favorite_folders (name, icon, parent_id, position, created_at)
15-VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorite_folders), 0), CURRENT_TIMESTAMP)
16-RETURNING id, name, icon, parent_id, position, created_at
17-`
18-
19-type CreateFolderParams struct {
20- Name string `json:"name"`
21- Icon sql.NullString `json:"icon"`
22- ParentID sql.NullInt64 `json:"parent_id"`
23-}
24-
25-func (q *Queries) CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) {
26- row := q.db.QueryRowContext(ctx, CreateFolder, arg.Name, arg.Icon, arg.ParentID)
27- var i FavoriteFolder
28- err := row.Scan(
29- &i.ID,
30- &i.Name,
31- &i.Icon,
32- &i.ParentID,
33- &i.Position,
34- &i.CreatedAt,
35- )
36- return i, err
37-}
38-
39-const DeleteFolder = `-- name: DeleteFolder :exec
40-DELETE FROM favorite_folders WHERE id = ?
41-`
42-
43-func (q *Queries) DeleteFolder(ctx context.Context, id int64) error {
44- _, err := q.db.ExecContext(ctx, DeleteFolder, id)
45- return err
46-}
47-
48-const GetAllFolders = `-- name: GetAllFolders :many
49-SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders ORDER BY position ASC
50-`
51-
52-func (q *Queries) GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) {
53- rows, err := q.db.QueryContext(ctx, GetAllFolders)
54- if err != nil {
55- return nil, err
56- }
57- defer rows.Close()
58- items := []FavoriteFolder{}
59- for rows.Next() {
60- var i FavoriteFolder
61- if err := rows.Scan(
62- &i.ID,
63- &i.Name,
64- &i.Icon,
65- &i.ParentID,
66- &i.Position,
67- &i.CreatedAt,
68- ); err != nil {
69- return nil, err
70- }
71- items = append(items, i)
72- }
73- if err := rows.Close(); err != nil {
74- return nil, err
75- }
76- if err := rows.Err(); err != nil {
77- return nil, err
78- }
79- return items, nil
80-}
81-
82-const GetChildFolders = `-- name: GetChildFolders :many
83-SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE parent_id = ? ORDER BY position ASC
84-`
85-
86-func (q *Queries) GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) {
87- rows, err := q.db.QueryContext(ctx, GetChildFolders, parentID)
88- if err != nil {
89- return nil, err
90- }
91- defer rows.Close()
92- items := []FavoriteFolder{}
93- for rows.Next() {
94- var i FavoriteFolder
95- if err := rows.Scan(
96- &i.ID,
97- &i.Name,
98- &i.Icon,
99- &i.ParentID,
100- &i.Position,
101- &i.CreatedAt,
102- ); err != nil {
103- return nil, err
104- }
105- items = append(items, i)
106- }
107- if err := rows.Close(); err != nil {
108- return nil, err
109- }
110- if err := rows.Err(); err != nil {
111- return nil, err
112- }
113- return items, nil
114-}
115-
116-const GetFolderByID = `-- name: GetFolderByID :one
117-SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE id = ? LIMIT 1
118-`
119-
120-func (q *Queries) GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error) {
121- row := q.db.QueryRowContext(ctx, GetFolderByID, id)
122- var i FavoriteFolder
123- err := row.Scan(
124- &i.ID,
125- &i.Name,
126- &i.Icon,
127- &i.ParentID,
128- &i.Position,
129- &i.CreatedAt,
130- )
131- return i, err
132-}
133-
134-const GetRootFolders = `-- name: GetRootFolders :many
135-SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE parent_id IS NULL ORDER BY position ASC
136-`
137-
138-func (q *Queries) GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) {
139- rows, err := q.db.QueryContext(ctx, GetRootFolders)
140- if err != nil {
141- return nil, err
142- }
143- defer rows.Close()
144- items := []FavoriteFolder{}
145- for rows.Next() {
146- var i FavoriteFolder
147- if err := rows.Scan(
148- &i.ID,
149- &i.Name,
150- &i.Icon,
151- &i.ParentID,
152- &i.Position,
153- &i.CreatedAt,
154- ); err != nil {
155- return nil, err
156- }
157- items = append(items, i)
158- }
159- if err := rows.Close(); err != nil {
160- return nil, err
161- }
162- if err := rows.Err(); err != nil {
163- return nil, err
164- }
165- return items, nil
166-}
167-
168-const UpdateFolder = `-- name: UpdateFolder :exec
169-UPDATE favorite_folders SET name = ?, icon = ? WHERE id = ?
170-`
171-
172-type UpdateFolderParams struct {
173- Name string `json:"name"`
174- Icon sql.NullString `json:"icon"`
175- ID int64 `json:"id"`
176-}
177-
178-func (q *Queries) UpdateFolder(ctx context.Context, arg UpdateFolderParams) error {
179- _, err := q.db.ExecContext(ctx, UpdateFolder, arg.Name, arg.Icon, arg.ID)
180- return err
181-}
182-
183-const UpdateFolderPosition = `-- name: UpdateFolderPosition :exec
184-UPDATE favorite_folders SET position = ? WHERE id = ?
185-`
186-
187-type UpdateFolderPositionParams struct {
188- Position int64 `json:"position"`
189- ID int64 `json:"id"`
190-}
191-
192-func (q *Queries) UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error {
193- _, err := q.db.ExecContext(ctx, UpdateFolderPosition, arg.Position, arg.ID)
194- return err
195-}
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +8 -17
@@ -13,15 +13,13 @@ type Querier interface {
13 AddToWhitelist(ctx context.Context, domain string) error13 AddToWhitelist(ctx context.Context, domain string) error
14 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error14 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
15 CapVisitCount(ctx context.Context, arg CapVisitCountParams) error15 CapVisitCount(ctx context.Context, arg CapVisitCountParams) error
16- CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)16+ CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (CreateFavoriteRow, error)
17- CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
18 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)17 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
19 DeleteAllHistory(ctx context.Context) error18 DeleteAllHistory(ctx context.Context) error
20 // Deletes exited browser sessions older than the given cutoff time.19 // Deletes exited browser sessions older than the given cutoff time.
21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)20 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
22 DeleteFavicon(ctx context.Context, key string) error21 DeleteFavicon(ctx context.Context, key string) error
23 DeleteFavorite(ctx context.Context, id int64) error22 DeleteFavorite(ctx context.Context, id int64) error
24- DeleteFolder(ctx context.Context, id int64) error
25 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error23 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
26 DeleteHistoryByID(ctx context.Context, id int64) error24 DeleteHistoryByID(ctx context.Context, id int64) error
27 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error25 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
@@ -34,24 +32,20 @@ type Querier interface {
34 DeleteTag(ctx context.Context, id int64) error32 DeleteTag(ctx context.Context, id int64) error
35 DeleteZoomLevel(ctx context.Context, domain string) error33 DeleteZoomLevel(ctx context.Context, domain string) error
36 GetActiveBrowserSession(ctx context.Context) (Session, error)34 GetActiveBrowserSession(ctx context.Context) (Session, error)
37- GetAllFavorites(ctx context.Context) ([]Favorite, error)35+ GetAllFavorites(ctx context.Context) ([]GetAllFavoritesRow, error)
38- GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
39 GetAllMostVisited(ctx context.Context) ([]History, error)36 GetAllMostVisited(ctx context.Context) ([]History, error)
40 GetAllRecentHistory(ctx context.Context) ([]History, error)37 GetAllRecentHistory(ctx context.Context) ([]History, error)
41 GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)38 GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)
42 GetAllSessionStates(ctx context.Context) ([]SessionState, error)39 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
43 GetAllTags(ctx context.Context) ([]FavoriteTag, error)40 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
44 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)41 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
45- GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
46 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)42 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
47 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)43 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
48 GetFavicon(ctx context.Context, key string) (Favicon, error)44 GetFavicon(ctx context.Context, key string) (Favicon, error)
49- GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)45+ GetFavoriteByID(ctx context.Context, id int64) (GetFavoriteByIDRow, error)
50- GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)46+ GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (GetFavoriteByShortcutRow, error)
51- GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)47+ GetFavoriteByURL(ctx context.Context, url string) (GetFavoriteByURLRow, error)
52- GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error)48+ GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error)
53- GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
54- GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
55 GetHistoryByURL(ctx context.Context, url string) (History, error)49 GetHistoryByURL(ctx context.Context, url string) (History, error)
56 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)50 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
57 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)51 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
@@ -63,13 +57,13 @@ type Querier interface {
63 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)57 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
64 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)58 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
65 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)59 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
66- GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
67 GetSessionByID(ctx context.Context, id string) (Session, error)60 GetSessionByID(ctx context.Context, id string) (Session, error)
68 GetSessionState(ctx context.Context, sessionID string) (SessionState, error)61 GetSessionState(ctx context.Context, sessionID string) (SessionState, error)
69 GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)62 GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)
70 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)63 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
71- GetTagByName(ctx context.Context, name string) (FavoriteTag, error)64+ GetTagByName(ctx context.Context, trim string) (FavoriteTag, error)
72 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)65 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
66+ GetTagsForFavorites(ctx context.Context, favoriteIds []int64) ([]GetTagsForFavoritesRow, error)
73 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)67 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
74 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)68 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
75 IncrementVisitCount(ctx context.Context, url string) error69 IncrementVisitCount(ctx context.Context, url string) error
@@ -86,15 +80,12 @@ type Querier interface {
86 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)80 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
87 SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)81 SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
88 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)82 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
89- SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
90 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error83 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
91 SetPermission(ctx context.Context, arg SetPermissionParams) error84 SetPermission(ctx context.Context, arg SetPermissionParams) error
92 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error85 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
93 UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error86 UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error
94 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error87 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
95 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error88 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
96- UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
97- UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
98 UpdateTag(ctx context.Context, arg UpdateTagParams) error89 UpdateTag(ctx context.Context, arg UpdateTagParams) error
99 UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error90 UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error
100 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error91 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
M internal/infrastructure/persistence/sqlite/sqlc/tags.sql.go +55 -3
@@ -7,6 +7,7 @@ package sqlc
7 7
8 import (8 import (
9 "context"9 "context"
10+ "strings"
10 )11 )
11 12
12 const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec13 const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec
@@ -102,11 +103,11 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
102 }103 }
103 104
104 const GetTagByName = `-- name: GetTagByName :one105 const GetTagByName = `-- name: GetTagByName :one
105-SELECT id, name, color, created_at FROM favorite_tags WHERE name = ? LIMIT 1106+SELECT id, name, color, created_at FROM favorite_tags WHERE lower(trim(name)) = lower(trim(?)) LIMIT 1
106 `107 `
107 108
108-func (q *Queries) GetTagByName(ctx context.Context, name string) (FavoriteTag, error) {109+func (q *Queries) GetTagByName(ctx context.Context, trim string) (FavoriteTag, error) {
109- row := q.db.QueryRowContext(ctx, GetTagByName, name)110+ row := q.db.QueryRowContext(ctx, GetTagByName, trim)
110 var i FavoriteTag111 var i FavoriteTag
111 err := row.Scan(112 err := row.Scan(
112 &i.ID,113 &i.ID,
@@ -152,6 +153,57 @@ func (q *Queries) GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]F
152 return items, nil153 return items, nil
153 }154 }
154 155
156+const GetTagsForFavorites = `-- name: GetTagsForFavorites :many
157+SELECT t.id, t.name, t.color, t.created_at, fta.favorite_id FROM favorite_tags t
158+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
159+WHERE fta.favorite_id IN (/*SLICE:favorite_ids*/?)
160+ORDER BY fta.favorite_id ASC, t.name ASC
161+`
162+
163+type GetTagsForFavoritesRow struct {
164+ FavoriteTag FavoriteTag `json:"favorite_tag"`
165+ FavoriteID int64 `json:"favorite_id"`
166+}
167+
168+func (q *Queries) GetTagsForFavorites(ctx context.Context, favoriteIds []int64) ([]GetTagsForFavoritesRow, error) {
169+ query := GetTagsForFavorites
170+ var queryParams []interface{}
171+ if len(favoriteIds) > 0 {
172+ for _, v := range favoriteIds {
173+ queryParams = append(queryParams, v)
174+ }
175+ query = strings.Replace(query, "/*SLICE:favorite_ids*/?", strings.Repeat(",?", len(favoriteIds))[1:], 1)
176+ } else {
177+ query = strings.Replace(query, "/*SLICE:favorite_ids*/?", "NULL", 1)
178+ }
179+ rows, err := q.db.QueryContext(ctx, query, queryParams...)
180+ if err != nil {
181+ return nil, err
182+ }
183+ defer rows.Close()
184+ items := []GetTagsForFavoritesRow{}
185+ for rows.Next() {
186+ var i GetTagsForFavoritesRow
187+ if err := rows.Scan(
188+ &i.FavoriteTag.ID,
189+ &i.FavoriteTag.Name,
190+ &i.FavoriteTag.Color,
191+ &i.FavoriteTag.CreatedAt,
192+ &i.FavoriteID,
193+ ); err != nil {
194+ return nil, err
195+ }
196+ items = append(items, i)
197+ }
198+ if err := rows.Close(); err != nil {
199+ return nil, err
200+ }
201+ if err := rows.Err(); err != nil {
202+ return nil, err
203+ }
204+ return items, nil
205+}
206+
155 const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec207 const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec
156 DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?208 DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?
157 `209 `
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +2 -2
@@ -25,8 +25,8 @@ func NewBrowserClient() *Client {
25 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {25 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
26 switch msgType {26 switch msgType {
27 case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics",27 case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics",
28- "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",28+ "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "tag_list",
29- "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",29+ "favorite_set_shortcut",
30 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":30 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
31 return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true31 return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
32 case "save_config":32 case "save_config":
M internal/infrastructure/systemviewsbridge/client.go +8 -90
@@ -272,42 +272,27 @@ func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
272 URL string `json:"url"`272 URL string `json:"url"`
273 Title string `json:"title"`273 Title string `json:"title"`
274 FaviconURL string `json:"favicon_url"`274 FaviconURL string `json:"favicon_url"`
275- FolderID *int64 `json:"folder_id"`
276 Tags []int64 `json:"tags"`275 Tags []int64 `json:"tags"`
277 }{276 }{
278 RequestID: nextRequestID(),277 RequestID: nextRequestID(),
279 URL: input.URL,278 URL: input.URL,
280 Title: input.Title,279 Title: input.Title,
281 FaviconURL: input.FaviconURL,280 FaviconURL: input.FaviconURL,
282- FolderID: folderIDPayload(input.FolderID),
283 Tags: tagIDPayloads(input.Tags),281 Tags: tagIDPayloads(input.Tags),
284 }282 }
285 }283 }
286 284
287 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {285 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
288- return struct {286+ payload := map[string]any{
289- RequestID string `json:"requestId"`287+ "requestId": nextRequestID(),
290- ID int64 `json:"id"`288+ "id": int64(input.ID),
291- Title string `json:"title"`289+ "title": input.Title,
292- FaviconURL string `json:"favicon_url"`290+ "favicon_url": input.FaviconURL,
293- FolderID *int64 `json:"folder_id"`
294- ShortcutKey *int `json:"shortcut_key"`
295- }{
296- RequestID: nextRequestID(),
297- ID: int64(input.ID),
298- Title: input.Title,
299- FaviconURL: input.FaviconURL,
300- FolderID: folderIDPayload(input.FolderID),
301- ShortcutKey: input.ShortcutKey,
302 }291 }
303-}292+ if input.ShortcutKeySet {
304-293+ payload["shortcut_key"] = input.ShortcutKey
305-func folderIDPayload(id *entity.FolderID) *int64 {
306- if id == nil {
307- return nil
308 }294 }
309- value := int64(*id)295+ return payload
310- return &value
311 }296 }
312 297
313 func tagIDPayloads(ids []entity.TagID) []int64 {298 func tagIDPayloads(ids []entity.TagID) []int64 {
@@ -318,12 +303,6 @@ func tagIDPayloads(ids []entity.TagID) []int64 {
318 return out303 return out
319 }304 }
320 305
321-func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
322- return request[[]*entity.Folder](c, ctx, "folder_list", struct {
323- RequestID string `json:"requestId"`
324- }{RequestID: nextRequestID()})
325-}
326-
327 func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {306 func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
328 return request[[]*entity.Tag](c, ctx, "tag_list", struct {307 return request[[]*entity.Tag](c, ctx, "tag_list", struct {
329 RequestID string `json:"requestId"`308 RequestID string `json:"requestId"`
@@ -342,67 +321,6 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey
342 return err321 return err
343 }322 }
344 323
345-func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
346- if favoriteID <= 0 {
347- return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
348- }
349- _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
350- RequestID string `json:"requestId"`
351- FavoriteID int64 `json:"favorite_id"`
352- FolderID *int64 `json:"folder_id"`
353- }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
354- return err
355-}
356-
357-func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
358- name = strings.TrimSpace(name)
359- if name == "" {
360- return nil, fmt.Errorf("folder name is required")
361- }
362- var iconPtr *string
363- if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
364- iconPtr = &trimmedIcon
365- }
366- return request[*entity.Folder](c, ctx, "folder_create", struct {
367- RequestID string `json:"requestId"`
368- Name string `json:"name"`
369- Icon *string `json:"icon"`
370- ParentID *int64 `json:"parent_id,omitempty"`
371- }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
372-}
373-
374-func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
375- if id <= 0 {
376- return fmt.Errorf("update folder id must be positive, got %d", id)
377- }
378- name = strings.TrimSpace(name)
379- if name == "" {
380- return fmt.Errorf("folder name is required")
381- }
382- var iconPtr *string
383- if trimmed := strings.TrimSpace(icon); trimmed != "" {
384- iconPtr = &trimmed
385- }
386- _, err := request[struct{}](c, ctx, "folder_update", struct {
387- RequestID string `json:"requestId"`
388- ID int64 `json:"id"`
389- Name string `json:"name"`
390- Icon *string `json:"icon"`
391- }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
392- return err
393-}
394-
395-func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
396- if id <= 0 {
397- return fmt.Errorf("delete folder id must be positive, got %d", id)
398- }
399- _, err := request[struct{}](c, ctx, "folder_delete", struct {
400- RequestID string `json:"requestId"`
401- ID int64 `json:"id"`
402- }{RequestID: nextRequestID(), ID: id})
403- return err
404-}
405-
406 func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {324 func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
407 name = strings.TrimSpace(name)325 name = strings.TrimSpace(name)
408 if name == "" {326 if name == "" {
M internal/infrastructure/systemviewsbridge/client_test.go +0 -139
@@ -268,32 +268,6 @@ func TestClientListDecodesFavorites(t *testing.T) {
268 }268 }
269 }269 }
270 270
271-func TestClientListFoldersDecodesFolders(t *testing.T) {
272- t.Parallel()
273-
274- native := newTransportRecorder(t, true, []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`))
275- client := NewClient(native, nil)
276-
277- folders, err := client.ListFolders(context.Background())
278- if err != nil {
279- t.Fatalf("ListFolders() error = %v", err)
280- }
281- if len(folders) != 1 {
282- t.Fatalf("ListFolders() len = %d, want 1", len(folders))
283- }
284- if folders[0].Name != "Read Later" {
285- t.Fatalf("ListFolders() folder = %+v", folders[0])
286- }
287-
288- var msg port.WebUIMessage
289- if err := json.Unmarshal(native.last, &msg); err != nil {
290- t.Fatalf("unmarshal sent envelope: %v", err)
291- }
292- if msg.Type != "folder_list" {
293- t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
294- }
295-}
296-
297 func TestClientListTagsDecodesTags(t *testing.T) {271 func TestClientListTagsDecodesTags(t *testing.T) {
298 t.Parallel()272 t.Parallel()
299 273
@@ -470,61 +444,6 @@ func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
470 }444 }
471 }445 }
472 446
473-func TestClientCreateFolderIncludesParentID(t *testing.T) {
474- t.Parallel()
475-
476- parentID := int64(42)
477- transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`))
478- client := NewClient(transport, nil)
479-
480- folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
481- if err != nil {
482- t.Fatalf("CreateFolder() error = %v", err)
483- }
484- if folder == nil {
485- t.Fatal("CreateFolder() returned nil folder")
486- }
487- if folder.Name != "Nested" {
488- t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
489- }
490- if folder.Icon != "📁" {
491- t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
492- }
493- if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
494- t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
495- }
496-
497- var msg port.WebUIMessage
498- if err := json.Unmarshal(transport.last, &msg); err != nil {
499- t.Fatalf("unmarshal sent envelope: %v", err)
500- }
501- if msg.Type != "folder_create" {
502- t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
503- }
504-
505- var payload struct {
506- RequestID string `json:"requestId"`
507- Name string `json:"name"`
508- Icon *string `json:"icon"`
509- ParentID *int64 `json:"parent_id"`
510- }
511- if err := json.Unmarshal(msg.Payload, &payload); err != nil {
512- t.Fatalf("unmarshal sent payload: %v", err)
513- }
514- if payload.ParentID == nil || *payload.ParentID != parentID {
515- t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
516- }
517- if payload.Name != "Nested" {
518- t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
519- }
520- if payload.Icon == nil || *payload.Icon != "📁" {
521- t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
522- }
523- if payload.RequestID == "" {
524- t.Fatal("sent requestId was empty")
525- }
526-}
527-
528 func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {447 func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
529 t.Parallel()448 t.Parallel()
530 449
@@ -555,59 +474,6 @@ func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
555 }474 }
556 }475 }
557 476
558-func TestClientUpdateFolderTrimsIcon(t *testing.T) {
559- t.Parallel()
560-
561- transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-18","success":true}`))
562- client := NewClient(transport, nil)
563-
564- if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil {
565- t.Fatalf("UpdateFolder() error = %v", err)
566- }
567-
568- var msg port.WebUIMessage
569- if err := json.Unmarshal(transport.last, &msg); err != nil {
570- t.Fatalf("unmarshal sent envelope: %v", err)
571- }
572- if msg.Type != "folder_update" {
573- t.Fatalf("sent type = %q, want folder_update", msg.Type)
574- }
575- var payload struct {
576- Icon *string `json:"icon"`
577- }
578- if err := json.Unmarshal(msg.Payload, &payload); err != nil {
579- t.Fatalf("unmarshal sent payload: %v", err)
580- }
581- if payload.Icon == nil || *payload.Icon != "📚" {
582- t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
583- }
584-}
585-
586-func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
587- t.Parallel()
588-
589- transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-19","success":true}`))
590- client := NewClient(transport, nil)
591-
592- if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil {
593- t.Fatalf("UpdateFolder() error = %v", err)
594- }
595-
596- var msg port.WebUIMessage
597- if err := json.Unmarshal(transport.last, &msg); err != nil {
598- t.Fatalf("unmarshal sent envelope: %v", err)
599- }
600- var payload struct {
601- Icon *string `json:"icon"`
602- }
603- if err := json.Unmarshal(msg.Payload, &payload); err != nil {
604- t.Fatalf("unmarshal sent payload: %v", err)
605- }
606- if payload.Icon != nil {
607- t.Fatalf("sent icon = %#v, want nil", payload.Icon)
608- }
609-}
610-
611 func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {477 func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
612 t.Parallel()478 t.Parallel()
613 479
@@ -719,11 +585,6 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
719 }{585 }{
720 {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},586 {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
721 {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},587 {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
722- {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
723- {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
724- {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
725- {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
726- {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
727 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},588 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
728 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},589 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
729 {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},590 {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
M internal/ui/component/omnibox.go +14 -9
@@ -1753,20 +1753,25 @@ func (o *Omnibox) loadFavorites(query string, token uint64) {
1753 return1753 return
1754 }1754 }
1755 1755
1756- favorites := make([]Favorite, 0, len(results))1756+ favorites := favoriteResultsForOmnibox(results)
1757- for i, r := range results {
1758- favorites = append(favorites, Favorite{
1759- ID: int64(r.ID),
1760- URL: r.URL,
1761- Title: r.Title,
1762- Position: i,
1763- })
1764- }
1765 1757
1766 o.idleAddUpdateFavorites(favorites, query, token)1758 o.idleAddUpdateFavorites(favorites, query, token)
1767 }()1759 }()
1768 }1760 }
1769 1761
1762+func favoriteResultsForOmnibox(results []*entity.Favorite) []Favorite {
1763+ favorites := make([]Favorite, 0, len(results))
1764+ for i, r := range results {
1765+ favorites = append(favorites, Favorite{
1766+ ID: int64(r.ID),
1767+ URL: r.URL,
1768+ Title: r.Title,
1769+ Position: i,
1770+ })
1771+ }
1772+ return favorites
1773+}
1774+
1770 func (o *Omnibox) loadBangSuggestions(query string, token uint64) {1775 func (o *Omnibox) loadBangSuggestions(query string, token uint64) {
1771 if o.shortcutsUC == nil {1776 if o.shortcutsUC == nil {
1772 return1777 return
A internal/ui/component/omnibox_favorite_search_test.go +30 -0
@@ -0,0 +1,30 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestOmniboxFavoriteSearchResultsAllowTagsPresent(t *testing.T) {
11+ results := []*entity.Favorite{
12+ {
13+ ID: 1,
14+ URL: "https://example.com/docs",
15+ Title: "Docs",
16+ Tags: []entity.Tag{{ID: 7, Name: "Go"}},
17+ },
18+ }
19+
20+ favorites := favoriteResultsForOmnibox(results)
21+
22+ assert.Equal(t, []Favorite{
23+ {
24+ ID: 1,
25+ URL: "https://example.com/docs",
26+ Title: "Docs",
27+ Position: 0,
28+ },
29+ }, favorites)
30+}
M internal/ui/systemviews/app.go +45 -56
@@ -24,44 +24,43 @@ type Dependencies struct {
24 type App struct {24 type App struct {
25 // mu protects rendered route state; actionMu protects worker channels.25 // mu protects rendered route state; actionMu protects worker channels.
26 // The two locks must not be held at the same time.26 // The two locks must not be held at the same time.
27- mu sync.Mutex27+ mu sync.Mutex
28- actionMu sync.Mutex28+ actionMu sync.Mutex
29- deps Dependencies29+ deps Dependencies
30- currentRoute Route30+ currentRoute Route
31- shellTheme shellTheme31+ shellTheme shellTheme
32- historyEntries []*entity.HistoryEntry32+ historyEntries []*entity.HistoryEntry
33- historyStats *entity.HistoryStats33+ historyStats *entity.HistoryStats
34- historyAnalytics *entity.HistoryAnalytics34+ historyAnalytics *entity.HistoryAnalytics
35- historyDomainStats []*entity.DomainStat35+ historyDomainStats []*entity.DomainStat
36- historyQuery string36+ historyQuery string
37- historyDomainFilter string37+ historyDomainFilter string
38- historyOffset int38+ historyOffset int
39- historyWindowBefore time.Time39+ historyWindowBefore time.Time
40- historyWindowAfter time.Time40+ historyWindowAfter time.Time
41- historyCursorID int6441+ historyCursorID int64
42- historyHasMore bool42+ historyHasMore bool
43- historyNotice string43+ historyNotice string
44- historyError string44+ historyError string
45- favorites []*entity.Favorite45+ favorites []*entity.Favorite
46- folders []*entity.Folder46+ tags []*entity.Tag
47- tags []*entity.Tag47+ favoriteTagFilter *entity.TagID
48- favoriteFolderFilter *entity.FolderID48+ favoriteUntaggedFilter bool
49- favoriteTagFilter *entity.TagID49+ favoritesNotice string
50- favoritesNotice string50+ favoritesError string
51- favoritesError string51+ config *dto.SystemviewConfigPayload
52- config *dto.SystemviewConfigPayload52+ keybindings port.KeybindingsConfig
53- keybindings port.KeybindingsConfig53+ configNotice string
54- configNotice string54+ configError string
55- configError string55+ renderedHTML string
56- renderedHTML string56+ renderGeneration uint64
57- renderGeneration uint6457+ closed bool
58- closed bool58+ actionQueue chan DOMAction
59- actionQueue chan DOMAction59+ actionErrorQueue chan error
60- actionErrorQueue chan error60+ actionCtx context.Context
61- actionCtx context.Context61+ actionCancel context.CancelFunc
62- actionCancel context.CancelFunc62+ actionClosed bool
63- actionClosed bool63+ actionWG sync.WaitGroup
64- actionWG sync.WaitGroup
65 }64 }
66 65
67 const (66 const (
@@ -513,7 +512,6 @@ func routeSubtitle(route Route) string {
513 512
514 func (a *App) loadHistoryLoadingRoute() {513 func (a *App) loadHistoryLoadingRoute() {
515 a.favorites = nil514 a.favorites = nil
516- a.folders = nil
517 a.tags = nil515 a.tags = nil
518 a.historyEntries = nil516 a.historyEntries = nil
519 a.historyStats = nil517 a.historyStats = nil
@@ -698,7 +696,6 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho
698 696
699 func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {697 func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
700 a.favorites = nil698 a.favorites = nil
701- a.folders = nil
702 a.tags = nil699 a.tags = nil
703 a.historyEntries = result.entries700 a.historyEntries = result.entries
704 a.historyStats = result.stats701 a.historyStats = result.stats
@@ -727,28 +724,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
727 if err != nil {724 if err != nil {
728 return err725 return err
729 }726 }
730- folders, err := a.deps.Favorites.ListFolders(ctx)
731- if err != nil {
732- return err
733- }
734 tags, err := a.deps.Favorites.ListTags(ctx)727 tags, err := a.deps.Favorites.ListTags(ctx)
735 if err != nil {728 if err != nil {
736 return err729 return err
737 }730 }
738 731
739 a.historyEntries = nil732 a.historyEntries = nil
740- favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)733+ favorites = filterFavorites(favorites, a.favoriteTagFilter, a.favoriteUntaggedFilter)
741 a.favorites = favorites734 a.favorites = favorites
742- a.folders = folders
743 a.tags = tags735 a.tags = tags
744 data := favoritesRenderData{736 data := favoritesRenderData{
745- Favorites: favorites,737+ Favorites: favorites,
746- Folders: folders,738+ Tags: tags,
747- Tags: tags,739+ TagFilter: a.favoriteTagFilter,
748- FolderFilter: a.favoriteFolderFilter,740+ UntaggedFilter: a.favoriteUntaggedFilter,
749- TagFilter: a.favoriteTagFilter,741+ Notice: a.favoritesNotice,
750- Notice: a.favoritesNotice,742+ Error: a.favoritesError,
751- Error: a.favoritesError,
752 }743 }
753 a.renderedHTML = renderAppFrame(renderedPage{744 a.renderedHTML = renderAppFrame(renderedPage{
754 route: RouteFavorites,745 route: RouteFavorites,
@@ -782,7 +773,6 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
782 773
783 a.historyEntries = nil774 a.historyEntries = nil
784 a.favorites = nil775 a.favorites = nil
785- a.folders = nil
786 a.tags = nil776 a.tags = nil
787 a.config = &config777 a.config = &config
788 a.keybindings = keybindings778 a.keybindings = keybindings
@@ -835,10 +825,9 @@ func (a *App) resetRouteState() {
835 a.historyNotice = ""825 a.historyNotice = ""
836 a.historyError = ""826 a.historyError = ""
837 a.favorites = nil827 a.favorites = nil
838- a.folders = nil
839 a.tags = nil828 a.tags = nil
840- a.favoriteFolderFilter = nil
841 a.favoriteTagFilter = nil829 a.favoriteTagFilter = nil
830+ a.favoriteUntaggedFilter = false
842 a.favoritesNotice = ""831 a.favoritesNotice = ""
843 a.favoritesError = ""832 a.favoritesError = ""
844 a.config = nil833 a.config = nil
M internal/ui/systemviews/app_test.go +14 -67
@@ -517,7 +517,6 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
517 517
518 favorites := &recordingFavoritesService{518 favorites := &recordingFavoritesService{
519 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},519 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
520- folders: []*entity.Folder{{Name: "Read Later"}},
521 tags: []*entity.Tag{{Name: "Go"}},520 tags: []*entity.Tag{{Name: "Go"}},
522 }521 }
523 522
@@ -529,14 +528,11 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
529 require.NoError(t, app.LoadInitial(context.Background()))528 require.NoError(t, app.LoadInitial(context.Background()))
530 assert.Equal(t, RouteFavorites, app.CurrentRoute())529 assert.Equal(t, RouteFavorites, app.CurrentRoute())
531 assert.Len(t, app.favorites, 1)530 assert.Len(t, app.favorites, 1)
532- assert.Len(t, app.folders, 1)
533 assert.Len(t, app.tags, 1)531 assert.Len(t, app.tags, 1)
534 assert.Contains(t, app.renderedHTML, "Favorites")532 assert.Contains(t, app.renderedHTML, "Favorites")
535 assert.Contains(t, app.renderedHTML, "Example")533 assert.Contains(t, app.renderedHTML, "Example")
536- assert.Contains(t, app.renderedHTML, "Read Later")
537 assert.Contains(t, app.renderedHTML, "Go")534 assert.Contains(t, app.renderedHTML, "Go")
538 assert.True(t, favorites.calledList)535 assert.True(t, favorites.calledList)
539- assert.True(t, favorites.calledFolders)
540 assert.True(t, favorites.calledTags)536 assert.True(t, favorites.calledTags)
541 537
542 // Shell frame present, no full document wrapper.538 // Shell frame present, no full document wrapper.
@@ -553,11 +549,9 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
553 func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {549 func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
554 t.Parallel()550 t.Parallel()
555 551
556- folderID := entity.FolderID(1)
557 shortcut := 3552 shortcut := 3
558 favorites := &recordingFavoritesService{553 favorites := &recordingFavoritesService{
559- favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},554+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", ShortcutKey: &shortcut}},
560- folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
561 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},555 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
562 }556 }
563 557
@@ -567,16 +561,16 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
567 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)561 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
568 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)562 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
569 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)563 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
570- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)564+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.create"`)
571- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)565+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.update"`)
572- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)566+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
573 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)567 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
574 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)568 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
575 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)569 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
576 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)570 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
571+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.filterUntagged"`)
577 assert.Contains(t, app.renderedHTML, `name="tags"`)572 assert.Contains(t, app.renderedHTML, `name="tags"`)
578 assert.Contains(t, app.renderedHTML, `value="7"`)573 assert.Contains(t, app.renderedHTML, `value="7"`)
579- assert.Contains(t, app.renderedHTML, "Read Later")
580 assert.Contains(t, app.renderedHTML, "Go")574 assert.Contains(t, app.renderedHTML, "Go")
581 assert.Contains(t, app.renderedHTML, "Shortcut 3")575 assert.Contains(t, app.renderedHTML, "Shortcut 3")
582 }576 }
@@ -585,18 +579,15 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
585 dom := &recordingDOM{}579 dom := &recordingDOM{}
586 favorites := &recordingFavoritesService{580 favorites := &recordingFavoritesService{
587 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},581 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
588- folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
589 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},582 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
590 }583 }
591 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})584 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
592 585
593 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{586 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
594 Action: favoriteActionCreate,587 Action: favoriteActionCreate,
595- Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},588+ Data: map[string]string{"url": "https://new.example", "title": "New", "tags": "7"},
596 }))589 }))
597- require.NotNil(t, favorites.createdFavorite.FolderID)
598 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)590 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
599- assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
600 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)591 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
601 assert.Contains(t, dom.HTML(), "Added favorite New")592 assert.Contains(t, dom.HTML(), "Added favorite New")
602 593
@@ -606,6 +597,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
606 }))597 }))
607 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)598 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
608 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)599 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
600+ assert.True(t, favorites.updatedFavorite.ShortcutKeySet)
609 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)601 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
610 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)602 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
611 assert.Contains(t, dom.HTML(), "Saved favorite Updated")603 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
@@ -617,29 +609,13 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
617 assert.Equal(t, int64(42), favorites.assignedFavoriteID)609 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
618 assert.Equal(t, int64(7), favorites.assignedTagID)610 assert.Equal(t, int64(7), favorites.assignedTagID)
619 611
620- require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
621- Action: favoriteActionFilterFolder,
622- Data: map[string]string{"folderId": "5"},
623- }))
624- require.NotNil(t, app.favoriteFolderFilter)
625- assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
626- assert.Nil(t, app.favoriteTagFilter)
627-
628 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{612 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
629 Action: favoriteActionFilterTag,613 Action: favoriteActionFilterTag,
630 Data: map[string]string{"tagId": "7"},614 Data: map[string]string{"tagId": "7"},
631 }))615 }))
632 require.NotNil(t, app.favoriteTagFilter)616 require.NotNil(t, app.favoriteTagFilter)
633 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)617 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
634- assert.Nil(t, app.favoriteFolderFilter)618+ assert.False(t, app.favoriteUntaggedFilter)
635-
636- require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
637- Action: folderActionCreate,
638- Data: map[string]string{"name": "Reading", "icon": "📚"},
639- }))
640- assert.Equal(t, "Reading", favorites.createdFolder)
641- assert.Equal(t, "📚", favorites.createdFolderIcon)
642- assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
643 619
644 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{620 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
645 Action: favoriteActionDelete,621 Action: favoriteActionDelete,
@@ -1199,20 +1175,14 @@ func (s *recordingHistoryService) DeleteDomain(_ context.Context, domain string)
1199 1175
1200 // Recording fixture to capture favorites state for stateful render assertions.1176 // Recording fixture to capture favorites state for stateful render assertions.
1201 type recordingFavoritesService struct {1177 type recordingFavoritesService struct {
1202- calledList bool1178+ calledList bool
1203- calledFolders bool1179+ calledTags bool
1204- calledTags bool1180+ favorites []*entity.Favorite
1205- favorites []*entity.Favorite1181+ tags []*entity.Tag
1206- folders []*entity.Folder
1207- tags []*entity.Tag
1208 1182
1209 createdFavorite dto.FavoriteCreateInput1183 createdFavorite dto.FavoriteCreateInput
1210 updatedFavorite dto.FavoriteUpdateInput1184 updatedFavorite dto.FavoriteUpdateInput
1211 deletedFavorite int641185 deletedFavorite int64
1212- createdFolder string
1213- createdFolderIcon string
1214- updatedFolderID int64
1215- deletedFolderID int64
1216 createdTag string1186 createdTag string
1217 updatedTagID int641187 updatedTagID int64
1218 deletedTagID int641188 deletedTagID int64
@@ -1227,11 +1197,6 @@ func (s *recordingFavoritesService) List(context.Context) ([]*entity.Favorite, e
1227 return s.favorites, nil1197 return s.favorites, nil
1228 }1198 }
1229 1199
1230-func (s *recordingFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1231- s.calledFolders = true
1232- return s.folders, nil
1233-}
1234-
1235 func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {1200 func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1236 s.calledTags = true1201 s.calledTags = true
1237 return s.tags, nil1202 return s.tags, nil
@@ -1239,12 +1204,12 @@ func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, er
1239 1204
1240 func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {1205 func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1241 s.createdFavorite = input1206 s.createdFavorite = input
1242- return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil1207+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
1243 }1208 }
1244 1209
1245 func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {1210 func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1246 s.updatedFavorite = input1211 s.updatedFavorite = input
1247- return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil1212+ return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, ShortcutKey: input.ShortcutKey}, nil
1248 }1213 }
1249 1214
1250 func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error {1215 func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
@@ -1254,24 +1219,6 @@ func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64)
1254 1219
1255 func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }1220 func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1256 1221
1257-func (s *recordingFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
1258-
1259-func (s *recordingFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
1260- s.createdFolder = name
1261- s.createdFolderIcon = icon
1262- return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
1263-}
1264-
1265-func (s *recordingFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
1266- s.updatedFolderID = id
1267- return nil
1268-}
1269-
1270-func (s *recordingFavoritesService) DeleteFolder(_ context.Context, id int64) error {
1271- s.deletedFolderID = id
1272- return nil
1273-}
1274-
1275 func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {1222 func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1276 s.createdTag = name1223 s.createdTag = name
1277 return &entity.Tag{ID: 55, Name: name, Color: color}, nil1224 return &entity.Tag{ID: 55, Name: name, Color: color}, nil
M internal/ui/systemviews/favorite_actions.go +25 -99
@@ -12,20 +12,17 @@ import (
12 )12 )
13 13
14 const (14 const (
15- favoriteActionCreate = "favorite.create"15+ favoriteActionCreate = "favorite.create"
16- favoriteActionUpdate = "favorite.update"16+ favoriteActionUpdate = "favorite.update"
17- favoriteActionDelete = "favorite.delete"17+ favoriteActionDelete = "favorite.delete"
18- favoriteActionFilterFolder = "favorite.filterFolder"18+ favoriteActionFilterTag = "favorite.filterTag"
19- favoriteActionFilterTag = "favorite.filterTag"19+ favoriteActionFilterUntagged = "favorite.filterUntagged"
20- favoriteActionClearFilters = "favorite.clearFilters"20+ favoriteActionClearFilters = "favorite.clearFilters"
21- folderActionCreate = "folder.create"21+ tagActionCreate = "tag.create"
22- folderActionUpdate = "folder.update"22+ tagActionUpdate = "tag.update"
23- folderActionDelete = "folder.delete"23+ tagActionDelete = "tag.delete"
24- tagActionCreate = "tag.create"24+ tagActionAssign = "tag.assign"
25- tagActionUpdate = "tag.update"25+ tagActionRemove = "tag.remove"
26- tagActionDelete = "tag.delete"
27- tagActionAssign = "tag.assign"
28- tagActionRemove = "tag.remove"
29 )26 )
30 27
31 //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.28 //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
@@ -36,10 +33,6 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
36 data := event.Data33 data := event.Data
37 switch event.Action {34 switch event.Action {
38 case favoriteActionCreate:35 case favoriteActionCreate:
39- folderID, err := optionalFolderID(data["folder_id"])
40- if err != nil {
41- return err
42- }
43 favoriteURL, err := validateFavoriteURL(data["url"])36 favoriteURL, err := validateFavoriteURL(data["url"])
44 if err != nil {37 if err != nil {
45 return err38 return err
@@ -49,10 +42,9 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
49 return err42 return err
50 }43 }
51 favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{44 favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52- URL: favoriteURL,45+ URL: favoriteURL,
53- Title: strings.TrimSpace(data["title"]),46+ Title: strings.TrimSpace(data["title"]),
54- FolderID: folderID,47+ Tags: tags,
55- Tags: tags,
56 })48 })
57 if err != nil {49 if err != nil {
58 return err50 return err
@@ -63,20 +55,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
63 if err != nil {55 if err != nil {
64 return err56 return err
65 }57 }
66- folderID, err := optionalFolderID(data["folder_id"])
67- if err != nil {
68- return err
69- }
70 shortcut, err := optionalShortcut(data["shortcut_key"])58 shortcut, err := optionalShortcut(data["shortcut_key"])
71 if err != nil {59 if err != nil {
72 return err60 return err
73 }61 }
74 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{62 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75- ID: entity.FavoriteID(id),63+ ID: entity.FavoriteID(id),
76- Title: strings.TrimSpace(data["title"]),64+ Title: strings.TrimSpace(data["title"]),
77- FaviconURL: strings.TrimSpace(data["favicon_url"]),65+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
78- FolderID: folderID,66+ ShortcutKey: shortcut,
79- ShortcutKey: shortcut,67+ ShortcutKeySet: true,
80 })68 })
81 if err != nil {69 if err != nil {
82 return err70 return err
@@ -91,14 +79,6 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
91 return err79 return err
92 }80 }
93 a.favoritesNotice = "Deleted favorite"81 a.favoritesNotice = "Deleted favorite"
94- case favoriteActionFilterFolder:
95- folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
96- if err != nil {
97- return err
98- }
99- a.favoriteFolderFilter = folderID
100- a.favoriteTagFilter = nil
101- a.favoritesNotice = ""
102 case favoriteActionFilterTag:82 case favoriteActionFilterTag:
103 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")83 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
104 if err != nil {84 if err != nil {
@@ -106,45 +86,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
106 }86 }
107 id := entity.TagID(tagID)87 id := entity.TagID(tagID)
108 a.favoriteTagFilter = &id88 a.favoriteTagFilter = &id
109- a.favoriteFolderFilter = nil89+ a.favoriteUntaggedFilter = false
90+ a.favoritesNotice = ""
91+ case favoriteActionFilterUntagged:
92+ a.favoriteTagFilter = nil
93+ a.favoriteUntaggedFilter = true
110 a.favoritesNotice = ""94 a.favoritesNotice = ""
111 case favoriteActionClearFilters:95 case favoriteActionClearFilters:
112- a.favoriteFolderFilter = nil
113 a.favoriteTagFilter = nil96 a.favoriteTagFilter = nil
97+ a.favoriteUntaggedFilter = false
114 a.favoritesNotice = ""98 a.favoritesNotice = ""
115- case folderActionCreate:
116- name := strings.TrimSpace(data["name"])
117- if name == "" {
118- return fmt.Errorf("folder name is required")
119- }
120- folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
121- if err != nil {
122- return err
123- }
124- a.favoritesNotice = "Created folder " + folderDisplayName(folder)
125- case folderActionUpdate:
126- id, err := parsePositiveInt64(data["id"], "folder id")
127- if err != nil {
128- return err
129- }
130- name := strings.TrimSpace(data["name"])
131- if name == "" {
132- return fmt.Errorf("folder name is required")
133- }
134- if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil {
135- return err
136- }
137- a.favoritesNotice = "Saved folder " + name
138- case folderActionDelete:
139- id, err := parsePositiveInt64(data["id"], "folder id")
140- if err != nil {
141- return err
142- }
143- if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil {
144- return err
145- }
146- a.favoriteFolderFilter = nil
147- a.favoritesNotice = "Deleted folder"
148 case tagActionCreate:99 case tagActionCreate:
149 name := strings.TrimSpace(data["name"])100 name := strings.TrimSpace(data["name"])
150 if name == "" {101 if name == "" {
@@ -272,31 +223,6 @@ func optionalTagIDs(raw string) ([]entity.TagID, error) {
272 return ids, nil223 return ids, nil
273 }224 }
274 225
275-// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
276-// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
277-func optionalFolderID(raw string) (*entity.FolderID, error) {
278- raw = strings.TrimSpace(raw)
279- if raw == "" || raw == "root" {
280- return nil, nil
281- }
282- id, err := parsePositiveInt64(raw, "folder id")
283- if err != nil {
284- return nil, err
285- }
286- folderID := entity.FolderID(id)
287- return &folderID, nil
288-}
289-
290-// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
291-// favorites in the root folder; other inputs delegate to optionalFolderID.
292-func filterFolderID(raw string) (*entity.FolderID, error) {
293- if strings.TrimSpace(raw) == "root" {
294- root := entity.FolderID(0)
295- return &root, nil
296- }
297- return optionalFolderID(raw)
298-}
299-
300 // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.226 // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301 func optionalShortcut(raw string) (*int, error) {227 func optionalShortcut(raw string) (*int, error) {
302 raw = strings.TrimSpace(raw)228 raw = strings.TrimSpace(raw)
M internal/ui/systemviews/favorites.templ +8 -64
@@ -19,22 +19,7 @@ templ FavoritesView(data favoritesRenderData) {
19 <ul class="sv-list">19 <ul class="sv-list">
20 for _, favorite := range data.Favorites {20 for _, favorite := range data.Favorites {
21 if favorite != nil {21 if favorite != nil {
22- @FavoriteItem(favorite, data.Folders, data.Tags)22+ @FavoriteItem(favorite, data.Tags)
23- }
24- }
25- </ul>
26- }
27- }
28- @Section("sv-folders-list", "Folders") {
29- @Meta(foldersSummary(data))
30- @FolderCreateForm()
31- if len(data.Folders) == 0 {
32- @EmptyState("No folders")
33- } else {
34- <ul class="sv-list">
35- for _, folder := range data.Folders {
36- if folder != nil {
37- @FolderItem(folder)
38 }23 }
39 }24 }
40 </ul>25 </ul>
@@ -61,23 +46,14 @@ templ FavoritesControls(data favoritesRenderData) {
61 <form class="sv-card-form" data-sv-action="favorite.create">46 <form class="sv-card-form" data-sv-action="favorite.create">
62 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>47 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>48 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64- @FolderSelect("folder_id", data.Folders, nil, "Folder")
65 @FavoriteCreateTagPicker(data.Tags)49 @FavoriteCreateTagPicker(data.Tags)
66 <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button>50 <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button>
67 </form>51 </form>
68 <div class="sv-filter-panel">52 <div class="sv-filter-panel">
69- <div class="sv-button-row">
70- <span class="sv-meta">Folders</span>
71- <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
72- <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
73- for _, folder := range data.Folders {
74- if folder != nil {
75- <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>
76- }
77- }
78- </div>
79 <div class="sv-button-row">53 <div class="sv-button-row">
80 <span class="sv-meta">Tags</span>54 <span class="sv-meta">Tags</span>
55+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
56+ <button type="button" class={ favoriteFilterClass(favoriteUntaggedFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteUntaggedFilterActive(data)) } data-sv-action="favorite.filterUntagged">Untagged</button>
81 for _, tag := range data.Tags {57 for _, tag := range data.Tags {
82 if tag != nil {58 if tag != nil {
83 <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>59 <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
@@ -108,15 +84,15 @@ templ FavoriteCreateTagPicker(tags []*entity.Tag) {
108 }84 }
109 }85 }
110 86
111-templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {87+templ FavoriteItem(favorite *entity.Favorite, tags []*entity.Tag) {
112 <li class="sv-list-row">88 <li class="sv-list-row">
113 <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>89 <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
114 <div class="sv-favorite-header">90 <div class="sv-favorite-header">
115 <div>91 <div>
116 <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>92 <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
117 <p class="sv-history-url">{ favorite.URL }</p>93 <p class="sv-history-url">{ favorite.URL }</p>
118- if favoriteMetaText(favorite, folders) != "" {94+ if favoriteMetaText(favorite) != "" {
119- <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>95+ <p class="sv-meta">{ favoriteMetaText(favorite) }</p>
120 }96 }
121 </div>97 </div>
122 <div class="sv-history-row-actions">98 <div class="sv-history-row-actions">
@@ -124,18 +100,17 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*
124 <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button>100 <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button>
125 </div>101 </div>
126 </div>102 </div>
127- @FavoriteEditForm(favorite, folders)103+ @FavoriteEditForm(favorite)
128 @FavoriteTagControls(favorite, tags)104 @FavoriteTagControls(favorite, tags)
129 </article>105 </article>
130 </li>106 </li>
131 }107 }
132 108
133-templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {109+templ FavoriteEditForm(favorite *entity.Favorite) {
134 <form class="sv-inline-form" data-sv-action="favorite.update">110 <form class="sv-inline-form" data-sv-action="favorite.update">
135 <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>111 <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
136 <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>112 <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
137 <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>113 <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
138- @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
139 @ShortcutSelect(favorite.ShortcutKey)114 @ShortcutSelect(favorite.ShortcutKey)
140 <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>115 <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
141 </form>116 </form>
@@ -156,26 +131,6 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
156 }131 }
157 }132 }
158 133
159-templ FolderCreateForm() {
160- <form class="sv-inline-form" data-sv-action="folder.create">
161- <label><span>Name</span><input name="name" type="text" required/></label>
162- <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label>
163- <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button>
164- </form>
165-}
166-
167-templ FolderItem(folder *entity.Folder) {
168- <li class="sv-list-row">
169- <form class="sv-inline-form" data-sv-action="folder.update">
170- <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
171- <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
172- <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label>
173- <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
174- <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button>
175- </form>
176- </li>
177-}
178-
179 templ TagCreateForm() {134 templ TagCreateForm() {
180 <form class="sv-inline-form" data-sv-action="tag.create">135 <form class="sv-inline-form" data-sv-action="tag.create">
181 <label><span>Name</span><input name="name" type="text" required/></label>136 <label><span>Name</span><input name="name" type="text" required/></label>
@@ -197,17 +152,6 @@ templ TagItem(tag *entity.Tag) {
197 </li>152 </li>
198 }153 }
199 154
200-templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) {
201- <label><span>{ label }</span><select name={ name }>
202- <option value="">Unfiled</option>
203- for _, folder := range folders {
204- if folder != nil {
205- <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option>
206- }
207- }
208- </select></label>
209-}
210-
211 templ ShortcutSelect(selected *int) {155 templ ShortcutSelect(selected *int) {
212 <label><span>Shortcut</span><select name="shortcut_key">156 <label><span>Shortcut</span><select name="shortcut_key">
213 <option value="">None</option>157 <option value="">None</option>
M internal/ui/systemviews/favorites_templ.go +245 -616 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +19 -62
@@ -10,13 +10,12 @@ import (
10 const defaultTagColor = "#808080"10 const defaultTagColor = "#808080"
11 11
12 type favoritesRenderData struct {12 type favoritesRenderData struct {
13- Favorites []*entity.Favorite13+ Favorites []*entity.Favorite
14- Folders []*entity.Folder14+ Tags []*entity.Tag
15- Tags []*entity.Tag15+ TagFilter *entity.TagID
16- FolderFilter *entity.FolderID16+ UntaggedFilter bool
17- TagFilter *entity.TagID17+ Notice string
18- Notice string18+ Error string
19- Error string
20 }19 }
21 20
22 func favoritesHTML(data favoritesRenderData) string {21 func favoritesHTML(data favoritesRenderData) string {
@@ -25,7 +24,7 @@ func favoritesHTML(data favoritesRenderData) string {
25 24
26 func favoritesDocumentTitle(data favoritesRenderData) string {25 func favoritesDocumentTitle(data favoritesRenderData) string {
27 count := len(data.Favorites)26 count := len(data.Favorites)
28- if data.FolderFilter != nil || data.TagFilter != nil {27+ if data.TagFilter != nil || data.UntaggedFilter {
29 if count == 1 {28 if count == 1 {
30 return "Favorites — 1 filtered bookmark"29 return "Favorites — 1 filtered bookmark"
31 }30 }
@@ -38,11 +37,7 @@ func favoritesDocumentTitle(data favoritesRenderData) string {
38 }37 }
39 38
40 func favoritesSummary(data favoritesRenderData) string {39 func favoritesSummary(data favoritesRenderData) string {
41- return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))40+ return fmt.Sprintf("%d favorites · %d tags", len(data.Favorites), len(data.Tags))
42-}
43-
44-func foldersSummary(data favoritesRenderData) string {
45- return fmt.Sprintf("%d folders", len(data.Folders))
46 }41 }
47 42
48 func tagsSummary(data favoritesRenderData) string {43 func tagsSummary(data favoritesRenderData) string {
@@ -57,12 +52,12 @@ func favoriteFilterClass(active bool) string {
57 return classes52 return classes
58 }53 }
59 54
60-func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {55+func favoriteUntaggedFilterActive(data favoritesRenderData) bool {
61- return data.FolderFilter != nil && *data.FolderFilter == folderID56+ return data.UntaggedFilter
62 }57 }
63 58
64 func favoriteAllFilterActive(data favoritesRenderData) bool {59 func favoriteAllFilterActive(data favoritesRenderData) bool {
65- return data.FolderFilter == nil && data.TagFilter == nil60+ return data.TagFilter == nil && !data.UntaggedFilter
66 }61 }
67 62
68 func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {63 func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
@@ -80,18 +75,11 @@ func favoriteItemLabel(favorite *entity.Favorite) string {
80 return label75 return label
81 }76 }
82 77
83-func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {78+func favoriteMetaText(favorite *entity.Favorite) string {
84- if favorite == nil {79+ if favorite == nil || favorite.ShortcutKey == nil {
85 return ""80 return ""
86 }81 }
87- parts := make([]string, 0, 2)82+ return fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)
88- if favorite.ShortcutKey != nil {
89- parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
90- }
91- if name := folderNameForID(folders, favorite.FolderID); name != "" {
92- parts = append(parts, "Folder "+name)
93- }
94- return strings.Join(parts, " · ")
95 }83 }
96 84
97 func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {85 func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
@@ -136,38 +124,12 @@ func safeTagColor(raw string) string {
136 return value124 return value
137 }125 }
138 126
139-func folderDisplayName(folder *entity.Folder) string {
140- if folder == nil {
141- return ""
142- }
143- if strings.TrimSpace(folder.Icon) != "" {
144- return folder.Icon + " " + folder.Name
145- }
146- return folder.Name
147-}
148-
149-func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string {
150- if id == nil {
151- return "Unfiled"
152- }
153- for _, folder := range folders {
154- if folder != nil && folder.ID == *id {
155- return folderDisplayName(folder)
156- }
157- }
158- return "Unknown"
159-}
160-
161-func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool {
162- return selected != nil && *selected == folderID
163-}
164-
165 func shortcutSelected(selected *int, value int) bool {127 func shortcutSelected(selected *int, value int) bool {
166 return selected != nil && *selected == value128 return selected != nil && *selected == value
167 }129 }
168 130
169-func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {131+func filterFavorites(favorites []*entity.Favorite, tagID *entity.TagID, untagged bool) []*entity.Favorite {
170- if folderID == nil && tagID == nil {132+ if tagID == nil && !untagged {
171 return favorites133 return favorites
172 }134 }
173 out := make([]*entity.Favorite, 0, len(favorites))135 out := make([]*entity.Favorite, 0, len(favorites))
@@ -175,16 +137,11 @@ func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, ta
175 if favorite == nil {137 if favorite == nil {
176 continue138 continue
177 }139 }
178- if folderID != nil {140+ if untagged {
179- if *folderID == 0 {141+ if len(favorite.Tags) != 0 {
180- if favorite.FolderID != nil {
181- continue
182- }
183- } else if favorite.FolderID == nil || *favorite.FolderID != *folderID {
184 continue142 continue
185 }143 }
186- }144+ } else if tagID != nil && !favorite.HasTag(*tagID) {
187- if tagID != nil && !favorite.HasTag(*tagID) {
188 continue145 continue
189 }146 }
190 out = append(out, favorite)147 out = append(out, favorite)

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