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
@@ -95,7 +95,7 @@ func FavoritesView(data favoritesRenderData) templ.Component {
9595 }
9696 for _, favorite := range data.Favorites {
9797 if favorite != nil {
98- templ_7745c5c3_Err = FavoriteItem(favorite, data.Folders, data.Tags).Render(ctx, templ_7745c5c3_Buffer)
98+ templ_7745c5c3_Err = FavoriteItem(favorite, data.Tags).Render(ctx, templ_7745c5c3_Buffer)
9999 if templ_7745c5c3_Err != nil {
100100 return templ_7745c5c3_Err
101101 }
@@ -113,63 +113,6 @@ func FavoritesView(data favoritesRenderData) templ.Component {
113113 return templ_7745c5c3_Err
114114 }
115115 templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
116- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
117- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
118- if !templ_7745c5c3_IsBuffer {
119- defer func() {
120- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
121- if templ_7745c5c3_Err == nil {
122- templ_7745c5c3_Err = templ_7745c5c3_BufErr
123- }
124- }()
125- }
126- ctx = templ.InitializeContext(ctx)
127- templ_7745c5c3_Err = Meta(foldersSummary(data)).Render(ctx, templ_7745c5c3_Buffer)
128- if templ_7745c5c3_Err != nil {
129- return templ_7745c5c3_Err
130- }
131- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
132- if templ_7745c5c3_Err != nil {
133- return templ_7745c5c3_Err
134- }
135- templ_7745c5c3_Err = FolderCreateForm().Render(ctx, templ_7745c5c3_Buffer)
136- if templ_7745c5c3_Err != nil {
137- return templ_7745c5c3_Err
138- }
139- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ")
140- if templ_7745c5c3_Err != nil {
141- return templ_7745c5c3_Err
142- }
143- if len(data.Folders) == 0 {
144- templ_7745c5c3_Err = EmptyState("No folders").Render(ctx, templ_7745c5c3_Buffer)
145- if templ_7745c5c3_Err != nil {
146- return templ_7745c5c3_Err
147- }
148- } else {
149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<ul class=\"sv-list\">")
150- if templ_7745c5c3_Err != nil {
151- return templ_7745c5c3_Err
152- }
153- for _, folder := range data.Folders {
154- if folder != nil {
155- templ_7745c5c3_Err = FolderItem(folder).Render(ctx, templ_7745c5c3_Buffer)
156- if templ_7745c5c3_Err != nil {
157- return templ_7745c5c3_Err
158- }
159- }
160- }
161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul>")
162- if templ_7745c5c3_Err != nil {
163- return templ_7745c5c3_Err
164- }
165- }
166- return nil
167- })
168- templ_7745c5c3_Err = Section("sv-folders-list", "Folders").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
169- if templ_7745c5c3_Err != nil {
170- return templ_7745c5c3_Err
171- }
172- templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
173116 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
174117 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
175118 if !templ_7745c5c3_IsBuffer {
@@ -185,7 +128,7 @@ func FavoritesView(data favoritesRenderData) templ.Component {
185128 if templ_7745c5c3_Err != nil {
186129 return templ_7745c5c3_Err
187130 }
188- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ")
131+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
189132 if templ_7745c5c3_Err != nil {
190133 return templ_7745c5c3_Err
191134 }
@@ -193,7 +136,7 @@ func FavoritesView(data favoritesRenderData) templ.Component {
193136 if templ_7745c5c3_Err != nil {
194137 return templ_7745c5c3_Err
195138 }
196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ")
139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ")
197140 if templ_7745c5c3_Err != nil {
198141 return templ_7745c5c3_Err
199142 }
@@ -203,7 +146,7 @@ func FavoritesView(data favoritesRenderData) templ.Component {
203146 return templ_7745c5c3_Err
204147 }
205148 } else {
206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<ul class=\"sv-list\">")
149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<ul class=\"sv-list\">")
207150 if templ_7745c5c3_Err != nil {
208151 return templ_7745c5c3_Err
209152 }
@@ -215,14 +158,14 @@ func FavoritesView(data favoritesRenderData) templ.Component {
215158 }
216159 }
217160 }
218- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul>")
161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul>")
219162 if templ_7745c5c3_Err != nil {
220163 return templ_7745c5c3_Err
221164 }
222165 }
223166 return nil
224167 })
225- templ_7745c5c3_Err = Section("sv-tags-list", "Tags").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
168+ templ_7745c5c3_Err = Section("sv-tags-list", "Tags").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
226169 if templ_7745c5c3_Err != nil {
227170 return templ_7745c5c3_Err
228171 }
@@ -246,16 +189,12 @@ func FavoritesControls(data favoritesRenderData) templ.Component {
246189 }()
247190 }
248191 ctx = templ.InitializeContext(ctx)
249- templ_7745c5c3_Var6 := templ.GetChildren(ctx)
250- if templ_7745c5c3_Var6 == nil {
251- templ_7745c5c3_Var6 = templ.NopComponent
192+ templ_7745c5c3_Var5 := templ.GetChildren(ctx)
193+ if templ_7745c5c3_Var5 == nil {
194+ templ_7745c5c3_Var5 = templ.NopComponent
252195 }
253196 ctx = templ.ClearChildren(ctx)
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form class=\"sv-card-form\" data-sv-action=\"favorite.create\"><label><span>URL</span><input name=\"url\" type=\"url\" placeholder=\"https://example.com\" required></label> <label><span>Title</span><input name=\"title\" type=\"text\" placeholder=\"Optional title\"></label>")
255- if templ_7745c5c3_Err != nil {
256- return templ_7745c5c3_Err
257- }
258- templ_7745c5c3_Err = FolderSelect("folder_id", data.Folders, nil, "Folder").Render(ctx, templ_7745c5c3_Buffer)
197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<form class=\"sv-card-form\" data-sv-action=\"favorite.create\"><label><span>URL</span><input name=\"url\" type=\"url\" placeholder=\"https://example.com\" required></label> <label><span>Title</span><input name=\"title\" type=\"text\" placeholder=\"Optional title\"></label>")
259198 if templ_7745c5c3_Err != nil {
260199 return templ_7745c5c3_Err
261200 }
@@ -263,7 +202,7 @@ func FavoritesControls(data favoritesRenderData) templ.Component {
263202 if templ_7745c5c3_Err != nil {
264203 return templ_7745c5c3_Err
265204 }
266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<button class=\"sv-button\" type=\"submit\">")
205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<button class=\"sv-button\" type=\"submit\">")
267206 if templ_7745c5c3_Err != nil {
268207 return templ_7745c5c3_Err
269208 }
@@ -271,228 +210,159 @@ func FavoritesControls(data favoritesRenderData) templ.Component {
271210 if templ_7745c5c3_Err != nil {
272211 return templ_7745c5c3_Err
273212 }
274- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button></form><div class=\"sv-filter-panel\"><div class=\"sv-button-row\"><span class=\"sv-meta\">Folders</span> ")
213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></form><div class=\"sv-filter-panel\"><div class=\"sv-button-row\"><span class=\"sv-meta\">Tags</span> ")
275214 if templ_7745c5c3_Err != nil {
276215 return templ_7745c5c3_Err
277216 }
278- var templ_7745c5c3_Var7 = []any{favoriteFilterClass(favoriteAllFilterActive(data))}
279- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
217+ var templ_7745c5c3_Var6 = []any{favoriteFilterClass(favoriteAllFilterActive(data))}
218+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
280219 if templ_7745c5c3_Err != nil {
281220 return templ_7745c5c3_Err
282221 }
283- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button type=\"button\" class=\"")
222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<button type=\"button\" class=\"")
284223 if templ_7745c5c3_Err != nil {
285224 return templ_7745c5c3_Err
286225 }
287- var templ_7745c5c3_Var8 string
288- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
226+ var templ_7745c5c3_Var7 string
227+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
289228 if templ_7745c5c3_Err != nil {
290229 return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 1, Col: 0}
291230 }
292- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
231+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
293232 if templ_7745c5c3_Err != nil {
294233 return templ_7745c5c3_Err
295234 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" aria-pressed=\"")
235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" aria-pressed=\"")
297236 if templ_7745c5c3_Err != nil {
298237 return templ_7745c5c3_Err
299238 }
300- var templ_7745c5c3_Var9 string
301- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteAllFilterActive(data)))
239+ var templ_7745c5c3_Var8 string
240+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteAllFilterActive(data)))
302241 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 71, Col: 149}
242+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 55, Col: 149}
304243 }
305- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
244+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
306245 if templ_7745c5c3_Err != nil {
307246 return templ_7745c5c3_Err
308247 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-sv-action=\"favorite.clearFilters\">All</button> ")
248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-sv-action=\"favorite.clearFilters\">All</button> ")
310249 if templ_7745c5c3_Err != nil {
311250 return templ_7745c5c3_Err
312251 }
313- var templ_7745c5c3_Var10 = []any{favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0)))}
314- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
252+ var templ_7745c5c3_Var9 = []any{favoriteFilterClass(favoriteUntaggedFilterActive(data))}
253+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
315254 if templ_7745c5c3_Err != nil {
316255 return templ_7745c5c3_Err
317256 }
318- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" class=\"")
257+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" class=\"")
319258 if templ_7745c5c3_Err != nil {
320259 return templ_7745c5c3_Err
321260 }
322- var templ_7745c5c3_Var11 string
323- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
261+ var templ_7745c5c3_Var10 string
262+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var9).String())
324263 if templ_7745c5c3_Err != nil {
325264 return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 1, Col: 0}
326265 }
327- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
266+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
328267 if templ_7745c5c3_Err != nil {
329268 return templ_7745c5c3_Err
330269 }
331- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" aria-pressed=\"")
270+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" aria-pressed=\"")
332271 if templ_7745c5c3_Err != nil {
333272 return templ_7745c5c3_Err
334273 }
335- var templ_7745c5c3_Var12 string
336- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))))
274+ var templ_7745c5c3_Var11 string
275+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteUntaggedFilterActive(data)))
337276 if templ_7745c5c3_Err != nil {
338- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 72, Col: 195}
277+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 56, Col: 159}
339278 }
340- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
279+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
341280 if templ_7745c5c3_Err != nil {
342281 return templ_7745c5c3_Err
343282 }
344- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"root\">Unfiled</button> ")
345- if templ_7745c5c3_Err != nil {
346- return templ_7745c5c3_Err
347- }
348- for _, folder := range data.Folders {
349- if folder != nil {
350- var templ_7745c5c3_Var13 = []any{favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID))}
351- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...)
352- if templ_7745c5c3_Err != nil {
353- return templ_7745c5c3_Err
354- }
355- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" class=\"")
356- if templ_7745c5c3_Err != nil {
357- return templ_7745c5c3_Err
358- }
359- var templ_7745c5c3_Var14 string
360- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var13).String())
361- if templ_7745c5c3_Err != nil {
362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 1, Col: 0}
363- }
364- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
365- if templ_7745c5c3_Err != nil {
366- return templ_7745c5c3_Err
367- }
368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" aria-pressed=\"")
369- if templ_7745c5c3_Err != nil {
370- return templ_7745c5c3_Err
371- }
372- var templ_7745c5c3_Var15 string
373- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)))
374- if templ_7745c5c3_Err != nil {
375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 75, Col: 179}
376- }
377- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
378- if templ_7745c5c3_Err != nil {
379- return templ_7745c5c3_Err
380- }
381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-sv-action=\"favorite.filterFolder\" data-folder-id=\"")
382- if templ_7745c5c3_Err != nil {
383- return templ_7745c5c3_Err
384- }
385- var templ_7745c5c3_Var16 string
386- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", folder.ID))
387- if templ_7745c5c3_Err != nil {
388- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 75, Col: 266}
389- }
390- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
391- if templ_7745c5c3_Err != nil {
392- return templ_7745c5c3_Err
393- }
394- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
395- if templ_7745c5c3_Err != nil {
396- return templ_7745c5c3_Err
397- }
398- var templ_7745c5c3_Var17 string
399- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folderDisplayName(folder))
400- if templ_7745c5c3_Err != nil {
401- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 75, Col: 296}
402- }
403- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
404- if templ_7745c5c3_Err != nil {
405- return templ_7745c5c3_Err
406- }
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</button>")
408- if templ_7745c5c3_Err != nil {
409- return templ_7745c5c3_Err
410- }
411- }
412- }
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"sv-button-row\"><span class=\"sv-meta\">Tags</span> ")
283+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-sv-action=\"favorite.filterUntagged\">Untagged</button> ")
414284 if templ_7745c5c3_Err != nil {
415285 return templ_7745c5c3_Err
416286 }
417287 for _, tag := range data.Tags {
418288 if tag != nil {
419- var templ_7745c5c3_Var18 = []any{favoriteFilterClass(favoriteTagFilterActive(data, tag.ID))}
420- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
289+ var templ_7745c5c3_Var12 = []any{favoriteFilterClass(favoriteTagFilterActive(data, tag.ID))}
290+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
421291 if templ_7745c5c3_Err != nil {
422292 return templ_7745c5c3_Err
423293 }
424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button type=\"button\" class=\"")
294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button type=\"button\" class=\"")
425295 if templ_7745c5c3_Err != nil {
426296 return templ_7745c5c3_Err
427297 }
428- var templ_7745c5c3_Var19 string
429- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
298+ var templ_7745c5c3_Var13 string
299+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
430300 if templ_7745c5c3_Err != nil {
431301 return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 1, Col: 0}
432302 }
433- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
303+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
434304 if templ_7745c5c3_Err != nil {
435305 return templ_7745c5c3_Err
436306 }
437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" aria-pressed=\"")
307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" aria-pressed=\"")
438308 if templ_7745c5c3_Err != nil {
439309 return templ_7745c5c3_Err
440310 }
441- var templ_7745c5c3_Var20 string
442- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)))
311+ var templ_7745c5c3_Var14 string
312+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)))
443313 if templ_7745c5c3_Err != nil {
444- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 83, Col: 167}
314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 59, Col: 167}
445315 }
446- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
316+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
447317 if templ_7745c5c3_Err != nil {
448318 return templ_7745c5c3_Err
449319 }
450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" data-sv-action=\"favorite.filterTag\" data-tag-id=\"")
320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sv-action=\"favorite.filterTag\" data-tag-id=\"")
451321 if templ_7745c5c3_Err != nil {
452322 return templ_7745c5c3_Err
453323 }
454- var templ_7745c5c3_Var21 string
455- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
324+ var templ_7745c5c3_Var15 string
325+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
456326 if templ_7745c5c3_Err != nil {
457- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 83, Col: 245}
327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 59, Col: 245}
458328 }
459- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
329+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
460330 if templ_7745c5c3_Err != nil {
461331 return templ_7745c5c3_Err
462332 }
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><span class=\"sv-tag-dot\" style=\"")
333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><span class=\"sv-tag-dot\" style=\"")
464334 if templ_7745c5c3_Err != nil {
465335 return templ_7745c5c3_Err
466336 }
467- var templ_7745c5c3_Var22 string
468- templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
337+ var templ_7745c5c3_Var16 string
338+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
469339 if templ_7745c5c3_Err != nil {
470- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 84, Col: 78}
340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 60, Col: 78}
471341 }
472- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
342+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
473343 if templ_7745c5c3_Err != nil {
474344 return templ_7745c5c3_Err
475345 }
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"></span>")
346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"></span>")
477347 if templ_7745c5c3_Err != nil {
478348 return templ_7745c5c3_Err
479349 }
480- var templ_7745c5c3_Var23 string
481- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tag.Name)
350+ var templ_7745c5c3_Var17 string
351+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(tag.Name)
482352 if templ_7745c5c3_Err != nil {
483- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 84, Col: 98}
353+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 60, Col: 98}
484354 }
485- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
355+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
486356 if templ_7745c5c3_Err != nil {
487357 return templ_7745c5c3_Err
488358 }
489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button>")
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</button>")
490360 if templ_7745c5c3_Err != nil {
491361 return templ_7745c5c3_Err
492362 }
493363 }
494364 }
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div><p class=\"sv-meta sv-key-hints\">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>")
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div><p class=\"sv-meta sv-key-hints\">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>")
496366 if templ_7745c5c3_Err != nil {
497367 return templ_7745c5c3_Err
498368 }
@@ -516,64 +386,64 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component {
516386 }()
517387 }
518388 ctx = templ.InitializeContext(ctx)
519- templ_7745c5c3_Var24 := templ.GetChildren(ctx)
520- if templ_7745c5c3_Var24 == nil {
521- templ_7745c5c3_Var24 = templ.NopComponent
389+ templ_7745c5c3_Var18 := templ.GetChildren(ctx)
390+ if templ_7745c5c3_Var18 == nil {
391+ templ_7745c5c3_Var18 = templ.NopComponent
522392 }
523393 ctx = templ.ClearChildren(ctx)
524394 if len(tags) > 0 {
525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<fieldset class=\"sv-tag-picker\"><legend>Tags</legend><div class=\"sv-tag-controls\">")
395+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<fieldset class=\"sv-tag-picker\"><legend>Tags</legend><div class=\"sv-tag-controls\">")
526396 if templ_7745c5c3_Err != nil {
527397 return templ_7745c5c3_Err
528398 }
529399 for _, tag := range tags {
530400 if tag != nil {
531- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<label class=\"sv-tag-toggle\"><input type=\"checkbox\" name=\"tags\" value=\"")
401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<label class=\"sv-tag-toggle\"><input type=\"checkbox\" name=\"tags\" value=\"")
532402 if templ_7745c5c3_Err != nil {
533403 return templ_7745c5c3_Err
534404 }
535- var templ_7745c5c3_Var25 string
536- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
405+ var templ_7745c5c3_Var19 string
406+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
537407 if templ_7745c5c3_Err != nil {
538- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 101, Col: 75}
408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 77, Col: 75}
539409 }
540- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
410+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
541411 if templ_7745c5c3_Err != nil {
542412 return templ_7745c5c3_Err
543413 }
544- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <span><span class=\"sv-tag-dot\" style=\"")
414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"> <span><span class=\"sv-tag-dot\" style=\"")
545415 if templ_7745c5c3_Err != nil {
546416 return templ_7745c5c3_Err
547417 }
548- var templ_7745c5c3_Var26 string
549- templ_7745c5c3_Var26, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
418+ var templ_7745c5c3_Var20 string
419+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
550420 if templ_7745c5c3_Err != nil {
551- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 102, Col: 85}
421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 78, Col: 85}
552422 }
553- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
554424 if templ_7745c5c3_Err != nil {
555425 return templ_7745c5c3_Err
556426 }
557- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"></span>")
427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></span>")
558428 if templ_7745c5c3_Err != nil {
559429 return templ_7745c5c3_Err
560430 }
561- var templ_7745c5c3_Var27 string
562- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tag.Name)
431+ var templ_7745c5c3_Var21 string
432+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(tag.Name)
563433 if templ_7745c5c3_Err != nil {
564- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 102, Col: 105}
434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 78, Col: 105}
565435 }
566- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
567437 if templ_7745c5c3_Err != nil {
568438 return templ_7745c5c3_Err
569439 }
570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></label>")
440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></label>")
571441 if templ_7745c5c3_Err != nil {
572442 return templ_7745c5c3_Err
573443 }
574444 }
575445 }
576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></fieldset>")
446+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></fieldset>")
577447 if templ_7745c5c3_Err != nil {
578448 return templ_7745c5c3_Err
579449 }
@@ -582,7 +452,7 @@ func FavoriteCreateTagPicker(tags []*entity.Tag) templ.Component {
582452 })
583453 }
584454
585-func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) templ.Component {
455+func FavoriteItem(favorite *entity.Favorite, tags []*entity.Tag) templ.Component {
586456 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
587457 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
588458 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -598,100 +468,100 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e
598468 }()
599469 }
600470 ctx = templ.InitializeContext(ctx)
601- templ_7745c5c3_Var28 := templ.GetChildren(ctx)
602- if templ_7745c5c3_Var28 == nil {
603- templ_7745c5c3_Var28 = templ.NopComponent
471+ templ_7745c5c3_Var22 := templ.GetChildren(ctx)
472+ if templ_7745c5c3_Var22 == nil {
473+ templ_7745c5c3_Var22 = templ.NopComponent
604474 }
605475 ctx = templ.ClearChildren(ctx)
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li class=\"sv-list-row\"><article class=\"sv-favorite-item\" data-sv-favorite-row data-favorite-id=\"")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"sv-list-row\"><article class=\"sv-favorite-item\" data-sv-favorite-row data-favorite-id=\"")
607477 if templ_7745c5c3_Err != nil {
608478 return templ_7745c5c3_Err
609479 }
610- var templ_7745c5c3_Var29 string
611- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
480+ var templ_7745c5c3_Var23 string
481+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
612482 if templ_7745c5c3_Err != nil {
613- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 113, Col: 106}
483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 89, Col: 106}
614484 }
615- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
616486 if templ_7745c5c3_Err != nil {
617487 return templ_7745c5c3_Err
618488 }
619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"sv-favorite-header\"><div><a class=\"sv-link sv-favorite-open\" href=\"")
489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><div class=\"sv-favorite-header\"><div><a class=\"sv-link sv-favorite-open\" href=\"")
620490 if templ_7745c5c3_Err != nil {
621491 return templ_7745c5c3_Err
622492 }
623- var templ_7745c5c3_Var30 templ.SafeURL
624- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(sanitizeHref(favorite.URL)))
493+ var templ_7745c5c3_Var24 templ.SafeURL
494+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(sanitizeHref(favorite.URL)))
625495 if templ_7745c5c3_Err != nil {
626- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 116, Col: 89}
496+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 92, Col: 89}
627497 }
628- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
498+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
629499 if templ_7745c5c3_Err != nil {
630500 return templ_7745c5c3_Err
631501 }
632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
502+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
633503 if templ_7745c5c3_Err != nil {
634504 return templ_7745c5c3_Err
635505 }
636- var templ_7745c5c3_Var31 string
637- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteItemLabel(favorite))
506+ var templ_7745c5c3_Var25 string
507+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteItemLabel(favorite))
638508 if templ_7745c5c3_Err != nil {
639- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 116, Col: 121}
509+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 92, Col: 121}
640510 }
641- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
511+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
642512 if templ_7745c5c3_Err != nil {
643513 return templ_7745c5c3_Err
644514 }
645- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a><p class=\"sv-history-url\">")
515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</a><p class=\"sv-history-url\">")
646516 if templ_7745c5c3_Err != nil {
647517 return templ_7745c5c3_Err
648518 }
649- var templ_7745c5c3_Var32 string
650- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(favorite.URL)
519+ var templ_7745c5c3_Var26 string
520+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(favorite.URL)
651521 if templ_7745c5c3_Err != nil {
652- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 117, Col: 45}
522+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 93, Col: 45}
653523 }
654- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
524+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
655525 if templ_7745c5c3_Err != nil {
656526 return templ_7745c5c3_Err
657527 }
658- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p>")
528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p>")
659529 if templ_7745c5c3_Err != nil {
660530 return templ_7745c5c3_Err
661531 }
662- if favoriteMetaText(favorite, folders) != "" {
663- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<p class=\"sv-meta\">")
532+ if favoriteMetaText(favorite) != "" {
533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"sv-meta\">")
664534 if templ_7745c5c3_Err != nil {
665535 return templ_7745c5c3_Err
666536 }
667- var templ_7745c5c3_Var33 string
668- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteMetaText(favorite, folders))
537+ var templ_7745c5c3_Var27 string
538+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteMetaText(favorite))
669539 if templ_7745c5c3_Err != nil {
670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 119, Col: 62}
540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 95, Col: 53}
671541 }
672- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
542+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
673543 if templ_7745c5c3_Err != nil {
674544 return templ_7745c5c3_Err
675545 }
676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</p>")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p>")
677547 if templ_7745c5c3_Err != nil {
678548 return templ_7745c5c3_Err
679549 }
680550 }
681- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"sv-history-row-actions\"><a class=\"sv-button sv-button-secondary\" href=\"")
551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div><div class=\"sv-history-row-actions\"><a class=\"sv-button sv-button-secondary\" href=\"")
682552 if templ_7745c5c3_Err != nil {
683553 return templ_7745c5c3_Err
684554 }
685- var templ_7745c5c3_Var34 templ.SafeURL
686- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(sanitizeHref(favorite.URL)))
555+ var templ_7745c5c3_Var28 templ.SafeURL
556+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(sanitizeHref(favorite.URL)))
687557 if templ_7745c5c3_Err != nil {
688- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 123, Col: 94}
558+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 99, Col: 94}
689559 }
690- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
560+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
691561 if templ_7745c5c3_Err != nil {
692562 return templ_7745c5c3_Err
693563 }
694- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")
564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
695565 if templ_7745c5c3_Err != nil {
696566 return templ_7745c5c3_Err
697567 }
@@ -699,20 +569,20 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e
699569 if templ_7745c5c3_Err != nil {
700570 return templ_7745c5c3_Err
701571 }
702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"favorite.delete\" data-id=\"")
572+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</a> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"favorite.delete\" data-id=\"")
703573 if templ_7745c5c3_Err != nil {
704574 return templ_7745c5c3_Err
705575 }
706- var templ_7745c5c3_Var35 string
707- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
576+ var templ_7745c5c3_Var29 string
577+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
708578 if templ_7745c5c3_Err != nil {
709- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 124, Col: 175}
579+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 100, Col: 175}
710580 }
711- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
581+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
712582 if templ_7745c5c3_Err != nil {
713583 return templ_7745c5c3_Err
714584 }
715- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-sv-confirm=\"Delete this favorite?\" aria-label=\"Delete this favorite\">")
585+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" data-sv-confirm=\"Delete this favorite?\" aria-label=\"Delete this favorite\">")
716586 if templ_7745c5c3_Err != nil {
717587 return templ_7745c5c3_Err
718588 }
@@ -720,11 +590,11 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e
720590 if templ_7745c5c3_Err != nil {
721591 return templ_7745c5c3_Err
722592 }
723- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button></div></div>")
593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</button></div></div>")
724594 if templ_7745c5c3_Err != nil {
725595 return templ_7745c5c3_Err
726596 }
727- templ_7745c5c3_Err = FavoriteEditForm(favorite, folders).Render(ctx, templ_7745c5c3_Buffer)
597+ templ_7745c5c3_Err = FavoriteEditForm(favorite).Render(ctx, templ_7745c5c3_Buffer)
728598 if templ_7745c5c3_Err != nil {
729599 return templ_7745c5c3_Err
730600 }
@@ -732,7 +602,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e
732602 if templ_7745c5c3_Err != nil {
733603 return templ_7745c5c3_Err
734604 }
735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</article></li>")
605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</article></li>")
736606 if templ_7745c5c3_Err != nil {
737607 return templ_7745c5c3_Err
738608 }
@@ -740,7 +610,7 @@ func FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*e
740610 })
741611 }
742612
743-func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ.Component {
613+func FavoriteEditForm(favorite *entity.Favorite) templ.Component {
744614 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
745615 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
746616 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -756,55 +626,51 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ
756626 }()
757627 }
758628 ctx = templ.InitializeContext(ctx)
759- templ_7745c5c3_Var36 := templ.GetChildren(ctx)
760- if templ_7745c5c3_Var36 == nil {
761- templ_7745c5c3_Var36 = templ.NopComponent
629+ templ_7745c5c3_Var30 := templ.GetChildren(ctx)
630+ if templ_7745c5c3_Var30 == nil {
631+ templ_7745c5c3_Var30 = templ.NopComponent
762632 }
763633 ctx = templ.ClearChildren(ctx)
764- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<form class=\"sv-inline-form\" data-sv-action=\"favorite.update\"><input type=\"hidden\" name=\"id\" value=\"")
634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<form class=\"sv-inline-form\" data-sv-action=\"favorite.update\"><input type=\"hidden\" name=\"id\" value=\"")
765635 if templ_7745c5c3_Err != nil {
766636 return templ_7745c5c3_Err
767637 }
768- var templ_7745c5c3_Var37 string
769- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
638+ var templ_7745c5c3_Var31 string
639+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
770640 if templ_7745c5c3_Err != nil {
771- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 135, Col: 71}
641+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 111, Col: 71}
772642 }
773- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
643+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
774644 if templ_7745c5c3_Err != nil {
775645 return templ_7745c5c3_Err
776646 }
777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"> <label><span>Title</span><input name=\"title\" type=\"text\" value=\"")
647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"> <label><span>Title</span><input name=\"title\" type=\"text\" value=\"")
778648 if templ_7745c5c3_Err != nil {
779649 return templ_7745c5c3_Err
780650 }
781- var templ_7745c5c3_Var38 string
782- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(favorite.Title)
651+ var templ_7745c5c3_Var32 string
652+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(favorite.Title)
783653 if templ_7745c5c3_Err != nil {
784- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 136, Col: 81}
654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 112, Col: 81}
785655 }
786- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
787657 if templ_7745c5c3_Err != nil {
788658 return templ_7745c5c3_Err
789659 }
790- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"></label> <label><span>Favicon</span><input name=\"favicon_url\" type=\"url\" value=\"")
660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"></label> <label><span>Favicon</span><input name=\"favicon_url\" type=\"url\" value=\"")
791661 if templ_7745c5c3_Err != nil {
792662 return templ_7745c5c3_Err
793663 }
794- var templ_7745c5c3_Var39 string
795- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(favorite.FaviconURL)
664+ var templ_7745c5c3_Var33 string
665+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(favorite.FaviconURL)
796666 if templ_7745c5c3_Err != nil {
797- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 137, Col: 93}
667+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 113, Col: 93}
798668 }
799- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
669+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
800670 if templ_7745c5c3_Err != nil {
801671 return templ_7745c5c3_Err
802672 }
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"></label>")
804- if templ_7745c5c3_Err != nil {
805- return templ_7745c5c3_Err
806- }
807- templ_7745c5c3_Err = FolderSelect("folder_id", folders, favorite.FolderID, "Folder").Render(ctx, templ_7745c5c3_Buffer)
673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"></label>")
808674 if templ_7745c5c3_Err != nil {
809675 return templ_7745c5c3_Err
810676 }
@@ -812,7 +678,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ
812678 if templ_7745c5c3_Err != nil {
813679 return templ_7745c5c3_Err
814680 }
815- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button class=\"sv-button sv-button-secondary\" type=\"submit\">")
681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button class=\"sv-button sv-button-secondary\" type=\"submit\">")
816682 if templ_7745c5c3_Err != nil {
817683 return templ_7745c5c3_Err
818684 }
@@ -820,7 +686,7 @@ func FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) templ
820686 if templ_7745c5c3_Err != nil {
821687 return templ_7745c5c3_Err
822688 }
823- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</button></form>")
689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></form>")
824690 if templ_7745c5c3_Err != nil {
825691 return templ_7745c5c3_Err
826692 }
@@ -844,108 +710,108 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co
844710 }()
845711 }
846712 ctx = templ.InitializeContext(ctx)
847- templ_7745c5c3_Var40 := templ.GetChildren(ctx)
848- if templ_7745c5c3_Var40 == nil {
849- templ_7745c5c3_Var40 = templ.NopComponent
713+ templ_7745c5c3_Var34 := templ.GetChildren(ctx)
714+ if templ_7745c5c3_Var34 == nil {
715+ templ_7745c5c3_Var34 = templ.NopComponent
850716 }
851717 ctx = templ.ClearChildren(ctx)
852718 if len(tags) > 0 {
853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"sv-tag-controls\"><span class=\"sv-meta\">Tags</span> ")
719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"sv-tag-controls\"><span class=\"sv-meta\">Tags</span> ")
854720 if templ_7745c5c3_Err != nil {
855721 return templ_7745c5c3_Err
856722 }
857723 for _, tag := range tags {
858724 if tag != nil {
859- var templ_7745c5c3_Var41 = []any{favoriteTagButtonClass(favorite, tag)}
860- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...)
725+ var templ_7745c5c3_Var35 = []any{favoriteTagButtonClass(favorite, tag)}
726+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...)
861727 if templ_7745c5c3_Err != nil {
862728 return templ_7745c5c3_Err
863729 }
864- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button type=\"button\" class=\"")
730+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<button type=\"button\" class=\"")
865731 if templ_7745c5c3_Err != nil {
866732 return templ_7745c5c3_Err
867733 }
868- var templ_7745c5c3_Var42 string
869- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var41).String())
734+ var templ_7745c5c3_Var36 string
735+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var35).String())
870736 if templ_7745c5c3_Err != nil {
871737 return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 1, Col: 0}
872738 }
873- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
739+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
874740 if templ_7745c5c3_Err != nil {
875741 return templ_7745c5c3_Err
876742 }
877- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" data-sv-action=\"")
743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" data-sv-action=\"")
878744 if templ_7745c5c3_Err != nil {
879745 return templ_7745c5c3_Err
880746 }
881- var templ_7745c5c3_Var43 string
882- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(favoriteTagButtonAction(favorite, tag))
747+ var templ_7745c5c3_Var37 string
748+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(favoriteTagButtonAction(favorite, tag))
883749 if templ_7745c5c3_Err != nil {
884- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 150, Col: 130}
750+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 125, Col: 130}
885751 }
886- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
752+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
887753 if templ_7745c5c3_Err != nil {
888754 return templ_7745c5c3_Err
889755 }
890- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-favorite-id=\"")
756+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-favorite-id=\"")
891757 if templ_7745c5c3_Err != nil {
892758 return templ_7745c5c3_Err
893759 }
894- var templ_7745c5c3_Var44 string
895- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
760+ var templ_7745c5c3_Var38 string
761+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", favorite.ID))
896762 if templ_7745c5c3_Err != nil {
897- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 150, Col: 182}
763+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 125, Col: 182}
898764 }
899- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
765+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
900766 if templ_7745c5c3_Err != nil {
901767 return templ_7745c5c3_Err
902768 }
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" data-tag-id=\"")
769+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-tag-id=\"")
904770 if templ_7745c5c3_Err != nil {
905771 return templ_7745c5c3_Err
906772 }
907- var templ_7745c5c3_Var45 string
908- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
773+ var templ_7745c5c3_Var39 string
774+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
909775 if templ_7745c5c3_Err != nil {
910- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 150, Col: 224}
776+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 125, Col: 224}
911777 }
912- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
778+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
913779 if templ_7745c5c3_Err != nil {
914780 return templ_7745c5c3_Err
915781 }
916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"><span class=\"sv-tag-dot\" style=\"")
782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"><span class=\"sv-tag-dot\" style=\"")
917783 if templ_7745c5c3_Err != nil {
918784 return templ_7745c5c3_Err
919785 }
920- var templ_7745c5c3_Var46 string
921- templ_7745c5c3_Var46, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
786+ var templ_7745c5c3_Var40 string
787+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
922788 if templ_7745c5c3_Err != nil {
923- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 151, Col: 78}
789+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 126, Col: 78}
924790 }
925- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
791+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
926792 if templ_7745c5c3_Err != nil {
927793 return templ_7745c5c3_Err
928794 }
929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"></span>")
795+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"></span>")
930796 if templ_7745c5c3_Err != nil {
931797 return templ_7745c5c3_Err
932798 }
933- var templ_7745c5c3_Var47 string
934- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteTagButtonLabel(favorite, tag))
799+ var templ_7745c5c3_Var41 string
800+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(favoriteTagButtonLabel(favorite, tag))
935801 if templ_7745c5c3_Err != nil {
936- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 151, Col: 127}
802+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 126, Col: 127}
937803 }
938- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
804+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
939805 if templ_7745c5c3_Err != nil {
940806 return templ_7745c5c3_Err
941807 }
942- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</button>")
808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button>")
943809 if templ_7745c5c3_Err != nil {
944810 return templ_7745c5c3_Err
945811 }
946812 }
947813 }
948- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
814+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div>")
949815 if templ_7745c5c3_Err != nil {
950816 return templ_7745c5c3_Err
951817 }
@@ -954,140 +820,6 @@ func FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) templ.Co
954820 })
955821 }
956822
957-func FolderCreateForm() templ.Component {
958- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
959- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
960- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
961- return templ_7745c5c3_CtxErr
962- }
963- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
964- if !templ_7745c5c3_IsBuffer {
965- defer func() {
966- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
967- if templ_7745c5c3_Err == nil {
968- templ_7745c5c3_Err = templ_7745c5c3_BufErr
969- }
970- }()
971- }
972- ctx = templ.InitializeContext(ctx)
973- templ_7745c5c3_Var48 := templ.GetChildren(ctx)
974- if templ_7745c5c3_Var48 == nil {
975- templ_7745c5c3_Var48 = templ.NopComponent
976- }
977- ctx = templ.ClearChildren(ctx)
978- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<form class=\"sv-inline-form\" data-sv-action=\"folder.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" placeholder=\"Optional\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
979- if templ_7745c5c3_Err != nil {
980- return templ_7745c5c3_Err
981- }
982- templ_7745c5c3_Err = ButtonIconLabel(iconFolder, "Create folder").Render(ctx, templ_7745c5c3_Buffer)
983- if templ_7745c5c3_Err != nil {
984- return templ_7745c5c3_Err
985- }
986- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</button></form>")
987- if templ_7745c5c3_Err != nil {
988- return templ_7745c5c3_Err
989- }
990- return nil
991- })
992-}
993-
994-func FolderItem(folder *entity.Folder) templ.Component {
995- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
996- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
997- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
998- return templ_7745c5c3_CtxErr
999- }
1000- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1001- if !templ_7745c5c3_IsBuffer {
1002- defer func() {
1003- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1004- if templ_7745c5c3_Err == nil {
1005- templ_7745c5c3_Err = templ_7745c5c3_BufErr
1006- }
1007- }()
1008- }
1009- ctx = templ.InitializeContext(ctx)
1010- templ_7745c5c3_Var49 := templ.GetChildren(ctx)
1011- if templ_7745c5c3_Var49 == nil {
1012- templ_7745c5c3_Var49 = templ.NopComponent
1013- }
1014- ctx = templ.ClearChildren(ctx)
1015- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"folder.update\"><input type=\"hidden\" name=\"id\" value=\"")
1016- if templ_7745c5c3_Err != nil {
1017- return templ_7745c5c3_Err
1018- }
1019- var templ_7745c5c3_Var50 string
1020- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", folder.ID))
1021- if templ_7745c5c3_Err != nil {
1022- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 170, Col: 70}
1023- }
1024- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
1025- if templ_7745c5c3_Err != nil {
1026- return templ_7745c5c3_Err
1027- }
1028- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"")
1029- if templ_7745c5c3_Err != nil {
1030- return templ_7745c5c3_Err
1031- }
1032- var templ_7745c5c3_Var51 string
1033- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(folder.Name)
1034- if templ_7745c5c3_Err != nil {
1035- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 171, Col: 77}
1036- }
1037- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
1038- if templ_7745c5c3_Err != nil {
1039- return templ_7745c5c3_Err
1040- }
1041- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" required></label> <label><span>Icon</span><input name=\"icon\" type=\"text\" value=\"")
1042- if templ_7745c5c3_Err != nil {
1043- return templ_7745c5c3_Err
1044- }
1045- var templ_7745c5c3_Var52 string
1046- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(folder.Icon)
1047- if templ_7745c5c3_Err != nil {
1048- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 172, Col: 77}
1049- }
1050- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
1051- if templ_7745c5c3_Err != nil {
1052- return templ_7745c5c3_Err
1053- }
1054- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1055- if templ_7745c5c3_Err != nil {
1056- return templ_7745c5c3_Err
1057- }
1058- templ_7745c5c3_Err = ButtonIconLabel(iconSave, "Save").Render(ctx, templ_7745c5c3_Buffer)
1059- if templ_7745c5c3_Err != nil {
1060- return templ_7745c5c3_Err
1061- }
1062- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"folder.delete\" data-id=\"")
1063- if templ_7745c5c3_Err != nil {
1064- return templ_7745c5c3_Err
1065- }
1066- var templ_7745c5c3_Var53 string
1067- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", folder.ID))
1068- if templ_7745c5c3_Err != nil {
1069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 174, Col: 169}
1070- }
1071- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
1072- if templ_7745c5c3_Err != nil {
1073- return templ_7745c5c3_Err
1074- }
1075- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" data-sv-confirm=\"Delete this folder? Favorites will move out of it.\" aria-label=\"Delete this folder\">")
1076- if templ_7745c5c3_Err != nil {
1077- return templ_7745c5c3_Err
1078- }
1079- templ_7745c5c3_Err = IconOnly(iconTrash, "Delete this folder").Render(ctx, templ_7745c5c3_Buffer)
1080- if templ_7745c5c3_Err != nil {
1081- return templ_7745c5c3_Err
1082- }
1083- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</button></form></li>")
1084- if templ_7745c5c3_Err != nil {
1085- return templ_7745c5c3_Err
1086- }
1087- return nil
1088- })
1089-}
1090-
1091823 func TagCreateForm() templ.Component {
1092824 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1093825 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -1104,12 +836,12 @@ func TagCreateForm() templ.Component {
1104836 }()
1105837 }
1106838 ctx = templ.InitializeContext(ctx)
1107- templ_7745c5c3_Var54 := templ.GetChildren(ctx)
1108- if templ_7745c5c3_Var54 == nil {
1109- templ_7745c5c3_Var54 = templ.NopComponent
839+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
840+ if templ_7745c5c3_Var42 == nil {
841+ templ_7745c5c3_Var42 = templ.NopComponent
1110842 }
1111843 ctx = templ.ClearChildren(ctx)
1112- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<form class=\"sv-inline-form\" data-sv-action=\"tag.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" placeholder=\"#808080\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<form class=\"sv-inline-form\" data-sv-action=\"tag.create\"><label><span>Name</span><input name=\"name\" type=\"text\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" placeholder=\"#808080\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1113845 if templ_7745c5c3_Err != nil {
1114846 return templ_7745c5c3_Err
1115847 }
@@ -1117,7 +849,7 @@ func TagCreateForm() templ.Component {
1117849 if templ_7745c5c3_Err != nil {
1118850 return templ_7745c5c3_Err
1119851 }
1120- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</button></form>")
852+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</button></form>")
1121853 if templ_7745c5c3_Err != nil {
1122854 return templ_7745c5c3_Err
1123855 }
@@ -1141,64 +873,64 @@ func TagItem(tag *entity.Tag) templ.Component {
1141873 }()
1142874 }
1143875 ctx = templ.InitializeContext(ctx)
1144- templ_7745c5c3_Var55 := templ.GetChildren(ctx)
1145- if templ_7745c5c3_Var55 == nil {
1146- templ_7745c5c3_Var55 = templ.NopComponent
876+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
877+ if templ_7745c5c3_Var43 == nil {
878+ templ_7745c5c3_Var43 = templ.NopComponent
1147879 }
1148880 ctx = templ.ClearChildren(ctx)
1149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"tag.update\"><input type=\"hidden\" name=\"id\" value=\"")
881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"tag.update\"><input type=\"hidden\" name=\"id\" value=\"")
1150882 if templ_7745c5c3_Err != nil {
1151883 return templ_7745c5c3_Err
1152884 }
1153- var templ_7745c5c3_Var56 string
1154- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
885+ var templ_7745c5c3_Var44 string
886+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
1155887 if templ_7745c5c3_Err != nil {
1156- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 190, Col: 67}
888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 145, Col: 67}
1157889 }
1158- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
890+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
1159891 if templ_7745c5c3_Err != nil {
1160892 return templ_7745c5c3_Err
1161893 }
1162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\"> <span class=\"sv-tag-dot\" style=\"")
894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"> <span class=\"sv-tag-dot\" style=\"")
1163895 if templ_7745c5c3_Err != nil {
1164896 return templ_7745c5c3_Err
1165897 }
1166- var templ_7745c5c3_Var57 string
1167- templ_7745c5c3_Var57, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
898+ var templ_7745c5c3_Var45 string
899+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + safeTagColor(tag.Color))
1168900 if templ_7745c5c3_Err != nil {
1169- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 191, Col: 75}
901+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 146, Col: 75}
1170902 }
1171- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
903+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
1172904 if templ_7745c5c3_Err != nil {
1173905 return templ_7745c5c3_Err
1174906 }
1175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\"></span> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"")
907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"></span> <label><span>Name</span><input name=\"name\" type=\"text\" value=\"")
1176908 if templ_7745c5c3_Err != nil {
1177909 return templ_7745c5c3_Err
1178910 }
1179- var templ_7745c5c3_Var58 string
1180- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag.Name)
911+ var templ_7745c5c3_Var46 string
912+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag.Name)
1181913 if templ_7745c5c3_Err != nil {
1182- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 192, Col: 74}
914+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 147, Col: 74}
1183915 }
1184- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
916+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
1185917 if templ_7745c5c3_Err != nil {
1186918 return templ_7745c5c3_Err
1187919 }
1188- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" value=\"")
920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" required></label> <label><span>Color</span><input name=\"color\" type=\"text\" value=\"")
1189921 if templ_7745c5c3_Err != nil {
1190922 return templ_7745c5c3_Err
1191923 }
1192- var templ_7745c5c3_Var59 string
1193- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag.Color)
924+ var templ_7745c5c3_Var47 string
925+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag.Color)
1194926 if templ_7745c5c3_Err != nil {
1195- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 193, Col: 77}
927+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 148, Col: 77}
1196928 }
1197- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
929+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
1198930 if templ_7745c5c3_Err != nil {
1199931 return templ_7745c5c3_Err
1200932 }
1201- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
933+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1202934 if templ_7745c5c3_Err != nil {
1203935 return templ_7745c5c3_Err
1204936 }
@@ -1206,20 +938,20 @@ func TagItem(tag *entity.Tag) templ.Component {
1206938 if templ_7745c5c3_Err != nil {
1207939 return templ_7745c5c3_Err
1208940 }
1209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"tag.delete\" data-id=\"")
941+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</button> <button type=\"button\" class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" data-sv-action=\"tag.delete\" data-id=\"")
1210942 if templ_7745c5c3_Err != nil {
1211943 return templ_7745c5c3_Err
1212944 }
1213- var templ_7745c5c3_Var60 string
1214- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
945+ var templ_7745c5c3_Var48 string
946+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", tag.ID))
1215947 if templ_7745c5c3_Err != nil {
1216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 195, Col: 163}
948+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 150, Col: 163}
1217949 }
1218- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
950+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
1219951 if templ_7745c5c3_Err != nil {
1220952 return templ_7745c5c3_Err
1221953 }
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-sv-confirm=\"Delete this tag?\" aria-label=\"Delete this tag\">")
954+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" data-sv-confirm=\"Delete this tag?\" aria-label=\"Delete this tag\">")
1223955 if templ_7745c5c3_Err != nil {
1224956 return templ_7745c5c3_Err
1225957 }
@@ -1227,110 +959,7 @@ func TagItem(tag *entity.Tag) templ.Component {
1227959 if templ_7745c5c3_Err != nil {
1228960 return templ_7745c5c3_Err
1229961 }
1230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</button></form></li>")
1231- if templ_7745c5c3_Err != nil {
1232- return templ_7745c5c3_Err
1233- }
1234- return nil
1235- })
1236-}
1237-
1238-func FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) templ.Component {
1239- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1240- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1241- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1242- return templ_7745c5c3_CtxErr
1243- }
1244- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1245- if !templ_7745c5c3_IsBuffer {
1246- defer func() {
1247- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1248- if templ_7745c5c3_Err == nil {
1249- templ_7745c5c3_Err = templ_7745c5c3_BufErr
1250- }
1251- }()
1252- }
1253- ctx = templ.InitializeContext(ctx)
1254- templ_7745c5c3_Var61 := templ.GetChildren(ctx)
1255- if templ_7745c5c3_Var61 == nil {
1256- templ_7745c5c3_Var61 = templ.NopComponent
1257- }
1258- ctx = templ.ClearChildren(ctx)
1259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<label><span>")
1260- if templ_7745c5c3_Err != nil {
1261- return templ_7745c5c3_Err
1262- }
1263- var templ_7745c5c3_Var62 string
1264- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(label)
1265- if templ_7745c5c3_Err != nil {
1266- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 201, Col: 21}
1267- }
1268- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1269- if templ_7745c5c3_Err != nil {
1270- return templ_7745c5c3_Err
1271- }
1272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span><select name=\"")
1273- if templ_7745c5c3_Err != nil {
1274- return templ_7745c5c3_Err
1275- }
1276- var templ_7745c5c3_Var63 string
1277- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
1278- if templ_7745c5c3_Err != nil {
1279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 201, Col: 49}
1280- }
1281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1282- if templ_7745c5c3_Err != nil {
1283- return templ_7745c5c3_Err
1284- }
1285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"><option value=\"\">Unfiled</option> ")
1286- if templ_7745c5c3_Err != nil {
1287- return templ_7745c5c3_Err
1288- }
1289- for _, folder := range folders {
1290- if folder != nil {
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<option value=\"")
1292- if templ_7745c5c3_Err != nil {
1293- return templ_7745c5c3_Err
1294- }
1295- var templ_7745c5c3_Var64 string
1296- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", folder.ID))
1297- if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 205, Col: 48}
1299- }
1300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1301- if templ_7745c5c3_Err != nil {
1302- return templ_7745c5c3_Err
1303- }
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"")
1305- if templ_7745c5c3_Err != nil {
1306- return templ_7745c5c3_Err
1307- }
1308- if folderSelected(selected, folder.ID) {
1309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " selected")
1310- if templ_7745c5c3_Err != nil {
1311- return templ_7745c5c3_Err
1312- }
1313- }
1314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, ">")
1315- if templ_7745c5c3_Err != nil {
1316- return templ_7745c5c3_Err
1317- }
1318- var templ_7745c5c3_Var65 string
1319- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(folderDisplayName(folder))
1320- if templ_7745c5c3_Err != nil {
1321- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 205, Col: 128}
1322- }
1323- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1324- if templ_7745c5c3_Err != nil {
1325- return templ_7745c5c3_Err
1326- }
1327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</option>")
1328- if templ_7745c5c3_Err != nil {
1329- return templ_7745c5c3_Err
1330- }
1331- }
1332- }
1333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</select></label>")
962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</button></form></li>")
1334963 if templ_7745c5c3_Err != nil {
1335964 return templ_7745c5c3_Err
1336965 }
@@ -1354,58 +983,58 @@ func ShortcutSelect(selected *int) templ.Component {
1354983 }()
1355984 }
1356985 ctx = templ.InitializeContext(ctx)
1357- templ_7745c5c3_Var66 := templ.GetChildren(ctx)
1358- if templ_7745c5c3_Var66 == nil {
1359- templ_7745c5c3_Var66 = templ.NopComponent
986+ templ_7745c5c3_Var49 := templ.GetChildren(ctx)
987+ if templ_7745c5c3_Var49 == nil {
988+ templ_7745c5c3_Var49 = templ.NopComponent
1360989 }
1361990 ctx = templ.ClearChildren(ctx)
1362- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<label><span>Shortcut</span><select name=\"shortcut_key\"><option value=\"\">None</option> ")
991+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<label><span>Shortcut</span><select name=\"shortcut_key\"><option value=\"\">None</option> ")
1363992 if templ_7745c5c3_Err != nil {
1364993 return templ_7745c5c3_Err
1365994 }
1366995 for i := 1; i <= 9; i++ {
1367- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<option value=\"")
996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<option value=\"")
1368997 if templ_7745c5c3_Err != nil {
1369998 return templ_7745c5c3_Err
1370999 }
1371- var templ_7745c5c3_Var67 string
1372- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", i))
1000+ var templ_7745c5c3_Var50 string
1001+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", i))
13731002 if templ_7745c5c3_Err != nil {
1374- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 215, Col: 39}
1003+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 159, Col: 39}
13751004 }
1376- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1005+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
13771006 if templ_7745c5c3_Err != nil {
13781007 return templ_7745c5c3_Err
13791008 }
1380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"")
1009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
13811010 if templ_7745c5c3_Err != nil {
13821011 return templ_7745c5c3_Err
13831012 }
13841013 if shortcutSelected(selected, i) {
1385- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " selected")
1014+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
13861015 if templ_7745c5c3_Err != nil {
13871016 return templ_7745c5c3_Err
13881017 }
13891018 }
1390- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, ">")
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">")
13911020 if templ_7745c5c3_Err != nil {
13921021 return templ_7745c5c3_Err
13931022 }
1394- var templ_7745c5c3_Var68 string
1395- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
1023+ var templ_7745c5c3_Var51 string
1024+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
13961025 if templ_7745c5c3_Err != nil {
1397- return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 215, Col: 108}
1026+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `favorites.templ`, Line: 159, Col: 108}
13981027 }
1399- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1028+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
14001029 if templ_7745c5c3_Err != nil {
14011030 return templ_7745c5c3_Err
14021031 }
1403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</option>")
1032+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</option>")
14041033 if templ_7745c5c3_Err != nil {
14051034 return templ_7745c5c3_Err
14061035 }
14071036 }
1408- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</select></label>")
1037+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</select></label>")
14091038 if templ_7745c5c3_Err != nil {
14101039 return templ_7745c5c3_Err
14111040 }
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