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:
152152 interfaces:
153153 ContentWhitelistRepository: {}
154154 FavoriteRepository: {}
155- FolderRepository: {}
156155 HistoryRepository: {}
157156 SessionRepository: {}
158157 SessionStateRepository: {}
M cmd/dumber/main.go +1 -4
@@ -691,7 +691,6 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
691691 type repositories struct {
692692 history repository.HistoryRepository
693693 favorite repository.FavoriteRepository
694- folder repository.FolderRepository
695694 tag repository.TagRepository
696695 zoom repository.ZoomRepository
697696 permission port.PermissionRepository
@@ -704,7 +703,6 @@ func createRepositories(db *sql.DB) *repositories {
704703 return &repositories{
705704 history: sqlite.NewHistoryRepository(db),
706705 favorite: sqlite.NewFavoriteRepository(db),
707- folder: sqlite.NewFolderRepository(db),
708706 tag: sqlite.NewTagRepository(db),
709707 zoom: sqlite.NewZoomRepository(db),
710708 permission: sqlite.NewPermissionRepository(db),
@@ -718,7 +716,6 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
718716 return &repositories{
719717 history: sqlite.NewLazyHistoryRepository(provider),
720718 favorite: sqlite.NewLazyFavoriteRepository(provider),
721- folder: sqlite.NewLazyFolderRepository(provider),
722719 tag: sqlite.NewLazyTagRepository(provider),
723720 zoom: sqlite.NewLazyZoomRepository(provider),
724721 permission: sqlite.NewLazyPermissionRepository(provider),
@@ -804,7 +801,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
804801 tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths),
805802 panes: usecase.NewManagePanesUseCase(idGenerator, localPaths),
806803 history: historyUC,
807- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
804+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.tag),
808805 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
809806 permission: permissionUC,
810807 navigate: usecase.NewNavigateUseCase(defaultZoom),
M cmd/systemviews/bootstrap_test.go +0 -16
@@ -64,7 +64,6 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
6464
6565 bridge := &bridgeServiceRecorder{
6666 favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
67- folders: []*entity.Folder{{Name: "Read Later"}},
6867 tags: []*entity.Tag{{Name: "Go"}},
6968 }
7069 app := newBridgeApp(&recordingDOM{}, "dumb://favorites", bridge)
@@ -141,7 +140,6 @@ type bridgeServiceRecorder struct {
141140
142141 historyEntries []*entity.HistoryEntry
143142 favorites []*entity.Favorite
144- folders []*entity.Folder
145143 tags []*entity.Tag
146144 currentConfig dto.SystemviewConfigPayload
147145 keybindings port.KeybindingsConfig
@@ -199,26 +197,12 @@ func (*bridgeServiceRecorder) UpdateFavorite(context.Context, dto.FavoriteUpdate
199197
200198 func (*bridgeServiceRecorder) DeleteFavorite(context.Context, int64) error { return nil }
201199
202-func (f *bridgeServiceRecorder) ListFolders(context.Context) ([]*entity.Folder, error) {
203- return f.folders, nil
204-}
205-
206200 func (f *bridgeServiceRecorder) ListTags(context.Context) ([]*entity.Tag, error) {
207201 return f.tags, nil
208202 }
209203
210204 func (*bridgeServiceRecorder) SetShortcut(context.Context, int64, *int) error { return nil }
211205
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-
222206 func (*bridgeServiceRecorder) CreateTag(context.Context, string, string) (*entity.Tag, error) {
223207 return nil, nil
224208 }
M docs/systemviews-wasm-crud-plan.md +6 -10
@@ -47,18 +47,14 @@ Preferred direction for new work:
4747 | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. |
4848 | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. |
4949
50-### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`)
50+### Favorites and tags (`webui/src/pages/homepage/favorites/*`)
5151
5252 | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
5353 | --- | --- | --- | --- | --- |
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. |
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. |
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. |
57-| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |
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. |
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.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, 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.DeleteFavorite`/`Remove`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_delete`. | Supported in WASM UI. | Keep destructive confirmation and refresh behavior covered. |
6258 | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
6359 | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
6460 | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
@@ -101,7 +97,7 @@ Implementation path:
10197 - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates.
10298 - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime.
10399 - 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.
105101 - 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.
106102 - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path.
107103 - 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"
44
55 // FavoriteCreateInput contains fields needed to create a favorite from a UI port.
66 type FavoriteCreateInput struct {
7- URL string `json:"url"`
8- Title string `json:"title"`
9- FaviconURL string `json:"favicon_url"`
10- FolderID *entity.FolderID `json:"folder_id"`
11- Tags []entity.TagID `json:"tags"`
7+ URL string `json:"url"`
8+ Title string `json:"title"`
9+ FaviconURL string `json:"favicon_url"`
10+ Tags []entity.TagID `json:"tags"`
1211 }
1312
1413 // FavoriteUpdateInput contains editable favorite metadata.
1514 type FavoriteUpdateInput struct {
16- ID entity.FavoriteID `json:"id"`
17- Title string `json:"title"`
18- FaviconURL string `json:"favicon_url"`
19- FolderID *entity.FolderID `json:"folder_id"`
20- ShortcutKey *int `json:"shortcut_key"`
15+ ID entity.FavoriteID `json:"id"`
16+ Title string `json:"title"`
17+ FaviconURL string `json:"favicon_url"`
18+ ShortcutKey *int `json:"shortcut_key"`
19+ ShortcutKeySet bool `json:"-"`
2120 }
M internal/application/port/homepage.go +1 -7
@@ -24,8 +24,7 @@ type HomepageHistory interface {
2424 DeleteByDomain(ctx context.Context, domain string) error
2525 }
2626
27-// HomepageFavorites provides favorites/folders/tags operations
28-// needed by the WebUI homepage handlers.
27+// HomepageFavorites provides favorite and tag operations needed by the WebUI homepage handlers.
2928 type HomepageFavorites interface {
3029 GetAll(ctx context.Context) ([]*entity.Favorite, error)
3130 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
@@ -33,11 +32,6 @@ type HomepageFavorites interface {
3332 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
3433 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
3534 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
4135 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
4236 AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
4337 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
982982 return _c
983983 }
984984
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-
1065985 // DeleteFavorite provides a mock function for the type MockHomepageFavorites
1066986 func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
1067987 ret := _mock.Called(ctx, id)
@@ -1119,63 +1039,6 @@ func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx c
11191039 return _c
11201040 }
11211041
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-
11791042 // DeleteTag provides a mock function for the type MockHomepageFavorites
11801043 func (_mock *MockHomepageFavorites) DeleteTag(ctx context.Context, id entity.TagID) error {
11811044 ret := _mock.Called(ctx, id)
@@ -1295,68 +1158,6 @@ func (_c *MockHomepageFavorites_GetAll_Call) RunAndReturn(run func(ctx context.C
12951158 return _c
12961159 }
12971160
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-
13601161 // GetAllTags provides a mock function for the type MockHomepageFavorites
13611162 func (_mock *MockHomepageFavorites) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
13621163 ret := _mock.Called(ctx)
@@ -1487,69 +1288,6 @@ func (_c *MockHomepageFavorites_GetByShortcut_Call) RunAndReturn(run func(ctx co
14871288 return _c
14881289 }
14891290
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-
15531291 // SetShortcut provides a mock function for the type MockHomepageFavorites
15541292 func (_mock *MockHomepageFavorites) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
15551293 ret := _mock.Called(ctx, id, key)
@@ -1807,75 +1545,6 @@ func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx c
18071545 return _c
18081546 }
18091547
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-
18791548 // UpdateTag provides a mock function for the type MockHomepageFavorites
18801549 func (_mock *MockHomepageFavorites) UpdateTag(ctx context.Context, id entity.TagID, name string, color string) error {
18811550 ret := _mock.Called(ctx, id, name, color)
M internal/application/port/systemviews.go +1 -6
@@ -22,19 +22,14 @@ type SystemviewHistoryService interface {
2222 DeleteDomain(ctx context.Context, domain string) error
2323 }
2424
25-// SystemviewFavoritesService exposes favorite, folder, and tag operations.
25+// SystemviewFavoritesService exposes favorite and tag operations.
2626 type SystemviewFavoritesService interface {
2727 List(ctx context.Context) ([]*entity.Favorite, error)
2828 CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
2929 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
3030 DeleteFavorite(ctx context.Context, id int64) error
31- ListFolders(ctx context.Context) ([]*entity.Folder, error)
3231 ListTags(ctx context.Context) ([]*entity.Tag, error)
3332 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
3833 CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
3934 UpdateTag(ctx context.Context, id int64, name, color string) error
4035 DeleteTag(ctx context.Context, id int64) error
M internal/application/usecase/manage_favorites.go +16 -192
@@ -20,10 +20,9 @@ import (
2020 // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
2121 var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
2222
23-// ManageFavoritesUseCase handles favorite, folder, and tag operations.
23+// ManageFavoritesUseCase handles favorite and tag operations.
2424 type ManageFavoritesUseCase struct {
2525 favoriteRepo repository.FavoriteRepository
26- folderRepo repository.FolderRepository
2726 tagRepo repository.TagRepository
2827
2928 cacheMu sync.Mutex
@@ -37,12 +36,10 @@ const favoritesCacheTTL = 2 * time.Second
3736 // NewManageFavoritesUseCase creates a new favorites management use case.
3837 func NewManageFavoritesUseCase(
3938 favoriteRepo repository.FavoriteRepository,
40- folderRepo repository.FolderRepository,
4139 tagRepo repository.TagRepository,
4240 ) *ManageFavoritesUseCase {
4341 return &ManageFavoritesUseCase{
4442 favoriteRepo: favoriteRepo,
45- folderRepo: folderRepo,
4643 tagRepo: tagRepo,
4744 }
4845 }
@@ -52,7 +49,6 @@ type AddFavoriteInput struct {
5249 URL string
5350 Title string
5451 FaviconURL string
55- FolderID *entity.FolderID
5652 Tags []entity.TagID
5753 }
5854
@@ -78,7 +74,6 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
7874 log := logging.FromContext(ctx)
7975 fav := entity.NewFavorite(input.URL, input.Title)
8076 fav.FaviconURL = input.FaviconURL
81- fav.FolderID = input.FolderID
8277
8378 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
8479 return nil, fmt.Errorf("failed to save favorite: %w", err)
@@ -87,11 +82,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
8782 // Assign tags
8883 for _, tagID := range input.Tags {
8984 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
90- log.Warn().
91- Int64("tag_id", int64(tagID)).
92- Int64("favorite_id", int64(fav.ID)).
93- Err(err).
94- Msg("failed to assign tag")
85+ return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
9586 }
9687 }
9788
@@ -125,7 +116,6 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav
125116 URL: favoriteURL,
126117 Title: title,
127118 FaviconURL: strings.TrimSpace(input.FaviconURL),
128- FolderID: input.FolderID,
129119 Tags: tags,
130120 })
131121 }
@@ -186,7 +176,7 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
186176 if input.ID <= 0 {
187177 return nil, fmt.Errorf("favorite ID is required")
188178 }
189- if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
179+ if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
190180 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
191181 }
192182
@@ -204,8 +194,9 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
204194 }
205195 fav.Title = title
206196 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
207- fav.FolderID = input.FolderID
208- fav.ShortcutKey = input.ShortcutKey
197+ if input.ShortcutKeySet {
198+ fav.ShortcutKey = input.ShortcutKey
199+ }
209200
210201 if err := uc.Update(ctx, fav); err != nil {
211202 return nil, err
@@ -263,22 +254,6 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori
263254 return nil
264255 }
265256
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-
282257 // SetShortcut assigns or removes a keyboard shortcut (1-9).
283258 func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
284259 log := logging.FromContext(ctx)
@@ -536,141 +511,13 @@ func (uc *ManageFavoritesUseCase) invalidateCache() {
536511 uc.cacheTime = time.Time{}
537512 }
538513
539-// GetTree builds a complete hierarchical view of folders and favorites.
540-func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, 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) {
514+// AddTag creates a new tag.
515+func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
587516 name = strings.TrimSpace(name)
588- icon = strings.TrimSpace(icon)
517+ color = strings.TrimSpace(color)
589518 if name == "" {
590- return nil, fmt.Errorf("folder 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)
519+ return nil, fmt.Errorf("tag name is required")
601520 }
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) {
674521 log := logging.FromContext(ctx)
675522 log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
676523
@@ -759,34 +606,13 @@ func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID
759606 return uc.tagRepo.GetForFavorite(ctx, favID)
760607 }
761608
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-
788609 // UpdateTag updates a tag's name and color.
789610 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+ }
790616 log := logging.FromContext(ctx)
791617 log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
792618
@@ -798,9 +624,7 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID
798624 return fmt.Errorf("tag %d not found", id)
799625 }
800626
801- if name != "" {
802- tag.Name = name
803- }
627+ tag.Name = name
804628 if color != "" {
805629 tag.Color = color
806630 }
M internal/application/usecase/manage_favorites_cache_test.go +1 -2
@@ -17,7 +17,6 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
1717 ctx := logging.WithContext(context.Background(), logger)
1818
1919 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20- folderRepo := repomocks.NewMockFolderRepository(t)
2120 tagRepo := repomocks.NewMockTagRepository(t)
2221
2322 fetchedFavorites := []*entity.Favorite{
@@ -27,7 +26,7 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) {
2726 {ID: 2, URL: "https://fresh.example"},
2827 }
2928
30- uc := NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
29+ uc := NewManageFavoritesUseCase(favoriteRepo, tagRepo)
3130 favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) {
3231 uc.cacheMu.Lock()
3332 uc.favoritesCache = refreshedFavorites
M internal/application/usecase/manage_favorites_test.go +125 -76
@@ -1,6 +1,7 @@
11 package usecase_test
22
33 import (
4+ "context"
45 "errors"
56 "testing"
67
@@ -17,7 +18,6 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
1718 ctx := testContext()
1819
1920 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
20- folderRepo := repomocks.NewMockFolderRepository(t)
2121 tagRepo := repomocks.NewMockTagRepository(t)
2222
2323 favorites := []*entity.Favorite{
@@ -28,7 +28,7 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
2828
2929 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
3030
31- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
3232
3333 urls, err := uc.GetAllURLs(ctx)
3434 require.NoError(t, err)
@@ -47,12 +47,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *tes
4747 ctx := testContext()
4848
4949 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
50- folderRepo := repomocks.NewMockFolderRepository(t)
5150 tagRepo := repomocks.NewMockTagRepository(t)
5251
5352 favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
5453
55- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
5655
5756 urls, err := uc.GetAllURLs(ctx)
5857 require.NoError(t, err)
@@ -64,12 +63,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.
6463 ctx := testContext()
6564
6665 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
67- folderRepo := repomocks.NewMockFolderRepository(t)
6866 tagRepo := repomocks.NewMockTagRepository(t)
6967
7068 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
7169
72- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
70+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
7371
7472 urls, err := uc.GetAllURLs(ctx)
7573 require.Error(t, err)
@@ -81,7 +79,6 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
8179 ctx := testContext()
8280
8381 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
84- folderRepo := repomocks.NewMockFolderRepository(t)
8582 tagRepo := repomocks.NewMockTagRepository(t)
8683
8784 // URL is not a favorite
@@ -90,7 +87,7 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
9087 return f.URL == "https://example.com" && f.Title == "Example"
9188 })).Return(nil)
9289
93- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
90+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
9491
9592 result, err := uc.Toggle(ctx, "https://example.com", "Example")
9693 require.NoError(t, err)
@@ -106,7 +103,6 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T)
106103 ctx := testContext()
107104
108105 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
109- folderRepo := repomocks.NewMockFolderRepository(t)
110106 tagRepo := repomocks.NewMockTagRepository(t)
111107
112108 existingFav := &entity.Favorite{
@@ -119,7 +115,7 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T)
119115 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
120116 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
121117
122- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
118+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
123119
124120 result, err := uc.Toggle(ctx, "https://example.com", "Example")
125121 require.NoError(t, err)
@@ -135,10 +131,9 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
135131 ctx := testContext()
136132
137133 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
138- folderRepo := repomocks.NewMockFolderRepository(t)
139134 tagRepo := repomocks.NewMockTagRepository(t)
140135
141- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
136+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
142137
143138 result, err := uc.Toggle(ctx, "", "Title")
144139 require.Error(t, err)
@@ -150,7 +145,6 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
150145 ctx := testContext()
151146
152147 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
153- folderRepo := repomocks.NewMockFolderRepository(t)
154148 tagRepo := repomocks.NewMockTagRepository(t)
155149
156150 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
@@ -159,7 +153,7 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
159153 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
160154 })).Return(nil).Once()
161155
162- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
156+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
163157
164158 result, err := uc.Toggle(ctx, " example.com ", " Example ")
165159 require.NoError(t, err)
@@ -173,12 +167,11 @@ func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T)
173167 ctx := testContext()
174168
175169 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
176- folderRepo := repomocks.NewMockFolderRepository(t)
177170 tagRepo := repomocks.NewMockTagRepository(t)
178171
179172 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
180173
181- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
174+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
182175
183176 result, err := uc.Toggle(ctx, "https://example.com", "Example")
184177 require.Error(t, err)
@@ -190,13 +183,12 @@ func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
190183 ctx := testContext()
191184
192185 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
193- folderRepo := repomocks.NewMockFolderRepository(t)
194186 tagRepo := repomocks.NewMockTagRepository(t)
195187
196188 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
197189 favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
198190
199- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
191+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
200192
201193 result, err := uc.Toggle(ctx, "https://example.com", "Example")
202194 require.Error(t, err)
@@ -208,14 +200,13 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
208200 ctx := testContext()
209201
210202 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
211- folderRepo := repomocks.NewMockFolderRepository(t)
212203 tagRepo := repomocks.NewMockTagRepository(t)
213204
214205 existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
215206 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
216207 favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
217208
218- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
209+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
219210
220211 result, err := uc.Toggle(ctx, "https://example.com", "Example")
221212 require.Error(t, err)
@@ -223,11 +214,64 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
223214 assert.Contains(t, err.Error(), "failed to remove favorite")
224215 }
225216
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+
226271 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
227272 ctx := testContext()
228273
229274 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
230- folderRepo := repomocks.NewMockFolderRepository(t)
231275 tagRepo := repomocks.NewMockTagRepository(t)
232276
233277 favorites := []*entity.Favorite{
@@ -237,7 +281,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *te
237281 }
238282 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
239283
240- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
284+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
241285
242286 results, err := uc.FilterForOmnibox(ctx, "git")
243287 require.NoError(t, err)
@@ -251,7 +295,6 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(
251295 ctx := testContext()
252296
253297 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
254- folderRepo := repomocks.NewMockFolderRepository(t)
255298 tagRepo := repomocks.NewMockTagRepository(t)
256299
257300 favorites := []*entity.Favorite{
@@ -260,7 +303,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(
260303 }
261304 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
262305
263- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
306+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
264307
265308 results, err := uc.FilterForOmnibox(ctx, "")
266309 require.NoError(t, err)
@@ -273,7 +316,6 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
273316 ctx := testContext()
274317
275318 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
276- folderRepo := repomocks.NewMockFolderRepository(t)
277319 tagRepo := repomocks.NewMockTagRepository(t)
278320
279321 favorites := []*entity.Favorite{
@@ -281,7 +323,7 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
281323 }
282324 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
283325
284- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
326+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
285327
286328 urls, err := uc.GetAllURLs(ctx)
287329 require.NoError(t, err)
@@ -296,7 +338,6 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
296338 ctx := testContext()
297339
298340 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
299- folderRepo := repomocks.NewMockFolderRepository(t)
300341 tagRepo := repomocks.NewMockTagRepository(t)
301342
302343 initialFavorites := []*entity.Favorite{
@@ -313,7 +354,7 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
313354 }
314355 favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
315356
316- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
357+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
317358
318359 urls, err := uc.GetAllURLs(ctx)
319360 require.NoError(t, err)
@@ -327,51 +368,10 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
327368 require.Contains(t, urls, "https://new.example")
328369 }
329370
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-
370371 func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
371372 ctx := testContext()
372373
373374 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
374- folderRepo := repomocks.NewMockFolderRepository(t)
375375 tagRepo := repomocks.NewMockTagRepository(t)
376376
377377 favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
@@ -380,7 +380,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
380380 return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381381 })).Return(nil).Once()
382382
383- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
383+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
384384
385385 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386386 require.NoError(t, err)
@@ -393,15 +393,66 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393393 ctx := testContext()
394394
395395 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396- folderRepo := repomocks.NewMockFolderRepository(t)
397396 tagRepo := repomocks.NewMockTagRepository(t)
398- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
397+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
399398
400399 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401400 require.Error(t, err)
402401 assert.Contains(t, err.Error(), "favorite tag id must be positive")
403402 }
404403
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+
405456 func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
406457 ctx := testContext()
407458
@@ -416,9 +467,8 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testin
416467 for _, raw := range tests {
417468 t.Run(raw, func(t *testing.T) {
418469 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
419- folderRepo := repomocks.NewMockFolderRepository(t)
420470 tagRepo := repomocks.NewMockTagRepository(t)
421- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
471+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
422472
423473 _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
424474 require.Error(t, err)
@@ -430,7 +480,6 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
430480 ctx := testContext()
431481
432482 favoriteRepo := repomocks.NewMockFavoriteRepository(t)
433- folderRepo := repomocks.NewMockFolderRepository(t)
434483 tagRepo := repomocks.NewMockTagRepository(t)
435484
436485 favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
@@ -439,7 +488,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.
439488 return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
440489 })).Return(nil).Once()
441490
442- uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
491+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
443492
444493 fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
445494 require.NoError(t, err)
M internal/domain/entity/favorite.go +6 -55
@@ -1,13 +1,13 @@
11 package entity
22
3-import "time"
3+import (
4+ "strings"
5+ "time"
6+)
47
58 // FavoriteID uniquely identifies a favorite/bookmark.
69 type FavoriteID int64
710
8-// FolderID uniquely identifies a bookmark folder.
9-type FolderID int64
10-
1111 // TagID uniquely identifies a tag.
1212 type TagID int64
1313
@@ -17,9 +17,8 @@ type Favorite struct {
1717 URL string `json:"url"`
1818 Title string `json:"title"`
1919 FaviconURL string `json:"favicon_url"`
20- FolderID *FolderID `json:"folder_id"` // nil = root level
2120 ShortcutKey *int `json:"shortcut_key"` // 1-9 for quick access (Alt+1 through Alt+9)
22- Position int `json:"position"` // Order within folder
21+ Position int `json:"position"` // Order within tag or default list
2322 Tags []Tag `json:"tags,omitempty"`
2423 CreatedAt time.Time `json:"created_at"`
2524 UpdatedAt time.Time `json:"updated_at"`
@@ -41,11 +40,6 @@ func (f *Favorite) HasShortcut() bool {
4140 return f.ShortcutKey != nil && *f.ShortcutKey >= 1 && *f.ShortcutKey <= 9
4241 }
4342
44-// InFolder returns true if this favorite is in a folder.
45-func (f *Favorite) InFolder() bool {
46- return f.FolderID != nil
47-}
48-
4943 // HasTag returns true if this favorite has the given tag.
5044 func (f *Favorite) HasTag(tagID TagID) bool {
5145 for _, t := range f.Tags {
@@ -56,29 +50,6 @@ func (f *Favorite) HasTag(tagID TagID) bool {
5650 return false
5751 }
5852
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-
8253 // Tag represents a label that can be applied to favorites.
8354 type Tag struct {
8455 ID TagID `json:"id"`
@@ -90,28 +61,8 @@ type Tag struct {
9061 // NewTag creates a new tag with default color.
9162 func NewTag(name string) *Tag {
9263 return &Tag{
93- Name: name,
64+ Name: strings.TrimSpace(name),
9465 Color: "#808080", // Default gray
9566 CreatedAt: time.Now(),
9667 }
9768 }
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 {
2020 // GetAll retrieves all favorites.
2121 GetAll(ctx context.Context) ([]*entity.Favorite, error)
2222
23- // GetByFolder retrieves favorites in a specific folder.
24- // Pass nil for root-level favorites.
25- GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error)
23+ // GetByTag retrieves favorites with a specific tag.
24+ GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error)
2625
2726 // GetByShortcut retrieves the favorite with the given shortcut key (1-9).
2827 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
2928
30- // UpdatePosition updates a favorite's position within its folder.
29+ // UpdatePosition updates a favorite's position.
3130 UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error
3231
33- // SetFolder moves a favorite to a different folder.
34- SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
35-
3632 // SetShortcut assigns a keyboard shortcut (1-9) or nil to remove.
3733 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
3834
@@ -40,29 +36,6 @@ type FavoriteRepository interface {
4036 Delete(ctx context.Context, id entity.FavoriteID) error
4137 }
4238
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-
6639 // TagRepository defines operations for tag persistence.
6740 type TagRepository interface {
6841 // 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.
293293 return _c
294294 }
295295
296-// GetByFolder provides a mock function for the type MockFavoriteRepository
297-func (_mock *MockFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
298- ret := _mock.Called(ctx, folderID)
296+// GetByShortcut provides a mock function for the type MockFavoriteRepository
297+func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
298+ ret := _mock.Called(ctx, key)
299299
300300 if len(ret) == 0 {
301- panic("no return value specified for GetByFolder")
301+ panic("no return value specified for GetByShortcut")
302302 }
303303
304- var r0 []*entity.Favorite
304+ var r0 *entity.Favorite
305305 var r1 error
306- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) ([]*entity.Favorite, error)); ok {
307- return returnFunc(ctx, folderID)
306+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok {
307+ return returnFunc(ctx, key)
308308 }
309- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) []*entity.Favorite); ok {
310- r0 = returnFunc(ctx, folderID)
309+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok {
310+ r0 = returnFunc(ctx, key)
311311 } else {
312312 if ret.Get(0) != nil {
313- r0 = ret.Get(0).([]*entity.Favorite)
313+ r0 = ret.Get(0).(*entity.Favorite)
314314 }
315315 }
316- if returnFunc, ok := ret.Get(1).(func(context.Context, *entity.FolderID) error); ok {
317- r1 = returnFunc(ctx, folderID)
316+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
317+ r1 = returnFunc(ctx, key)
318318 } else {
319319 r1 = ret.Error(1)
320320 }
321321 return r0, r1
322322 }
323323
324-// MockFavoriteRepository_GetByFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByFolder'
325-type MockFavoriteRepository_GetByFolder_Call struct {
324+// MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut'
325+type MockFavoriteRepository_GetByShortcut_Call struct {
326326 *mock.Call
327327 }
328328
329-// GetByFolder is a helper method to define mock.On call
329+// GetByShortcut is a helper method to define mock.On call
330330 // - ctx context.Context
331-// - folderID *entity.FolderID
332-func (_e *MockFavoriteRepository_Expecter) GetByFolder(ctx any, folderID any) *MockFavoriteRepository_GetByFolder_Call {
333- return &MockFavoriteRepository_GetByFolder_Call{Call: _e.mock.On("GetByFolder", ctx, folderID)}
331+// - key int
332+func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call {
333+ return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)}
334334 }
335335
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 {
337337 _c.Call.Run(func(args mock.Arguments) {
338338 var arg0 context.Context
339339 if args[0] != nil {
340340 arg0 = args[0].(context.Context)
341341 }
342- var arg1 *entity.FolderID
342+ var arg1 int
343343 if args[1] != nil {
344- arg1 = args[1].(*entity.FolderID)
344+ arg1 = args[1].(int)
345345 }
346346 run(
347347 arg0,
@@ -351,65 +351,65 @@ func (_c *MockFavoriteRepository_GetByFolder_Call) Run(run func(ctx context.Cont
351351 return _c
352352 }
353353
354-func (_c *MockFavoriteRepository_GetByFolder_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByFolder_Call {
355- _c.Call.Return(favorites, err)
354+func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call {
355+ _c.Call.Return(favorite, err)
356356 return _c
357357 }
358358
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 {
360360 _c.Call.Return(run)
361361 return _c
362362 }
363363
364-// GetByShortcut provides a mock function for the type MockFavoriteRepository
365-func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
366- ret := _mock.Called(ctx, key)
364+// GetByTag provides a mock function for the type MockFavoriteRepository
365+func (_mock *MockFavoriteRepository) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) {
366+ ret := _mock.Called(ctx, tagID)
367367
368368 if len(ret) == 0 {
369- panic("no return value specified for GetByShortcut")
369+ panic("no return value specified for GetByTag")
370370 }
371371
372- var r0 *entity.Favorite
372+ var r0 []*entity.Favorite
373373 var r1 error
374- if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok {
375- return returnFunc(ctx, key)
374+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) ([]*entity.Favorite, error)); ok {
375+ return returnFunc(ctx, tagID)
376376 }
377- if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok {
378- r0 = returnFunc(ctx, key)
377+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) []*entity.Favorite); ok {
378+ r0 = returnFunc(ctx, tagID)
379379 } else {
380380 if ret.Get(0) != nil {
381- r0 = ret.Get(0).(*entity.Favorite)
381+ r0 = ret.Get(0).([]*entity.Favorite)
382382 }
383383 }
384- if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
385- r1 = returnFunc(ctx, key)
384+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.TagID) error); ok {
385+ r1 = returnFunc(ctx, tagID)
386386 } else {
387387 r1 = ret.Error(1)
388388 }
389389 return r0, r1
390390 }
391391
392-// MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut'
393-type MockFavoriteRepository_GetByShortcut_Call struct {
392+// MockFavoriteRepository_GetByTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByTag'
393+type MockFavoriteRepository_GetByTag_Call struct {
394394 *mock.Call
395395 }
396396
397-// GetByShortcut is a helper method to define mock.On call
397+// GetByTag is a helper method to define mock.On call
398398 // - ctx context.Context
399-// - key int
400-func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call {
401- return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)}
399+// - tagID entity.TagID
400+func (_e *MockFavoriteRepository_Expecter) GetByTag(ctx any, tagID any) *MockFavoriteRepository_GetByTag_Call {
401+ return &MockFavoriteRepository_GetByTag_Call{Call: _e.mock.On("GetByTag", ctx, tagID)}
402402 }
403403
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 {
405405 _c.Call.Run(func(args mock.Arguments) {
406406 var arg0 context.Context
407407 if args[0] != nil {
408408 arg0 = args[0].(context.Context)
409409 }
410- var arg1 int
410+ var arg1 entity.TagID
411411 if args[1] != nil {
412- arg1 = args[1].(int)
412+ arg1 = args[1].(entity.TagID)
413413 }
414414 run(
415415 arg0,
@@ -419,12 +419,12 @@ func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Co
419419 return _c
420420 }
421421
422-func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call {
423- _c.Call.Return(favorite, err)
422+func (_c *MockFavoriteRepository_GetByTag_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByTag_Call {
423+ _c.Call.Return(favorites, err)
424424 return _c
425425 }
426426
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 {
428428 _c.Call.Return(run)
429429 return _c
430430 }
@@ -486,69 +486,6 @@ func (_c *MockFavoriteRepository_Save_Call) RunAndReturn(run func(ctx context.Co
486486 return _c
487487 }
488488
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-
552489 // SetShortcut provides a mock function for the type MockFavoriteRepository
553490 func (_mock *MockFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
554491 ret := _mock.Called(ctx, id, key)
@@ -675,408 +612,6 @@ func (_c *MockFavoriteRepository_UpdatePosition_Call) RunAndReturn(run func(ctx
675612 return _c
676613 }
677614
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-
1080615 // 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.
1081616 // The first argument is typically a *testing.T value.
1082617 func NewMockTagRepository(t interface {
M internal/infrastructure/handlers/homepage/favorites.go +10 -67
@@ -12,17 +12,6 @@ import (
1212 "github.com/bnema/dumber/internal/logging"
1313 )
1414
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-
2615 func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
2716 out := make([]entity.TagID, 0, len(ids))
2817 for _, id := range ids {
@@ -69,7 +58,6 @@ type favoriteCreateRequest struct {
6958 URL string `json:"url"`
7059 Title string `json:"title"`
7160 FaviconURL string `json:"favicon_url"`
72- FolderID *int64 `json:"folder_id"`
7361 Tags []int64 `json:"tags"`
7462 }
7563
@@ -91,10 +79,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
9179 if trimmedURL == "" {
9280 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
9381 }
94- folderID, err := folderIDFromInt64(req.FolderID)
95- if err != nil {
96- return NewErrorResponse(req.RequestID, err), nil
97- }
9882 tags, err := tagIDsFromInt64s(req.Tags)
9983 if err != nil {
10084 return NewErrorResponse(req.RequestID, err), nil
@@ -104,7 +88,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
10488 URL: trimmedURL,
10589 Title: req.Title,
10690 FaviconURL: req.FaviconURL,
107- FolderID: folderID,
10891 Tags: tags,
10992 }
11093 favorite, err := h.favoritesUC.AddFavorite(ctx, input)
@@ -121,7 +104,6 @@ type favoriteUpdateRequest struct {
121104 ID int64 `json:"id"`
122105 Title string `json:"title"`
123106 FaviconURL string `json:"favicon_url"`
124- FolderID *int64 `json:"folder_id"`
125107 ShortcutKey *int `json:"shortcut_key"`
126108 }
127109
@@ -134,6 +116,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
134116 if err := json.Unmarshal(payload, &req); err != nil {
135117 return NewErrorResponse("", err), nil
136118 }
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"]
137124
138125 log.Debug().
139126 Str("request_id", req.RequestID).
@@ -144,17 +131,12 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
144131 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
145132 }
146133
147- folderID, err := folderIDFromInt64(req.FolderID)
148- if err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151-
152134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
153- ID: entity.FavoriteID(req.ID),
154- Title: req.Title,
155- FaviconURL: req.FaviconURL,
156- FolderID: folderID,
157- ShortcutKey: req.ShortcutKey,
135+ ID: entity.FavoriteID(req.ID),
136+ Title: req.Title,
137+ FaviconURL: req.FaviconURL,
138+ ShortcutKey: req.ShortcutKey,
139+ ShortcutKeySet: shortcutKeySet,
158140 })
159141 if err != nil {
160142 return NewErrorResponse(req.RequestID, err), nil
@@ -259,42 +241,3 @@ func (h *FavoritesHandlers) HandleGetByShortcut() port.WebUIMessageHandler {
259241 return NewSuccessResponse(req.RequestID, favorite), nil
260242 })
261243 }
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
4848 handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
4949 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
5050 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-
6051 // Tag handlers
6152 tagHandlers := NewTagHandlers(cfg.FavoritesUC)
6253 handlers["tag_list"] = tagHandlers.HandleList()
M internal/infrastructure/persistence/sqlite/favorite_repo.go +120 -179
@@ -13,79 +13,46 @@ import (
1313
1414 // ==================== Favorite Repository ====================
1515
16+const favoriteTagHydrationBatchSize = 900
17+
1618 type favoriteRepo struct {
17- db *sql.DB
1819 queries *sqlc.Queries
1920 }
2021
2122 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
2223 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
23- return &favoriteRepo{db: db, queries: sqlc.New(db)}
24+ return &favoriteRepo{queries: sqlc.New(db)}
2425 }
2526
2627 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
2728 log := logging.FromContext(ctx)
2829 log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite")
2930
30- var folderID sql.NullInt64
31- if fav.FolderID != nil {
32- folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
33- }
34-
3531 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+ })
3742 }
3843
3944 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
4045 Url: fav.URL,
4146 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
4247 FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
43- FolderID: folderID,
4448 })
4549 if err != nil {
4650 return err
4751 }
48- fav.ID = entity.FavoriteID(row.ID)
52+ favoriteFromRow(favoriteRowFromCreate(row), fav)
4953 return nil
5054 }
5155
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-
8956 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
9057 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
9158 if err != nil {
@@ -94,7 +61,8 @@ func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*ent
9461 }
9562 return nil, err
9663 }
97- return favoriteFromRow(row), nil
64+ fav := favoriteFromRow(favoriteRowFromGetByID(row), nil)
65+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
9866 }
9967
10068 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
10573 }
10674 return nil, err
10775 }
108- return favoriteFromRow(row), nil
76+ fav := favoriteFromRow(favoriteRowFromGetByURL(row), nil)
77+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
10978 }
11079
11180 func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
@@ -113,22 +82,17 @@ func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
11382 if err != nil {
11483 return nil, err
11584 }
116- return favoritesFromRows(rows), nil
85+ favorites := favoritesFromAllRows(rows)
86+ return favorites, r.hydrateTags(ctx, favorites)
11787 }
11888
119-func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
120- var rows []sqlc.Favorite
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- }
89+func (r *favoriteRepo) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) {
90+ rows, err := r.queries.GetFavoritesByTag(ctx, int64(tagID))
12891 if err != nil {
12992 return nil, err
13093 }
131- return favoritesFromRows(rows), nil
94+ favorites := favoritesFromTagRows(rows)
95+ return favorites, r.hydrateTags(ctx, favorites)
13296 }
13397
13498 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
139103 }
140104 return nil, err
141105 }
142- return favoriteFromRow(row), nil
106+ fav := favoriteFromRow(favoriteRowFromGetByShortcut(row), nil)
107+ return fav, r.hydrateTags(ctx, []*entity.Favorite{fav})
143108 }
144109
145110 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,
149114 })
150115 }
151116
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-
163117 func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
164118 var k sql.NullInt64
165119 if key != nil {
@@ -175,141 +129,128 @@ func (r *favoriteRepo) Delete(ctx context.Context, id entity.FavoriteID) error {
175129 return r.queries.DeleteFavorite(ctx, int64(id))
176130 }
177131
178-func favoriteFromRow(row sqlc.Favorite) *entity.Favorite {
179- fav := &entity.Favorite{
180- ID: entity.FavoriteID(row.ID),
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,
132+func (r *favoriteRepo) hydrateTags(ctx context.Context, favorites []*entity.Favorite) error {
133+ if len(favorites) == 0 {
134+ return nil
187135 }
188- if row.FolderID.Valid {
189- fid := entity.FolderID(row.FolderID.Int64)
190- fav.FolderID = &fid
136+
137+ ids := make([]int64, 0, len(favorites))
138+ 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
191146 }
192- if row.ShortcutKey.Valid {
193- key := int(row.ShortcutKey.Int64)
194- fav.ShortcutKey = &key
147+ if len(ids) == 0 {
148+ return nil
195149 }
196- return fav
197-}
198150
199-func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite {
200- favorites := make([]*entity.Favorite, len(rows))
201- for i := range rows {
202- favorites[i] = favoriteFromRow(rows[i])
151+ for start := 0; start < len(ids); start += favoriteTagHydrationBatchSize {
152+ end := start + favoriteTagHydrationBatchSize
153+ if end > len(ids) {
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+ }
203167 }
204- return favorites
205-}
206-
207-// ==================== Folder Repository ====================
208-
209-type folderRepo struct {
210- queries *sqlc.Queries
168+ return nil
211169 }
212170
213-// NewFolderRepository creates a new SQLite-backed folder repository.
214-func NewFolderRepository(db *sql.DB) repository.FolderRepository {
215- return &folderRepo{queries: sqlc.New(db)}
171+type favoriteRow struct {
172+ ID int64
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
216180 }
217181
218-func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
219- log := logging.FromContext(ctx)
220- log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder")
221-
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- })
182+func favoriteFromRow(row favoriteRow, target *entity.Favorite) *entity.Favorite {
183+ fav := target
184+ if fav == nil {
185+ fav = &entity.Favorite{}
233186 }
234-
235- row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
236- Name: folder.Name,
237- Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
238- ParentID: parentID,
239- })
240- if err != nil {
241- return err
187+ fav.ID = entity.FavoriteID(row.ID)
188+ fav.URL = row.URL
189+ fav.Title = row.Title.String
190+ fav.FaviconURL = row.FaviconURL.String
191+ fav.Position = int(row.Position)
192+ fav.CreatedAt = row.CreatedAt.Time
193+ fav.UpdatedAt = row.UpdatedAt.Time
194+ fav.Tags = nil
195+ if row.ShortcutKey.Valid {
196+ key := int(row.ShortcutKey.Int64)
197+ fav.ShortcutKey = &key
198+ } else {
199+ fav.ShortcutKey = nil
242200 }
243- folder.ID = entity.FolderID(row.ID)
244- return nil
201+ return fav
245202 }
246203
247-func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
248- row, err := r.queries.GetFolderByID(ctx, int64(id))
249- if err != nil {
250- if errors.Is(err, sql.ErrNoRows) {
251- return nil, nil
252- }
253- return nil, err
204+func favoriteRowFromCreate(row sqlc.CreateFavoriteRow) favoriteRow {
205+ return favoriteRow{
206+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
207+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
254208 }
255- return folderFromRow(row), nil
256209 }
257210
258-func (r *folderRepo) GetAll(ctx context.Context) ([]*entity.Folder, error) {
259- rows, err := r.queries.GetAllFolders(ctx)
260- if err != nil {
261- return nil, err
211+func favoriteRowFromGetByID(row sqlc.GetFavoriteByIDRow) favoriteRow {
212+ return favoriteRow{
213+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
214+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
262215 }
263- return foldersFromRows(rows), nil
264216 }
265217
266-func (r *folderRepo) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
267- var rows []sqlc.FavoriteFolder
268- var err error
269-
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
218+func favoriteRowFromGetByURL(row sqlc.GetFavoriteByURLRow) favoriteRow {
219+ return favoriteRow{
220+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
221+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
277222 }
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- })
286223 }
287224
288-func (r *folderRepo) Delete(ctx context.Context, id entity.FolderID) error {
289- return r.queries.DeleteFolder(ctx, int64(id))
225+func favoriteRowFromGetByShortcut(row sqlc.GetFavoriteByShortcutRow) favoriteRow {
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+ }
290230 }
291231
292-func folderFromRow(row sqlc.FavoriteFolder) *entity.Folder {
293- folder := &entity.Folder{
294- ID: entity.FolderID(row.ID),
295- Name: row.Name,
296- Icon: row.Icon.String,
297- Position: int(row.Position),
298- CreatedAt: row.CreatedAt.Time,
299- }
300- if row.ParentID.Valid {
301- pid := entity.FolderID(row.ParentID.Int64)
302- folder.ParentID = &pid
232+func favoritesFromAllRows(rows []sqlc.GetAllFavoritesRow) []*entity.Favorite {
233+ favorites := make([]*entity.Favorite, len(rows))
234+ for i := range rows {
235+ row := rows[i]
236+ favorites[i] = favoriteFromRow(favoriteRow{
237+ ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl,
238+ ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
239+ }, nil)
303240 }
304- return folder
241+ return favorites
305242 }
306243
307-func foldersFromRows(rows []sqlc.FavoriteFolder) []*entity.Folder {
308- folders := make([]*entity.Folder, len(rows))
309- for i, row := range rows {
310- folders[i] = folderFromRow(row)
244+func favoritesFromTagRows(rows []sqlc.GetFavoritesByTagRow) []*entity.Favorite {
245+ favorites := make([]*entity.Favorite, len(rows))
246+ for i := range rows {
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)
311252 }
312- return folders
253+ return favorites
313254 }
314255
315256 // ==================== 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
357357 return r.repo.GetAll(ctx)
358358 }
359359
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) {
361361 if err := r.init(ctx); err != nil {
362362 return nil, err
363363 }
364- return r.repo.GetByFolder(ctx, folderID)
364+ return r.repo.GetByTag(ctx, tagID)
365365 }
366366
367367 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
378378 return r.repo.UpdatePosition(ctx, id, position)
379379 }
380380
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-
388381 func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
389382 if err := r.init(ctx); err != nil {
390383 return err
@@ -399,73 +392,6 @@ func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteI
399392 return r.repo.Delete(ctx, id)
400393 }
401394
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-
469395 // LazyTagRepository wraps a tag repository with lazy database initialization.
470396 type LazyTagRepository struct {
471397 provider port.DatabaseProvider
@@ -798,7 +724,6 @@ func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string,
798724 type LazyRepositories struct {
799725 History repository.HistoryRepository
800726 Favorite repository.FavoriteRepository
801- Folder repository.FolderRepository
802727 Tag repository.TagRepository
803728 Zoom repository.ZoomRepository
804729 Session repository.SessionRepository
@@ -812,7 +737,6 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
812737 return &LazyRepositories{
813738 History: NewLazyHistoryRepository(provider),
814739 Favorite: NewLazyFavoriteRepository(provider),
815- Folder: NewLazyFolderRepository(provider),
816740 Tag: NewLazyTagRepository(provider),
817741 Zoom: NewLazyZoomRepository(provider),
818742 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 @@
11 -- 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;
35
46 -- 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;
610
711 -- 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;
915
10--- name: GetFavoritesByFolder :many
11-SELECT * FROM favorites WHERE folder_id = ? ORDER BY position ASC;
12-
13--- name: GetFavoritesWithoutFolder :many
14-SELECT * FROM favorites WHERE folder_id IS NULL ORDER BY position ASC;
16+-- name: GetFavoritesByTag :many
17+SELECT f.id, f.url, f.title, f.favicon_url, f.shortcut_key, f.position, f.created_at, f.updated_at
18+FROM favorites f
19+INNER JOIN favorite_tag_assignments fta ON f.id = fta.favorite_id
20+WHERE fta.tag_id = ?
21+ORDER BY f.position ASC;
1522
1623 -- 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;
1827
1928 -- name: CreateFavorite :one
20-INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
21-VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
22-RETURNING *;
29+INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at)
30+VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
31+RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at;
2332
2433 -- name: UpdateFavorite :exec
2534 UPDATE favorites
26-SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
35+SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP
2736 WHERE id = ?;
2837
2938 -- name: UpdateFavoritePosition :exec
3039 UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
3140
32--- name: SetFavoriteFolder :exec
33-UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
34-
3541 -- name: SetFavoriteShortcut :exec
3642 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
3743
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;
55 SELECT * FROM favorite_tags WHERE id = ? LIMIT 1;
66
77 -- 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;
99
1010 -- name: CreateTag :one
1111 INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *;
@@ -27,3 +27,9 @@ SELECT t.* FROM favorite_tags t
2727 INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
2828 WHERE fta.favorite_id = ?
2929 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 (
1111 )
1212
1313 const CreateFavorite = `-- name: CreateFavorite :one
14-INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
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_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)
16+RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
1717 `
1818
1919 type CreateFavoriteParams struct {
2020 Url string `json:"url"`
2121 Title sql.NullString `json:"title"`
2222 FaviconUrl sql.NullString `json:"favicon_url"`
23- FolderID sql.NullInt64 `json:"folder_id"`
2423 }
2524
26-func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) {
27- row := q.db.QueryRowContext(ctx, CreateFavorite,
28- arg.Url,
29- arg.Title,
30- arg.FaviconUrl,
31- arg.FolderID,
32- )
33- var i Favorite
25+type CreateFavoriteRow struct {
26+ ID int64 `json:"id"`
27+ Url string `json:"url"`
28+ Title sql.NullString `json:"title"`
29+ FaviconUrl sql.NullString `json:"favicon_url"`
30+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
31+ Position int64 `json:"position"`
32+ 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
3439 err := row.Scan(
3540 &i.ID,
3641 &i.Url,
3742 &i.Title,
3843 &i.FaviconUrl,
39- &i.FolderID,
4044 &i.ShortcutKey,
4145 &i.Position,
4246 &i.CreatedAt,
@@ -55,24 +59,36 @@ func (q *Queries) DeleteFavorite(ctx context.Context, id int64) error {
5559 }
5660
5761 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 ASC
62+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
63+FROM favorites
64+ORDER BY position ASC
5965 `
6066
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) {
6279 rows, err := q.db.QueryContext(ctx, GetAllFavorites)
6380 if err != nil {
6481 return nil, err
6582 }
6683 defer rows.Close()
67- items := []Favorite{}
84+ items := []GetAllFavoritesRow{}
6885 for rows.Next() {
69- var i Favorite
86+ var i GetAllFavoritesRow
7087 if err := rows.Scan(
7188 &i.ID,
7289 &i.Url,
7390 &i.Title,
7491 &i.FaviconUrl,
75- &i.FolderID,
7692 &i.ShortcutKey,
7793 &i.Position,
7894 &i.CreatedAt,
@@ -92,18 +108,30 @@ func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
92108 }
93109
94110 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 1
111+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
112+FROM favorites
113+WHERE id = ? LIMIT 1
96114 `
97115
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) {
99128 row := q.db.QueryRowContext(ctx, GetFavoriteByID, id)
100- var i Favorite
129+ var i GetFavoriteByIDRow
101130 err := row.Scan(
102131 &i.ID,
103132 &i.Url,
104133 &i.Title,
105134 &i.FaviconUrl,
106- &i.FolderID,
107135 &i.ShortcutKey,
108136 &i.Position,
109137 &i.CreatedAt,
@@ -113,18 +141,30 @@ func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, erro
113141 }
114142
115143 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 1
144+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
145+FROM favorites
146+WHERE shortcut_key = ? LIMIT 1
117147 `
118148
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) {
120161 row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey)
121- var i Favorite
162+ var i GetFavoriteByShortcutRow
122163 err := row.Scan(
123164 &i.ID,
124165 &i.Url,
125166 &i.Title,
126167 &i.FaviconUrl,
127- &i.FolderID,
128168 &i.ShortcutKey,
129169 &i.Position,
130170 &i.CreatedAt,
@@ -134,18 +174,30 @@ func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.Nul
134174 }
135175
136176 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 1
177+SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at
178+FROM favorites
179+WHERE url = ? LIMIT 1
138180 `
139181
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) {
141194 row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
142- var i Favorite
195+ var i GetFavoriteByURLRow
143196 err := row.Scan(
144197 &i.ID,
145198 &i.Url,
146199 &i.Title,
147200 &i.FaviconUrl,
148- &i.FolderID,
149201 &i.ShortcutKey,
150202 &i.Position,
151203 &i.CreatedAt,
@@ -154,62 +206,39 @@ func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, e
154206 return i, err
155207 }
156208
157-const GetFavoritesByFolder = `-- name: GetFavoritesByFolder :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 ASC
209+const GetFavoritesByTag = `-- name: GetFavoritesByTag :many
210+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
159215 `
160216
161-func (q *Queries) GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) {
162- rows, err := q.db.QueryContext(ctx, GetFavoritesByFolder, folderID)
163- if err != nil {
164- return nil, err
165- }
166- defer rows.Close()
167- items := []Favorite{}
168- for rows.Next() {
169- var i Favorite
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
217+type GetFavoritesByTagRow struct {
218+ ID int64 `json:"id"`
219+ Url string `json:"url"`
220+ Title sql.NullString `json:"title"`
221+ FaviconUrl sql.NullString `json:"favicon_url"`
222+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
223+ Position int64 `json:"position"`
224+ CreatedAt sql.NullTime `json:"created_at"`
225+ UpdatedAt sql.NullTime `json:"updated_at"`
192226 }
193227
194-const GetFavoritesWithoutFolder = `-- name: GetFavoritesWithoutFolder :many
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 ASC
196-`
197-
198-func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) {
199- rows, err := q.db.QueryContext(ctx, GetFavoritesWithoutFolder)
228+func (q *Queries) GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error) {
229+ rows, err := q.db.QueryContext(ctx, GetFavoritesByTag, tagID)
200230 if err != nil {
201231 return nil, err
202232 }
203233 defer rows.Close()
204- items := []Favorite{}
234+ items := []GetFavoritesByTagRow{}
205235 for rows.Next() {
206- var i Favorite
236+ var i GetFavoritesByTagRow
207237 if err := rows.Scan(
208238 &i.ID,
209239 &i.Url,
210240 &i.Title,
211241 &i.FaviconUrl,
212- &i.FolderID,
213242 &i.ShortcutKey,
214243 &i.Position,
215244 &i.CreatedAt,
@@ -228,20 +257,6 @@ func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, er
228257 return items, nil
229258 }
230259
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-
245260 const SetFavoriteShortcut = `-- name: SetFavoriteShortcut :exec
246261 UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
247262 `
@@ -258,18 +273,24 @@ func (q *Queries) SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortc
258273
259274 const UpdateFavorite = `-- name: UpdateFavorite :exec
260275 UPDATE favorites
261-SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
276+SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP
262277 WHERE id = ?
263278 `
264279
265280 type UpdateFavoriteParams struct {
266- Title sql.NullString `json:"title"`
267- FaviconUrl sql.NullString `json:"favicon_url"`
268- ID int64 `json:"id"`
281+ Title sql.NullString `json:"title"`
282+ FaviconUrl sql.NullString `json:"favicon_url"`
283+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
284+ ID int64 `json:"id"`
269285 }
270286
271287 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+ )
273294 return err
274295 }
275296
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 {
1313 AddToWhitelist(ctx context.Context, domain string) error
1414 AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
1515 CapVisitCount(ctx context.Context, arg CapVisitCountParams) error
16- CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)
17- CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
16+ CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (CreateFavoriteRow, error)
1817 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
1918 DeleteAllHistory(ctx context.Context) error
2019 // Deletes exited browser sessions older than the given cutoff time.
2120 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
2221 DeleteFavicon(ctx context.Context, key string) error
2322 DeleteFavorite(ctx context.Context, id int64) error
24- DeleteFolder(ctx context.Context, id int64) error
2523 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
2624 DeleteHistoryByID(ctx context.Context, id int64) error
2725 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
@@ -34,24 +32,20 @@ type Querier interface {
3432 DeleteTag(ctx context.Context, id int64) error
3533 DeleteZoomLevel(ctx context.Context, domain string) error
3634 GetActiveBrowserSession(ctx context.Context) (Session, error)
37- GetAllFavorites(ctx context.Context) ([]Favorite, error)
38- GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
35+ GetAllFavorites(ctx context.Context) ([]GetAllFavoritesRow, error)
3936 GetAllMostVisited(ctx context.Context) ([]History, error)
4037 GetAllRecentHistory(ctx context.Context) ([]History, error)
4138 GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)
4239 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
4340 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
4441 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
45- GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
4642 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
4743 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
4844 GetFavicon(ctx context.Context, key string) (Favicon, error)
49- GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
50- GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
51- GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
52- GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error)
53- GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
54- GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
45+ GetFavoriteByID(ctx context.Context, id int64) (GetFavoriteByIDRow, error)
46+ GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (GetFavoriteByShortcutRow, error)
47+ GetFavoriteByURL(ctx context.Context, url string) (GetFavoriteByURLRow, error)
48+ GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error)
5549 GetHistoryByURL(ctx context.Context, url string) (History, error)
5650 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
5751 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
@@ -63,13 +57,13 @@ type Querier interface {
6357 GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
6458 GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
6559 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
66- GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
6760 GetSessionByID(ctx context.Context, id string) (Session, error)
6861 GetSessionState(ctx context.Context, sessionID string) (SessionState, error)
6962 GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)
7063 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)
7265 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
66+ GetTagsForFavorites(ctx context.Context, favoriteIds []int64) ([]GetTagsForFavoritesRow, error)
7367 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
7468 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
7569 IncrementVisitCount(ctx context.Context, url string) error
@@ -86,15 +80,12 @@ type Querier interface {
8680 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
8781 SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
8882 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
89- SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
9083 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
9184 SetPermission(ctx context.Context, arg SetPermissionParams) error
9285 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
9386 UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error
9487 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
9588 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
96- UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
97- UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
9889 UpdateTag(ctx context.Context, arg UpdateTagParams) error
9990 UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error
10091 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
M internal/infrastructure/persistence/sqlite/sqlc/tags.sql.go +55 -3
@@ -7,6 +7,7 @@ package sqlc
77
88 import (
99 "context"
10+ "strings"
1011 )
1112
1213 const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec
@@ -102,11 +103,11 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
102103 }
103104
104105 const GetTagByName = `-- name: GetTagByName :one
105-SELECT id, name, color, created_at FROM favorite_tags WHERE name = ? LIMIT 1
106+SELECT id, name, color, created_at FROM favorite_tags WHERE lower(trim(name)) = lower(trim(?)) LIMIT 1
106107 `
107108
108-func (q *Queries) GetTagByName(ctx context.Context, name string) (FavoriteTag, error) {
109- row := q.db.QueryRowContext(ctx, GetTagByName, name)
109+func (q *Queries) GetTagByName(ctx context.Context, trim string) (FavoriteTag, error) {
110+ row := q.db.QueryRowContext(ctx, GetTagByName, trim)
110111 var i FavoriteTag
111112 err := row.Scan(
112113 &i.ID,
@@ -152,6 +153,57 @@ func (q *Queries) GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]F
152153 return items, nil
153154 }
154155
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+
155207 const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec
156208 DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?
157209 `
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +2 -2
@@ -25,8 +25,8 @@ func NewBrowserClient() *Client {
2525 func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
2626 switch msgType {
2727 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",
29- "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
28+ "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "tag_list",
29+ "favorite_set_shortcut",
3030 "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
3131 return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
3232 case "save_config":
M internal/infrastructure/systemviewsbridge/client.go +8 -90
@@ -272,42 +272,27 @@ func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
272272 URL string `json:"url"`
273273 Title string `json:"title"`
274274 FaviconURL string `json:"favicon_url"`
275- FolderID *int64 `json:"folder_id"`
276275 Tags []int64 `json:"tags"`
277276 }{
278277 RequestID: nextRequestID(),
279278 URL: input.URL,
280279 Title: input.Title,
281280 FaviconURL: input.FaviconURL,
282- FolderID: folderIDPayload(input.FolderID),
283281 Tags: tagIDPayloads(input.Tags),
284282 }
285283 }
286284
287285 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
288- return struct {
289- RequestID string `json:"requestId"`
290- ID int64 `json:"id"`
291- Title string `json:"title"`
292- FaviconURL string `json:"favicon_url"`
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,
286+ payload := map[string]any{
287+ "requestId": nextRequestID(),
288+ "id": int64(input.ID),
289+ "title": input.Title,
290+ "favicon_url": input.FaviconURL,
302291 }
303-}
304-
305-func folderIDPayload(id *entity.FolderID) *int64 {
306- if id == nil {
307- return nil
292+ if input.ShortcutKeySet {
293+ payload["shortcut_key"] = input.ShortcutKey
308294 }
309- value := int64(*id)
310- return &value
295+ return payload
311296 }
312297
313298 func tagIDPayloads(ids []entity.TagID) []int64 {
@@ -318,12 +303,6 @@ func tagIDPayloads(ids []entity.TagID) []int64 {
318303 return out
319304 }
320305
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-
327306 func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
328307 return request[[]*entity.Tag](c, ctx, "tag_list", struct {
329308 RequestID string `json:"requestId"`
@@ -342,67 +321,6 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey
342321 return err
343322 }
344323
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-
406324 func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
407325 name = strings.TrimSpace(name)
408326 if name == "" {
M internal/infrastructure/systemviewsbridge/client_test.go +0 -139
@@ -268,32 +268,6 @@ func TestClientListDecodesFavorites(t *testing.T) {
268268 }
269269 }
270270
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-
297271 func TestClientListTagsDecodesTags(t *testing.T) {
298272 t.Parallel()
299273
@@ -470,61 +444,6 @@ func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
470444 }
471445 }
472446
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-
528447 func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
529448 t.Parallel()
530449
@@ -555,59 +474,6 @@ func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
555474 }
556475 }
557476
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-
611477 func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
612478 t.Parallel()
613479
@@ -719,11 +585,6 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
719585 }{
720586 {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
721587 {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) }},
727588 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
728589 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
729590 {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) {
17531753 return
17541754 }
17551755
1756- favorites := make([]Favorite, 0, len(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- }
1756+ favorites := favoriteResultsForOmnibox(results)
17651757
17661758 o.idleAddUpdateFavorites(favorites, query, token)
17671759 }()
17681760 }
17691761
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+
17701775 func (o *Omnibox) loadBangSuggestions(query string, token uint64) {
17711776 if o.shortcutsUC == nil {
17721777 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 {
2424 type App struct {
2525 // mu protects rendered route state; actionMu protects worker channels.
2626 // The two locks must not be held at the same time.
27- mu sync.Mutex
28- actionMu sync.Mutex
29- deps Dependencies
30- currentRoute Route
31- shellTheme shellTheme
32- historyEntries []*entity.HistoryEntry
33- historyStats *entity.HistoryStats
34- historyAnalytics *entity.HistoryAnalytics
35- historyDomainStats []*entity.DomainStat
36- historyQuery string
37- historyDomainFilter string
38- historyOffset int
39- historyWindowBefore time.Time
40- historyWindowAfter time.Time
41- historyCursorID int64
42- historyHasMore bool
43- historyNotice string
44- historyError string
45- favorites []*entity.Favorite
46- folders []*entity.Folder
47- tags []*entity.Tag
48- favoriteFolderFilter *entity.FolderID
49- favoriteTagFilter *entity.TagID
50- favoritesNotice string
51- favoritesError string
52- config *dto.SystemviewConfigPayload
53- keybindings port.KeybindingsConfig
54- configNotice string
55- configError string
56- renderedHTML string
57- renderGeneration uint64
58- closed bool
59- actionQueue chan DOMAction
60- actionErrorQueue chan error
61- actionCtx context.Context
62- actionCancel context.CancelFunc
63- actionClosed bool
64- actionWG sync.WaitGroup
27+ mu sync.Mutex
28+ actionMu sync.Mutex
29+ deps Dependencies
30+ currentRoute Route
31+ shellTheme shellTheme
32+ historyEntries []*entity.HistoryEntry
33+ historyStats *entity.HistoryStats
34+ historyAnalytics *entity.HistoryAnalytics
35+ historyDomainStats []*entity.DomainStat
36+ historyQuery string
37+ historyDomainFilter string
38+ historyOffset int
39+ historyWindowBefore time.Time
40+ historyWindowAfter time.Time
41+ historyCursorID int64
42+ historyHasMore bool
43+ historyNotice string
44+ historyError string
45+ favorites []*entity.Favorite
46+ tags []*entity.Tag
47+ favoriteTagFilter *entity.TagID
48+ favoriteUntaggedFilter bool
49+ favoritesNotice string
50+ favoritesError string
51+ config *dto.SystemviewConfigPayload
52+ keybindings port.KeybindingsConfig
53+ configNotice string
54+ configError string
55+ renderedHTML string
56+ renderGeneration uint64
57+ closed bool
58+ actionQueue chan DOMAction
59+ actionErrorQueue chan error
60+ actionCtx context.Context
61+ actionCancel context.CancelFunc
62+ actionClosed bool
63+ actionWG sync.WaitGroup
6564 }
6665
6766 const (
@@ -513,7 +512,6 @@ func routeSubtitle(route Route) string {
513512
514513 func (a *App) loadHistoryLoadingRoute() {
515514 a.favorites = nil
516- a.folders = nil
517515 a.tags = nil
518516 a.historyEntries = nil
519517 a.historyStats = nil
@@ -698,7 +696,6 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho
698696
699697 func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
700698 a.favorites = nil
701- a.folders = nil
702699 a.tags = nil
703700 a.historyEntries = result.entries
704701 a.historyStats = result.stats
@@ -727,28 +724,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
727724 if err != nil {
728725 return err
729726 }
730- folders, err := a.deps.Favorites.ListFolders(ctx)
731- if err != nil {
732- return err
733- }
734727 tags, err := a.deps.Favorites.ListTags(ctx)
735728 if err != nil {
736729 return err
737730 }
738731
739732 a.historyEntries = nil
740- favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
733+ favorites = filterFavorites(favorites, a.favoriteTagFilter, a.favoriteUntaggedFilter)
741734 a.favorites = favorites
742- a.folders = folders
743735 a.tags = tags
744736 data := favoritesRenderData{
745- Favorites: favorites,
746- Folders: folders,
747- Tags: tags,
748- FolderFilter: a.favoriteFolderFilter,
749- TagFilter: a.favoriteTagFilter,
750- Notice: a.favoritesNotice,
751- Error: a.favoritesError,
737+ Favorites: favorites,
738+ Tags: tags,
739+ TagFilter: a.favoriteTagFilter,
740+ UntaggedFilter: a.favoriteUntaggedFilter,
741+ Notice: a.favoritesNotice,
742+ Error: a.favoritesError,
752743 }
753744 a.renderedHTML = renderAppFrame(renderedPage{
754745 route: RouteFavorites,
@@ -782,7 +773,6 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
782773
783774 a.historyEntries = nil
784775 a.favorites = nil
785- a.folders = nil
786776 a.tags = nil
787777 a.config = &config
788778 a.keybindings = keybindings
@@ -835,10 +825,9 @@ func (a *App) resetRouteState() {
835825 a.historyNotice = ""
836826 a.historyError = ""
837827 a.favorites = nil
838- a.folders = nil
839828 a.tags = nil
840- a.favoriteFolderFilter = nil
841829 a.favoriteTagFilter = nil
830+ a.favoriteUntaggedFilter = false
842831 a.favoritesNotice = ""
843832 a.favoritesError = ""
844833 a.config = nil
M internal/ui/systemviews/app_test.go +14 -67
@@ -517,7 +517,6 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
517517
518518 favorites := &recordingFavoritesService{
519519 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
520- folders: []*entity.Folder{{Name: "Read Later"}},
521520 tags: []*entity.Tag{{Name: "Go"}},
522521 }
523522
@@ -529,14 +528,11 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
529528 require.NoError(t, app.LoadInitial(context.Background()))
530529 assert.Equal(t, RouteFavorites, app.CurrentRoute())
531530 assert.Len(t, app.favorites, 1)
532- assert.Len(t, app.folders, 1)
533531 assert.Len(t, app.tags, 1)
534532 assert.Contains(t, app.renderedHTML, "Favorites")
535533 assert.Contains(t, app.renderedHTML, "Example")
536- assert.Contains(t, app.renderedHTML, "Read Later")
537534 assert.Contains(t, app.renderedHTML, "Go")
538535 assert.True(t, favorites.calledList)
539- assert.True(t, favorites.calledFolders)
540536 assert.True(t, favorites.calledTags)
541537
542538 // Shell frame present, no full document wrapper.
@@ -553,11 +549,9 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
553549 func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
554550 t.Parallel()
555551
556- folderID := entity.FolderID(1)
557552 shortcut := 3
558553 favorites := &recordingFavoritesService{
559- favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},
560- folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
554+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", ShortcutKey: &shortcut}},
561555 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
562556 }
563557
@@ -567,16 +561,16 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
567561 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
568562 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
569563 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
570- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)
571- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)
572- assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
564+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.create"`)
565+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.update"`)
566+ assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
573567 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
574568 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
575569 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
576570 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
571+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.filterUntagged"`)
577572 assert.Contains(t, app.renderedHTML, `name="tags"`)
578573 assert.Contains(t, app.renderedHTML, `value="7"`)
579- assert.Contains(t, app.renderedHTML, "Read Later")
580574 assert.Contains(t, app.renderedHTML, "Go")
581575 assert.Contains(t, app.renderedHTML, "Shortcut 3")
582576 }
@@ -585,18 +579,15 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
585579 dom := &recordingDOM{}
586580 favorites := &recordingFavoritesService{
587581 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
588- folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
589582 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
590583 }
591584 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
592585
593586 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
594587 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"},
596589 }))
597- require.NotNil(t, favorites.createdFavorite.FolderID)
598590 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
599- assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
600591 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
601592 assert.Contains(t, dom.HTML(), "Added favorite New")
602593
@@ -606,6 +597,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
606597 }))
607598 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
608599 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
600+ assert.True(t, favorites.updatedFavorite.ShortcutKeySet)
609601 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
610602 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
611603 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
@@ -617,29 +609,13 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
617609 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
618610 assert.Equal(t, int64(7), favorites.assignedTagID)
619611
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-
628612 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
629613 Action: favoriteActionFilterTag,
630614 Data: map[string]string{"tagId": "7"},
631615 }))
632616 require.NotNil(t, app.favoriteTagFilter)
633617 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
634- assert.Nil(t, app.favoriteFolderFilter)
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")
618+ assert.False(t, app.favoriteUntaggedFilter)
643619
644620 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
645621 Action: favoriteActionDelete,
@@ -1199,20 +1175,14 @@ func (s *recordingHistoryService) DeleteDomain(_ context.Context, domain string)
11991175
12001176 // Recording fixture to capture favorites state for stateful render assertions.
12011177 type recordingFavoritesService struct {
1202- calledList bool
1203- calledFolders bool
1204- calledTags bool
1205- favorites []*entity.Favorite
1206- folders []*entity.Folder
1207- tags []*entity.Tag
1178+ calledList bool
1179+ calledTags bool
1180+ favorites []*entity.Favorite
1181+ tags []*entity.Tag
12081182
12091183 createdFavorite dto.FavoriteCreateInput
12101184 updatedFavorite dto.FavoriteUpdateInput
12111185 deletedFavorite int64
1212- createdFolder string
1213- createdFolderIcon string
1214- updatedFolderID int64
1215- deletedFolderID int64
12161186 createdTag string
12171187 updatedTagID int64
12181188 deletedTagID int64
@@ -1227,11 +1197,6 @@ func (s *recordingFavoritesService) List(context.Context) ([]*entity.Favorite, e
12271197 return s.favorites, nil
12281198 }
12291199
1230-func (s *recordingFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1231- s.calledFolders = true
1232- return s.folders, nil
1233-}
1234-
12351200 func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
12361201 s.calledTags = true
12371202 return s.tags, nil
@@ -1239,12 +1204,12 @@ func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, er
12391204
12401205 func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
12411206 s.createdFavorite = input
1242- return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1207+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
12431208 }
12441209
12451210 func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
12461211 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}, nil
1212+ return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, ShortcutKey: input.ShortcutKey}, nil
12481213 }
12491214
12501215 func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
@@ -1254,24 +1219,6 @@ func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64)
12541219
12551220 func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
12561221
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-
12751222 func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
12761223 s.createdTag = name
12771224 return &entity.Tag{ID: 55, Name: name, Color: color}, nil
M internal/ui/systemviews/favorite_actions.go +25 -99
@@ -12,20 +12,17 @@ import (
1212 )
1313
1414 const (
15- favoriteActionCreate = "favorite.create"
16- favoriteActionUpdate = "favorite.update"
17- favoriteActionDelete = "favorite.delete"
18- favoriteActionFilterFolder = "favorite.filterFolder"
19- favoriteActionFilterTag = "favorite.filterTag"
20- favoriteActionClearFilters = "favorite.clearFilters"
21- folderActionCreate = "folder.create"
22- folderActionUpdate = "folder.update"
23- folderActionDelete = "folder.delete"
24- tagActionCreate = "tag.create"
25- tagActionUpdate = "tag.update"
26- tagActionDelete = "tag.delete"
27- tagActionAssign = "tag.assign"
28- tagActionRemove = "tag.remove"
15+ favoriteActionCreate = "favorite.create"
16+ favoriteActionUpdate = "favorite.update"
17+ favoriteActionDelete = "favorite.delete"
18+ favoriteActionFilterTag = "favorite.filterTag"
19+ favoriteActionFilterUntagged = "favorite.filterUntagged"
20+ favoriteActionClearFilters = "favorite.clearFilters"
21+ tagActionCreate = "tag.create"
22+ tagActionUpdate = "tag.update"
23+ tagActionDelete = "tag.delete"
24+ tagActionAssign = "tag.assign"
25+ tagActionRemove = "tag.remove"
2926 )
3027
3128 //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 {
3633 data := event.Data
3734 switch event.Action {
3835 case favoriteActionCreate:
39- folderID, err := optionalFolderID(data["folder_id"])
40- if err != nil {
41- return err
42- }
4336 favoriteURL, err := validateFavoriteURL(data["url"])
4437 if err != nil {
4538 return err
@@ -49,10 +42,9 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
4942 return err
5043 }
5144 favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52- URL: favoriteURL,
53- Title: strings.TrimSpace(data["title"]),
54- FolderID: folderID,
55- Tags: tags,
45+ URL: favoriteURL,
46+ Title: strings.TrimSpace(data["title"]),
47+ Tags: tags,
5648 })
5749 if err != nil {
5850 return err
@@ -63,20 +55,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
6355 if err != nil {
6456 return err
6557 }
66- folderID, err := optionalFolderID(data["folder_id"])
67- if err != nil {
68- return err
69- }
7058 shortcut, err := optionalShortcut(data["shortcut_key"])
7159 if err != nil {
7260 return err
7361 }
7462 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75- ID: entity.FavoriteID(id),
76- Title: strings.TrimSpace(data["title"]),
77- FaviconURL: strings.TrimSpace(data["favicon_url"]),
78- FolderID: folderID,
79- ShortcutKey: shortcut,
63+ ID: entity.FavoriteID(id),
64+ Title: strings.TrimSpace(data["title"]),
65+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
66+ ShortcutKey: shortcut,
67+ ShortcutKeySet: true,
8068 })
8169 if err != nil {
8270 return err
@@ -91,14 +79,6 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
9179 return err
9280 }
9381 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 = ""
10282 case favoriteActionFilterTag:
10383 tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
10484 if err != nil {
@@ -106,45 +86,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
10686 }
10787 id := entity.TagID(tagID)
10888 a.favoriteTagFilter = &id
109- a.favoriteFolderFilter = nil
89+ a.favoriteUntaggedFilter = false
90+ a.favoritesNotice = ""
91+ case favoriteActionFilterUntagged:
92+ a.favoriteTagFilter = nil
93+ a.favoriteUntaggedFilter = true
11094 a.favoritesNotice = ""
11195 case favoriteActionClearFilters:
112- a.favoriteFolderFilter = nil
11396 a.favoriteTagFilter = nil
97+ a.favoriteUntaggedFilter = false
11498 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"
14899 case tagActionCreate:
149100 name := strings.TrimSpace(data["name"])
150101 if name == "" {
@@ -272,31 +223,6 @@ func optionalTagIDs(raw string) ([]entity.TagID, error) {
272223 return ids, nil
273224 }
274225
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-
300226 // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301227 func optionalShortcut(raw string) (*int, error) {
302228 raw = strings.TrimSpace(raw)
M internal/ui/systemviews/favorites.templ +8 -64
@@ -19,22 +19,7 @@ templ FavoritesView(data favoritesRenderData) {
1919 <ul class="sv-list">
2020 for _, favorite := range data.Favorites {
2121 if favorite != nil {
22- @FavoriteItem(favorite, data.Folders, 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)
22+ @FavoriteItem(favorite, data.Tags)
3823 }
3924 }
4025 </ul>
@@ -61,23 +46,14 @@ templ FavoritesControls(data favoritesRenderData) {
6146 <form class="sv-card-form" data-sv-action="favorite.create">
6247 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
6348 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64- @FolderSelect("folder_id", data.Folders, nil, "Folder")
6549 @FavoriteCreateTagPicker(data.Tags)
6650 <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button>
6751 </form>
6852 <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>
7953 <div class="sv-button-row">
8054 <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>
8157 for _, tag := range data.Tags {
8258 if tag != nil {
8359 <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) {
10884 }
10985 }
11086
111-templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
87+templ FavoriteItem(favorite *entity.Favorite, tags []*entity.Tag) {
11288 <li class="sv-list-row">
11389 <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
11490 <div class="sv-favorite-header">
11591 <div>
11692 <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
11793 <p class="sv-history-url">{ favorite.URL }</p>
118- if favoriteMetaText(favorite, folders) != "" {
119- <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>
94+ if favoriteMetaText(favorite) != "" {
95+ <p class="sv-meta">{ favoriteMetaText(favorite) }</p>
12096 }
12197 </div>
12298 <div class="sv-history-row-actions">
@@ -124,18 +100,17 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*
124100 <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>
125101 </div>
126102 </div>
127- @FavoriteEditForm(favorite, folders)
103+ @FavoriteEditForm(favorite)
128104 @FavoriteTagControls(favorite, tags)
129105 </article>
130106 </li>
131107 }
132108
133-templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
109+templ FavoriteEditForm(favorite *entity.Favorite) {
134110 <form class="sv-inline-form" data-sv-action="favorite.update">
135111 <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
136112 <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
137113 <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
138- @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
139114 @ShortcutSelect(favorite.ShortcutKey)
140115 <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
141116 </form>
@@ -156,26 +131,6 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
156131 }
157132 }
158133
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-
179134 templ TagCreateForm() {
180135 <form class="sv-inline-form" data-sv-action="tag.create">
181136 <label><span>Name</span><input name="name" type="text" required/></label>
@@ -197,17 +152,6 @@ templ TagItem(tag *entity.Tag) {
197152 </li>
198153 }
199154
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-
211155 templ ShortcutSelect(selected *int) {
212156 <label><span>Shortcut</span><select name="shortcut_key">
213157 <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 (
1010 const defaultTagColor = "#808080"
1111
1212 type favoritesRenderData struct {
13- Favorites []*entity.Favorite
14- Folders []*entity.Folder
15- Tags []*entity.Tag
16- FolderFilter *entity.FolderID
17- TagFilter *entity.TagID
18- Notice string
19- Error string
13+ Favorites []*entity.Favorite
14+ Tags []*entity.Tag
15+ TagFilter *entity.TagID
16+ UntaggedFilter bool
17+ Notice string
18+ Error string
2019 }
2120
2221 func favoritesHTML(data favoritesRenderData) string {
@@ -25,7 +24,7 @@ func favoritesHTML(data favoritesRenderData) string {
2524
2625 func favoritesDocumentTitle(data favoritesRenderData) string {
2726 count := len(data.Favorites)
28- if data.FolderFilter != nil || data.TagFilter != nil {
27+ if data.TagFilter != nil || data.UntaggedFilter {
2928 if count == 1 {
3029 return "Favorites — 1 filtered bookmark"
3130 }
@@ -38,11 +37,7 @@ func favoritesDocumentTitle(data favoritesRenderData) string {
3837 }
3938
4039 func favoritesSummary(data favoritesRenderData) string {
41- return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
42-}
43-
44-func foldersSummary(data favoritesRenderData) string {
45- return fmt.Sprintf("%d folders", len(data.Folders))
40+ return fmt.Sprintf("%d favorites · %d tags", len(data.Favorites), len(data.Tags))
4641 }
4742
4843 func tagsSummary(data favoritesRenderData) string {
@@ -57,12 +52,12 @@ func favoriteFilterClass(active bool) string {
5752 return classes
5853 }
5954
60-func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {
61- return data.FolderFilter != nil && *data.FolderFilter == folderID
55+func favoriteUntaggedFilterActive(data favoritesRenderData) bool {
56+ return data.UntaggedFilter
6257 }
6358
6459 func favoriteAllFilterActive(data favoritesRenderData) bool {
65- return data.FolderFilter == nil && data.TagFilter == nil
60+ return data.TagFilter == nil && !data.UntaggedFilter
6661 }
6762
6863 func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
@@ -80,18 +75,11 @@ func favoriteItemLabel(favorite *entity.Favorite) string {
8075 return label
8176 }
8277
83-func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {
84- if favorite == nil {
78+func favoriteMetaText(favorite *entity.Favorite) string {
79+ if favorite == nil || favorite.ShortcutKey == nil {
8580 return ""
8681 }
87- parts := make([]string, 0, 2)
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, " · ")
82+ return fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)
9583 }
9684
9785 func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
@@ -136,38 +124,12 @@ func safeTagColor(raw string) string {
136124 return value
137125 }
138126
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-
165127 func shortcutSelected(selected *int, value int) bool {
166128 return selected != nil && *selected == value
167129 }
168130
169-func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {
170- if folderID == nil && tagID == nil {
131+func filterFavorites(favorites []*entity.Favorite, tagID *entity.TagID, untagged bool) []*entity.Favorite {
132+ if tagID == nil && !untagged {
171133 return favorites
172134 }
173135 out := make([]*entity.Favorite, 0, len(favorites))
@@ -175,16 +137,11 @@ func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, ta
175137 if favorite == nil {
176138 continue
177139 }
178- if folderID != nil {
179- if *folderID == 0 {
180- if favorite.FolderID != nil {
181- continue
182- }
183- } else if favorite.FolderID == nil || *favorite.FolderID != *folderID {
140+ if untagged {
141+ if len(favorite.Tags) != 0 {
184142 continue
185143 }
186- }
187- if tagID != nil && !favorite.HasTag(*tagID) {
144+ } else if tagID != nil && !favorite.HasTag(*tagID) {
188145 continue
189146 }
190147 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