Merge pull request #332 from bnema/refactor/favorites-tags-first
refactor(favorites): move organization to tags
42 files · +1607 -3284
M .mockery.yaml +0 -1
| @@ -152,7 +152,6 @@ packages: | |||||
| 152 | interfaces: | 152 | interfaces: | ||
| 153 | ContentWhitelistRepository: {} | 153 | ContentWhitelistRepository: {} | ||
| 154 | FavoriteRepository: {} | 154 | FavoriteRepository: {} | ||
| 155 | - | FolderRepository: {} | |||
| 156 | HistoryRepository: {} | 155 | HistoryRepository: {} | ||
| 157 | SessionRepository: {} | 156 | SessionRepository: {} | ||
| 158 | SessionStateRepository: {} | 157 | SessionStateRepository: {} | ||
M cmd/dumber/main.go +1 -4
| @@ -691,7 +691,6 @@ func setupSignalHandler(ctx context.Context, app *ui.App) { | |||||
| 691 | type repositories struct { | 691 | type repositories struct { | ||
| 692 | history repository.HistoryRepository | 692 | history repository.HistoryRepository | ||
| 693 | favorite repository.FavoriteRepository | 693 | favorite repository.FavoriteRepository | ||
| 694 | - | folder repository.FolderRepository | |||
| 695 | tag repository.TagRepository | 694 | tag repository.TagRepository | ||
| 696 | zoom repository.ZoomRepository | 695 | zoom repository.ZoomRepository | ||
| 697 | permission port.PermissionRepository | 696 | permission port.PermissionRepository | ||
| @@ -704,7 +703,6 @@ func createRepositories(db *sql.DB) *repositories { | |||||
| 704 | return &repositories{ | 703 | return &repositories{ | ||
| 705 | history: sqlite.NewHistoryRepository(db), | 704 | history: sqlite.NewHistoryRepository(db), | ||
| 706 | favorite: sqlite.NewFavoriteRepository(db), | 705 | favorite: sqlite.NewFavoriteRepository(db), | ||
| 707 | - | folder: sqlite.NewFolderRepository(db), | |||
| 708 | tag: sqlite.NewTagRepository(db), | 706 | tag: sqlite.NewTagRepository(db), | ||
| 709 | zoom: sqlite.NewZoomRepository(db), | 707 | zoom: sqlite.NewZoomRepository(db), | ||
| 710 | permission: sqlite.NewPermissionRepository(db), | 708 | permission: sqlite.NewPermissionRepository(db), | ||
| @@ -718,7 +716,6 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories { | |||||
| 718 | return &repositories{ | 716 | return &repositories{ | ||
| 719 | history: sqlite.NewLazyHistoryRepository(provider), | 717 | history: sqlite.NewLazyHistoryRepository(provider), | ||
| 720 | favorite: sqlite.NewLazyFavoriteRepository(provider), | 718 | favorite: sqlite.NewLazyFavoriteRepository(provider), | ||
| 721 | - | folder: sqlite.NewLazyFolderRepository(provider), | |||
| 722 | tag: sqlite.NewLazyTagRepository(provider), | 719 | tag: sqlite.NewLazyTagRepository(provider), | ||
| 723 | zoom: sqlite.NewLazyZoomRepository(provider), | 720 | zoom: sqlite.NewLazyZoomRepository(provider), | ||
| 724 | permission: sqlite.NewLazyPermissionRepository(provider), | 721 | permission: sqlite.NewLazyPermissionRepository(provider), | ||
| @@ -804,7 +801,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||||
| 804 | tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths), | 801 | tabs: usecase.NewManageTabsUseCase(idGenerator, localPaths), | ||
| 805 | panes: usecase.NewManagePanesUseCase(idGenerator, localPaths), | 802 | panes: usecase.NewManagePanesUseCase(idGenerator, localPaths), | ||
| 806 | history: historyUC, | 803 | history: historyUC, | ||
| 807 | - | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | 804 | + | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.tag), |
| 808 | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | 805 | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | ||
| 809 | permission: permissionUC, | 806 | permission: permissionUC, | ||
| 810 | navigate: usecase.NewNavigateUseCase(defaultZoom), | 807 | navigate: usecase.NewNavigateUseCase(defaultZoom), | ||
M cmd/systemviews/bootstrap_test.go +0 -16
| @@ -64,7 +64,6 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |||||
| 64 | 64 | ||||
| 65 | bridge := &bridgeServiceRecorder{ | 65 | bridge := &bridgeServiceRecorder{ | ||
| 66 | favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}}, | 66 | favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}}, | ||
| 67 | - | folders: []*entity.Folder{{Name: "Read Later"}}, | |||
| 68 | tags: []*entity.Tag{{Name: "Go"}}, | 67 | tags: []*entity.Tag{{Name: "Go"}}, | ||
| 69 | } | 68 | } | ||
| 70 | app := newBridgeApp(&recordingDOM{}, "dumb://favorites", bridge) | 69 | app := newBridgeApp(&recordingDOM{}, "dumb://favorites", bridge) | ||
| @@ -141,7 +140,6 @@ type bridgeServiceRecorder struct { | |||||
| 141 | 140 | ||||
| 142 | historyEntries []*entity.HistoryEntry | 141 | historyEntries []*entity.HistoryEntry | ||
| 143 | favorites []*entity.Favorite | 142 | favorites []*entity.Favorite | ||
| 144 | - | folders []*entity.Folder | |||
| 145 | tags []*entity.Tag | 143 | tags []*entity.Tag | ||
| 146 | currentConfig dto.SystemviewConfigPayload | 144 | currentConfig dto.SystemviewConfigPayload | ||
| 147 | keybindings port.KeybindingsConfig | 145 | keybindings port.KeybindingsConfig | ||
| @@ -199,26 +197,12 @@ func (*bridgeServiceRecorder) UpdateFavorite(context.Context, dto.FavoriteUpdate | |||||
| 199 | 197 | ||||
| 200 | func (*bridgeServiceRecorder) DeleteFavorite(context.Context, int64) error { return nil } | 198 | func (*bridgeServiceRecorder) DeleteFavorite(context.Context, int64) error { return nil } | ||
| 201 | 199 | ||||
| 202 | - | func (f *bridgeServiceRecorder) ListFolders(context.Context) ([]*entity.Folder, error) { | |||
| 203 | - | return f.folders, nil | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | func (f *bridgeServiceRecorder) ListTags(context.Context) ([]*entity.Tag, error) { | 200 | func (f *bridgeServiceRecorder) ListTags(context.Context) ([]*entity.Tag, error) { | ||
| 207 | return f.tags, nil | 201 | return f.tags, nil | ||
| 208 | } | 202 | } | ||
| 209 | 203 | ||||
| 210 | func (*bridgeServiceRecorder) SetShortcut(context.Context, int64, *int) error { return nil } | 204 | func (*bridgeServiceRecorder) SetShortcut(context.Context, int64, *int) error { return nil } | ||
| 211 | 205 | ||||
| 212 | - | func (*bridgeServiceRecorder) SetFolder(context.Context, int64, *int64) error { return nil } | |||
| 213 | - | ||||
| 214 | - | func (*bridgeServiceRecorder) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) { | |||
| 215 | - | return nil, nil | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | func (*bridgeServiceRecorder) UpdateFolder(context.Context, int64, string, string) error { return nil } | |||
| 219 | - | ||||
| 220 | - | func (*bridgeServiceRecorder) DeleteFolder(context.Context, int64) error { return nil } | |||
| 221 | - | ||||
| 222 | func (*bridgeServiceRecorder) CreateTag(context.Context, string, string) (*entity.Tag, error) { | 206 | func (*bridgeServiceRecorder) CreateTag(context.Context, string, string) (*entity.Tag, error) { | ||
| 223 | return nil, nil | 207 | return nil, nil | ||
| 224 | } | 208 | } | ||
M docs/systemviews-wasm-crud-plan.md +6 -10
| @@ -47,18 +47,14 @@ Preferred direction for new work: | |||||
| 47 | | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. | | 47 | | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. | | ||
| 48 | | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. | | 48 | | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. | | ||
| 49 | 49 | ||||
| 50 | - | ### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`) | 50 | + | ### Favorites and tags (`webui/src/pages/homepage/favorites/*`) |
| 51 | 51 | ||||
| 52 | | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap | | 52 | | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap | | ||
| 53 | | --- | --- | --- | --- | --- | | 53 | | --- | --- | --- | --- | --- | | ||
| 54 | - | | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`). | Add rich rows/cards, favicons, shortcut badges, folder/tag metadata. | | 54 | + | | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`) with tags-first data. | Add rich rows/cards, favicons, shortcut badges, and tag metadata. | |
| 55 | - | | Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. | | 55 | + | | Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.AddFavorite`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_create`. | Supported in WASM UI. | Keep validation/error states covered. | |
| 56 | - | | Update favorite | `FavoriteEditor.svelte` locally updates title and uses folder/shortcut/tag handlers. | `favorite_set_shortcut`, `favorite_set_folder`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep folder/shortcut/tag specialized calls or fold into update after design. | | 56 | + | | Update favorite | `FavoriteEditor.svelte` locally updates title, shortcut, and tags. | `ManageFavoritesUseCase.UpdateFavorite`, `favorite_update`, `favorite_set_shortcut`, `tag_assign`, and `tag_remove` are exposed through the ports, handlers, and bridge. | Supported in WASM UI. | Keep shortcut/tag semantics covered by regression tests. | |
| 57 | - | | Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. | | 57 | + | | Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.DeleteFavorite`/`Remove`, `port.HomepageFavorites`, `SystemviewFavoritesService`, homepage handlers, and bridge expose `favorite_delete`. | Supported in WASM UI. | Keep destructive confirmation and refresh behavior covered. | |
| 58 | - | | Read folders | `FolderList.svelte` | `folder_list` | Present as static list. | Add counts, selected filter, empty state. | | |||
| 59 | - | | Create folder | `FolderList.svelte` inline create | `folder_create` | Bridge and handler exist. | Add interactive UI. | | |||
| 60 | - | | Update folder | Handler exists (`folder_update`). | `folder_update` | Bridge exists. | Add rename/icon UI. | | |||
| 61 | - | | Delete folder | Handler exists (`folder_delete`). | `folder_delete` | Bridge exists. | Add UI, confirm, refresh favorite folder state. | | |||
| 62 | | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. | | 58 | | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. | | ||
| 63 | | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. | | 59 | | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. | | ||
| 64 | | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. | | 60 | | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. | | ||
| @@ -101,7 +97,7 @@ Implementation path: | |||||
| 101 | - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates. | 97 | - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates. | ||
| 102 | - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime. | 98 | - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime. | ||
| 103 | - History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation. | 99 | - History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation. | ||
| 104 | - | - Favorites supports create/update/delete for favorites, folders, and tags; folder/tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation. | 100 | + | - Favorites supports create/update/delete for favorites and tags; tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation. |
| 105 | - Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save. | 101 | - Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save. | ||
| 106 | - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path. | 102 | - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path. | ||
| 107 | - Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states. | 103 | - Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states. | ||
M internal/application/dto/favorites.go +9 -10
| @@ -4,18 +4,17 @@ import "github.com/bnema/dumber/internal/domain/entity" | |||||
| 4 | 4 | ||||
| 5 | // FavoriteCreateInput contains fields needed to create a favorite from a UI port. | 5 | // FavoriteCreateInput contains fields needed to create a favorite from a UI port. | ||
| 6 | type FavoriteCreateInput struct { | 6 | type FavoriteCreateInput struct { | ||
| 7 | - | URL string `json:"url"` | 7 | + | URL string `json:"url"` |
| 8 | - | Title string `json:"title"` | 8 | + | Title string `json:"title"` |
| 9 | - | FaviconURL string `json:"favicon_url"` | 9 | + | FaviconURL string `json:"favicon_url"` |
| 10 | - | FolderID *entity.FolderID `json:"folder_id"` | 10 | + | Tags []entity.TagID `json:"tags"` |
| 11 | - | Tags []entity.TagID `json:"tags"` | |||
| 12 | } | 11 | } | ||
| 13 | 12 | ||||
| 14 | // FavoriteUpdateInput contains editable favorite metadata. | 13 | // FavoriteUpdateInput contains editable favorite metadata. | ||
| 15 | type FavoriteUpdateInput struct { | 14 | type FavoriteUpdateInput struct { | ||
| 16 | - | ID entity.FavoriteID `json:"id"` | 15 | + | ID entity.FavoriteID `json:"id"` |
| 17 | - | Title string `json:"title"` | 16 | + | Title string `json:"title"` |
| 18 | - | FaviconURL string `json:"favicon_url"` | 17 | + | FaviconURL string `json:"favicon_url"` |
| 19 | - | FolderID *entity.FolderID `json:"folder_id"` | 18 | + | ShortcutKey *int `json:"shortcut_key"` |
| 20 | - | ShortcutKey *int `json:"shortcut_key"` | 19 | + | ShortcutKeySet bool `json:"-"` |
| 21 | } | 20 | } | ||
M internal/application/port/homepage.go +1 -7
| @@ -24,8 +24,7 @@ type HomepageHistory interface { | |||||
| 24 | DeleteByDomain(ctx context.Context, domain string) error | 24 | DeleteByDomain(ctx context.Context, domain string) error | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | // HomepageFavorites provides favorites/folders/tags operations | 27 | + | // HomepageFavorites provides favorite and tag operations needed by the WebUI homepage handlers. |
| 28 | - | // needed by the WebUI homepage handlers. | |||
| 29 | type HomepageFavorites interface { | 28 | type HomepageFavorites interface { | ||
| 30 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | 29 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | ||
| 31 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | 30 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | ||
| @@ -33,11 +32,6 @@ type HomepageFavorites interface { | |||||
| 33 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | 32 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | ||
| 34 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | 33 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | ||
| 35 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | 34 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | ||
| 36 | - | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | |||
| 37 | - | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | |||
| 38 | - | CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) | |||
| 39 | - | DeleteFolder(ctx context.Context, id entity.FolderID) error | |||
| 40 | - | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | |||
| 41 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | 35 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | ||
| 42 | AddTag(ctx context.Context, name, color string) (*entity.Tag, error) | 36 | AddTag(ctx context.Context, name, color string) (*entity.Tag, error) | ||
| 43 | DeleteTag(ctx context.Context, id entity.TagID) error | 37 | DeleteTag(ctx context.Context, id entity.TagID) error | ||
M internal/application/port/mocks/homepage.go +0 -331
| @@ -982,86 +982,6 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C | |||||
| 982 | return _c | 982 | return _c | ||
| 983 | } | 983 | } | ||
| 984 | 984 | ||||
| 985 | - | // CreateFolder provides a mock function for the type MockHomepageFavorites | |||
| 986 | - | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) { | |||
| 987 | - | ret := _mock.Called(ctx, name, icon, parentID) | |||
| 988 | - | ||||
| 989 | - | if len(ret) == 0 { | |||
| 990 | - | panic("no return value specified for CreateFolder") | |||
| 991 | - | } | |||
| 992 | - | ||||
| 993 | - | var r0 *entity.Folder | |||
| 994 | - | var r1 error | |||
| 995 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok { | |||
| 996 | - | return returnFunc(ctx, name, icon, parentID) | |||
| 997 | - | } | |||
| 998 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok { | |||
| 999 | - | r0 = returnFunc(ctx, name, icon, parentID) | |||
| 1000 | - | } else { | |||
| 1001 | - | if ret.Get(0) != nil { | |||
| 1002 | - | r0 = ret.Get(0).(*entity.Folder) | |||
| 1003 | - | } | |||
| 1004 | - | } | |||
| 1005 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok { | |||
| 1006 | - | r1 = returnFunc(ctx, name, icon, parentID) | |||
| 1007 | - | } else { | |||
| 1008 | - | r1 = ret.Error(1) | |||
| 1009 | - | } | |||
| 1010 | - | return r0, r1 | |||
| 1011 | - | } | |||
| 1012 | - | ||||
| 1013 | - | // MockHomepageFavorites_CreateFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFolder' | |||
| 1014 | - | type MockHomepageFavorites_CreateFolder_Call struct { | |||
| 1015 | - | *mock.Call | |||
| 1016 | - | } | |||
| 1017 | - | ||||
| 1018 | - | // CreateFolder is a helper method to define mock.On call | |||
| 1019 | - | // - ctx context.Context | |||
| 1020 | - | // - name string | |||
| 1021 | - | // - icon string | |||
| 1022 | - | // - parentID *entity.FolderID | |||
| 1023 | - | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx any, name any, icon any, parentID any) *MockHomepageFavorites_CreateFolder_Call { | |||
| 1024 | - | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)} | |||
| 1025 | - | } | |||
| 1026 | - | ||||
| 1027 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { | |||
| 1028 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1029 | - | var arg0 context.Context | |||
| 1030 | - | if args[0] != nil { | |||
| 1031 | - | arg0 = args[0].(context.Context) | |||
| 1032 | - | } | |||
| 1033 | - | var arg1 string | |||
| 1034 | - | if args[1] != nil { | |||
| 1035 | - | arg1 = args[1].(string) | |||
| 1036 | - | } | |||
| 1037 | - | var arg2 string | |||
| 1038 | - | if args[2] != nil { | |||
| 1039 | - | arg2 = args[2].(string) | |||
| 1040 | - | } | |||
| 1041 | - | var arg3 *entity.FolderID | |||
| 1042 | - | if args[3] != nil { | |||
| 1043 | - | arg3 = args[3].(*entity.FolderID) | |||
| 1044 | - | } | |||
| 1045 | - | run( | |||
| 1046 | - | arg0, | |||
| 1047 | - | arg1, | |||
| 1048 | - | arg2, | |||
| 1049 | - | arg3, | |||
| 1050 | - | ) | |||
| 1051 | - | }) | |||
| 1052 | - | return _c | |||
| 1053 | - | } | |||
| 1054 | - | ||||
| 1055 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder, err error) *MockHomepageFavorites_CreateFolder_Call { | |||
| 1056 | - | _c.Call.Return(folder, err) | |||
| 1057 | - | return _c | |||
| 1058 | - | } | |||
| 1059 | - | ||||
| 1060 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call { | |||
| 1061 | - | _c.Call.Return(run) | |||
| 1062 | - | return _c | |||
| 1063 | - | } | |||
| 1064 | - | ||||
| 1065 | // DeleteFavorite provides a mock function for the type MockHomepageFavorites | 985 | // DeleteFavorite provides a mock function for the type MockHomepageFavorites | ||
| 1066 | func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | 986 | func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | ||
| 1067 | ret := _mock.Called(ctx, id) | 987 | ret := _mock.Called(ctx, id) | ||
| @@ -1119,63 +1039,6 @@ func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx c | |||||
| 1119 | return _c | 1039 | return _c | ||
| 1120 | } | 1040 | } | ||
| 1121 | 1041 | ||||
| 1122 | - | // DeleteFolder provides a mock function for the type MockHomepageFavorites | |||
| 1123 | - | func (_mock *MockHomepageFavorites) DeleteFolder(ctx context.Context, id entity.FolderID) error { | |||
| 1124 | - | ret := _mock.Called(ctx, id) | |||
| 1125 | - | ||||
| 1126 | - | if len(ret) == 0 { | |||
| 1127 | - | panic("no return value specified for DeleteFolder") | |||
| 1128 | - | } | |||
| 1129 | - | ||||
| 1130 | - | var r0 error | |||
| 1131 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) error); ok { | |||
| 1132 | - | r0 = returnFunc(ctx, id) | |||
| 1133 | - | } else { | |||
| 1134 | - | r0 = ret.Error(0) | |||
| 1135 | - | } | |||
| 1136 | - | return r0 | |||
| 1137 | - | } | |||
| 1138 | - | ||||
| 1139 | - | // MockHomepageFavorites_DeleteFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFolder' | |||
| 1140 | - | type MockHomepageFavorites_DeleteFolder_Call struct { | |||
| 1141 | - | *mock.Call | |||
| 1142 | - | } | |||
| 1143 | - | ||||
| 1144 | - | // DeleteFolder is a helper method to define mock.On call | |||
| 1145 | - | // - ctx context.Context | |||
| 1146 | - | // - id entity.FolderID | |||
| 1147 | - | func (_e *MockHomepageFavorites_Expecter) DeleteFolder(ctx any, id any) *MockHomepageFavorites_DeleteFolder_Call { | |||
| 1148 | - | return &MockHomepageFavorites_DeleteFolder_Call{Call: _e.mock.On("DeleteFolder", ctx, id)} | |||
| 1149 | - | } | |||
| 1150 | - | ||||
| 1151 | - | func (_c *MockHomepageFavorites_DeleteFolder_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockHomepageFavorites_DeleteFolder_Call { | |||
| 1152 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1153 | - | var arg0 context.Context | |||
| 1154 | - | if args[0] != nil { | |||
| 1155 | - | arg0 = args[0].(context.Context) | |||
| 1156 | - | } | |||
| 1157 | - | var arg1 entity.FolderID | |||
| 1158 | - | if args[1] != nil { | |||
| 1159 | - | arg1 = args[1].(entity.FolderID) | |||
| 1160 | - | } | |||
| 1161 | - | run( | |||
| 1162 | - | arg0, | |||
| 1163 | - | arg1, | |||
| 1164 | - | ) | |||
| 1165 | - | }) | |||
| 1166 | - | return _c | |||
| 1167 | - | } | |||
| 1168 | - | ||||
| 1169 | - | func (_c *MockHomepageFavorites_DeleteFolder_Call) Return(err error) *MockHomepageFavorites_DeleteFolder_Call { | |||
| 1170 | - | _c.Call.Return(err) | |||
| 1171 | - | return _c | |||
| 1172 | - | } | |||
| 1173 | - | ||||
| 1174 | - | func (_c *MockHomepageFavorites_DeleteFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) error) *MockHomepageFavorites_DeleteFolder_Call { | |||
| 1175 | - | _c.Call.Return(run) | |||
| 1176 | - | return _c | |||
| 1177 | - | } | |||
| 1178 | - | ||||
| 1179 | // DeleteTag provides a mock function for the type MockHomepageFavorites | 1042 | // DeleteTag provides a mock function for the type MockHomepageFavorites | ||
| 1180 | func (_mock *MockHomepageFavorites) DeleteTag(ctx context.Context, id entity.TagID) error { | 1043 | func (_mock *MockHomepageFavorites) DeleteTag(ctx context.Context, id entity.TagID) error { | ||
| 1181 | ret := _mock.Called(ctx, id) | 1044 | ret := _mock.Called(ctx, id) | ||
| @@ -1295,68 +1158,6 @@ func (_c *MockHomepageFavorites_GetAll_Call) RunAndReturn(run func(ctx context.C | |||||
| 1295 | return _c | 1158 | return _c | ||
| 1296 | } | 1159 | } | ||
| 1297 | 1160 | ||||
| 1298 | - | // GetAllFolders provides a mock function for the type MockHomepageFavorites | |||
| 1299 | - | func (_mock *MockHomepageFavorites) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) { | |||
| 1300 | - | ret := _mock.Called(ctx) | |||
| 1301 | - | ||||
| 1302 | - | if len(ret) == 0 { | |||
| 1303 | - | panic("no return value specified for GetAllFolders") | |||
| 1304 | - | } | |||
| 1305 | - | ||||
| 1306 | - | var r0 []*entity.Folder | |||
| 1307 | - | var r1 error | |||
| 1308 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.Folder, error)); ok { | |||
| 1309 | - | return returnFunc(ctx) | |||
| 1310 | - | } | |||
| 1311 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.Folder); ok { | |||
| 1312 | - | r0 = returnFunc(ctx) | |||
| 1313 | - | } else { | |||
| 1314 | - | if ret.Get(0) != nil { | |||
| 1315 | - | r0 = ret.Get(0).([]*entity.Folder) | |||
| 1316 | - | } | |||
| 1317 | - | } | |||
| 1318 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1319 | - | r1 = returnFunc(ctx) | |||
| 1320 | - | } else { | |||
| 1321 | - | r1 = ret.Error(1) | |||
| 1322 | - | } | |||
| 1323 | - | return r0, r1 | |||
| 1324 | - | } | |||
| 1325 | - | ||||
| 1326 | - | // MockHomepageFavorites_GetAllFolders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllFolders' | |||
| 1327 | - | type MockHomepageFavorites_GetAllFolders_Call struct { | |||
| 1328 | - | *mock.Call | |||
| 1329 | - | } | |||
| 1330 | - | ||||
| 1331 | - | // GetAllFolders is a helper method to define mock.On call | |||
| 1332 | - | // - ctx context.Context | |||
| 1333 | - | func (_e *MockHomepageFavorites_Expecter) GetAllFolders(ctx any) *MockHomepageFavorites_GetAllFolders_Call { | |||
| 1334 | - | return &MockHomepageFavorites_GetAllFolders_Call{Call: _e.mock.On("GetAllFolders", ctx)} | |||
| 1335 | - | } | |||
| 1336 | - | ||||
| 1337 | - | func (_c *MockHomepageFavorites_GetAllFolders_Call) Run(run func(ctx context.Context)) *MockHomepageFavorites_GetAllFolders_Call { | |||
| 1338 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1339 | - | var arg0 context.Context | |||
| 1340 | - | if args[0] != nil { | |||
| 1341 | - | arg0 = args[0].(context.Context) | |||
| 1342 | - | } | |||
| 1343 | - | run( | |||
| 1344 | - | arg0, | |||
| 1345 | - | ) | |||
| 1346 | - | }) | |||
| 1347 | - | return _c | |||
| 1348 | - | } | |||
| 1349 | - | ||||
| 1350 | - | func (_c *MockHomepageFavorites_GetAllFolders_Call) Return(folders []*entity.Folder, err error) *MockHomepageFavorites_GetAllFolders_Call { | |||
| 1351 | - | _c.Call.Return(folders, err) | |||
| 1352 | - | return _c | |||
| 1353 | - | } | |||
| 1354 | - | ||||
| 1355 | - | func (_c *MockHomepageFavorites_GetAllFolders_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.Folder, error)) *MockHomepageFavorites_GetAllFolders_Call { | |||
| 1356 | - | _c.Call.Return(run) | |||
| 1357 | - | return _c | |||
| 1358 | - | } | |||
| 1359 | - | ||||
| 1360 | // GetAllTags provides a mock function for the type MockHomepageFavorites | 1161 | // GetAllTags provides a mock function for the type MockHomepageFavorites | ||
| 1361 | func (_mock *MockHomepageFavorites) GetAllTags(ctx context.Context) ([]*entity.Tag, error) { | 1162 | func (_mock *MockHomepageFavorites) GetAllTags(ctx context.Context) ([]*entity.Tag, error) { | ||
| 1362 | ret := _mock.Called(ctx) | 1163 | ret := _mock.Called(ctx) | ||
| @@ -1487,69 +1288,6 @@ func (_c *MockHomepageFavorites_GetByShortcut_Call) RunAndReturn(run func(ctx co | |||||
| 1487 | return _c | 1288 | return _c | ||
| 1488 | } | 1289 | } | ||
| 1489 | 1290 | ||||
| 1490 | - | // Move provides a mock function for the type MockHomepageFavorites | |||
| 1491 | - | func (_mock *MockHomepageFavorites) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error { | |||
| 1492 | - | ret := _mock.Called(ctx, id, folderID) | |||
| 1493 | - | ||||
| 1494 | - | if len(ret) == 0 { | |||
| 1495 | - | panic("no return value specified for Move") | |||
| 1496 | - | } | |||
| 1497 | - | ||||
| 1498 | - | var r0 error | |||
| 1499 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID, *entity.FolderID) error); ok { | |||
| 1500 | - | r0 = returnFunc(ctx, id, folderID) | |||
| 1501 | - | } else { | |||
| 1502 | - | r0 = ret.Error(0) | |||
| 1503 | - | } | |||
| 1504 | - | return r0 | |||
| 1505 | - | } | |||
| 1506 | - | ||||
| 1507 | - | // MockHomepageFavorites_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move' | |||
| 1508 | - | type MockHomepageFavorites_Move_Call struct { | |||
| 1509 | - | *mock.Call | |||
| 1510 | - | } | |||
| 1511 | - | ||||
| 1512 | - | // Move is a helper method to define mock.On call | |||
| 1513 | - | // - ctx context.Context | |||
| 1514 | - | // - id entity.FavoriteID | |||
| 1515 | - | // - folderID *entity.FolderID | |||
| 1516 | - | func (_e *MockHomepageFavorites_Expecter) Move(ctx any, id any, folderID any) *MockHomepageFavorites_Move_Call { | |||
| 1517 | - | return &MockHomepageFavorites_Move_Call{Call: _e.mock.On("Move", ctx, id, folderID)} | |||
| 1518 | - | } | |||
| 1519 | - | ||||
| 1520 | - | func (_c *MockHomepageFavorites_Move_Call) Run(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID)) *MockHomepageFavorites_Move_Call { | |||
| 1521 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1522 | - | var arg0 context.Context | |||
| 1523 | - | if args[0] != nil { | |||
| 1524 | - | arg0 = args[0].(context.Context) | |||
| 1525 | - | } | |||
| 1526 | - | var arg1 entity.FavoriteID | |||
| 1527 | - | if args[1] != nil { | |||
| 1528 | - | arg1 = args[1].(entity.FavoriteID) | |||
| 1529 | - | } | |||
| 1530 | - | var arg2 *entity.FolderID | |||
| 1531 | - | if args[2] != nil { | |||
| 1532 | - | arg2 = args[2].(*entity.FolderID) | |||
| 1533 | - | } | |||
| 1534 | - | run( | |||
| 1535 | - | arg0, | |||
| 1536 | - | arg1, | |||
| 1537 | - | arg2, | |||
| 1538 | - | ) | |||
| 1539 | - | }) | |||
| 1540 | - | return _c | |||
| 1541 | - | } | |||
| 1542 | - | ||||
| 1543 | - | func (_c *MockHomepageFavorites_Move_Call) Return(err error) *MockHomepageFavorites_Move_Call { | |||
| 1544 | - | _c.Call.Return(err) | |||
| 1545 | - | return _c | |||
| 1546 | - | } | |||
| 1547 | - | ||||
| 1548 | - | func (_c *MockHomepageFavorites_Move_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error) *MockHomepageFavorites_Move_Call { | |||
| 1549 | - | _c.Call.Return(run) | |||
| 1550 | - | return _c | |||
| 1551 | - | } | |||
| 1552 | - | ||||
| 1553 | // SetShortcut provides a mock function for the type MockHomepageFavorites | 1291 | // SetShortcut provides a mock function for the type MockHomepageFavorites | ||
| 1554 | func (_mock *MockHomepageFavorites) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | 1292 | func (_mock *MockHomepageFavorites) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | ||
| 1555 | ret := _mock.Called(ctx, id, key) | 1293 | ret := _mock.Called(ctx, id, key) | ||
| @@ -1807,75 +1545,6 @@ func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx c | |||||
| 1807 | return _c | 1545 | return _c | ||
| 1808 | } | 1546 | } | ||
| 1809 | 1547 | ||||
| 1810 | - | // UpdateFolder provides a mock function for the type MockHomepageFavorites | |||
| 1811 | - | func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error { | |||
| 1812 | - | ret := _mock.Called(ctx, id, name, icon) | |||
| 1813 | - | ||||
| 1814 | - | if len(ret) == 0 { | |||
| 1815 | - | panic("no return value specified for UpdateFolder") | |||
| 1816 | - | } | |||
| 1817 | - | ||||
| 1818 | - | var r0 error | |||
| 1819 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID, string, string) error); ok { | |||
| 1820 | - | r0 = returnFunc(ctx, id, name, icon) | |||
| 1821 | - | } else { | |||
| 1822 | - | r0 = ret.Error(0) | |||
| 1823 | - | } | |||
| 1824 | - | return r0 | |||
| 1825 | - | } | |||
| 1826 | - | ||||
| 1827 | - | // MockHomepageFavorites_UpdateFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFolder' | |||
| 1828 | - | type MockHomepageFavorites_UpdateFolder_Call struct { | |||
| 1829 | - | *mock.Call | |||
| 1830 | - | } | |||
| 1831 | - | ||||
| 1832 | - | // UpdateFolder is a helper method to define mock.On call | |||
| 1833 | - | // - ctx context.Context | |||
| 1834 | - | // - id entity.FolderID | |||
| 1835 | - | // - name string | |||
| 1836 | - | // - icon string | |||
| 1837 | - | func (_e *MockHomepageFavorites_Expecter) UpdateFolder(ctx any, id any, name any, icon any) *MockHomepageFavorites_UpdateFolder_Call { | |||
| 1838 | - | return &MockHomepageFavorites_UpdateFolder_Call{Call: _e.mock.On("UpdateFolder", ctx, id, name, icon)} | |||
| 1839 | - | } | |||
| 1840 | - | ||||
| 1841 | - | func (_c *MockHomepageFavorites_UpdateFolder_Call) Run(run func(ctx context.Context, id entity.FolderID, name string, icon string)) *MockHomepageFavorites_UpdateFolder_Call { | |||
| 1842 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1843 | - | var arg0 context.Context | |||
| 1844 | - | if args[0] != nil { | |||
| 1845 | - | arg0 = args[0].(context.Context) | |||
| 1846 | - | } | |||
| 1847 | - | var arg1 entity.FolderID | |||
| 1848 | - | if args[1] != nil { | |||
| 1849 | - | arg1 = args[1].(entity.FolderID) | |||
| 1850 | - | } | |||
| 1851 | - | var arg2 string | |||
| 1852 | - | if args[2] != nil { | |||
| 1853 | - | arg2 = args[2].(string) | |||
| 1854 | - | } | |||
| 1855 | - | var arg3 string | |||
| 1856 | - | if args[3] != nil { | |||
| 1857 | - | arg3 = args[3].(string) | |||
| 1858 | - | } | |||
| 1859 | - | run( | |||
| 1860 | - | arg0, | |||
| 1861 | - | arg1, | |||
| 1862 | - | arg2, | |||
| 1863 | - | arg3, | |||
| 1864 | - | ) | |||
| 1865 | - | }) | |||
| 1866 | - | return _c | |||
| 1867 | - | } | |||
| 1868 | - | ||||
| 1869 | - | func (_c *MockHomepageFavorites_UpdateFolder_Call) Return(err error) *MockHomepageFavorites_UpdateFolder_Call { | |||
| 1870 | - | _c.Call.Return(err) | |||
| 1871 | - | return _c | |||
| 1872 | - | } | |||
| 1873 | - | ||||
| 1874 | - | func (_c *MockHomepageFavorites_UpdateFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID, name string, icon string) error) *MockHomepageFavorites_UpdateFolder_Call { | |||
| 1875 | - | _c.Call.Return(run) | |||
| 1876 | - | return _c | |||
| 1877 | - | } | |||
| 1878 | - | ||||
| 1879 | // UpdateTag provides a mock function for the type MockHomepageFavorites | 1548 | // UpdateTag provides a mock function for the type MockHomepageFavorites | ||
| 1880 | func (_mock *MockHomepageFavorites) UpdateTag(ctx context.Context, id entity.TagID, name string, color string) error { | 1549 | func (_mock *MockHomepageFavorites) UpdateTag(ctx context.Context, id entity.TagID, name string, color string) error { | ||
| 1881 | ret := _mock.Called(ctx, id, name, color) | 1550 | ret := _mock.Called(ctx, id, name, color) | ||
M internal/application/port/systemviews.go +1 -6
| @@ -22,19 +22,14 @@ type SystemviewHistoryService interface { | |||||
| 22 | DeleteDomain(ctx context.Context, domain string) error | 22 | DeleteDomain(ctx context.Context, domain string) error | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | // SystemviewFavoritesService exposes favorite, folder, and tag operations. | 25 | + | // SystemviewFavoritesService exposes favorite and tag operations. |
| 26 | type SystemviewFavoritesService interface { | 26 | type SystemviewFavoritesService interface { | ||
| 27 | List(ctx context.Context) ([]*entity.Favorite, error) | 27 | List(ctx context.Context) ([]*entity.Favorite, error) | ||
| 28 | CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | 28 | CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | ||
| 29 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | 29 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | ||
| 30 | DeleteFavorite(ctx context.Context, id int64) error | 30 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 31 | - | ListFolders(ctx context.Context) ([]*entity.Folder, error) | |||
| 32 | ListTags(ctx context.Context) ([]*entity.Tag, error) | 31 | ListTags(ctx context.Context) ([]*entity.Tag, error) | ||
| 33 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | 32 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | ||
| 34 | - | SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error | |||
| 35 | - | CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) | |||
| 36 | - | UpdateFolder(ctx context.Context, id int64, name, icon string) error | |||
| 37 | - | DeleteFolder(ctx context.Context, id int64) error | |||
| 38 | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | 33 | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | ||
| 39 | UpdateTag(ctx context.Context, id int64, name, color string) error | 34 | UpdateTag(ctx context.Context, id int64, name, color string) error | ||
| 40 | DeleteTag(ctx context.Context, id int64) error | 35 | DeleteTag(ctx context.Context, id int64) error | ||
M internal/application/usecase/manage_favorites.go +16 -192
| @@ -20,10 +20,9 @@ import ( | |||||
| 20 | // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites. | 20 | // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites. | ||
| 21 | var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil) | 21 | var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil) | ||
| 22 | 22 | ||||
| 23 | - | // ManageFavoritesUseCase handles favorite, folder, and tag operations. | 23 | + | // ManageFavoritesUseCase handles favorite and tag operations. |
| 24 | type ManageFavoritesUseCase struct { | 24 | type ManageFavoritesUseCase struct { | ||
| 25 | favoriteRepo repository.FavoriteRepository | 25 | favoriteRepo repository.FavoriteRepository | ||
| 26 | - | folderRepo repository.FolderRepository | |||
| 27 | tagRepo repository.TagRepository | 26 | tagRepo repository.TagRepository | ||
| 28 | 27 | ||||
| 29 | cacheMu sync.Mutex | 28 | cacheMu sync.Mutex | ||
| @@ -37,12 +36,10 @@ const favoritesCacheTTL = 2 * time.Second | |||||
| 37 | // NewManageFavoritesUseCase creates a new favorites management use case. | 36 | // NewManageFavoritesUseCase creates a new favorites management use case. | ||
| 38 | func NewManageFavoritesUseCase( | 37 | func NewManageFavoritesUseCase( | ||
| 39 | favoriteRepo repository.FavoriteRepository, | 38 | favoriteRepo repository.FavoriteRepository, | ||
| 40 | - | folderRepo repository.FolderRepository, | |||
| 41 | tagRepo repository.TagRepository, | 39 | tagRepo repository.TagRepository, | ||
| 42 | ) *ManageFavoritesUseCase { | 40 | ) *ManageFavoritesUseCase { | ||
| 43 | return &ManageFavoritesUseCase{ | 41 | return &ManageFavoritesUseCase{ | ||
| 44 | favoriteRepo: favoriteRepo, | 42 | favoriteRepo: favoriteRepo, | ||
| 45 | - | folderRepo: folderRepo, | |||
| 46 | tagRepo: tagRepo, | 43 | tagRepo: tagRepo, | ||
| 47 | } | 44 | } | ||
| 48 | } | 45 | } | ||
| @@ -52,7 +49,6 @@ type AddFavoriteInput struct { | |||||
| 52 | URL string | 49 | URL string | ||
| 53 | Title string | 50 | Title string | ||
| 54 | FaviconURL string | 51 | FaviconURL string | ||
| 55 | - | FolderID *entity.FolderID | |||
| 56 | Tags []entity.TagID | 52 | Tags []entity.TagID | ||
| 57 | } | 53 | } | ||
| 58 | 54 | ||||
| @@ -78,7 +74,6 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||||
| 78 | log := logging.FromContext(ctx) | 74 | log := logging.FromContext(ctx) | ||
| 79 | fav := entity.NewFavorite(input.URL, input.Title) | 75 | fav := entity.NewFavorite(input.URL, input.Title) | ||
| 80 | fav.FaviconURL = input.FaviconURL | 76 | fav.FaviconURL = input.FaviconURL | ||
| 81 | - | fav.FolderID = input.FolderID | |||
| 82 | 77 | ||||
| 83 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | 78 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | ||
| 84 | return nil, fmt.Errorf("failed to save favorite: %w", err) | 79 | return nil, fmt.Errorf("failed to save favorite: %w", err) | ||
| @@ -87,11 +82,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF | |||||
| 87 | // Assign tags | 82 | // Assign tags | ||
| 88 | for _, tagID := range input.Tags { | 83 | for _, tagID := range input.Tags { | ||
| 89 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil { | 84 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil { | ||
| 90 | - | log.Warn(). | 85 | + | return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err) |
| 91 | - | Int64("tag_id", int64(tagID)). | |||
| 92 | - | Int64("favorite_id", int64(fav.ID)). | |||
| 93 | - | Err(err). | |||
| 94 | - | Msg("failed to assign tag") | |||
| 95 | } | 86 | } | ||
| 96 | } | 87 | } | ||
| 97 | 88 | ||||
| @@ -125,7 +116,6 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.Fav | |||||
| 125 | URL: favoriteURL, | 116 | URL: favoriteURL, | ||
| 126 | Title: title, | 117 | Title: title, | ||
| 127 | FaviconURL: strings.TrimSpace(input.FaviconURL), | 118 | FaviconURL: strings.TrimSpace(input.FaviconURL), | ||
| 128 | - | FolderID: input.FolderID, | |||
| 129 | Tags: tags, | 119 | Tags: tags, | ||
| 130 | }) | 120 | }) | ||
| 131 | } | 121 | } | ||
| @@ -186,7 +176,7 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto. | |||||
| 186 | if input.ID <= 0 { | 176 | if input.ID <= 0 { | ||
| 187 | return nil, fmt.Errorf("favorite ID is required") | 177 | return nil, fmt.Errorf("favorite ID is required") | ||
| 188 | } | 178 | } | ||
| 189 | - | if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { | 179 | + | if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { |
| 190 | return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) | 180 | return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) | ||
| 191 | } | 181 | } | ||
| 192 | 182 | ||||
| @@ -204,8 +194,9 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto. | |||||
| 204 | } | 194 | } | ||
| 205 | fav.Title = title | 195 | fav.Title = title | ||
| 206 | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | 196 | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | ||
| 207 | - | fav.FolderID = input.FolderID | 197 | + | if input.ShortcutKeySet { |
| 208 | - | fav.ShortcutKey = input.ShortcutKey | 198 | + | fav.ShortcutKey = input.ShortcutKey |
| 199 | + | } | |||
| 209 | 200 | ||||
| 210 | if err := uc.Update(ctx, fav); err != nil { | 201 | if err := uc.Update(ctx, fav); err != nil { | ||
| 211 | return nil, err | 202 | return nil, err | ||
| @@ -263,22 +254,6 @@ func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favori | |||||
| 263 | return nil | 254 | return nil | ||
| 264 | } | 255 | } | ||
| 265 | 256 | ||||
| 266 | - | // Move changes a favorite's folder. | |||
| 267 | - | func (uc *ManageFavoritesUseCase) Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error { | |||
| 268 | - | log := logging.FromContext(ctx) | |||
| 269 | - | log.Debug(). | |||
| 270 | - | Int64("favorite_id", int64(id)). | |||
| 271 | - | Msg("moving favorite to folder") | |||
| 272 | - | ||||
| 273 | - | if err := uc.favoriteRepo.SetFolder(ctx, id, folderID); err != nil { | |||
| 274 | - | return fmt.Errorf("failed to move favorite: %w", err) | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | - | log.Info().Int64("id", int64(id)).Msg("favorite moved") | |||
| 278 | - | uc.invalidateCache() | |||
| 279 | - | return nil | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | // SetShortcut assigns or removes a keyboard shortcut (1-9). | 257 | // SetShortcut assigns or removes a keyboard shortcut (1-9). | ||
| 283 | func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | 258 | func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | ||
| 284 | log := logging.FromContext(ctx) | 259 | log := logging.FromContext(ctx) | ||
| @@ -536,141 +511,13 @@ func (uc *ManageFavoritesUseCase) invalidateCache() { | |||||
| 536 | uc.cacheTime = time.Time{} | 511 | uc.cacheTime = time.Time{} | ||
| 537 | } | 512 | } | ||
| 538 | 513 | ||||
| 539 | - | // GetTree builds a complete hierarchical view of folders and favorites. | 514 | + | // AddTag creates a new tag. |
| 540 | - | func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) { | 515 | + | func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) { |
| 541 | - | log := logging.FromContext(ctx) | |||
| 542 | - | log.Debug().Msg("building favorite tree") | |||
| 543 | - | ||||
| 544 | - | tree := entity.NewFavoriteTree() | |||
| 545 | - | ||||
| 546 | - | // Get all folders | |||
| 547 | - | folders, err := uc.folderRepo.GetAll(ctx) | |||
| 548 | - | if err != nil { | |||
| 549 | - | return nil, fmt.Errorf("failed to get folders: %w", err) | |||
| 550 | - | } | |||
| 551 | - | ||||
| 552 | - | // Build folder map and organize by parent | |||
| 553 | - | for _, folder := range folders { | |||
| 554 | - | tree.FolderMap[folder.ID] = folder | |||
| 555 | - | if folder.ParentID == nil { | |||
| 556 | - | tree.RootFolders = append(tree.RootFolders, folder) | |||
| 557 | - | } else { | |||
| 558 | - | tree.ChildFolders[*folder.ParentID] = append(tree.ChildFolders[*folder.ParentID], folder) | |||
| 559 | - | } | |||
| 560 | - | } | |||
| 561 | - | ||||
| 562 | - | // Get all favorites | |||
| 563 | - | favorites, err := uc.favoriteRepo.GetAll(ctx) | |||
| 564 | - | if err != nil { | |||
| 565 | - | return nil, fmt.Errorf("failed to get favorites: %w", err) | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | - | // Organize favorites by folder | |||
| 569 | - | for _, fav := range favorites { | |||
| 570 | - | if fav.FolderID == nil { | |||
| 571 | - | tree.RootFavorites = append(tree.RootFavorites, fav) | |||
| 572 | - | } else { | |||
| 573 | - | tree.ChildFavorites[*fav.FolderID] = append(tree.ChildFavorites[*fav.FolderID], fav) | |||
| 574 | - | } | |||
| 575 | - | } | |||
| 576 | - | ||||
| 577 | - | log.Debug(). | |||
| 578 | - | Int("folders", len(folders)). | |||
| 579 | - | Int("favorites", len(favorites)). | |||
| 580 | - | Msg("favorite tree built") | |||
| 581 | - | ||||
| 582 | - | return tree, nil | |||
| 583 | - | } | |||
| 584 | - | ||||
| 585 | - | // CreateFolder creates a new folder. | |||
| 586 | - | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) { | |||
| 587 | name = strings.TrimSpace(name) | 516 | name = strings.TrimSpace(name) | ||
| 588 | - | icon = strings.TrimSpace(icon) | 517 | + | color = strings.TrimSpace(color) |
| 589 | if name == "" { | 518 | if name == "" { | ||
| 590 | - | return nil, fmt.Errorf("folder name is required") | 519 | + | return nil, fmt.Errorf("tag name is required") |
| 591 | - | } | |||
| 592 | - | log := logging.FromContext(ctx) | |||
| 593 | - | log.Debug().Str("name", name).Msg("creating folder") | |||
| 594 | - | ||||
| 595 | - | folder := entity.NewFolder(name) | |||
| 596 | - | folder.Icon = icon | |||
| 597 | - | folder.ParentID = parentID | |||
| 598 | - | ||||
| 599 | - | if err := uc.folderRepo.Save(ctx, folder); err != nil { | |||
| 600 | - | return nil, fmt.Errorf("failed to create folder: %w", err) | |||
| 601 | } | 520 | } | ||
| 602 | - | ||||
| 603 | - | log.Info().Str("name", name).Int64("id", int64(folder.ID)).Msg("folder created") | |||
| 604 | - | return folder, nil | |||
| 605 | - | } | |||
| 606 | - | ||||
| 607 | - | // DeleteFolder removes a folder. Favorites in the folder are moved to root. | |||
| 608 | - | func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.FolderID) error { | |||
| 609 | - | log := logging.FromContext(ctx) | |||
| 610 | - | log.Debug().Int64("id", int64(id)).Msg("deleting folder") | |||
| 611 | - | ||||
| 612 | - | // Move favorites from this folder to root (or parent) | |||
| 613 | - | folder, err := uc.folderRepo.FindByID(ctx, id) | |||
| 614 | - | if err != nil { | |||
| 615 | - | return fmt.Errorf("failed to find folder: %w", err) | |||
| 616 | - | } | |||
| 617 | - | if folder == nil { | |||
| 618 | - | log.Debug().Int64("id", int64(id)).Msg("folder not found") | |||
| 619 | - | return nil | |||
| 620 | - | } | |||
| 621 | - | ||||
| 622 | - | // Get favorites in this folder | |||
| 623 | - | favorites, err := uc.favoriteRepo.GetByFolder(ctx, &id) | |||
| 624 | - | if err != nil { | |||
| 625 | - | return fmt.Errorf("failed to get folder favorites: %w", err) | |||
| 626 | - | } | |||
| 627 | - | ||||
| 628 | - | // Move favorites to parent folder (or root) | |||
| 629 | - | for _, fav := range favorites { | |||
| 630 | - | if setErr := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); setErr != nil { | |||
| 631 | - | log.Warn(). | |||
| 632 | - | Int64("favorite_id", int64(fav.ID)). | |||
| 633 | - | Err(setErr). | |||
| 634 | - | Msg("failed to move favorite from deleted folder") | |||
| 635 | - | } | |||
| 636 | - | } | |||
| 637 | - | uc.invalidateCache() | |||
| 638 | - | ||||
| 639 | - | // Get child folders | |||
| 640 | - | children, err := uc.folderRepo.GetChildren(ctx, &id) | |||
| 641 | - | if err != nil { | |||
| 642 | - | return fmt.Errorf("failed to get child folders: %w", err) | |||
| 643 | - | } | |||
| 644 | - | ||||
| 645 | - | // Recursively delete child folders | |||
| 646 | - | for _, child := range children { | |||
| 647 | - | if err := uc.DeleteFolder(ctx, child.ID); err != nil { | |||
| 648 | - | log.Warn(). | |||
| 649 | - | Int64("child_id", int64(child.ID)). | |||
| 650 | - | Err(err). | |||
| 651 | - | Msg("failed to delete child folder") | |||
| 652 | - | } | |||
| 653 | - | } | |||
| 654 | - | ||||
| 655 | - | // Delete the folder itself | |||
| 656 | - | if err := uc.folderRepo.Delete(ctx, id); err != nil { | |||
| 657 | - | return fmt.Errorf("failed to delete folder: %w", err) | |||
| 658 | - | } | |||
| 659 | - | ||||
| 660 | - | log.Info().Int64("id", int64(id)).Msg("folder deleted") | |||
| 661 | - | return nil | |||
| 662 | - | } | |||
| 663 | - | ||||
| 664 | - | // GetAllFolders retrieves all folders. | |||
| 665 | - | func (uc *ManageFavoritesUseCase) GetAllFolders(ctx context.Context) ([]*entity.Folder, error) { | |||
| 666 | - | log := logging.FromContext(ctx) | |||
| 667 | - | log.Debug().Msg("getting all folders") | |||
| 668 | - | ||||
| 669 | - | return uc.folderRepo.GetAll(ctx) | |||
| 670 | - | } | |||
| 671 | - | ||||
| 672 | - | // AddTag creates a new tag. | |||
| 673 | - | func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) { | |||
| 674 | log := logging.FromContext(ctx) | 521 | log := logging.FromContext(ctx) | ||
| 675 | log.Debug().Str("name", name).Str("color", color).Msg("creating tag") | 522 | log.Debug().Str("name", name).Str("color", color).Msg("creating tag") | ||
| 676 | 523 | ||||
| @@ -759,34 +606,13 @@ func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID | |||||
| 759 | return uc.tagRepo.GetForFavorite(ctx, favID) | 606 | return uc.tagRepo.GetForFavorite(ctx, favID) | ||
| 760 | } | 607 | } | ||
| 761 | 608 | ||||
| 762 | - | // UpdateFolder updates a folder's name and icon. | |||
| 763 | - | func (uc *ManageFavoritesUseCase) UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error { | |||
| 764 | - | log := logging.FromContext(ctx) | |||
| 765 | - | log.Debug().Int64("id", int64(id)).Str("name", name).Msg("updating folder") | |||
| 766 | - | ||||
| 767 | - | folder, err := uc.folderRepo.FindByID(ctx, id) | |||
| 768 | - | if err != nil { | |||
| 769 | - | return fmt.Errorf("failed to find folder: %w", err) | |||
| 770 | - | } | |||
| 771 | - | if folder == nil { | |||
| 772 | - | return fmt.Errorf("folder %d not found", id) | |||
| 773 | - | } | |||
| 774 | - | ||||
| 775 | - | if name != "" { | |||
| 776 | - | folder.Name = name | |||
| 777 | - | } | |||
| 778 | - | folder.Icon = icon | |||
| 779 | - | ||||
| 780 | - | if err := uc.folderRepo.Save(ctx, folder); err != nil { | |||
| 781 | - | return fmt.Errorf("failed to update folder: %w", err) | |||
| 782 | - | } | |||
| 783 | - | ||||
| 784 | - | log.Info().Int64("id", int64(id)).Msg("folder updated") | |||
| 785 | - | return nil | |||
| 786 | - | } | |||
| 787 | - | ||||
| 788 | // UpdateTag updates a tag's name and color. | 609 | // UpdateTag updates a tag's name and color. | ||
| 789 | func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error { | 610 | func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error { | ||
| 611 | + | name = strings.TrimSpace(name) | |||
| 612 | + | color = strings.TrimSpace(color) | |||
| 613 | + | if name == "" { | |||
| 614 | + | return fmt.Errorf("tag name is required") | |||
| 615 | + | } | |||
| 790 | log := logging.FromContext(ctx) | 616 | log := logging.FromContext(ctx) | ||
| 791 | log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag") | 617 | log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag") | ||
| 792 | 618 | ||||
| @@ -798,9 +624,7 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID | |||||
| 798 | return fmt.Errorf("tag %d not found", id) | 624 | return fmt.Errorf("tag %d not found", id) | ||
| 799 | } | 625 | } | ||
| 800 | 626 | ||||
| 801 | - | if name != "" { | 627 | + | tag.Name = name |
| 802 | - | tag.Name = name | |||
| 803 | - | } | |||
| 804 | if color != "" { | 628 | if color != "" { | ||
| 805 | tag.Color = color | 629 | tag.Color = color | ||
| 806 | } | 630 | } | ||
M internal/application/usecase/manage_favorites_cache_test.go +1 -2
| @@ -17,7 +17,6 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) { | |||||
| 17 | ctx := logging.WithContext(context.Background(), logger) | 17 | ctx := logging.WithContext(context.Background(), logger) | ||
| 18 | 18 | ||||
| 19 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 19 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 20 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 21 | tagRepo := repomocks.NewMockTagRepository(t) | 20 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 22 | 21 | ||||
| 23 | fetchedFavorites := []*entity.Favorite{ | 22 | fetchedFavorites := []*entity.Favorite{ | ||
| @@ -27,7 +26,7 @@ func TestManageFavoritesUseCase_GetAllDoesNotOverwriteFreshCache(t *testing.T) { | |||||
| 27 | {ID: 2, URL: "https://fresh.example"}, | 26 | {ID: 2, URL: "https://fresh.example"}, | ||
| 28 | } | 27 | } | ||
| 29 | 28 | ||||
| 30 | - | uc := NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 29 | + | uc := NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 31 | favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) { | 30 | favoriteRepo.EXPECT().GetAll(ctx).Run(func(_ context.Context) { | ||
| 32 | uc.cacheMu.Lock() | 31 | uc.cacheMu.Lock() | ||
| 33 | uc.favoritesCache = refreshedFavorites | 32 | uc.favoritesCache = refreshedFavorites | ||
M internal/application/usecase/manage_favorites_test.go +125 -76
| @@ -1,6 +1,7 @@ | |||||
| 1 | package usecase_test | 1 | package usecase_test | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -17,7 +18,6 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) { | |||||
| 17 | ctx := testContext() | 18 | ctx := testContext() | ||
| 18 | 19 | ||||
| 19 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 20 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 20 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 21 | tagRepo := repomocks.NewMockTagRepository(t) | 21 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 22 | 22 | ||||
| 23 | favorites := []*entity.Favorite{ | 23 | favorites := []*entity.Favorite{ | ||
| @@ -28,7 +28,7 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) { | |||||
| 28 | 28 | ||||
| 29 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | 29 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | ||
| 30 | 30 | ||||
| 31 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 31 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 32 | 32 | ||||
| 33 | urls, err := uc.GetAllURLs(ctx) | 33 | urls, err := uc.GetAllURLs(ctx) | ||
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
| @@ -47,12 +47,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *tes | |||||
| 47 | ctx := testContext() | 47 | ctx := testContext() | ||
| 48 | 48 | ||||
| 49 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 49 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 50 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 51 | tagRepo := repomocks.NewMockTagRepository(t) | 50 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 52 | 51 | ||||
| 53 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | 52 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | ||
| 54 | 53 | ||||
| 55 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 54 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 56 | 55 | ||||
| 57 | urls, err := uc.GetAllURLs(ctx) | 56 | urls, err := uc.GetAllURLs(ctx) | ||
| 58 | require.NoError(t, err) | 57 | require.NoError(t, err) | ||
| @@ -64,12 +63,11 @@ func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing. | |||||
| 64 | ctx := testContext() | 63 | ctx := testContext() | ||
| 65 | 64 | ||||
| 66 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 65 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 67 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 68 | tagRepo := repomocks.NewMockTagRepository(t) | 66 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 69 | 67 | ||||
| 70 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed")) | 68 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed")) | ||
| 71 | 69 | ||||
| 72 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 70 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 73 | 71 | ||||
| 74 | urls, err := uc.GetAllURLs(ctx) | 72 | urls, err := uc.GetAllURLs(ctx) | ||
| 75 | require.Error(t, err) | 73 | require.Error(t, err) | ||
| @@ -81,7 +79,6 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |||||
| 81 | ctx := testContext() | 79 | ctx := testContext() | ||
| 82 | 80 | ||||
| 83 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 81 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 84 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 85 | tagRepo := repomocks.NewMockTagRepository(t) | 82 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 86 | 83 | ||||
| 87 | // URL is not a favorite | 84 | // URL is not a favorite | ||
| @@ -90,7 +87,7 @@ func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |||||
| 90 | return f.URL == "https://example.com" && f.Title == "Example" | 87 | return f.URL == "https://example.com" && f.Title == "Example" | ||
| 91 | })).Return(nil) | 88 | })).Return(nil) | ||
| 92 | 89 | ||||
| 93 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 90 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 94 | 91 | ||||
| 95 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | 92 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | ||
| 96 | require.NoError(t, err) | 93 | require.NoError(t, err) | ||
| @@ -106,7 +103,6 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) | |||||
| 106 | ctx := testContext() | 103 | ctx := testContext() | ||
| 107 | 104 | ||||
| 108 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 105 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 109 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 110 | tagRepo := repomocks.NewMockTagRepository(t) | 106 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 111 | 107 | ||||
| 112 | existingFav := &entity.Favorite{ | 108 | existingFav := &entity.Favorite{ | ||
| @@ -119,7 +115,7 @@ func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) | |||||
| 119 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | 115 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | ||
| 120 | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | 116 | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | ||
| 121 | 117 | ||||
| 122 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 118 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 123 | 119 | ||||
| 124 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | 120 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | ||
| 125 | require.NoError(t, err) | 121 | require.NoError(t, err) | ||
| @@ -135,10 +131,9 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||||
| 135 | ctx := testContext() | 131 | ctx := testContext() | ||
| 136 | 132 | ||||
| 137 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 133 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 138 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 139 | tagRepo := repomocks.NewMockTagRepository(t) | 134 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 140 | 135 | ||||
| 141 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 136 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 142 | 137 | ||||
| 143 | result, err := uc.Toggle(ctx, "", "Title") | 138 | result, err := uc.Toggle(ctx, "", "Title") | ||
| 144 | require.Error(t, err) | 139 | require.Error(t, err) | ||
| @@ -150,7 +145,6 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) { | |||||
| 150 | ctx := testContext() | 145 | ctx := testContext() | ||
| 151 | 146 | ||||
| 152 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 147 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 153 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 154 | tagRepo := repomocks.NewMockTagRepository(t) | 148 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 155 | 149 | ||||
| 156 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | 150 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | ||
| @@ -159,7 +153,7 @@ func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) { | |||||
| 159 | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | 153 | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | ||
| 160 | })).Return(nil).Once() | 154 | })).Return(nil).Once() | ||
| 161 | 155 | ||||
| 162 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 156 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 163 | 157 | ||||
| 164 | result, err := uc.Toggle(ctx, " example.com ", " Example ") | 158 | result, err := uc.Toggle(ctx, " example.com ", " Example ") | ||
| 165 | require.NoError(t, err) | 159 | require.NoError(t, err) | ||
| @@ -173,12 +167,11 @@ func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) | |||||
| 173 | ctx := testContext() | 167 | ctx := testContext() | ||
| 174 | 168 | ||||
| 175 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 169 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 176 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 177 | tagRepo := repomocks.NewMockTagRepository(t) | 170 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 178 | 171 | ||||
| 179 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | 172 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | ||
| 180 | 173 | ||||
| 181 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 174 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 182 | 175 | ||||
| 183 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | 176 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | ||
| 184 | require.Error(t, err) | 177 | require.Error(t, err) | ||
| @@ -190,13 +183,12 @@ func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |||||
| 190 | ctx := testContext() | 183 | ctx := testContext() | ||
| 191 | 184 | ||||
| 192 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 185 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 193 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 194 | tagRepo := repomocks.NewMockTagRepository(t) | 186 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 195 | 187 | ||||
| 196 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | 188 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | ||
| 197 | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | 189 | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | ||
| 198 | 190 | ||||
| 199 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 191 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 200 | 192 | ||||
| 201 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | 193 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | ||
| 202 | require.Error(t, err) | 194 | require.Error(t, err) | ||
| @@ -208,14 +200,13 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||||
| 208 | ctx := testContext() | 200 | ctx := testContext() | ||
| 209 | 201 | ||||
| 210 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 202 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 211 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 212 | tagRepo := repomocks.NewMockTagRepository(t) | 203 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 213 | 204 | ||||
| 214 | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | 205 | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | ||
| 215 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | 206 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | ||
| 216 | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | 207 | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | ||
| 217 | 208 | ||||
| 218 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 209 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 219 | 210 | ||||
| 220 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | 211 | result, err := uc.Toggle(ctx, "https://example.com", "Example") | ||
| 221 | require.Error(t, err) | 212 | require.Error(t, err) | ||
| @@ -223,11 +214,64 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |||||
| 223 | assert.Contains(t, err.Error(), "failed to remove favorite") | 214 | assert.Contains(t, err.Error(), "failed to remove favorite") | ||
| 224 | } | 215 | } | ||
| 225 | 216 | ||||
| 217 | + | func TestManageFavoritesUseCase_UpdateFavoritePreservesShortcutWhenOmitted(t *testing.T) { | |||
| 218 | + | ctx := testContext() | |||
| 219 | + | shortcut := 3 | |||
| 220 | + | existing := &entity.Favorite{ | |||
| 221 | + | ID: 42, | |||
| 222 | + | URL: "https://example.com", | |||
| 223 | + | Title: "Example", | |||
| 224 | + | ShortcutKey: &shortcut, | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 228 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 229 | + | favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil) | |||
| 230 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 231 | + | return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey != nil && *fav.ShortcutKey == 3 | |||
| 232 | + | })).Return(nil) | |||
| 233 | + | ||||
| 234 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) | |||
| 235 | + | updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | |||
| 236 | + | ID: 42, | |||
| 237 | + | Title: "Updated", | |||
| 238 | + | }) | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | require.NotNil(t, updated.ShortcutKey) | |||
| 241 | + | assert.Equal(t, 3, *updated.ShortcutKey) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestManageFavoritesUseCase_UpdateFavoriteClearsShortcutWhenExplicit(t *testing.T) { | |||
| 245 | + | ctx := testContext() | |||
| 246 | + | shortcut := 3 | |||
| 247 | + | existing := &entity.Favorite{ | |||
| 248 | + | ID: 42, | |||
| 249 | + | URL: "https://example.com", | |||
| 250 | + | Title: "Example", | |||
| 251 | + | ShortcutKey: &shortcut, | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 255 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 256 | + | favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil) | |||
| 257 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 258 | + | return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey == nil | |||
| 259 | + | })).Return(nil) | |||
| 260 | + | ||||
| 261 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) | |||
| 262 | + | updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | |||
| 263 | + | ID: 42, | |||
| 264 | + | Title: "Updated", | |||
| 265 | + | ShortcutKeySet: true, | |||
| 266 | + | }) | |||
| 267 | + | require.NoError(t, err) | |||
| 268 | + | assert.Nil(t, updated.ShortcutKey) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 226 | func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) { | 271 | func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) { | ||
| 227 | ctx := testContext() | 272 | ctx := testContext() | ||
| 228 | 273 | ||||
| 229 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 274 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 230 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 231 | tagRepo := repomocks.NewMockTagRepository(t) | 275 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 232 | 276 | ||||
| 233 | favorites := []*entity.Favorite{ | 277 | favorites := []*entity.Favorite{ | ||
| @@ -237,7 +281,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *te | |||||
| 237 | } | 281 | } | ||
| 238 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | 282 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | ||
| 239 | 283 | ||||
| 240 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 284 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 241 | 285 | ||||
| 242 | results, err := uc.FilterForOmnibox(ctx, "git") | 286 | results, err := uc.FilterForOmnibox(ctx, "git") | ||
| 243 | require.NoError(t, err) | 287 | require.NoError(t, err) | ||
| @@ -251,7 +295,6 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder( | |||||
| 251 | ctx := testContext() | 295 | ctx := testContext() | ||
| 252 | 296 | ||||
| 253 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 297 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 254 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 255 | tagRepo := repomocks.NewMockTagRepository(t) | 298 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 256 | 299 | ||||
| 257 | favorites := []*entity.Favorite{ | 300 | favorites := []*entity.Favorite{ | ||
| @@ -260,7 +303,7 @@ func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder( | |||||
| 260 | } | 303 | } | ||
| 261 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | 304 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | ||
| 262 | 305 | ||||
| 263 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 306 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 264 | 307 | ||||
| 265 | results, err := uc.FilterForOmnibox(ctx, "") | 308 | results, err := uc.FilterForOmnibox(ctx, "") | ||
| 266 | require.NoError(t, err) | 309 | require.NoError(t, err) | ||
| @@ -273,7 +316,6 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) { | |||||
| 273 | ctx := testContext() | 316 | ctx := testContext() | ||
| 274 | 317 | ||||
| 275 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 318 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 276 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 277 | tagRepo := repomocks.NewMockTagRepository(t) | 319 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 278 | 320 | ||||
| 279 | favorites := []*entity.Favorite{ | 321 | favorites := []*entity.Favorite{ | ||
| @@ -281,7 +323,7 @@ func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) { | |||||
| 281 | } | 323 | } | ||
| 282 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | 324 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once() | ||
| 283 | 325 | ||||
| 284 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 326 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 285 | 327 | ||||
| 286 | urls, err := uc.GetAllURLs(ctx) | 328 | urls, err := uc.GetAllURLs(ctx) | ||
| 287 | require.NoError(t, err) | 329 | require.NoError(t, err) | ||
| @@ -296,7 +338,6 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) { | |||||
| 296 | ctx := testContext() | 338 | ctx := testContext() | ||
| 297 | 339 | ||||
| 298 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 340 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 299 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 300 | tagRepo := repomocks.NewMockTagRepository(t) | 341 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 301 | 342 | ||||
| 302 | initialFavorites := []*entity.Favorite{ | 343 | initialFavorites := []*entity.Favorite{ | ||
| @@ -313,7 +354,7 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) { | |||||
| 313 | } | 354 | } | ||
| 314 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once() | 355 | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once() | ||
| 315 | 356 | ||||
| 316 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 357 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 317 | 358 | ||||
| 318 | urls, err := uc.GetAllURLs(ctx) | 359 | urls, err := uc.GetAllURLs(ctx) | ||
| 319 | require.NoError(t, err) | 360 | require.NoError(t, err) | ||
| @@ -327,51 +368,10 @@ func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) { | |||||
| 327 | require.Contains(t, urls, "https://new.example") | 368 | require.Contains(t, urls, "https://new.example") | ||
| 328 | } | 369 | } | ||
| 329 | 370 | ||||
| 330 | - | func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testing.T) { | |||
| 331 | - | ctx := testContext() | |||
| 332 | - | ||||
| 333 | - | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 334 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 335 | - | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 336 | - | ||||
| 337 | - | folderID := entity.FolderID(9) | |||
| 338 | - | initialFolderID := folderID | |||
| 339 | - | cachedFavorites := []*entity.Favorite{ | |||
| 340 | - | {ID: 1, URL: "https://example.com", FolderID: &initialFolderID}, | |||
| 341 | - | } | |||
| 342 | - | updatedFavorites := []*entity.Favorite{ | |||
| 343 | - | {ID: 1, URL: "https://example.com", FolderID: nil}, | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(cachedFavorites, nil).Once() | |||
| 347 | - | folderRepo.EXPECT().FindByID(mock.Anything, folderID).Return(&entity.Folder{ID: folderID}, nil).Once() | |||
| 348 | - | favoriteRepo.EXPECT().GetByFolder(mock.Anything, &folderID).Return(cachedFavorites, nil).Once() | |||
| 349 | - | favoriteRepo.EXPECT().SetFolder(mock.Anything, entity.FavoriteID(1), (*entity.FolderID)(nil)).Return(nil).Once() | |||
| 350 | - | folderRepo.EXPECT().GetChildren(mock.Anything, &folderID).Return([]*entity.Folder{}, nil).Once() | |||
| 351 | - | folderRepo.EXPECT().Delete(mock.Anything, folderID).Return(nil).Once() | |||
| 352 | - | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once() | |||
| 353 | - | ||||
| 354 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 355 | - | ||||
| 356 | - | favoritesBefore, err := uc.GetAll(ctx) | |||
| 357 | - | require.NoError(t, err) | |||
| 358 | - | require.Len(t, favoritesBefore, 1) | |||
| 359 | - | require.NotNil(t, favoritesBefore[0].FolderID) | |||
| 360 | - | ||||
| 361 | - | err = uc.DeleteFolder(ctx, folderID) | |||
| 362 | - | require.NoError(t, err) | |||
| 363 | - | ||||
| 364 | - | favoritesAfter, err := uc.GetAll(ctx) | |||
| 365 | - | require.NoError(t, err) | |||
| 366 | - | require.Len(t, favoritesAfter, 1) | |||
| 367 | - | assert.Nil(t, favoritesAfter[0].FolderID) | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | 371 | func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | ||
| 371 | ctx := testContext() | 372 | ctx := testContext() | ||
| 372 | 373 | ||||
| 373 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 374 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 374 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 375 | tagRepo := repomocks.NewMockTagRepository(t) | 375 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 376 | 376 | ||||
| 377 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | 377 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | ||
| @@ -380,7 +380,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |||||
| 380 | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | 380 | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | ||
| 381 | })).Return(nil).Once() | 381 | })).Return(nil).Once() | ||
| 382 | 382 | ||||
| 383 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 383 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 384 | 384 | ||||
| 385 | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "}) | 385 | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "}) | ||
| 386 | require.NoError(t, err) | 386 | require.NoError(t, err) | ||
| @@ -393,15 +393,66 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) { | |||||
| 393 | ctx := testContext() | 393 | ctx := testContext() | ||
| 394 | 394 | ||||
| 395 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 395 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 396 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 397 | tagRepo := repomocks.NewMockTagRepository(t) | 396 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 398 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 397 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 399 | 398 | ||||
| 400 | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}}) | 399 | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}}) | ||
| 401 | require.Error(t, err) | 400 | require.Error(t, err) | ||
| 402 | assert.Contains(t, err.Error(), "favorite tag id must be positive") | 401 | assert.Contains(t, err.Error(), "favorite tag id must be positive") | ||
| 403 | } | 402 | } | ||
| 404 | 403 | ||||
| 404 | + | func TestManageFavoritesUseCase_AddFavoriteAssignsTagsWithoutFolder(t *testing.T) { | |||
| 405 | + | ctx := testContext() | |||
| 406 | + | ||||
| 407 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 408 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 409 | + | ||||
| 410 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 411 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 412 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" && fav.FaviconURL == "https://example.com/favicon.ico" | |||
| 413 | + | })).Run(func(_ context.Context, fav *entity.Favorite) { | |||
| 414 | + | fav.ID = entity.FavoriteID(42) | |||
| 415 | + | }).Return(nil).Once() | |||
| 416 | + | tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(nil).Once() | |||
| 417 | + | tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(8), entity.FavoriteID(42)).Return(nil).Once() | |||
| 418 | + | ||||
| 419 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) | |||
| 420 | + | ||||
| 421 | + | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{ | |||
| 422 | + | URL: "https://example.com", | |||
| 423 | + | Title: "Example", | |||
| 424 | + | FaviconURL: " https://example.com/favicon.ico ", | |||
| 425 | + | Tags: []entity.TagID{7, 8}, | |||
| 426 | + | }) | |||
| 427 | + | require.NoError(t, err) | |||
| 428 | + | require.NotNil(t, fav) | |||
| 429 | + | assert.Equal(t, entity.FavoriteID(42), fav.ID) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestManageFavoritesUseCase_AddFavoriteReturnsAssignTagError(t *testing.T) { | |||
| 433 | + | ctx := testContext() | |||
| 434 | + | ||||
| 435 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 436 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 437 | + | ||||
| 438 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 439 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Run(func(_ context.Context, fav *entity.Favorite) { | |||
| 440 | + | fav.ID = entity.FavoriteID(42) | |||
| 441 | + | }).Return(nil).Once() | |||
| 442 | + | tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(errors.New("tag missing")).Once() | |||
| 443 | + | ||||
| 444 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) | |||
| 445 | + | ||||
| 446 | + | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{ | |||
| 447 | + | URL: "https://example.com", | |||
| 448 | + | Title: "Example", | |||
| 449 | + | Tags: []entity.TagID{7}, | |||
| 450 | + | }) | |||
| 451 | + | require.Error(t, err) | |||
| 452 | + | assert.Nil(t, fav) | |||
| 453 | + | assert.Contains(t, err.Error(), "failed to assign tag 7 to favorite 42") | |||
| 454 | + | } | |||
| 455 | + | ||||
| 405 | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | 456 | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | ||
| 406 | ctx := testContext() | 457 | ctx := testContext() | ||
| 407 | 458 | ||||
| @@ -416,9 +467,8 @@ func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testin | |||||
| 416 | for _, raw := range tests { | 467 | for _, raw := range tests { | ||
| 417 | t.Run(raw, func(t *testing.T) { | 468 | t.Run(raw, func(t *testing.T) { | ||
| 418 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 469 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 419 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 420 | tagRepo := repomocks.NewMockTagRepository(t) | 470 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 421 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 471 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 422 | 472 | ||||
| 423 | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw}) | 473 | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw}) | ||
| 424 | require.Error(t, err) | 474 | require.Error(t, err) | ||
| @@ -430,7 +480,6 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing. | |||||
| 430 | ctx := testContext() | 480 | ctx := testContext() | ||
| 431 | 481 | ||||
| 432 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | 482 | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | ||
| 433 | - | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 434 | tagRepo := repomocks.NewMockTagRepository(t) | 483 | tagRepo := repomocks.NewMockTagRepository(t) | ||
| 435 | 484 | ||||
| 436 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once() | 485 | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once() | ||
| @@ -439,7 +488,7 @@ func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing. | |||||
| 439 | return fav != nil && fav.URL == "dumb://history" && fav.Title == "History" | 488 | return fav != nil && fav.URL == "dumb://history" && fav.Title == "History" | ||
| 440 | })).Return(nil).Once() | 489 | })).Return(nil).Once() | ||
| 441 | 490 | ||||
| 442 | - | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | 491 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo) |
| 443 | 492 | ||||
| 444 | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"}) | 493 | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"}) | ||
| 445 | require.NoError(t, err) | 494 | require.NoError(t, err) | ||
M internal/domain/entity/favorite.go +6 -55
| @@ -1,13 +1,13 @@ | |||||
| 1 | package entity | 1 | package entity | ||
| 2 | 2 | ||||
| 3 | - | import "time" | 3 | + | import ( |
| 4 | + | "strings" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // FavoriteID uniquely identifies a favorite/bookmark. | 8 | // FavoriteID uniquely identifies a favorite/bookmark. | ||
| 6 | type FavoriteID int64 | 9 | type FavoriteID int64 | ||
| 7 | 10 | ||||
| 8 | - | // FolderID uniquely identifies a bookmark folder. | |||
| 9 | - | type FolderID int64 | |||
| 10 | - | ||||
| 11 | // TagID uniquely identifies a tag. | 11 | // TagID uniquely identifies a tag. | ||
| 12 | type TagID int64 | 12 | type TagID int64 | ||
| 13 | 13 | ||||
| @@ -17,9 +17,8 @@ type Favorite struct { | |||||
| 17 | URL string `json:"url"` | 17 | URL string `json:"url"` | ||
| 18 | Title string `json:"title"` | 18 | Title string `json:"title"` | ||
| 19 | FaviconURL string `json:"favicon_url"` | 19 | FaviconURL string `json:"favicon_url"` | ||
| 20 | - | FolderID *FolderID `json:"folder_id"` // nil = root level | |||
| 21 | ShortcutKey *int `json:"shortcut_key"` // 1-9 for quick access (Alt+1 through Alt+9) | 20 | ShortcutKey *int `json:"shortcut_key"` // 1-9 for quick access (Alt+1 through Alt+9) | ||
| 22 | - | Position int `json:"position"` // Order within folder | 21 | + | Position int `json:"position"` // Order within tag or default list |
| 23 | Tags []Tag `json:"tags,omitempty"` | 22 | Tags []Tag `json:"tags,omitempty"` | ||
| 24 | CreatedAt time.Time `json:"created_at"` | 23 | CreatedAt time.Time `json:"created_at"` | ||
| 25 | UpdatedAt time.Time `json:"updated_at"` | 24 | UpdatedAt time.Time `json:"updated_at"` | ||
| @@ -41,11 +40,6 @@ func (f *Favorite) HasShortcut() bool { | |||||
| 41 | return f.ShortcutKey != nil && *f.ShortcutKey >= 1 && *f.ShortcutKey <= 9 | 40 | return f.ShortcutKey != nil && *f.ShortcutKey >= 1 && *f.ShortcutKey <= 9 | ||
| 42 | } | 41 | } | ||
| 43 | 42 | ||||
| 44 | - | // InFolder returns true if this favorite is in a folder. | |||
| 45 | - | func (f *Favorite) InFolder() bool { | |||
| 46 | - | return f.FolderID != nil | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | // HasTag returns true if this favorite has the given tag. | 43 | // HasTag returns true if this favorite has the given tag. | ||
| 50 | func (f *Favorite) HasTag(tagID TagID) bool { | 44 | func (f *Favorite) HasTag(tagID TagID) bool { | ||
| 51 | for _, t := range f.Tags { | 45 | for _, t := range f.Tags { | ||
| @@ -56,29 +50,6 @@ func (f *Favorite) HasTag(tagID TagID) bool { | |||||
| 56 | return false | 50 | return false | ||
| 57 | } | 51 | } | ||
| 58 | 52 | ||||
| 59 | - | // Folder represents a container for organizing favorites. | |||
| 60 | - | type Folder struct { | |||
| 61 | - | ID FolderID `json:"id"` | |||
| 62 | - | Name string `json:"name"` | |||
| 63 | - | Icon string `json:"icon"` // Optional icon identifier | |||
| 64 | - | ParentID *FolderID `json:"parent_id"` // nil = root level | |||
| 65 | - | Position int `json:"position"` // Order within parent | |||
| 66 | - | CreatedAt time.Time `json:"created_at"` | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // NewFolder creates a new folder. | |||
| 70 | - | func NewFolder(name string) *Folder { | |||
| 71 | - | return &Folder{ | |||
| 72 | - | Name: name, | |||
| 73 | - | CreatedAt: time.Now(), | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // IsRoot returns true if this folder is at root level. | |||
| 78 | - | func (f *Folder) IsRoot() bool { | |||
| 79 | - | return f.ParentID == nil | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | // Tag represents a label that can be applied to favorites. | 53 | // Tag represents a label that can be applied to favorites. | ||
| 83 | type Tag struct { | 54 | type Tag struct { | ||
| 84 | ID TagID `json:"id"` | 55 | ID TagID `json:"id"` | ||
| @@ -90,28 +61,8 @@ type Tag struct { | |||||
| 90 | // NewTag creates a new tag with default color. | 61 | // NewTag creates a new tag with default color. | ||
| 91 | func NewTag(name string) *Tag { | 62 | func NewTag(name string) *Tag { | ||
| 92 | return &Tag{ | 63 | return &Tag{ | ||
| 93 | - | Name: name, | 64 | + | Name: strings.TrimSpace(name), |
| 94 | Color: "#808080", // Default gray | 65 | Color: "#808080", // Default gray | ||
| 95 | CreatedAt: time.Now(), | 66 | CreatedAt: time.Now(), | ||
| 96 | } | 67 | } | ||
| 97 | } | 68 | } | ||
| 98 | - | ||||
| 99 | - | // FavoriteTree represents a hierarchical view of folders and favorites. | |||
| 100 | - | type FavoriteTree struct { | |||
| 101 | - | RootFolders []*Folder | |||
| 102 | - | RootFavorites []*Favorite | |||
| 103 | - | FolderMap map[FolderID]*Folder // Quick lookup | |||
| 104 | - | ChildFolders map[FolderID][]*Folder // Children of each folder | |||
| 105 | - | ChildFavorites map[FolderID][]*Favorite // Favorites in each folder | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // NewFavoriteTree creates an empty favorite tree. | |||
| 109 | - | func NewFavoriteTree() *FavoriteTree { | |||
| 110 | - | return &FavoriteTree{ | |||
| 111 | - | RootFolders: make([]*Folder, 0), | |||
| 112 | - | RootFavorites: make([]*Favorite, 0), | |||
| 113 | - | FolderMap: make(map[FolderID]*Folder), | |||
| 114 | - | ChildFolders: make(map[FolderID][]*Folder), | |||
| 115 | - | ChildFavorites: make(map[FolderID][]*Favorite), | |||
| 116 | - | } | |||
| 117 | - | } | |||
A internal/domain/entity/favorite_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestFavoriteSupportsMultipleTagsAndNoTags(t *testing.T) { | |||
| 6 | + | fav := NewFavorite("https://example.com", "Example") | |||
| 7 | + | if fav.HasTag(TagID(1)) { | |||
| 8 | + | t.Fatal("new favorite unexpectedly has tag") | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | fav.Tags = []Tag{{ID: 1, Name: "Go"}, {ID: 2, Name: "Docs"}} | |||
| 12 | + | if !fav.HasTag(1) || !fav.HasTag(2) { | |||
| 13 | + | t.Fatalf("favorite tags not detected: %+v", fav.Tags) | |||
| 14 | + | } | |||
| 15 | + | if fav.HasTag(3) { | |||
| 16 | + | t.Fatal("favorite reported missing tag as present") | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestTagStoresTrimmedDisplayNameAndColor(t *testing.T) { | |||
| 21 | + | tag := NewTag(" Read Later ") | |||
| 22 | + | if tag.Name != "Read Later" { | |||
| 23 | + | t.Fatalf("tag name = %q", tag.Name) | |||
| 24 | + | } | |||
| 25 | + | if tag.Color == "" { | |||
| 26 | + | t.Fatal("tag default color is empty") | |||
| 27 | + | } | |||
| 28 | + | } | |||
M internal/domain/repository/favorite.go +3 -30
| @@ -20,19 +20,15 @@ type FavoriteRepository interface { | |||||
| 20 | // GetAll retrieves all favorites. | 20 | // GetAll retrieves all favorites. | ||
| 21 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | 21 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | ||
| 22 | 22 | ||||
| 23 | - | // GetByFolder retrieves favorites in a specific folder. | 23 | + | // GetByTag retrieves favorites with a specific tag. |
| 24 | - | // Pass nil for root-level favorites. | 24 | + | GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) |
| 25 | - | GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) | |||
| 26 | 25 | ||||
| 27 | // GetByShortcut retrieves the favorite with the given shortcut key (1-9). | 26 | // GetByShortcut retrieves the favorite with the given shortcut key (1-9). | ||
| 28 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | 27 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | ||
| 29 | 28 | ||||
| 30 | - | // UpdatePosition updates a favorite's position within its folder. | 29 | + | // UpdatePosition updates a favorite's position. |
| 31 | UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error | 30 | UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error | ||
| 32 | 31 | ||||
| 33 | - | // SetFolder moves a favorite to a different folder. | |||
| 34 | - | SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | |||
| 35 | - | ||||
| 36 | // SetShortcut assigns a keyboard shortcut (1-9) or nil to remove. | 32 | // SetShortcut assigns a keyboard shortcut (1-9) or nil to remove. | ||
| 37 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | 33 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | ||
| 38 | 34 | ||||
| @@ -40,29 +36,6 @@ type FavoriteRepository interface { | |||||
| 40 | Delete(ctx context.Context, id entity.FavoriteID) error | 36 | Delete(ctx context.Context, id entity.FavoriteID) error | ||
| 41 | } | 37 | } | ||
| 42 | 38 | ||||
| 43 | - | // FolderRepository defines operations for bookmark folder persistence. | |||
| 44 | - | type FolderRepository interface { | |||
| 45 | - | // Save creates or updates a folder. | |||
| 46 | - | Save(ctx context.Context, folder *entity.Folder) error | |||
| 47 | - | ||||
| 48 | - | // FindByID retrieves a folder by its ID. | |||
| 49 | - | FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) | |||
| 50 | - | ||||
| 51 | - | // GetAll retrieves all folders. | |||
| 52 | - | GetAll(ctx context.Context) ([]*entity.Folder, error) | |||
| 53 | - | ||||
| 54 | - | // GetChildren retrieves child folders of a parent. | |||
| 55 | - | // Pass nil for root-level folders. | |||
| 56 | - | GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) | |||
| 57 | - | ||||
| 58 | - | // UpdatePosition updates a folder's position within its parent. | |||
| 59 | - | UpdatePosition(ctx context.Context, id entity.FolderID, position int) error | |||
| 60 | - | ||||
| 61 | - | // Delete removes a folder by ID. | |||
| 62 | - | // Favorites in the folder should be moved to root. | |||
| 63 | - | Delete(ctx context.Context, id entity.FolderID) error | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | // TagRepository defines operations for tag persistence. | 39 | // TagRepository defines operations for tag persistence. | ||
| 67 | type TagRepository interface { | 40 | type TagRepository interface { | ||
| 68 | // Save creates or updates a tag. | 41 | // Save creates or updates a tag. | ||
M internal/domain/repository/mocks/favorite.go +48 -513
| @@ -293,55 +293,55 @@ func (_c *MockFavoriteRepository_GetAll_Call) RunAndReturn(run func(ctx context. | |||||
| 293 | return _c | 293 | return _c | ||
| 294 | } | 294 | } | ||
| 295 | 295 | ||||
| 296 | - | // GetByFolder provides a mock function for the type MockFavoriteRepository | 296 | + | // GetByShortcut provides a mock function for the type MockFavoriteRepository |
| 297 | - | func (_mock *MockFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) { | 297 | + | func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { |
| 298 | - | ret := _mock.Called(ctx, folderID) | 298 | + | ret := _mock.Called(ctx, key) |
| 299 | 299 | ||||
| 300 | if len(ret) == 0 { | 300 | if len(ret) == 0 { | ||
| 301 | - | panic("no return value specified for GetByFolder") | 301 | + | panic("no return value specified for GetByShortcut") |
| 302 | } | 302 | } | ||
| 303 | 303 | ||||
| 304 | - | var r0 []*entity.Favorite | 304 | + | var r0 *entity.Favorite |
| 305 | var r1 error | 305 | var r1 error | ||
| 306 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) ([]*entity.Favorite, error)); ok { | 306 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok { |
| 307 | - | return returnFunc(ctx, folderID) | 307 | + | return returnFunc(ctx, key) |
| 308 | } | 308 | } | ||
| 309 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) []*entity.Favorite); ok { | 309 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok { |
| 310 | - | r0 = returnFunc(ctx, folderID) | 310 | + | r0 = returnFunc(ctx, key) |
| 311 | } else { | 311 | } else { | ||
| 312 | if ret.Get(0) != nil { | 312 | if ret.Get(0) != nil { | ||
| 313 | - | r0 = ret.Get(0).([]*entity.Favorite) | 313 | + | r0 = ret.Get(0).(*entity.Favorite) |
| 314 | } | 314 | } | ||
| 315 | } | 315 | } | ||
| 316 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *entity.FolderID) error); ok { | 316 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { |
| 317 | - | r1 = returnFunc(ctx, folderID) | 317 | + | r1 = returnFunc(ctx, key) |
| 318 | } else { | 318 | } else { | ||
| 319 | r1 = ret.Error(1) | 319 | r1 = ret.Error(1) | ||
| 320 | } | 320 | } | ||
| 321 | return r0, r1 | 321 | return r0, r1 | ||
| 322 | } | 322 | } | ||
| 323 | 323 | ||||
| 324 | - | // MockFavoriteRepository_GetByFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByFolder' | 324 | + | // MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut' |
| 325 | - | type MockFavoriteRepository_GetByFolder_Call struct { | 325 | + | type MockFavoriteRepository_GetByShortcut_Call struct { |
| 326 | *mock.Call | 326 | *mock.Call | ||
| 327 | } | 327 | } | ||
| 328 | 328 | ||||
| 329 | - | // GetByFolder is a helper method to define mock.On call | 329 | + | // GetByShortcut is a helper method to define mock.On call |
| 330 | // - ctx context.Context | 330 | // - ctx context.Context | ||
| 331 | - | // - folderID *entity.FolderID | 331 | + | // - key int |
| 332 | - | func (_e *MockFavoriteRepository_Expecter) GetByFolder(ctx any, folderID any) *MockFavoriteRepository_GetByFolder_Call { | 332 | + | func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call { |
| 333 | - | return &MockFavoriteRepository_GetByFolder_Call{Call: _e.mock.On("GetByFolder", ctx, folderID)} | 333 | + | return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)} |
| 334 | } | 334 | } | ||
| 335 | 335 | ||||
| 336 | - | func (_c *MockFavoriteRepository_GetByFolder_Call) Run(run func(ctx context.Context, folderID *entity.FolderID)) *MockFavoriteRepository_GetByFolder_Call { | 336 | + | func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Context, key int)) *MockFavoriteRepository_GetByShortcut_Call { |
| 337 | _c.Call.Run(func(args mock.Arguments) { | 337 | _c.Call.Run(func(args mock.Arguments) { | ||
| 338 | var arg0 context.Context | 338 | var arg0 context.Context | ||
| 339 | if args[0] != nil { | 339 | if args[0] != nil { | ||
| 340 | arg0 = args[0].(context.Context) | 340 | arg0 = args[0].(context.Context) | ||
| 341 | } | 341 | } | ||
| 342 | - | var arg1 *entity.FolderID | 342 | + | var arg1 int |
| 343 | if args[1] != nil { | 343 | if args[1] != nil { | ||
| 344 | - | arg1 = args[1].(*entity.FolderID) | 344 | + | arg1 = args[1].(int) |
| 345 | } | 345 | } | ||
| 346 | run( | 346 | run( | ||
| 347 | arg0, | 347 | arg0, | ||
| @@ -351,65 +351,65 @@ func (_c *MockFavoriteRepository_GetByFolder_Call) Run(run func(ctx context.Cont | |||||
| 351 | return _c | 351 | return _c | ||
| 352 | } | 352 | } | ||
| 353 | 353 | ||||
| 354 | - | func (_c *MockFavoriteRepository_GetByFolder_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByFolder_Call { | 354 | + | func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call { |
| 355 | - | _c.Call.Return(favorites, err) | 355 | + | _c.Call.Return(favorite, err) |
| 356 | return _c | 356 | return _c | ||
| 357 | } | 357 | } | ||
| 358 | 358 | ||||
| 359 | - | func (_c *MockFavoriteRepository_GetByFolder_Call) RunAndReturn(run func(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error)) *MockFavoriteRepository_GetByFolder_Call { | 359 | + | func (_c *MockFavoriteRepository_GetByShortcut_Call) RunAndReturn(run func(ctx context.Context, key int) (*entity.Favorite, error)) *MockFavoriteRepository_GetByShortcut_Call { |
| 360 | _c.Call.Return(run) | 360 | _c.Call.Return(run) | ||
| 361 | return _c | 361 | return _c | ||
| 362 | } | 362 | } | ||
| 363 | 363 | ||||
| 364 | - | // GetByShortcut provides a mock function for the type MockFavoriteRepository | 364 | + | // GetByTag provides a mock function for the type MockFavoriteRepository |
| 365 | - | func (_mock *MockFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | 365 | + | func (_mock *MockFavoriteRepository) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) { |
| 366 | - | ret := _mock.Called(ctx, key) | 366 | + | ret := _mock.Called(ctx, tagID) |
| 367 | 367 | ||||
| 368 | if len(ret) == 0 { | 368 | if len(ret) == 0 { | ||
| 369 | - | panic("no return value specified for GetByShortcut") | 369 | + | panic("no return value specified for GetByTag") |
| 370 | } | 370 | } | ||
| 371 | 371 | ||||
| 372 | - | var r0 *entity.Favorite | 372 | + | var r0 []*entity.Favorite |
| 373 | var r1 error | 373 | var r1 error | ||
| 374 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (*entity.Favorite, error)); ok { | 374 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) ([]*entity.Favorite, error)); ok { |
| 375 | - | return returnFunc(ctx, key) | 375 | + | return returnFunc(ctx, tagID) |
| 376 | } | 376 | } | ||
| 377 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, int) *entity.Favorite); ok { | 377 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.TagID) []*entity.Favorite); ok { |
| 378 | - | r0 = returnFunc(ctx, key) | 378 | + | r0 = returnFunc(ctx, tagID) |
| 379 | } else { | 379 | } else { | ||
| 380 | if ret.Get(0) != nil { | 380 | if ret.Get(0) != nil { | ||
| 381 | - | r0 = ret.Get(0).(*entity.Favorite) | 381 | + | r0 = ret.Get(0).([]*entity.Favorite) |
| 382 | } | 382 | } | ||
| 383 | } | 383 | } | ||
| 384 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | 384 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.TagID) error); ok { |
| 385 | - | r1 = returnFunc(ctx, key) | 385 | + | r1 = returnFunc(ctx, tagID) |
| 386 | } else { | 386 | } else { | ||
| 387 | r1 = ret.Error(1) | 387 | r1 = ret.Error(1) | ||
| 388 | } | 388 | } | ||
| 389 | return r0, r1 | 389 | return r0, r1 | ||
| 390 | } | 390 | } | ||
| 391 | 391 | ||||
| 392 | - | // MockFavoriteRepository_GetByShortcut_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByShortcut' | 392 | + | // MockFavoriteRepository_GetByTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByTag' |
| 393 | - | type MockFavoriteRepository_GetByShortcut_Call struct { | 393 | + | type MockFavoriteRepository_GetByTag_Call struct { |
| 394 | *mock.Call | 394 | *mock.Call | ||
| 395 | } | 395 | } | ||
| 396 | 396 | ||||
| 397 | - | // GetByShortcut is a helper method to define mock.On call | 397 | + | // GetByTag is a helper method to define mock.On call |
| 398 | // - ctx context.Context | 398 | // - ctx context.Context | ||
| 399 | - | // - key int | 399 | + | // - tagID entity.TagID |
| 400 | - | func (_e *MockFavoriteRepository_Expecter) GetByShortcut(ctx any, key any) *MockFavoriteRepository_GetByShortcut_Call { | 400 | + | func (_e *MockFavoriteRepository_Expecter) GetByTag(ctx any, tagID any) *MockFavoriteRepository_GetByTag_Call { |
| 401 | - | return &MockFavoriteRepository_GetByShortcut_Call{Call: _e.mock.On("GetByShortcut", ctx, key)} | 401 | + | return &MockFavoriteRepository_GetByTag_Call{Call: _e.mock.On("GetByTag", ctx, tagID)} |
| 402 | } | 402 | } | ||
| 403 | 403 | ||||
| 404 | - | func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Context, key int)) *MockFavoriteRepository_GetByShortcut_Call { | 404 | + | func (_c *MockFavoriteRepository_GetByTag_Call) Run(run func(ctx context.Context, tagID entity.TagID)) *MockFavoriteRepository_GetByTag_Call { |
| 405 | _c.Call.Run(func(args mock.Arguments) { | 405 | _c.Call.Run(func(args mock.Arguments) { | ||
| 406 | var arg0 context.Context | 406 | var arg0 context.Context | ||
| 407 | if args[0] != nil { | 407 | if args[0] != nil { | ||
| 408 | arg0 = args[0].(context.Context) | 408 | arg0 = args[0].(context.Context) | ||
| 409 | } | 409 | } | ||
| 410 | - | var arg1 int | 410 | + | var arg1 entity.TagID |
| 411 | if args[1] != nil { | 411 | if args[1] != nil { | ||
| 412 | - | arg1 = args[1].(int) | 412 | + | arg1 = args[1].(entity.TagID) |
| 413 | } | 413 | } | ||
| 414 | run( | 414 | run( | ||
| 415 | arg0, | 415 | arg0, | ||
| @@ -419,12 +419,12 @@ func (_c *MockFavoriteRepository_GetByShortcut_Call) Run(run func(ctx context.Co | |||||
| 419 | return _c | 419 | return _c | ||
| 420 | } | 420 | } | ||
| 421 | 421 | ||||
| 422 | - | func (_c *MockFavoriteRepository_GetByShortcut_Call) Return(favorite *entity.Favorite, err error) *MockFavoriteRepository_GetByShortcut_Call { | 422 | + | func (_c *MockFavoriteRepository_GetByTag_Call) Return(favorites []*entity.Favorite, err error) *MockFavoriteRepository_GetByTag_Call { |
| 423 | - | _c.Call.Return(favorite, err) | 423 | + | _c.Call.Return(favorites, err) |
| 424 | return _c | 424 | return _c | ||
| 425 | } | 425 | } | ||
| 426 | 426 | ||||
| 427 | - | func (_c *MockFavoriteRepository_GetByShortcut_Call) RunAndReturn(run func(ctx context.Context, key int) (*entity.Favorite, error)) *MockFavoriteRepository_GetByShortcut_Call { | 427 | + | func (_c *MockFavoriteRepository_GetByTag_Call) RunAndReturn(run func(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error)) *MockFavoriteRepository_GetByTag_Call { |
| 428 | _c.Call.Return(run) | 428 | _c.Call.Return(run) | ||
| 429 | return _c | 429 | return _c | ||
| 430 | } | 430 | } | ||
| @@ -486,69 +486,6 @@ func (_c *MockFavoriteRepository_Save_Call) RunAndReturn(run func(ctx context.Co | |||||
| 486 | return _c | 486 | return _c | ||
| 487 | } | 487 | } | ||
| 488 | 488 | ||||
| 489 | - | // SetFolder provides a mock function for the type MockFavoriteRepository | |||
| 490 | - | func (_mock *MockFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error { | |||
| 491 | - | ret := _mock.Called(ctx, id, folderID) | |||
| 492 | - | ||||
| 493 | - | if len(ret) == 0 { | |||
| 494 | - | panic("no return value specified for SetFolder") | |||
| 495 | - | } | |||
| 496 | - | ||||
| 497 | - | var r0 error | |||
| 498 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID, *entity.FolderID) error); ok { | |||
| 499 | - | r0 = returnFunc(ctx, id, folderID) | |||
| 500 | - | } else { | |||
| 501 | - | r0 = ret.Error(0) | |||
| 502 | - | } | |||
| 503 | - | return r0 | |||
| 504 | - | } | |||
| 505 | - | ||||
| 506 | - | // MockFavoriteRepository_SetFolder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFolder' | |||
| 507 | - | type MockFavoriteRepository_SetFolder_Call struct { | |||
| 508 | - | *mock.Call | |||
| 509 | - | } | |||
| 510 | - | ||||
| 511 | - | // SetFolder is a helper method to define mock.On call | |||
| 512 | - | // - ctx context.Context | |||
| 513 | - | // - id entity.FavoriteID | |||
| 514 | - | // - folderID *entity.FolderID | |||
| 515 | - | func (_e *MockFavoriteRepository_Expecter) SetFolder(ctx any, id any, folderID any) *MockFavoriteRepository_SetFolder_Call { | |||
| 516 | - | return &MockFavoriteRepository_SetFolder_Call{Call: _e.mock.On("SetFolder", ctx, id, folderID)} | |||
| 517 | - | } | |||
| 518 | - | ||||
| 519 | - | func (_c *MockFavoriteRepository_SetFolder_Call) Run(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID)) *MockFavoriteRepository_SetFolder_Call { | |||
| 520 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 521 | - | var arg0 context.Context | |||
| 522 | - | if args[0] != nil { | |||
| 523 | - | arg0 = args[0].(context.Context) | |||
| 524 | - | } | |||
| 525 | - | var arg1 entity.FavoriteID | |||
| 526 | - | if args[1] != nil { | |||
| 527 | - | arg1 = args[1].(entity.FavoriteID) | |||
| 528 | - | } | |||
| 529 | - | var arg2 *entity.FolderID | |||
| 530 | - | if args[2] != nil { | |||
| 531 | - | arg2 = args[2].(*entity.FolderID) | |||
| 532 | - | } | |||
| 533 | - | run( | |||
| 534 | - | arg0, | |||
| 535 | - | arg1, | |||
| 536 | - | arg2, | |||
| 537 | - | ) | |||
| 538 | - | }) | |||
| 539 | - | return _c | |||
| 540 | - | } | |||
| 541 | - | ||||
| 542 | - | func (_c *MockFavoriteRepository_SetFolder_Call) Return(err error) *MockFavoriteRepository_SetFolder_Call { | |||
| 543 | - | _c.Call.Return(err) | |||
| 544 | - | return _c | |||
| 545 | - | } | |||
| 546 | - | ||||
| 547 | - | func (_c *MockFavoriteRepository_SetFolder_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error) *MockFavoriteRepository_SetFolder_Call { | |||
| 548 | - | _c.Call.Return(run) | |||
| 549 | - | return _c | |||
| 550 | - | } | |||
| 551 | - | ||||
| 552 | // SetShortcut provides a mock function for the type MockFavoriteRepository | 489 | // SetShortcut provides a mock function for the type MockFavoriteRepository | ||
| 553 | func (_mock *MockFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | 490 | func (_mock *MockFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | ||
| 554 | ret := _mock.Called(ctx, id, key) | 491 | ret := _mock.Called(ctx, id, key) | ||
| @@ -675,408 +612,6 @@ func (_c *MockFavoriteRepository_UpdatePosition_Call) RunAndReturn(run func(ctx | |||||
| 675 | return _c | 612 | return _c | ||
| 676 | } | 613 | } | ||
| 677 | 614 | ||||
| 678 | - | // NewMockFolderRepository creates a new instance of MockFolderRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 679 | - | // The first argument is typically a *testing.T value. | |||
| 680 | - | func NewMockFolderRepository(t interface { | |||
| 681 | - | mock.TestingT | |||
| 682 | - | Cleanup(func()) | |||
| 683 | - | }) *MockFolderRepository { | |||
| 684 | - | mock := &MockFolderRepository{} | |||
| 685 | - | mock.Mock.Test(t) | |||
| 686 | - | ||||
| 687 | - | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 688 | - | ||||
| 689 | - | return mock | |||
| 690 | - | } | |||
| 691 | - | ||||
| 692 | - | // MockFolderRepository is an autogenerated mock type for the FolderRepository type | |||
| 693 | - | type MockFolderRepository struct { | |||
| 694 | - | mock.Mock | |||
| 695 | - | } | |||
| 696 | - | ||||
| 697 | - | type MockFolderRepository_Expecter struct { | |||
| 698 | - | mock *mock.Mock | |||
| 699 | - | } | |||
| 700 | - | ||||
| 701 | - | func (_m *MockFolderRepository) EXPECT() *MockFolderRepository_Expecter { | |||
| 702 | - | return &MockFolderRepository_Expecter{mock: &_m.Mock} | |||
| 703 | - | } | |||
| 704 | - | ||||
| 705 | - | // Delete provides a mock function for the type MockFolderRepository | |||
| 706 | - | func (_mock *MockFolderRepository) Delete(ctx context.Context, id entity.FolderID) error { | |||
| 707 | - | ret := _mock.Called(ctx, id) | |||
| 708 | - | ||||
| 709 | - | if len(ret) == 0 { | |||
| 710 | - | panic("no return value specified for Delete") | |||
| 711 | - | } | |||
| 712 | - | ||||
| 713 | - | var r0 error | |||
| 714 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) error); ok { | |||
| 715 | - | r0 = returnFunc(ctx, id) | |||
| 716 | - | } else { | |||
| 717 | - | r0 = ret.Error(0) | |||
| 718 | - | } | |||
| 719 | - | return r0 | |||
| 720 | - | } | |||
| 721 | - | ||||
| 722 | - | // MockFolderRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 723 | - | type MockFolderRepository_Delete_Call struct { | |||
| 724 | - | *mock.Call | |||
| 725 | - | } | |||
| 726 | - | ||||
| 727 | - | // Delete is a helper method to define mock.On call | |||
| 728 | - | // - ctx context.Context | |||
| 729 | - | // - id entity.FolderID | |||
| 730 | - | func (_e *MockFolderRepository_Expecter) Delete(ctx any, id any) *MockFolderRepository_Delete_Call { | |||
| 731 | - | return &MockFolderRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} | |||
| 732 | - | } | |||
| 733 | - | ||||
| 734 | - | func (_c *MockFolderRepository_Delete_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockFolderRepository_Delete_Call { | |||
| 735 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 736 | - | var arg0 context.Context | |||
| 737 | - | if args[0] != nil { | |||
| 738 | - | arg0 = args[0].(context.Context) | |||
| 739 | - | } | |||
| 740 | - | var arg1 entity.FolderID | |||
| 741 | - | if args[1] != nil { | |||
| 742 | - | arg1 = args[1].(entity.FolderID) | |||
| 743 | - | } | |||
| 744 | - | run( | |||
| 745 | - | arg0, | |||
| 746 | - | arg1, | |||
| 747 | - | ) | |||
| 748 | - | }) | |||
| 749 | - | return _c | |||
| 750 | - | } | |||
| 751 | - | ||||
| 752 | - | func (_c *MockFolderRepository_Delete_Call) Return(err error) *MockFolderRepository_Delete_Call { | |||
| 753 | - | _c.Call.Return(err) | |||
| 754 | - | return _c | |||
| 755 | - | } | |||
| 756 | - | ||||
| 757 | - | func (_c *MockFolderRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) error) *MockFolderRepository_Delete_Call { | |||
| 758 | - | _c.Call.Return(run) | |||
| 759 | - | return _c | |||
| 760 | - | } | |||
| 761 | - | ||||
| 762 | - | // FindByID provides a mock function for the type MockFolderRepository | |||
| 763 | - | func (_mock *MockFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) { | |||
| 764 | - | ret := _mock.Called(ctx, id) | |||
| 765 | - | ||||
| 766 | - | if len(ret) == 0 { | |||
| 767 | - | panic("no return value specified for FindByID") | |||
| 768 | - | } | |||
| 769 | - | ||||
| 770 | - | var r0 *entity.Folder | |||
| 771 | - | var r1 error | |||
| 772 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) (*entity.Folder, error)); ok { | |||
| 773 | - | return returnFunc(ctx, id) | |||
| 774 | - | } | |||
| 775 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID) *entity.Folder); ok { | |||
| 776 | - | r0 = returnFunc(ctx, id) | |||
| 777 | - | } else { | |||
| 778 | - | if ret.Get(0) != nil { | |||
| 779 | - | r0 = ret.Get(0).(*entity.Folder) | |||
| 780 | - | } | |||
| 781 | - | } | |||
| 782 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.FolderID) error); ok { | |||
| 783 | - | r1 = returnFunc(ctx, id) | |||
| 784 | - | } else { | |||
| 785 | - | r1 = ret.Error(1) | |||
| 786 | - | } | |||
| 787 | - | return r0, r1 | |||
| 788 | - | } | |||
| 789 | - | ||||
| 790 | - | // MockFolderRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID' | |||
| 791 | - | type MockFolderRepository_FindByID_Call struct { | |||
| 792 | - | *mock.Call | |||
| 793 | - | } | |||
| 794 | - | ||||
| 795 | - | // FindByID is a helper method to define mock.On call | |||
| 796 | - | // - ctx context.Context | |||
| 797 | - | // - id entity.FolderID | |||
| 798 | - | func (_e *MockFolderRepository_Expecter) FindByID(ctx any, id any) *MockFolderRepository_FindByID_Call { | |||
| 799 | - | return &MockFolderRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)} | |||
| 800 | - | } | |||
| 801 | - | ||||
| 802 | - | func (_c *MockFolderRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.FolderID)) *MockFolderRepository_FindByID_Call { | |||
| 803 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 804 | - | var arg0 context.Context | |||
| 805 | - | if args[0] != nil { | |||
| 806 | - | arg0 = args[0].(context.Context) | |||
| 807 | - | } | |||
| 808 | - | var arg1 entity.FolderID | |||
| 809 | - | if args[1] != nil { | |||
| 810 | - | arg1 = args[1].(entity.FolderID) | |||
| 811 | - | } | |||
| 812 | - | run( | |||
| 813 | - | arg0, | |||
| 814 | - | arg1, | |||
| 815 | - | ) | |||
| 816 | - | }) | |||
| 817 | - | return _c | |||
| 818 | - | } | |||
| 819 | - | ||||
| 820 | - | func (_c *MockFolderRepository_FindByID_Call) Return(folder *entity.Folder, err error) *MockFolderRepository_FindByID_Call { | |||
| 821 | - | _c.Call.Return(folder, err) | |||
| 822 | - | return _c | |||
| 823 | - | } | |||
| 824 | - | ||||
| 825 | - | func (_c *MockFolderRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID) (*entity.Folder, error)) *MockFolderRepository_FindByID_Call { | |||
| 826 | - | _c.Call.Return(run) | |||
| 827 | - | return _c | |||
| 828 | - | } | |||
| 829 | - | ||||
| 830 | - | // GetAll provides a mock function for the type MockFolderRepository | |||
| 831 | - | func (_mock *MockFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) { | |||
| 832 | - | ret := _mock.Called(ctx) | |||
| 833 | - | ||||
| 834 | - | if len(ret) == 0 { | |||
| 835 | - | panic("no return value specified for GetAll") | |||
| 836 | - | } | |||
| 837 | - | ||||
| 838 | - | var r0 []*entity.Folder | |||
| 839 | - | var r1 error | |||
| 840 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.Folder, error)); ok { | |||
| 841 | - | return returnFunc(ctx) | |||
| 842 | - | } | |||
| 843 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.Folder); ok { | |||
| 844 | - | r0 = returnFunc(ctx) | |||
| 845 | - | } else { | |||
| 846 | - | if ret.Get(0) != nil { | |||
| 847 | - | r0 = ret.Get(0).([]*entity.Folder) | |||
| 848 | - | } | |||
| 849 | - | } | |||
| 850 | - | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 851 | - | r1 = returnFunc(ctx) | |||
| 852 | - | } else { | |||
| 853 | - | r1 = ret.Error(1) | |||
| 854 | - | } | |||
| 855 | - | return r0, r1 | |||
| 856 | - | } | |||
| 857 | - | ||||
| 858 | - | // MockFolderRepository_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 859 | - | type MockFolderRepository_GetAll_Call struct { | |||
| 860 | - | *mock.Call | |||
| 861 | - | } | |||
| 862 | - | ||||
| 863 | - | // GetAll is a helper method to define mock.On call | |||
| 864 | - | // - ctx context.Context | |||
| 865 | - | func (_e *MockFolderRepository_Expecter) GetAll(ctx any) *MockFolderRepository_GetAll_Call { | |||
| 866 | - | return &MockFolderRepository_GetAll_Call{Call: _e.mock.On("GetAll", ctx)} | |||
| 867 | - | } | |||
| 868 | - | ||||
| 869 | - | func (_c *MockFolderRepository_GetAll_Call) Run(run func(ctx context.Context)) *MockFolderRepository_GetAll_Call { | |||
| 870 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 871 | - | var arg0 context.Context | |||
| 872 | - | if args[0] != nil { | |||
| 873 | - | arg0 = args[0].(context.Context) | |||
| 874 | - | } | |||
| 875 | - | run( | |||
| 876 | - | arg0, | |||
| 877 | - | ) | |||
| 878 | - | }) | |||
| 879 | - | return _c | |||
| 880 | - | } | |||
| 881 | - | ||||
| 882 | - | func (_c *MockFolderRepository_GetAll_Call) Return(folders []*entity.Folder, err error) *MockFolderRepository_GetAll_Call { | |||
| 883 | - | _c.Call.Return(folders, err) | |||
| 884 | - | return _c | |||
| 885 | - | } | |||
| 886 | - | ||||
| 887 | - | func (_c *MockFolderRepository_GetAll_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.Folder, error)) *MockFolderRepository_GetAll_Call { | |||
| 888 | - | _c.Call.Return(run) | |||
| 889 | - | return _c | |||
| 890 | - | } | |||
| 891 | - | ||||
| 892 | - | // GetChildren provides a mock function for the type MockFolderRepository | |||
| 893 | - | func (_mock *MockFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) { | |||
| 894 | - | ret := _mock.Called(ctx, parentID) | |||
| 895 | - | ||||
| 896 | - | if len(ret) == 0 { | |||
| 897 | - | panic("no return value specified for GetChildren") | |||
| 898 | - | } | |||
| 899 | - | ||||
| 900 | - | var r0 []*entity.Folder | |||
| 901 | - | var r1 error | |||
| 902 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) ([]*entity.Folder, error)); ok { | |||
| 903 | - | return returnFunc(ctx, parentID) | |||
| 904 | - | } | |||
| 905 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.FolderID) []*entity.Folder); ok { | |||
| 906 | - | r0 = returnFunc(ctx, parentID) | |||
| 907 | - | } else { | |||
| 908 | - | if ret.Get(0) != nil { | |||
| 909 | - | r0 = ret.Get(0).([]*entity.Folder) | |||
| 910 | - | } | |||
| 911 | - | } | |||
| 912 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, *entity.FolderID) error); ok { | |||
| 913 | - | r1 = returnFunc(ctx, parentID) | |||
| 914 | - | } else { | |||
| 915 | - | r1 = ret.Error(1) | |||
| 916 | - | } | |||
| 917 | - | return r0, r1 | |||
| 918 | - | } | |||
| 919 | - | ||||
| 920 | - | // MockFolderRepository_GetChildren_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChildren' | |||
| 921 | - | type MockFolderRepository_GetChildren_Call struct { | |||
| 922 | - | *mock.Call | |||
| 923 | - | } | |||
| 924 | - | ||||
| 925 | - | // GetChildren is a helper method to define mock.On call | |||
| 926 | - | // - ctx context.Context | |||
| 927 | - | // - parentID *entity.FolderID | |||
| 928 | - | func (_e *MockFolderRepository_Expecter) GetChildren(ctx any, parentID any) *MockFolderRepository_GetChildren_Call { | |||
| 929 | - | return &MockFolderRepository_GetChildren_Call{Call: _e.mock.On("GetChildren", ctx, parentID)} | |||
| 930 | - | } | |||
| 931 | - | ||||
| 932 | - | func (_c *MockFolderRepository_GetChildren_Call) Run(run func(ctx context.Context, parentID *entity.FolderID)) *MockFolderRepository_GetChildren_Call { | |||
| 933 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 934 | - | var arg0 context.Context | |||
| 935 | - | if args[0] != nil { | |||
| 936 | - | arg0 = args[0].(context.Context) | |||
| 937 | - | } | |||
| 938 | - | var arg1 *entity.FolderID | |||
| 939 | - | if args[1] != nil { | |||
| 940 | - | arg1 = args[1].(*entity.FolderID) | |||
| 941 | - | } | |||
| 942 | - | run( | |||
| 943 | - | arg0, | |||
| 944 | - | arg1, | |||
| 945 | - | ) | |||
| 946 | - | }) | |||
| 947 | - | return _c | |||
| 948 | - | } | |||
| 949 | - | ||||
| 950 | - | func (_c *MockFolderRepository_GetChildren_Call) Return(folders []*entity.Folder, err error) *MockFolderRepository_GetChildren_Call { | |||
| 951 | - | _c.Call.Return(folders, err) | |||
| 952 | - | return _c | |||
| 953 | - | } | |||
| 954 | - | ||||
| 955 | - | func (_c *MockFolderRepository_GetChildren_Call) RunAndReturn(run func(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error)) *MockFolderRepository_GetChildren_Call { | |||
| 956 | - | _c.Call.Return(run) | |||
| 957 | - | return _c | |||
| 958 | - | } | |||
| 959 | - | ||||
| 960 | - | // Save provides a mock function for the type MockFolderRepository | |||
| 961 | - | func (_mock *MockFolderRepository) Save(ctx context.Context, folder *entity.Folder) error { | |||
| 962 | - | ret := _mock.Called(ctx, folder) | |||
| 963 | - | ||||
| 964 | - | if len(ret) == 0 { | |||
| 965 | - | panic("no return value specified for Save") | |||
| 966 | - | } | |||
| 967 | - | ||||
| 968 | - | var r0 error | |||
| 969 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.Folder) error); ok { | |||
| 970 | - | r0 = returnFunc(ctx, folder) | |||
| 971 | - | } else { | |||
| 972 | - | r0 = ret.Error(0) | |||
| 973 | - | } | |||
| 974 | - | return r0 | |||
| 975 | - | } | |||
| 976 | - | ||||
| 977 | - | // MockFolderRepository_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' | |||
| 978 | - | type MockFolderRepository_Save_Call struct { | |||
| 979 | - | *mock.Call | |||
| 980 | - | } | |||
| 981 | - | ||||
| 982 | - | // Save is a helper method to define mock.On call | |||
| 983 | - | // - ctx context.Context | |||
| 984 | - | // - folder *entity.Folder | |||
| 985 | - | func (_e *MockFolderRepository_Expecter) Save(ctx any, folder any) *MockFolderRepository_Save_Call { | |||
| 986 | - | return &MockFolderRepository_Save_Call{Call: _e.mock.On("Save", ctx, folder)} | |||
| 987 | - | } | |||
| 988 | - | ||||
| 989 | - | func (_c *MockFolderRepository_Save_Call) Run(run func(ctx context.Context, folder *entity.Folder)) *MockFolderRepository_Save_Call { | |||
| 990 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 991 | - | var arg0 context.Context | |||
| 992 | - | if args[0] != nil { | |||
| 993 | - | arg0 = args[0].(context.Context) | |||
| 994 | - | } | |||
| 995 | - | var arg1 *entity.Folder | |||
| 996 | - | if args[1] != nil { | |||
| 997 | - | arg1 = args[1].(*entity.Folder) | |||
| 998 | - | } | |||
| 999 | - | run( | |||
| 1000 | - | arg0, | |||
| 1001 | - | arg1, | |||
| 1002 | - | ) | |||
| 1003 | - | }) | |||
| 1004 | - | return _c | |||
| 1005 | - | } | |||
| 1006 | - | ||||
| 1007 | - | func (_c *MockFolderRepository_Save_Call) Return(err error) *MockFolderRepository_Save_Call { | |||
| 1008 | - | _c.Call.Return(err) | |||
| 1009 | - | return _c | |||
| 1010 | - | } | |||
| 1011 | - | ||||
| 1012 | - | func (_c *MockFolderRepository_Save_Call) RunAndReturn(run func(ctx context.Context, folder *entity.Folder) error) *MockFolderRepository_Save_Call { | |||
| 1013 | - | _c.Call.Return(run) | |||
| 1014 | - | return _c | |||
| 1015 | - | } | |||
| 1016 | - | ||||
| 1017 | - | // UpdatePosition provides a mock function for the type MockFolderRepository | |||
| 1018 | - | func (_mock *MockFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error { | |||
| 1019 | - | ret := _mock.Called(ctx, id, position) | |||
| 1020 | - | ||||
| 1021 | - | if len(ret) == 0 { | |||
| 1022 | - | panic("no return value specified for UpdatePosition") | |||
| 1023 | - | } | |||
| 1024 | - | ||||
| 1025 | - | var r0 error | |||
| 1026 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FolderID, int) error); ok { | |||
| 1027 | - | r0 = returnFunc(ctx, id, position) | |||
| 1028 | - | } else { | |||
| 1029 | - | r0 = ret.Error(0) | |||
| 1030 | - | } | |||
| 1031 | - | return r0 | |||
| 1032 | - | } | |||
| 1033 | - | ||||
| 1034 | - | // MockFolderRepository_UpdatePosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePosition' | |||
| 1035 | - | type MockFolderRepository_UpdatePosition_Call struct { | |||
| 1036 | - | *mock.Call | |||
| 1037 | - | } | |||
| 1038 | - | ||||
| 1039 | - | // UpdatePosition is a helper method to define mock.On call | |||
| 1040 | - | // - ctx context.Context | |||
| 1041 | - | // - id entity.FolderID | |||
| 1042 | - | // - position int | |||
| 1043 | - | func (_e *MockFolderRepository_Expecter) UpdatePosition(ctx any, id any, position any) *MockFolderRepository_UpdatePosition_Call { | |||
| 1044 | - | return &MockFolderRepository_UpdatePosition_Call{Call: _e.mock.On("UpdatePosition", ctx, id, position)} | |||
| 1045 | - | } | |||
| 1046 | - | ||||
| 1047 | - | func (_c *MockFolderRepository_UpdatePosition_Call) Run(run func(ctx context.Context, id entity.FolderID, position int)) *MockFolderRepository_UpdatePosition_Call { | |||
| 1048 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 1049 | - | var arg0 context.Context | |||
| 1050 | - | if args[0] != nil { | |||
| 1051 | - | arg0 = args[0].(context.Context) | |||
| 1052 | - | } | |||
| 1053 | - | var arg1 entity.FolderID | |||
| 1054 | - | if args[1] != nil { | |||
| 1055 | - | arg1 = args[1].(entity.FolderID) | |||
| 1056 | - | } | |||
| 1057 | - | var arg2 int | |||
| 1058 | - | if args[2] != nil { | |||
| 1059 | - | arg2 = args[2].(int) | |||
| 1060 | - | } | |||
| 1061 | - | run( | |||
| 1062 | - | arg0, | |||
| 1063 | - | arg1, | |||
| 1064 | - | arg2, | |||
| 1065 | - | ) | |||
| 1066 | - | }) | |||
| 1067 | - | return _c | |||
| 1068 | - | } | |||
| 1069 | - | ||||
| 1070 | - | func (_c *MockFolderRepository_UpdatePosition_Call) Return(err error) *MockFolderRepository_UpdatePosition_Call { | |||
| 1071 | - | _c.Call.Return(err) | |||
| 1072 | - | return _c | |||
| 1073 | - | } | |||
| 1074 | - | ||||
| 1075 | - | func (_c *MockFolderRepository_UpdatePosition_Call) RunAndReturn(run func(ctx context.Context, id entity.FolderID, position int) error) *MockFolderRepository_UpdatePosition_Call { | |||
| 1076 | - | _c.Call.Return(run) | |||
| 1077 | - | return _c | |||
| 1078 | - | } | |||
| 1079 | - | ||||
| 1080 | // NewMockTagRepository creates a new instance of MockTagRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 615 | // NewMockTagRepository creates a new instance of MockTagRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1081 | // The first argument is typically a *testing.T value. | 616 | // The first argument is typically a *testing.T value. | ||
| 1082 | func NewMockTagRepository(t interface { | 617 | func NewMockTagRepository(t interface { | ||
M internal/infrastructure/handlers/homepage/favorites.go +10 -67
| @@ -12,17 +12,6 @@ import ( | |||||
| 12 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | func folderIDFromInt64(id *int64) (*entity.FolderID, error) { | |||
| 16 | - | if id == nil { | |||
| 17 | - | return nil, nil | |||
| 18 | - | } | |||
| 19 | - | if *id <= 0 { | |||
| 20 | - | return nil, fmt.Errorf("folder id must be positive") | |||
| 21 | - | } | |||
| 22 | - | folderID := entity.FolderID(*id) | |||
| 23 | - | return &folderID, nil | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) { | 15 | func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) { | ||
| 27 | out := make([]entity.TagID, 0, len(ids)) | 16 | out := make([]entity.TagID, 0, len(ids)) | ||
| 28 | for _, id := range ids { | 17 | for _, id := range ids { | ||
| @@ -69,7 +58,6 @@ type favoriteCreateRequest struct { | |||||
| 69 | URL string `json:"url"` | 58 | URL string `json:"url"` | ||
| 70 | Title string `json:"title"` | 59 | Title string `json:"title"` | ||
| 71 | FaviconURL string `json:"favicon_url"` | 60 | FaviconURL string `json:"favicon_url"` | ||
| 72 | - | FolderID *int64 `json:"folder_id"` | |||
| 73 | Tags []int64 `json:"tags"` | 61 | Tags []int64 `json:"tags"` | ||
| 74 | } | 62 | } | ||
| 75 | 63 | ||||
| @@ -91,10 +79,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||||
| 91 | if trimmedURL == "" { | 79 | if trimmedURL == "" { | ||
| 92 | return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil | 80 | return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil | ||
| 93 | } | 81 | } | ||
| 94 | - | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 95 | - | if err != nil { | |||
| 96 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 97 | - | } | |||
| 98 | tags, err := tagIDsFromInt64s(req.Tags) | 82 | tags, err := tagIDsFromInt64s(req.Tags) | ||
| 99 | if err != nil { | 83 | if err != nil { | ||
| 100 | return NewErrorResponse(req.RequestID, err), nil | 84 | return NewErrorResponse(req.RequestID, err), nil | ||
| @@ -104,7 +88,6 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||||
| 104 | URL: trimmedURL, | 88 | URL: trimmedURL, | ||
| 105 | Title: req.Title, | 89 | Title: req.Title, | ||
| 106 | FaviconURL: req.FaviconURL, | 90 | FaviconURL: req.FaviconURL, | ||
| 107 | - | FolderID: folderID, | |||
| 108 | Tags: tags, | 91 | Tags: tags, | ||
| 109 | } | 92 | } | ||
| 110 | favorite, err := h.favoritesUC.AddFavorite(ctx, input) | 93 | favorite, err := h.favoritesUC.AddFavorite(ctx, input) | ||
| @@ -121,7 +104,6 @@ type favoriteUpdateRequest struct { | |||||
| 121 | ID int64 `json:"id"` | 104 | ID int64 `json:"id"` | ||
| 122 | Title string `json:"title"` | 105 | Title string `json:"title"` | ||
| 123 | FaviconURL string `json:"favicon_url"` | 106 | FaviconURL string `json:"favicon_url"` | ||
| 124 | - | FolderID *int64 `json:"folder_id"` | |||
| 125 | ShortcutKey *int `json:"shortcut_key"` | 107 | ShortcutKey *int `json:"shortcut_key"` | ||
| 126 | } | 108 | } | ||
| 127 | 109 | ||||
| @@ -134,6 +116,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||||
| 134 | if err := json.Unmarshal(payload, &req); err != nil { | 116 | if err := json.Unmarshal(payload, &req); err != nil { | ||
| 135 | return NewErrorResponse("", err), nil | 117 | return NewErrorResponse("", err), nil | ||
| 136 | } | 118 | } | ||
| 119 | + | var fields map[string]json.RawMessage | |||
| 120 | + | if err := json.Unmarshal(payload, &fields); err != nil { | |||
| 121 | + | return NewErrorResponse("", err), nil | |||
| 122 | + | } | |||
| 123 | + | _, shortcutKeySet := fields["shortcut_key"] | |||
| 137 | 124 | ||||
| 138 | log.Debug(). | 125 | log.Debug(). | ||
| 139 | Str("request_id", req.RequestID). | 126 | Str("request_id", req.RequestID). | ||
| @@ -144,17 +131,12 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||||
| 144 | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | 131 | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | ||
| 145 | } | 132 | } | ||
| 146 | 133 | ||||
| 147 | - | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 148 | - | if err != nil { | |||
| 149 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | 134 | favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | ||
| 153 | - | ID: entity.FavoriteID(req.ID), | 135 | + | ID: entity.FavoriteID(req.ID), |
| 154 | - | Title: req.Title, | 136 | + | Title: req.Title, |
| 155 | - | FaviconURL: req.FaviconURL, | 137 | + | FaviconURL: req.FaviconURL, |
| 156 | - | FolderID: folderID, | 138 | + | ShortcutKey: req.ShortcutKey, |
| 157 | - | ShortcutKey: req.ShortcutKey, | 139 | + | ShortcutKeySet: shortcutKeySet, |
| 158 | }) | 140 | }) | ||
| 159 | if err != nil { | 141 | if err != nil { | ||
| 160 | return NewErrorResponse(req.RequestID, err), nil | 142 | return NewErrorResponse(req.RequestID, err), nil | ||
| @@ -259,42 +241,3 @@ func (h *FavoritesHandlers) HandleGetByShortcut() port.WebUIMessageHandler { | |||||
| 259 | return NewSuccessResponse(req.RequestID, favorite), nil | 241 | return NewSuccessResponse(req.RequestID, favorite), nil | ||
| 260 | }) | 242 | }) | ||
| 261 | } | 243 | } | ||
| 262 | - | ||||
| 263 | - | // setFolderRequest is the payload for favorite_set_folder messages. | |||
| 264 | - | type setFolderRequest struct { | |||
| 265 | - | RequestID string `json:"requestId"` | |||
| 266 | - | FavoriteID int64 `json:"favorite_id"` | |||
| 267 | - | FolderID *int64 `json:"folder_id"` // null to move to root | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | // HandleSetFolder handles favorite_set_folder messages. | |||
| 271 | - | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||
| 272 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 273 | - | log := logging.FromContext(ctx) | |||
| 274 | - | ||||
| 275 | - | var req setFolderRequest | |||
| 276 | - | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 277 | - | return NewErrorResponse("", err), nil | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | log.Debug(). | |||
| 281 | - | Str("request_id", req.RequestID). | |||
| 282 | - | Int64("favorite_id", req.FavoriteID). | |||
| 283 | - | Msg("handling favorite_set_folder") | |||
| 284 | - | ||||
| 285 | - | if req.FavoriteID <= 0 { | |||
| 286 | - | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 290 | - | if err != nil { | |||
| 291 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil { | |||
| 295 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 299 | - | }) | |||
| 300 | - | } | |||
A internal/infrastructure/handlers/homepage/favorites_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package homepage | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestHandleFavoriteCreatePassesTagsOnlyPayload(t *testing.T) { | |||
| 17 | + | t.Parallel() | |||
| 18 | + | ||||
| 19 | + | favorites := portmocks.NewMockHomepageFavorites(t) | |||
| 20 | + | favorites.EXPECT(). | |||
| 21 | + | AddFavorite(mock.Anything, dto.FavoriteCreateInput{ | |||
| 22 | + | URL: "https://example.com", | |||
| 23 | + | Title: "Example", | |||
| 24 | + | Tags: []entity.TagID{7, 9}, | |||
| 25 | + | }). | |||
| 26 | + | Return(&entity.Favorite{ID: 1, URL: "https://example.com", Title: "Example"}, nil). | |||
| 27 | + | Once() | |||
| 28 | + | ||||
| 29 | + | handler := NewFavoritesHandlers(favorites).HandleCreate() | |||
| 30 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-1","url":" https://example.com ","title":"Example","tags":[7,9]}`)) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | ||||
| 33 | + | resp, ok := got.(Response) | |||
| 34 | + | require.True(t, ok) | |||
| 35 | + | require.True(t, resp.Success) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestHandleFavoriteCreateRejectsInvalidTagIDs(t *testing.T) { | |||
| 39 | + | t.Parallel() | |||
| 40 | + | ||||
| 41 | + | handler := NewFavoritesHandlers(portmocks.NewMockHomepageFavorites(t)).HandleCreate() | |||
| 42 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-2","url":"https://example.com","tags":[0]}`)) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | ||||
| 45 | + | resp, ok := got.(Response) | |||
| 46 | + | require.True(t, ok) | |||
| 47 | + | require.False(t, resp.Success) | |||
| 48 | + | require.Contains(t, resp.Error, "tag id must be positive") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) { | |||
| 52 | + | t.Parallel() | |||
| 53 | + | ||||
| 54 | + | shortcut := 4 | |||
| 55 | + | favorites := portmocks.NewMockHomepageFavorites(t) | |||
| 56 | + | favorites.EXPECT(). | |||
| 57 | + | UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{ | |||
| 58 | + | ID: entity.FavoriteID(42), | |||
| 59 | + | Title: "Updated", | |||
| 60 | + | FaviconURL: "https://example.com/favicon.ico", | |||
| 61 | + | ShortcutKey: &shortcut, | |||
| 62 | + | ShortcutKeySet: true, | |||
| 63 | + | }). | |||
| 64 | + | Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated", ShortcutKey: &shortcut}, nil). | |||
| 65 | + | Once() | |||
| 66 | + | ||||
| 67 | + | handler := NewFavoritesHandlers(favorites).HandleUpdate() | |||
| 68 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-3","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":4}`)) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | resp, ok := got.(Response) | |||
| 72 | + | require.True(t, ok) | |||
| 73 | + | require.True(t, resp.Success) | |||
| 74 | + | } | |||
| 75 | + | func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) { | |||
| 76 | + | t.Parallel() | |||
| 77 | + | ||||
| 78 | + | favorites := portmocks.NewMockHomepageFavorites(t) | |||
| 79 | + | favorites.EXPECT(). | |||
| 80 | + | UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{ | |||
| 81 | + | ID: entity.FavoriteID(42), | |||
| 82 | + | Title: "Updated", | |||
| 83 | + | FaviconURL: "https://example.com/favicon.ico", | |||
| 84 | + | }). | |||
| 85 | + | Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil). | |||
| 86 | + | Once() | |||
| 87 | + | ||||
| 88 | + | handler := NewFavoritesHandlers(favorites).HandleUpdate() | |||
| 89 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-4","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico"}`)) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | resp, ok := got.(Response) | |||
| 93 | + | require.True(t, ok) | |||
| 94 | + | require.True(t, resp.Success) | |||
| 95 | + | } | |||
| 96 | + | func TestHandleFavoriteUpdateClearsShortcutWhenExplicitNull(t *testing.T) { | |||
| 97 | + | t.Parallel() | |||
| 98 | + | ||||
| 99 | + | favorites := portmocks.NewMockHomepageFavorites(t) | |||
| 100 | + | favorites.EXPECT(). | |||
| 101 | + | UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{ | |||
| 102 | + | ID: entity.FavoriteID(42), | |||
| 103 | + | Title: "Updated", | |||
| 104 | + | FaviconURL: "https://example.com/favicon.ico", | |||
| 105 | + | ShortcutKeySet: true, | |||
| 106 | + | }). | |||
| 107 | + | Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil). | |||
| 108 | + | Once() | |||
| 109 | + | ||||
| 110 | + | handler := NewFavoritesHandlers(favorites).HandleUpdate() | |||
| 111 | + | got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-5","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico","shortcut_key":null}`)) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | ||||
| 114 | + | resp, ok := got.(Response) | |||
| 115 | + | require.True(t, ok) | |||
| 116 | + | require.True(t, resp.Success) | |||
| 117 | + | } | |||
D internal/infrastructure/handlers/homepage/folders.go +0 -146
| @@ -1,146 +0,0 @@ | |||||
| 1 | - | package homepage | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "encoding/json" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | - | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | - | "github.com/bnema/dumber/internal/logging" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // FolderHandlers handles folder-related messages from the homepage. | |||
| 13 | - | type FolderHandlers struct { | |||
| 14 | - | favoritesUC port.HomepageFavorites | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | // NewFolderHandlers creates a new FolderHandlers instance. | |||
| 18 | - | func NewFolderHandlers(favoritesUC port.HomepageFavorites) *FolderHandlers { | |||
| 19 | - | return &FolderHandlers{favoritesUC: favoritesUC} | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | // HandleList handles folder_list messages. | |||
| 23 | - | func (h *FolderHandlers) HandleList() port.WebUIMessageHandler { | |||
| 24 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 25 | - | log := logging.FromContext(ctx) | |||
| 26 | - | ||||
| 27 | - | requestID := ParseRequestID(payload) | |||
| 28 | - | ||||
| 29 | - | log.Debug(). | |||
| 30 | - | Str("request_id", requestID). | |||
| 31 | - | Msg("handling folder_list") | |||
| 32 | - | ||||
| 33 | - | folders, err := h.favoritesUC.GetAllFolders(ctx) | |||
| 34 | - | if err != nil { | |||
| 35 | - | return NewErrorResponse(requestID, err), nil | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | return NewSuccessResponse(requestID, folders), nil | |||
| 39 | - | }) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // createFolderRequest is the payload for folder_create messages. | |||
| 43 | - | type createFolderRequest struct { | |||
| 44 | - | RequestID string `json:"requestId"` | |||
| 45 | - | Name string `json:"name"` | |||
| 46 | - | Icon *string `json:"icon"` | |||
| 47 | - | ParentID *int64 `json:"parent_id"` | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // HandleCreate handles folder_create messages. | |||
| 51 | - | func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler { | |||
| 52 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 53 | - | log := logging.FromContext(ctx) | |||
| 54 | - | ||||
| 55 | - | var req createFolderRequest | |||
| 56 | - | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 57 | - | return NewErrorResponse("", err), nil | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | log.Debug(). | |||
| 61 | - | Str("request_id", req.RequestID). | |||
| 62 | - | Str("name", req.Name). | |||
| 63 | - | Msg("handling folder_create") | |||
| 64 | - | ||||
| 65 | - | parentID, err := folderIDFromInt64(req.ParentID) | |||
| 66 | - | if err != nil { | |||
| 67 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID) | |||
| 71 | - | if err != nil { | |||
| 72 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | return NewSuccessResponse(req.RequestID, folder), nil | |||
| 76 | - | }) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // deleteFolderRequest is the payload for folder_delete messages. | |||
| 80 | - | type deleteFolderRequest struct { | |||
| 81 | - | RequestID string `json:"requestId"` | |||
| 82 | - | ID int64 `json:"id"` | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | // HandleDelete handles folder_delete messages. | |||
| 86 | - | func (h *FolderHandlers) HandleDelete() port.WebUIMessageHandler { | |||
| 87 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 88 | - | log := logging.FromContext(ctx) | |||
| 89 | - | ||||
| 90 | - | var req deleteFolderRequest | |||
| 91 | - | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 92 | - | return NewErrorResponse("", err), nil | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | log.Debug(). | |||
| 96 | - | Str("request_id", req.RequestID). | |||
| 97 | - | Int64("id", req.ID). | |||
| 98 | - | Msg("handling folder_delete") | |||
| 99 | - | ||||
| 100 | - | if err := h.favoritesUC.DeleteFolder(ctx, entity.FolderID(req.ID)); err != nil { | |||
| 101 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 105 | - | }) | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // updateFolderRequest is the payload for folder_update messages. | |||
| 109 | - | type updateFolderRequest struct { | |||
| 110 | - | RequestID string `json:"requestId"` | |||
| 111 | - | ID int64 `json:"id"` | |||
| 112 | - | Name string `json:"name"` | |||
| 113 | - | Icon *string `json:"icon"` | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | // HandleUpdate handles folder_update messages. | |||
| 117 | - | // It delegates to the port.HomepageFavorites interface's UpdateFolder method. | |||
| 118 | - | func optionalStringValue(value *string) string { | |||
| 119 | - | if value == nil { | |||
| 120 | - | return "" | |||
| 121 | - | } | |||
| 122 | - | return *value | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler { | |||
| 126 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 127 | - | log := logging.FromContext(ctx) | |||
| 128 | - | ||||
| 129 | - | var req updateFolderRequest | |||
| 130 | - | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 131 | - | return NewErrorResponse("", err), nil | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | log.Debug(). | |||
| 135 | - | Str("request_id", req.RequestID). | |||
| 136 | - | Int64("id", req.ID). | |||
| 137 | - | Str("name", req.Name). | |||
| 138 | - | Msg("handling folder_update") | |||
| 139 | - | ||||
| 140 | - | if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil { | |||
| 141 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 145 | - | }) | |||
| 146 | - | } | |||
M internal/infrastructure/handlers/homepage/registry.go +0 -9
| @@ -48,15 +48,6 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 48 | handlers["favorite_delete"] = favoritesHandlers.HandleDelete() | 48 | handlers["favorite_delete"] = favoritesHandlers.HandleDelete() | ||
| 49 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | 49 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | ||
| 50 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | 50 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | ||
| 51 | - | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | |||
| 52 | - | ||||
| 53 | - | // Folder handlers | |||
| 54 | - | folderHandlers := NewFolderHandlers(cfg.FavoritesUC) | |||
| 55 | - | handlers["folder_list"] = folderHandlers.HandleList() | |||
| 56 | - | handlers["folder_create"] = folderHandlers.HandleCreate() | |||
| 57 | - | handlers["folder_delete"] = folderHandlers.HandleDelete() | |||
| 58 | - | handlers["folder_update"] = folderHandlers.HandleUpdate() // Requires UpdateFolder() | |||
| 59 | - | ||||
| 60 | // Tag handlers | 51 | // Tag handlers | ||
| 61 | tagHandlers := NewTagHandlers(cfg.FavoritesUC) | 52 | tagHandlers := NewTagHandlers(cfg.FavoritesUC) | ||
| 62 | handlers["tag_list"] = tagHandlers.HandleList() | 53 | handlers["tag_list"] = tagHandlers.HandleList() | ||
M internal/infrastructure/persistence/sqlite/favorite_repo.go +120 -179
| @@ -13,79 +13,46 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | // ==================== Favorite Repository ==================== | 14 | // ==================== Favorite Repository ==================== | ||
| 15 | 15 | ||||
| 16 | + | const favoriteTagHydrationBatchSize = 900 | |||
| 17 | + | ||||
| 16 | type favoriteRepo struct { | 18 | type favoriteRepo struct { | ||
| 17 | - | db *sql.DB | |||
| 18 | queries *sqlc.Queries | 19 | queries *sqlc.Queries | ||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | // NewFavoriteRepository creates a new SQLite-backed favorite repository. | 22 | // NewFavoriteRepository creates a new SQLite-backed favorite repository. | ||
| 22 | func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | 23 | func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | ||
| 23 | - | return &favoriteRepo{db: db, queries: sqlc.New(db)} | 24 | + | return &favoriteRepo{queries: sqlc.New(db)} |
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | 27 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | ||
| 27 | log := logging.FromContext(ctx) | 28 | log := logging.FromContext(ctx) | ||
| 28 | log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite") | 29 | log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite") | ||
| 29 | 30 | ||||
| 30 | - | var folderID sql.NullInt64 | |||
| 31 | - | if fav.FolderID != nil { | |||
| 32 | - | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | if fav.ID > 0 { | 31 | if fav.ID > 0 { | ||
| 36 | - | return r.updateExisting(ctx, fav, folderID) | 32 | + | shortcutKey := sql.NullInt64{} |
| 33 | + | if fav.ShortcutKey != nil { | |||
| 34 | + | shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true} | |||
| 35 | + | } | |||
| 36 | + | return r.queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |||
| 37 | + | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |||
| 38 | + | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | |||
| 39 | + | ShortcutKey: shortcutKey, | |||
| 40 | + | ID: int64(fav.ID), | |||
| 41 | + | }) | |||
| 37 | } | 42 | } | ||
| 38 | 43 | ||||
| 39 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | 44 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | ||
| 40 | Url: fav.URL, | 45 | Url: fav.URL, | ||
| 41 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | 46 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | ||
| 42 | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | 47 | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | ||
| 43 | - | FolderID: folderID, | |||
| 44 | }) | 48 | }) | ||
| 45 | if err != nil { | 49 | if err != nil { | ||
| 46 | return err | 50 | return err | ||
| 47 | } | 51 | } | ||
| 48 | - | fav.ID = entity.FavoriteID(row.ID) | 52 | + | favoriteFromRow(favoriteRowFromCreate(row), fav) |
| 49 | return nil | 53 | return nil | ||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 52 | - | func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error { | |||
| 53 | - | log := logging.FromContext(ctx) | |||
| 54 | - | tx, err := r.db.BeginTx(ctx, nil) | |||
| 55 | - | if err != nil { | |||
| 56 | - | return err | |||
| 57 | - | } | |||
| 58 | - | defer func() { | |||
| 59 | - | if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | |||
| 60 | - | log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error") | |||
| 61 | - | } | |||
| 62 | - | }() | |||
| 63 | - | ||||
| 64 | - | shortcutKey := sql.NullInt64{} | |||
| 65 | - | if fav.ShortcutKey != nil { | |||
| 66 | - | shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true} | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | queries := r.queries.WithTx(tx) | |||
| 70 | - | if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |||
| 71 | - | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |||
| 72 | - | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | |||
| 73 | - | ID: int64(fav.ID), | |||
| 74 | - | }); err != nil { | |||
| 75 | - | return err | |||
| 76 | - | } | |||
| 77 | - | if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil { | |||
| 78 | - | return err | |||
| 79 | - | } | |||
| 80 | - | if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | |||
| 81 | - | ShortcutKey: shortcutKey, | |||
| 82 | - | ID: int64(fav.ID), | |||
| 83 | - | }); err != nil { | |||
| 84 | - | return err | |||
| 85 | - | } | |||
| 86 | - | return tx.Commit() | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | 56 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | ||
| 90 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | 57 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | ||
| 91 | if err != nil { | 58 | if err != nil { | ||
| @@ -94,7 +61,8 @@ func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*ent | |||||
| 94 | } | 61 | } | ||
| 95 | return nil, err | 62 | return nil, err | ||
| 96 | } | 63 | } | ||
| 97 | - | return favoriteFromRow(row), nil | 64 | + | fav := favoriteFromRow(favoriteRowFromGetByID(row), nil) |
| 65 | + | return fav, r.hydrateTags(ctx, []*entity.Favorite{fav}) | |||
| 98 | } | 66 | } | ||
| 99 | 67 | ||||
| 100 | func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) { | 68 | func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) { | ||
| @@ -105,7 +73,8 @@ func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favor | |||||
| 105 | } | 73 | } | ||
| 106 | return nil, err | 74 | return nil, err | ||
| 107 | } | 75 | } | ||
| 108 | - | return favoriteFromRow(row), nil | 76 | + | fav := favoriteFromRow(favoriteRowFromGetByURL(row), nil) |
| 77 | + | return fav, r.hydrateTags(ctx, []*entity.Favorite{fav}) | |||
| 109 | } | 78 | } | ||
| 110 | 79 | ||||
| 111 | func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) { | 80 | func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) { | ||
| @@ -113,22 +82,17 @@ func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) { | |||||
| 113 | if err != nil { | 82 | if err != nil { | ||
| 114 | return nil, err | 83 | return nil, err | ||
| 115 | } | 84 | } | ||
| 116 | - | return favoritesFromRows(rows), nil | 85 | + | favorites := favoritesFromAllRows(rows) |
| 86 | + | return favorites, r.hydrateTags(ctx, favorites) | |||
| 117 | } | 87 | } | ||
| 118 | 88 | ||||
| 119 | - | func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) { | 89 | + | func (r *favoriteRepo) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) { |
| 120 | - | var rows []sqlc.Favorite | 90 | + | rows, err := r.queries.GetFavoritesByTag(ctx, int64(tagID)) |
| 121 | - | var err error | |||
| 122 | - | ||||
| 123 | - | if folderID == nil { | |||
| 124 | - | rows, err = r.queries.GetFavoritesWithoutFolder(ctx) | |||
| 125 | - | } else { | |||
| 126 | - | rows, err = r.queries.GetFavoritesByFolder(ctx, sql.NullInt64{Int64: int64(*folderID), Valid: true}) | |||
| 127 | - | } | |||
| 128 | if err != nil { | 91 | if err != nil { | ||
| 129 | return nil, err | 92 | return nil, err | ||
| 130 | } | 93 | } | ||
| 131 | - | return favoritesFromRows(rows), nil | 94 | + | favorites := favoritesFromTagRows(rows) |
| 95 | + | return favorites, r.hydrateTags(ctx, favorites) | |||
| 132 | } | 96 | } | ||
| 133 | 97 | ||||
| 134 | func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | 98 | func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | ||
| @@ -139,7 +103,8 @@ func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favo | |||||
| 139 | } | 103 | } | ||
| 140 | return nil, err | 104 | return nil, err | ||
| 141 | } | 105 | } | ||
| 142 | - | return favoriteFromRow(row), nil | 106 | + | fav := favoriteFromRow(favoriteRowFromGetByShortcut(row), nil) |
| 107 | + | return fav, r.hydrateTags(ctx, []*entity.Favorite{fav}) | |||
| 143 | } | 108 | } | ||
| 144 | 109 | ||||
| 145 | func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error { | 110 | func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error { | ||
| @@ -149,17 +114,6 @@ func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, | |||||
| 149 | }) | 114 | }) | ||
| 150 | } | 115 | } | ||
| 151 | 116 | ||||
| 152 | - | func (r *favoriteRepo) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error { | |||
| 153 | - | var fid sql.NullInt64 | |||
| 154 | - | if folderID != nil { | |||
| 155 | - | fid = sql.NullInt64{Int64: int64(*folderID), Valid: true} | |||
| 156 | - | } | |||
| 157 | - | return r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{ | |||
| 158 | - | FolderID: fid, | |||
| 159 | - | ID: int64(id), | |||
| 160 | - | }) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | 117 | func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | ||
| 164 | var k sql.NullInt64 | 118 | var k sql.NullInt64 | ||
| 165 | if key != nil { | 119 | if key != nil { | ||
| @@ -175,141 +129,128 @@ func (r *favoriteRepo) Delete(ctx context.Context, id entity.FavoriteID) error { | |||||
| 175 | return r.queries.DeleteFavorite(ctx, int64(id)) | 129 | return r.queries.DeleteFavorite(ctx, int64(id)) | ||
| 176 | } | 130 | } | ||
| 177 | 131 | ||||
| 178 | - | func favoriteFromRow(row sqlc.Favorite) *entity.Favorite { | 132 | + | func (r *favoriteRepo) hydrateTags(ctx context.Context, favorites []*entity.Favorite) error { |
| 179 | - | fav := &entity.Favorite{ | 133 | + | if len(favorites) == 0 { |
| 180 | - | ID: entity.FavoriteID(row.ID), | 134 | + | return nil |
| 181 | - | URL: row.Url, | |||
| 182 | - | Title: row.Title.String, | |||
| 183 | - | FaviconURL: row.FaviconUrl.String, | |||
| 184 | - | Position: int(row.Position), | |||
| 185 | - | CreatedAt: row.CreatedAt.Time, | |||
| 186 | - | UpdatedAt: row.UpdatedAt.Time, | |||
| 187 | } | 135 | } | ||
| 188 | - | if row.FolderID.Valid { | 136 | + | |
| 189 | - | fid := entity.FolderID(row.FolderID.Int64) | 137 | + | ids := make([]int64, 0, len(favorites)) |
| 190 | - | fav.FolderID = &fid | 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 | |||
| 191 | } | 146 | } | ||
| 192 | - | if row.ShortcutKey.Valid { | 147 | + | if len(ids) == 0 { |
| 193 | - | key := int(row.ShortcutKey.Int64) | 148 | + | return nil |
| 194 | - | fav.ShortcutKey = &key | |||
| 195 | } | 149 | } | ||
| 196 | - | return fav | |||
| 197 | - | } | |||
| 198 | 150 | ||||
| 199 | - | func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite { | 151 | + | for start := 0; start < len(ids); start += favoriteTagHydrationBatchSize { |
| 200 | - | favorites := make([]*entity.Favorite, len(rows)) | 152 | + | end := start + favoriteTagHydrationBatchSize |
| 201 | - | for i := range rows { | 153 | + | if end > len(ids) { |
| 202 | - | favorites[i] = favoriteFromRow(rows[i]) | 154 | + | end = len(ids) |
| 155 | + | } | |||
| 156 | + | rows, err := r.queries.GetTagsForFavorites(ctx, ids[start:end]) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | for _, row := range rows { | |||
| 161 | + | fav := byID[entity.FavoriteID(row.FavoriteID)] | |||
| 162 | + | if fav == nil { | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | fav.Tags = append(fav.Tags, *tagFromRow(row.FavoriteTag)) | |||
| 166 | + | } | |||
| 203 | } | 167 | } | ||
| 204 | - | return favorites | 168 | + | return nil |
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | // ==================== Folder Repository ==================== | |||
| 208 | - | ||||
| 209 | - | type folderRepo struct { | |||
| 210 | - | queries *sqlc.Queries | |||
| 211 | } | 169 | } | ||
| 212 | 170 | ||||
| 213 | - | // NewFolderRepository creates a new SQLite-backed folder repository. | 171 | + | type favoriteRow struct { |
| 214 | - | func NewFolderRepository(db *sql.DB) repository.FolderRepository { | 172 | + | ID int64 |
| 215 | - | return &folderRepo{queries: sqlc.New(db)} | 173 | + | URL string |
| 174 | + | Title sql.NullString | |||
| 175 | + | FaviconURL sql.NullString | |||
| 176 | + | ShortcutKey sql.NullInt64 | |||
| 177 | + | Position int64 | |||
| 178 | + | CreatedAt sql.NullTime | |||
| 179 | + | UpdatedAt sql.NullTime | |||
| 216 | } | 180 | } | ||
| 217 | 181 | ||||
| 218 | - | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | 182 | + | func favoriteFromRow(row favoriteRow, target *entity.Favorite) *entity.Favorite { |
| 219 | - | log := logging.FromContext(ctx) | 183 | + | fav := target |
| 220 | - | log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder") | 184 | + | if fav == nil { |
| 221 | - | 185 | + | fav = &entity.Favorite{} | |
| 222 | - | var parentID sql.NullInt64 | |||
| 223 | - | if folder.ParentID != nil { | |||
| 224 | - | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | if folder.ID > 0 { | |||
| 228 | - | return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{ | |||
| 229 | - | Name: folder.Name, | |||
| 230 | - | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | |||
| 231 | - | ID: int64(folder.ID), | |||
| 232 | - | }) | |||
| 233 | } | 186 | } | ||
| 234 | - | 187 | + | fav.ID = entity.FavoriteID(row.ID) | |
| 235 | - | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | 188 | + | fav.URL = row.URL |
| 236 | - | Name: folder.Name, | 189 | + | fav.Title = row.Title.String |
| 237 | - | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | 190 | + | fav.FaviconURL = row.FaviconURL.String |
| 238 | - | ParentID: parentID, | 191 | + | fav.Position = int(row.Position) |
| 239 | - | }) | 192 | + | fav.CreatedAt = row.CreatedAt.Time |
| 240 | - | if err != nil { | 193 | + | fav.UpdatedAt = row.UpdatedAt.Time |
| 241 | - | return err | 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 | |||
| 242 | } | 200 | } | ||
| 243 | - | folder.ID = entity.FolderID(row.ID) | 201 | + | return fav |
| 244 | - | return nil | |||
| 245 | } | 202 | } | ||
| 246 | 203 | ||||
| 247 | - | func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) { | 204 | + | func favoriteRowFromCreate(row sqlc.CreateFavoriteRow) favoriteRow { |
| 248 | - | row, err := r.queries.GetFolderByID(ctx, int64(id)) | 205 | + | return favoriteRow{ |
| 249 | - | if err != nil { | 206 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, |
| 250 | - | if errors.Is(err, sql.ErrNoRows) { | 207 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, |
| 251 | - | return nil, nil | |||
| 252 | - | } | |||
| 253 | - | return nil, err | |||
| 254 | } | 208 | } | ||
| 255 | - | return folderFromRow(row), nil | |||
| 256 | } | 209 | } | ||
| 257 | 210 | ||||
| 258 | - | func (r *folderRepo) GetAll(ctx context.Context) ([]*entity.Folder, error) { | 211 | + | func favoriteRowFromGetByID(row sqlc.GetFavoriteByIDRow) favoriteRow { |
| 259 | - | rows, err := r.queries.GetAllFolders(ctx) | 212 | + | return favoriteRow{ |
| 260 | - | if err != nil { | 213 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, |
| 261 | - | return nil, err | 214 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, |
| 262 | } | 215 | } | ||
| 263 | - | return foldersFromRows(rows), nil | |||
| 264 | } | 216 | } | ||
| 265 | 217 | ||||
| 266 | - | func (r *folderRepo) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) { | 218 | + | func favoriteRowFromGetByURL(row sqlc.GetFavoriteByURLRow) favoriteRow { |
| 267 | - | var rows []sqlc.FavoriteFolder | 219 | + | return favoriteRow{ |
| 268 | - | var err error | 220 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, |
| 269 | - | 221 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, | |
| 270 | - | if parentID == nil { | |||
| 271 | - | rows, err = r.queries.GetRootFolders(ctx) | |||
| 272 | - | } else { | |||
| 273 | - | rows, err = r.queries.GetChildFolders(ctx, sql.NullInt64{Int64: int64(*parentID), Valid: true}) | |||
| 274 | - | } | |||
| 275 | - | if err != nil { | |||
| 276 | - | return nil, err | |||
| 277 | } | 222 | } | ||
| 278 | - | return foldersFromRows(rows), nil | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | func (r *folderRepo) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error { | |||
| 282 | - | return r.queries.UpdateFolderPosition(ctx, sqlc.UpdateFolderPositionParams{ | |||
| 283 | - | Position: int64(position), | |||
| 284 | - | ID: int64(id), | |||
| 285 | - | }) | |||
| 286 | } | 223 | } | ||
| 287 | 224 | ||||
| 288 | - | func (r *folderRepo) Delete(ctx context.Context, id entity.FolderID) error { | 225 | + | func favoriteRowFromGetByShortcut(row sqlc.GetFavoriteByShortcutRow) favoriteRow { |
| 289 | - | return r.queries.DeleteFolder(ctx, int64(id)) | 226 | + | return favoriteRow{ |
| 227 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, | |||
| 228 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, | |||
| 229 | + | } | |||
| 290 | } | 230 | } | ||
| 291 | 231 | ||||
| 292 | - | func folderFromRow(row sqlc.FavoriteFolder) *entity.Folder { | 232 | + | func favoritesFromAllRows(rows []sqlc.GetAllFavoritesRow) []*entity.Favorite { |
| 293 | - | folder := &entity.Folder{ | 233 | + | favorites := make([]*entity.Favorite, len(rows)) |
| 294 | - | ID: entity.FolderID(row.ID), | 234 | + | for i := range rows { |
| 295 | - | Name: row.Name, | 235 | + | row := rows[i] |
| 296 | - | Icon: row.Icon.String, | 236 | + | favorites[i] = favoriteFromRow(favoriteRow{ |
| 297 | - | Position: int(row.Position), | 237 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, |
| 298 | - | CreatedAt: row.CreatedAt.Time, | 238 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, |
| 299 | - | } | 239 | + | }, nil) |
| 300 | - | if row.ParentID.Valid { | |||
| 301 | - | pid := entity.FolderID(row.ParentID.Int64) | |||
| 302 | - | folder.ParentID = &pid | |||
| 303 | } | 240 | } | ||
| 304 | - | return folder | 241 | + | return favorites |
| 305 | } | 242 | } | ||
| 306 | 243 | ||||
| 307 | - | func foldersFromRows(rows []sqlc.FavoriteFolder) []*entity.Folder { | 244 | + | func favoritesFromTagRows(rows []sqlc.GetFavoritesByTagRow) []*entity.Favorite { |
| 308 | - | folders := make([]*entity.Folder, len(rows)) | 245 | + | favorites := make([]*entity.Favorite, len(rows)) |
| 309 | - | for i, row := range rows { | 246 | + | for i := range rows { |
| 310 | - | folders[i] = folderFromRow(row) | 247 | + | row := rows[i] |
| 248 | + | favorites[i] = favoriteFromRow(favoriteRow{ | |||
| 249 | + | ID: row.ID, URL: row.Url, Title: row.Title, FaviconURL: row.FaviconUrl, | |||
| 250 | + | ShortcutKey: row.ShortcutKey, Position: row.Position, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, | |||
| 251 | + | }, nil) | |||
| 311 | } | 252 | } | ||
| 312 | - | return folders | 253 | + | return favorites |
| 313 | } | 254 | } | ||
| 314 | 255 | ||||
| 315 | // ==================== Tag Repository ==================== | 256 | // ==================== Tag Repository ==================== | ||
A internal/infrastructure/persistence/sqlite/favorite_repo_test.go +203 -0
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package sqlite | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | _ "github.com/bnema/purego-sqlite/driver" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func openFavoriteRepoTestDB(t *testing.T) *sql.DB { | |||
| 14 | + | t.Helper() | |||
| 15 | + | db, err := sql.Open("sqlite3", ":memory:") | |||
| 16 | + | if err != nil { | |||
| 17 | + | t.Fatalf("open sqlite: %v", err) | |||
| 18 | + | } | |||
| 19 | + | db.SetMaxOpenConns(1) | |||
| 20 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 21 | + | if err := RunMigrations(context.Background(), db); err != nil { | |||
| 22 | + | t.Fatalf("run migrations: %v", err) | |||
| 23 | + | } | |||
| 24 | + | return db | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func seedFavoriteWithTags(t *testing.T, db *sql.DB) (entity.FavoriteID, entity.TagID) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, shortcut_key, position) VALUES (1, 'https://one.example', 'One', 1, 1), (2, 'https://two.example', 'Two', NULL, 2)`) | |||
| 30 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'research', '#111111'), (2, 'engines', '#222222')`) | |||
| 31 | + | mustExec(t, db, `INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (1, 1), (1, 2), (2, 1)`) | |||
| 32 | + | return 1, 1 | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestFavoriteRepositoryGetAllHydratesTags(t *testing.T) { | |||
| 36 | + | db := openFavoriteRepoTestDB(t) | |||
| 37 | + | seedFavoriteWithTags(t, db) | |||
| 38 | + | repo := NewFavoriteRepository(db) | |||
| 39 | + | ||||
| 40 | + | favorites, err := repo.GetAll(context.Background()) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("GetAll: %v", err) | |||
| 43 | + | } | |||
| 44 | + | if len(favorites) != 2 { | |||
| 45 | + | t.Fatalf("expected 2 favorites, got %d", len(favorites)) | |||
| 46 | + | } | |||
| 47 | + | assertTagNames(t, favorites[0], "engines", "research") | |||
| 48 | + | assertTagNames(t, favorites[1], "research") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFavoriteRepositoryFindByIDHydratesTags(t *testing.T) { | |||
| 52 | + | db := openFavoriteRepoTestDB(t) | |||
| 53 | + | favID, _ := seedFavoriteWithTags(t, db) | |||
| 54 | + | repo := NewFavoriteRepository(db) | |||
| 55 | + | ||||
| 56 | + | fav, err := repo.FindByID(context.Background(), favID) | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatalf("FindByID: %v", err) | |||
| 59 | + | } | |||
| 60 | + | assertTagNames(t, fav, "engines", "research") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestFavoriteRepositoryFindByURLHydratesTags(t *testing.T) { | |||
| 64 | + | db := openFavoriteRepoTestDB(t) | |||
| 65 | + | seedFavoriteWithTags(t, db) | |||
| 66 | + | repo := NewFavoriteRepository(db) | |||
| 67 | + | ||||
| 68 | + | fav, err := repo.FindByURL(context.Background(), "https://one.example") | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatalf("FindByURL: %v", err) | |||
| 71 | + | } | |||
| 72 | + | assertTagNames(t, fav, "engines", "research") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestFavoriteRepositoryGetByTagHydratesTags(t *testing.T) { | |||
| 76 | + | db := openFavoriteRepoTestDB(t) | |||
| 77 | + | _, tagID := seedFavoriteWithTags(t, db) | |||
| 78 | + | repo := NewFavoriteRepository(db) | |||
| 79 | + | ||||
| 80 | + | favorites, err := repo.GetByTag(context.Background(), tagID) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatalf("GetByTag: %v", err) | |||
| 83 | + | } | |||
| 84 | + | if len(favorites) != 2 { | |||
| 85 | + | t.Fatalf("expected 2 tagged favorites, got %d", len(favorites)) | |||
| 86 | + | } | |||
| 87 | + | assertTagNames(t, favorites[0], "engines", "research") | |||
| 88 | + | assertTagNames(t, favorites[1], "research") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestFavoriteRepositoryGetByShortcutHydratesTags(t *testing.T) { | |||
| 92 | + | db := openFavoriteRepoTestDB(t) | |||
| 93 | + | seedFavoriteWithTags(t, db) | |||
| 94 | + | repo := NewFavoriteRepository(db) | |||
| 95 | + | ||||
| 96 | + | fav, err := repo.GetByShortcut(context.Background(), 1) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatalf("GetByShortcut: %v", err) | |||
| 99 | + | } | |||
| 100 | + | assertTagNames(t, fav, "engines", "research") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestFavoriteRepositoryNoTagsHydratesEmptyTags(t *testing.T) { | |||
| 104 | + | db := openFavoriteRepoTestDB(t) | |||
| 105 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, position) VALUES (1, 'https://none.example', 'None', 1)`) | |||
| 106 | + | repo := NewFavoriteRepository(db) | |||
| 107 | + | ||||
| 108 | + | fav, err := repo.FindByID(context.Background(), 1) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatalf("FindByID: %v", err) | |||
| 111 | + | } | |||
| 112 | + | if fav == nil { | |||
| 113 | + | t.Fatal("expected favorite") | |||
| 114 | + | } | |||
| 115 | + | if fav.Tags == nil || len(fav.Tags) != 0 { | |||
| 116 | + | t.Fatalf("expected non-nil empty Tags, got %#v", fav.Tags) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestFavoriteRepositorySaveUpdatesShortcutKey(t *testing.T) { | |||
| 121 | + | db := openFavoriteRepoTestDB(t) | |||
| 122 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, position) VALUES (1, 'https://shortcut.example', 'Shortcut', 1)`) | |||
| 123 | + | repo := NewFavoriteRepository(db) | |||
| 124 | + | ||||
| 125 | + | fav, err := repo.FindByID(context.Background(), 1) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatalf("FindByID: %v", err) | |||
| 128 | + | } | |||
| 129 | + | key := 5 | |||
| 130 | + | fav.ShortcutKey = &key | |||
| 131 | + | err = repo.Save(context.Background(), fav) | |||
| 132 | + | if err != nil { | |||
| 133 | + | t.Fatalf("Save: %v", err) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | updated, err := repo.GetByShortcut(context.Background(), 5) | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatalf("GetByShortcut: %v", err) | |||
| 139 | + | } | |||
| 140 | + | if updated == nil || updated.ID != fav.ID { | |||
| 141 | + | t.Fatalf("expected favorite %d by shortcut, got %#v", fav.ID, updated) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | updated.ShortcutKey = nil | |||
| 145 | + | err = repo.Save(context.Background(), updated) | |||
| 146 | + | if err != nil { | |||
| 147 | + | t.Fatalf("Save clear shortcut: %v", err) | |||
| 148 | + | } | |||
| 149 | + | cleared, err := repo.FindByID(context.Background(), updated.ID) | |||
| 150 | + | if err != nil { | |||
| 151 | + | t.Fatalf("FindByID after clear: %v", err) | |||
| 152 | + | } | |||
| 153 | + | if cleared.ShortcutKey != nil { | |||
| 154 | + | t.Fatalf("expected shortcut cleared, got %d", *cleared.ShortcutKey) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestFavoriteRepositoryGetAllHydratesTagsInBatches(t *testing.T) { | |||
| 159 | + | db := openFavoriteRepoTestDB(t) | |||
| 160 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'batched', '#111111')`) | |||
| 161 | + | for i := int64(1); i <= favoriteTagHydrationBatchSize+5; i++ { | |||
| 162 | + | _, err := db.Exec( | |||
| 163 | + | `INSERT INTO favorites(id, url, title, position) VALUES (?, ?, ?, ?)`, | |||
| 164 | + | i, | |||
| 165 | + | fmt.Sprintf("https://batch.example/%d", i), | |||
| 166 | + | "Batch", | |||
| 167 | + | i, | |||
| 168 | + | ) | |||
| 169 | + | if err != nil { | |||
| 170 | + | t.Fatalf("insert favorite %d: %v", i, err) | |||
| 171 | + | } | |||
| 172 | + | _, err = db.Exec(`INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (?, 1)`, i) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatalf("insert assignment %d: %v", i, err) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | repo := NewFavoriteRepository(db) | |||
| 178 | + | ||||
| 179 | + | favorites, err := repo.GetAll(context.Background()) | |||
| 180 | + | if err != nil { | |||
| 181 | + | t.Fatalf("GetAll: %v", err) | |||
| 182 | + | } | |||
| 183 | + | if len(favorites) != favoriteTagHydrationBatchSize+5 { | |||
| 184 | + | t.Fatalf("expected %d favorites, got %d", favoriteTagHydrationBatchSize+5, len(favorites)) | |||
| 185 | + | } | |||
| 186 | + | assertTagNames(t, favorites[0], "batched") | |||
| 187 | + | assertTagNames(t, favorites[len(favorites)-1], "batched") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func assertTagNames(t *testing.T, fav *entity.Favorite, names ...string) { | |||
| 191 | + | t.Helper() | |||
| 192 | + | if fav == nil { | |||
| 193 | + | t.Fatal("favorite is nil") | |||
| 194 | + | } | |||
| 195 | + | if len(fav.Tags) != len(names) { | |||
| 196 | + | t.Fatalf("expected %d tags, got %d (%#v)", len(names), len(fav.Tags), fav.Tags) | |||
| 197 | + | } | |||
| 198 | + | for i, name := range names { | |||
| 199 | + | if fav.Tags[i].Name != name { | |||
| 200 | + | t.Fatalf("tag %d: expected %q, got %q", i, name, fav.Tags[i].Name) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | } | |||
A internal/infrastructure/persistence/sqlite/favorites_migration_test.go +161 -0
| @@ -0,0 +1,161 @@ | |||||
| 1 | + | package sqlite | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | _ "github.com/bnema/purego-sqlite/driver" | |||
| 9 | + | "github.com/pressly/goose/v3" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func openMigrationTestDB(t *testing.T) *sql.DB { | |||
| 13 | + | t.Helper() | |||
| 14 | + | db, err := sql.Open("sqlite3", ":memory:") | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("open sqlite: %v", err) | |||
| 17 | + | } | |||
| 18 | + | db.SetMaxOpenConns(1) | |||
| 19 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 20 | + | return db | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func runMigrationsThroughFavoritesSchema(t *testing.T, db *sql.DB) { | |||
| 24 | + | t.Helper() | |||
| 25 | + | goose.SetBaseFS(embedMigrations) | |||
| 26 | + | if err := goose.SetDialect("sqlite3"); err != nil { | |||
| 27 | + | t.Fatalf("set dialect: %v", err) | |||
| 28 | + | } | |||
| 29 | + | goose.SetLogger(goose.NopLogger()) | |||
| 30 | + | if err := goose.UpTo(db, "migrations", 10); err != nil { | |||
| 31 | + | t.Fatalf("migrate through favorites schema: %v", err) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func runAllMigrations(t *testing.T, db *sql.DB) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | if err := RunMigrations(context.Background(), db); err != nil { | |||
| 38 | + | t.Fatalf("run migrations: %v", err) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func countRows(t *testing.T, db *sql.DB, query string, args ...any) int { | |||
| 43 | + | t.Helper() | |||
| 44 | + | var count int | |||
| 45 | + | if err := db.QueryRow(query, args...).Scan(&count); err != nil { | |||
| 46 | + | t.Fatalf("count rows %q: %v", query, err) | |||
| 47 | + | } | |||
| 48 | + | return count | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFavoritesTagsFirstMigrationFolderPathToTag(t *testing.T) { | |||
| 52 | + | db := openMigrationTestDB(t) | |||
| 53 | + | runMigrationsThroughFavoritesSchema(t, db) | |||
| 54 | + | ||||
| 55 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (1, 'Research', NULL), (2, 'Browser Engines', 1)`) | |||
| 56 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://webkit.org', 'WebKit', 2)`) | |||
| 57 | + | ||||
| 58 | + | runAllMigrations(t, db) | |||
| 59 | + | ||||
| 60 | + | var tagID int64 | |||
| 61 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'research-browser-engines'`).Scan(&tagID); err != nil { | |||
| 62 | + | t.Fatalf("expected folder path tag: %v", err) | |||
| 63 | + | } | |||
| 64 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 { | |||
| 65 | + | t.Fatalf("expected assignment to folder path tag, got %d", got) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) { | |||
| 70 | + | db := openMigrationTestDB(t) | |||
| 71 | + | runMigrationsThroughFavoritesSchema(t, db) | |||
| 72 | + | ||||
| 73 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'dev-go', '#111111')`) | |||
| 74 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (7, 'Dev', NULL), (8, 'Go', 7)`) | |||
| 75 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://go.dev', 'Go', 8)`) | |||
| 76 | + | ||||
| 77 | + | runAllMigrations(t, db) | |||
| 78 | + | ||||
| 79 | + | var tagID int64 | |||
| 80 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'dev-go-folder-8'`).Scan(&tagID); err != nil { | |||
| 81 | + | t.Fatalf("expected suffixed collision tag: %v", err) | |||
| 82 | + | } | |||
| 83 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 { | |||
| 84 | + | t.Fatalf("expected assignment to suffixed tag, got %d", got) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) { | |||
| 89 | + | db := openMigrationTestDB(t) | |||
| 90 | + | runMigrationsThroughFavoritesSchema(t, db) | |||
| 91 | + | ||||
| 92 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`) | |||
| 93 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`) | |||
| 94 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`) | |||
| 95 | + | ||||
| 96 | + | runAllMigrations(t, db) | |||
| 97 | + | ||||
| 98 | + | var tagID int64 | |||
| 99 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil { | |||
| 100 | + | t.Fatalf("expected second-order suffixed collision tag: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 { | |||
| 103 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) { | |||
| 108 | + | db := openMigrationTestDB(t) | |||
| 109 | + | runMigrationsThroughFavoritesSchema(t, db) | |||
| 110 | + | ||||
| 111 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`) | |||
| 112 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`) | |||
| 113 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`) | |||
| 114 | + | ||||
| 115 | + | runAllMigrations(t, db) | |||
| 116 | + | ||||
| 117 | + | var folder3TagID int64 | |||
| 118 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil { | |||
| 119 | + | t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err) | |||
| 120 | + | } | |||
| 121 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 { | |||
| 122 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | var folder4TagID int64 | |||
| 126 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil { | |||
| 127 | + | t.Fatalf("expected natural folder slug tag: %v", err) | |||
| 128 | + | } | |||
| 129 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 { | |||
| 130 | + | t.Fatalf("expected assignment to natural slug tag, got %d", got) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) { | |||
| 135 | + | db := openMigrationTestDB(t) | |||
| 136 | + | runMigrationsThroughFavoritesSchema(t, db) | |||
| 137 | + | ||||
| 138 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, ' Dev-Go ', '#111111'), (2, 'dev-go', '#222222')`) | |||
| 139 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title) VALUES (1, 'https://one.example', 'One'), (2, 'https://two.example', 'Two')`) | |||
| 140 | + | mustExec(t, db, `INSERT INTO favorite_tag_assignments(favorite_id, tag_id) VALUES (1, 1), (2, 2)`) | |||
| 141 | + | ||||
| 142 | + | runAllMigrations(t, db) | |||
| 143 | + | ||||
| 144 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tags WHERE lower(trim(name)) = 'dev-go'`); got != 1 { | |||
| 145 | + | t.Fatalf("expected one folded dev-go tag, got %d", got) | |||
| 146 | + | } | |||
| 147 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE tag_id = 1`); got != 2 { | |||
| 148 | + | t.Fatalf("expected assignments moved to canonical tag 1, got %d", got) | |||
| 149 | + | } | |||
| 150 | + | mustExec(t, db, `INSERT INTO favorite_tags(name, color) VALUES ('another', '#333333')`) | |||
| 151 | + | if _, err := db.Exec(`INSERT INTO favorite_tags(name, color) VALUES (' DEV-GO ', '#444444')`); err == nil { | |||
| 152 | + | t.Fatal("expected folded unique index to reject duplicate tag") | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func mustExec(t *testing.T, db *sql.DB, query string) { | |||
| 157 | + | t.Helper() | |||
| 158 | + | if _, err := db.Exec(query); err != nil { | |||
| 159 | + | t.Fatalf("exec %s: %v", query, err) | |||
| 160 | + | } | |||
| 161 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +2 -78
| @@ -357,11 +357,11 @@ func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite | |||||
| 357 | return r.repo.GetAll(ctx) | 357 | return r.repo.GetAll(ctx) | ||
| 358 | } | 358 | } | ||
| 359 | 359 | ||||
| 360 | - | func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) { | 360 | + | func (r *LazyFavoriteRepository) GetByTag(ctx context.Context, tagID entity.TagID) ([]*entity.Favorite, error) { |
| 361 | if err := r.init(ctx); err != nil { | 361 | if err := r.init(ctx); err != nil { | ||
| 362 | return nil, err | 362 | return nil, err | ||
| 363 | } | 363 | } | ||
| 364 | - | return r.repo.GetByFolder(ctx, folderID) | 364 | + | return r.repo.GetByTag(ctx, tagID) |
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | 367 | func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | ||
| @@ -378,13 +378,6 @@ func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.F | |||||
| 378 | return r.repo.UpdatePosition(ctx, id, position) | 378 | return r.repo.UpdatePosition(ctx, id, position) | ||
| 379 | } | 379 | } | ||
| 380 | 380 | ||||
| 381 | - | func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error { | |||
| 382 | - | if err := r.init(ctx); err != nil { | |||
| 383 | - | return err | |||
| 384 | - | } | |||
| 385 | - | return r.repo.SetFolder(ctx, id, folderID) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | 381 | func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error { | ||
| 389 | if err := r.init(ctx); err != nil { | 382 | if err := r.init(ctx); err != nil { | ||
| 390 | return err | 383 | return err | ||
| @@ -399,73 +392,6 @@ func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteI | |||||
| 399 | return r.repo.Delete(ctx, id) | 392 | return r.repo.Delete(ctx, id) | ||
| 400 | } | 393 | } | ||
| 401 | 394 | ||||
| 402 | - | // LazyFolderRepository wraps a folder repository with lazy database initialization. | |||
| 403 | - | type LazyFolderRepository struct { | |||
| 404 | - | provider port.DatabaseProvider | |||
| 405 | - | repo repository.FolderRepository | |||
| 406 | - | once sync.Once | |||
| 407 | - | initErr error | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // NewLazyFolderRepository creates a lazy-loading folder repository. | |||
| 411 | - | func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository { | |||
| 412 | - | return &LazyFolderRepository{provider: provider} | |||
| 413 | - | } | |||
| 414 | - | ||||
| 415 | - | func (r *LazyFolderRepository) init(ctx context.Context) error { | |||
| 416 | - | r.once.Do(func() { | |||
| 417 | - | db, err := r.provider.DB(ctx) | |||
| 418 | - | if err != nil { | |||
| 419 | - | r.initErr = err | |||
| 420 | - | return | |||
| 421 | - | } | |||
| 422 | - | r.repo = NewFolderRepository(db) | |||
| 423 | - | }) | |||
| 424 | - | return r.initErr | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error { | |||
| 428 | - | if err := r.init(ctx); err != nil { | |||
| 429 | - | return err | |||
| 430 | - | } | |||
| 431 | - | return r.repo.Save(ctx, folder) | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) { | |||
| 435 | - | if err := r.init(ctx); err != nil { | |||
| 436 | - | return nil, err | |||
| 437 | - | } | |||
| 438 | - | return r.repo.FindByID(ctx, id) | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) { | |||
| 442 | - | if err := r.init(ctx); err != nil { | |||
| 443 | - | return nil, err | |||
| 444 | - | } | |||
| 445 | - | return r.repo.GetAll(ctx) | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) { | |||
| 449 | - | if err := r.init(ctx); err != nil { | |||
| 450 | - | return nil, err | |||
| 451 | - | } | |||
| 452 | - | return r.repo.GetChildren(ctx, parentID) | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error { | |||
| 456 | - | if err := r.init(ctx); err != nil { | |||
| 457 | - | return err | |||
| 458 | - | } | |||
| 459 | - | return r.repo.UpdatePosition(ctx, id, position) | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error { | |||
| 463 | - | if err := r.init(ctx); err != nil { | |||
| 464 | - | return err | |||
| 465 | - | } | |||
| 466 | - | return r.repo.Delete(ctx, id) | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | // LazyTagRepository wraps a tag repository with lazy database initialization. | 395 | // LazyTagRepository wraps a tag repository with lazy database initialization. | ||
| 470 | type LazyTagRepository struct { | 396 | type LazyTagRepository struct { | ||
| 471 | provider port.DatabaseProvider | 397 | provider port.DatabaseProvider | ||
| @@ -798,7 +724,6 @@ func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, | |||||
| 798 | type LazyRepositories struct { | 724 | type LazyRepositories struct { | ||
| 799 | History repository.HistoryRepository | 725 | History repository.HistoryRepository | ||
| 800 | Favorite repository.FavoriteRepository | 726 | Favorite repository.FavoriteRepository | ||
| 801 | - | Folder repository.FolderRepository | |||
| 802 | Tag repository.TagRepository | 727 | Tag repository.TagRepository | ||
| 803 | Zoom repository.ZoomRepository | 728 | Zoom repository.ZoomRepository | ||
| 804 | Session repository.SessionRepository | 729 | Session repository.SessionRepository | ||
| @@ -812,7 +737,6 @@ func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories { | |||||
| 812 | return &LazyRepositories{ | 737 | return &LazyRepositories{ | ||
| 813 | History: NewLazyHistoryRepository(provider), | 738 | History: NewLazyHistoryRepository(provider), | ||
| 814 | Favorite: NewLazyFavoriteRepository(provider), | 739 | Favorite: NewLazyFavoriteRepository(provider), | ||
| 815 | - | Folder: NewLazyFolderRepository(provider), | |||
| 816 | Tag: NewLazyTagRepository(provider), | 740 | Tag: NewLazyTagRepository(provider), | ||
| 817 | Zoom: NewLazyZoomRepository(provider), | 741 | Zoom: NewLazyZoomRepository(provider), | ||
| 818 | Session: NewLazySessionRepository(provider), | 742 | Session: NewLazySessionRepository(provider), | ||
A internal/infrastructure/persistence/sqlite/migrations/011_favorites_tags_first.sql +135 -0
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | -- +goose Up | |||
| 2 | + | -- Convert favorite organization from folders to tags. | |||
| 3 | + | ||||
| 4 | + | -- Reconcile duplicate tags by folded name before adding folded uniqueness. | |||
| 5 | + | CREATE TEMP TABLE _favorite_tag_canonical AS | |||
| 6 | + | SELECT lower(trim(name)) AS folded_name, MIN(id) AS canonical_id | |||
| 7 | + | FROM favorite_tags | |||
| 8 | + | GROUP BY lower(trim(name)); | |||
| 9 | + | ||||
| 10 | + | INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id) | |||
| 11 | + | SELECT fta.favorite_id, c.canonical_id | |||
| 12 | + | FROM favorite_tag_assignments fta | |||
| 13 | + | JOIN favorite_tags t ON t.id = fta.tag_id | |||
| 14 | + | JOIN _favorite_tag_canonical c ON c.folded_name = lower(trim(t.name)) | |||
| 15 | + | WHERE fta.tag_id <> c.canonical_id; | |||
| 16 | + | ||||
| 17 | + | DELETE FROM favorite_tag_assignments | |||
| 18 | + | WHERE tag_id NOT IN (SELECT canonical_id FROM _favorite_tag_canonical); | |||
| 19 | + | ||||
| 20 | + | DELETE FROM favorite_tags | |||
| 21 | + | WHERE id NOT IN (SELECT canonical_id FROM _favorite_tag_canonical); | |||
| 22 | + | ||||
| 23 | + | DROP TABLE _favorite_tag_canonical; | |||
| 24 | + | ||||
| 25 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_favorite_tags_name_folded ON favorite_tags(lower(trim(name))); | |||
| 26 | + | ||||
| 27 | + | -- Build deterministic folder-path slugs. Keep folder tables/columns for compatibility, | |||
| 28 | + | -- but materialize every current folder assignment as a tag assignment. | |||
| 29 | + | CREATE TEMP TABLE _folder_tag_map ( | |||
| 30 | + | folder_id INTEGER PRIMARY KEY, | |||
| 31 | + | tag_name TEXT NOT NULL UNIQUE | |||
| 32 | + | ); | |||
| 33 | + | ||||
| 34 | + | WITH RECURSIVE folder_paths(id, parent_id, path) AS ( | |||
| 35 | + | SELECT id, parent_id, trim(name) | |||
| 36 | + | FROM favorite_folders | |||
| 37 | + | WHERE parent_id IS NULL | |||
| 38 | + | UNION ALL | |||
| 39 | + | SELECT child.id, child.parent_id, folder_paths.path || '-' || trim(child.name) | |||
| 40 | + | FROM favorite_folders child | |||
| 41 | + | JOIN folder_paths ON child.parent_id = folder_paths.id | |||
| 42 | + | ), folder_slugs AS ( | |||
| 43 | + | SELECT | |||
| 44 | + | id, | |||
| 45 | + | lower( | |||
| 46 | + | trim( | |||
| 47 | + | replace(replace(replace(replace(replace(path, '/', '-'), '\\', '-'), ' ', '-'), '_', '-'), '.', '-'), | |||
| 48 | + | '-' | |||
| 49 | + | ) | |||
| 50 | + | ) AS base_slug | |||
| 51 | + | FROM folder_paths | |||
| 52 | + | ), folder_names AS ( | |||
| 53 | + | SELECT | |||
| 54 | + | id, | |||
| 55 | + | CASE | |||
| 56 | + | WHEN base_slug = '' THEN 'folder-' || id | |||
| 57 | + | ELSE base_slug | |||
| 58 | + | END AS natural_name | |||
| 59 | + | FROM folder_slugs | |||
| 60 | + | ), base_assignments AS ( | |||
| 61 | + | SELECT id, natural_name AS tag_name | |||
| 62 | + | FROM folder_names current_folder | |||
| 63 | + | WHERE NOT EXISTS ( | |||
| 64 | + | SELECT 1 FROM favorite_tags t | |||
| 65 | + | WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name)) | |||
| 66 | + | ) | |||
| 67 | + | AND NOT EXISTS ( | |||
| 68 | + | SELECT 1 FROM folder_names earlier | |||
| 69 | + | WHERE earlier.id < current_folder.id | |||
| 70 | + | AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name)) | |||
| 71 | + | ) | |||
| 72 | + | ), unresolved_folders AS ( | |||
| 73 | + | SELECT id, natural_name | |||
| 74 | + | FROM folder_names | |||
| 75 | + | WHERE id NOT IN (SELECT id FROM base_assignments) | |||
| 76 | + | ), candidate_limit(max_attempt) AS ( | |||
| 77 | + | SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3 | |||
| 78 | + | ), candidate_numbers(attempt) AS ( | |||
| 79 | + | SELECT 1 | |||
| 80 | + | UNION ALL | |||
| 81 | + | SELECT attempt + 1 | |||
| 82 | + | FROM candidate_numbers, candidate_limit | |||
| 83 | + | WHERE attempt < max_attempt | |||
| 84 | + | ), suffix_candidates AS ( | |||
| 85 | + | SELECT | |||
| 86 | + | unresolved_folders.id, | |||
| 87 | + | candidate_numbers.attempt, | |||
| 88 | + | CASE | |||
| 89 | + | WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id | |||
| 90 | + | ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt | |||
| 91 | + | END AS tag_name | |||
| 92 | + | FROM unresolved_folders | |||
| 93 | + | CROSS JOIN candidate_numbers | |||
| 94 | + | ), available_suffix_candidates AS ( | |||
| 95 | + | SELECT id, attempt, tag_name | |||
| 96 | + | FROM suffix_candidates candidate | |||
| 97 | + | WHERE NOT EXISTS ( | |||
| 98 | + | SELECT 1 FROM favorite_tags t | |||
| 99 | + | WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name)) | |||
| 100 | + | ) | |||
| 101 | + | AND NOT EXISTS ( | |||
| 102 | + | SELECT 1 FROM base_assignments assigned | |||
| 103 | + | WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name)) | |||
| 104 | + | ) | |||
| 105 | + | ), suffix_assignments AS ( | |||
| 106 | + | SELECT id, tag_name | |||
| 107 | + | FROM available_suffix_candidates candidate | |||
| 108 | + | WHERE attempt = ( | |||
| 109 | + | SELECT MIN(attempt) | |||
| 110 | + | FROM available_suffix_candidates earlier | |||
| 111 | + | WHERE earlier.id = candidate.id | |||
| 112 | + | ) | |||
| 113 | + | ), resolved AS ( | |||
| 114 | + | SELECT id, tag_name FROM base_assignments | |||
| 115 | + | UNION ALL | |||
| 116 | + | SELECT id, tag_name FROM suffix_assignments | |||
| 117 | + | ) | |||
| 118 | + | INSERT INTO _folder_tag_map(folder_id, tag_name) | |||
| 119 | + | SELECT id, tag_name FROM resolved; | |||
| 120 | + | ||||
| 121 | + | INSERT OR IGNORE INTO favorite_tags(name, color) | |||
| 122 | + | SELECT tag_name, '#808080' | |||
| 123 | + | FROM _folder_tag_map; | |||
| 124 | + | ||||
| 125 | + | INSERT OR IGNORE INTO favorite_tag_assignments(favorite_id, tag_id) | |||
| 126 | + | SELECT f.id, t.id | |||
| 127 | + | FROM favorites f | |||
| 128 | + | JOIN _folder_tag_map ftm ON ftm.folder_id = f.folder_id | |||
| 129 | + | JOIN favorite_tags t ON lower(trim(t.name)) = lower(trim(ftm.tag_name)) | |||
| 130 | + | WHERE f.folder_id IS NOT NULL; | |||
| 131 | + | ||||
| 132 | + | DROP TABLE _folder_tag_map; | |||
| 133 | + | ||||
| 134 | + | -- +goose Down | |||
| 135 | + | DROP INDEX IF EXISTS idx_favorite_tags_name_folded; | |||
M internal/infrastructure/persistence/sqlite/queries/favorites.sql +22 -16
| @@ -1,37 +1,43 @@ | |||||
| 1 | -- name: GetAllFavorites :many | 1 | -- name: GetAllFavorites :many | ||
| 2 | - | SELECT * FROM favorites ORDER BY position ASC; | 2 | + | SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at |
| 3 | + | FROM favorites | |||
| 4 | + | ORDER BY position ASC; | |||
| 3 | 5 | ||||
| 4 | -- name: GetFavoriteByID :one | 6 | -- name: GetFavoriteByID :one | ||
| 5 | - | SELECT * FROM favorites WHERE id = ? LIMIT 1; | 7 | + | SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at |
| 8 | + | FROM favorites | |||
| 9 | + | WHERE id = ? LIMIT 1; | |||
| 6 | 10 | ||||
| 7 | -- name: GetFavoriteByURL :one | 11 | -- name: GetFavoriteByURL :one | ||
| 8 | - | SELECT * FROM favorites WHERE url = ? LIMIT 1; | 12 | + | SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at |
| 13 | + | FROM favorites | |||
| 14 | + | WHERE url = ? LIMIT 1; | |||
| 9 | 15 | ||||
| 10 | - | -- name: GetFavoritesByFolder :many | 16 | + | -- name: GetFavoritesByTag :many |
| 11 | - | SELECT * FROM favorites WHERE folder_id = ? ORDER BY position ASC; | 17 | + | SELECT f.id, f.url, f.title, f.favicon_url, f.shortcut_key, f.position, f.created_at, f.updated_at |
| 12 | - | 18 | + | FROM favorites f | |
| 13 | - | -- name: GetFavoritesWithoutFolder :many | 19 | + | INNER JOIN favorite_tag_assignments fta ON f.id = fta.favorite_id |
| 14 | - | SELECT * FROM favorites WHERE folder_id IS NULL ORDER BY position ASC; | 20 | + | WHERE fta.tag_id = ? |
| 21 | + | ORDER BY f.position ASC; | |||
| 15 | 22 | ||||
| 16 | -- name: GetFavoriteByShortcut :one | 23 | -- name: GetFavoriteByShortcut :one | ||
| 17 | - | SELECT * FROM favorites WHERE shortcut_key = ? LIMIT 1; | 24 | + | SELECT id, url, title, favicon_url, shortcut_key, position, created_at, updated_at |
| 25 | + | FROM favorites | |||
| 26 | + | WHERE shortcut_key = ? LIMIT 1; | |||
| 18 | 27 | ||||
| 19 | -- name: CreateFavorite :one | 28 | -- name: CreateFavorite :one | ||
| 20 | - | INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at) | 29 | + | INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at) |
| 21 | - | VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) | 30 | + | VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) |
| 22 | - | RETURNING *; | 31 | + | RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at; |
| 23 | 32 | ||||
| 24 | -- name: UpdateFavorite :exec | 33 | -- name: UpdateFavorite :exec | ||
| 25 | UPDATE favorites | 34 | UPDATE favorites | ||
| 26 | - | SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP | 35 | + | SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP |
| 27 | WHERE id = ?; | 36 | WHERE id = ?; | ||
| 28 | 37 | ||||
| 29 | -- name: UpdateFavoritePosition :exec | 38 | -- name: UpdateFavoritePosition :exec | ||
| 30 | UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; | 39 | UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; | ||
| 31 | 40 | ||||
| 32 | - | -- name: SetFavoriteFolder :exec | |||
| 33 | - | UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; | |||
| 34 | - | ||||
| 35 | -- name: SetFavoriteShortcut :exec | 41 | -- name: SetFavoriteShortcut :exec | ||
| 36 | UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; | 42 | UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; | ||
| 37 | 43 | ||||
D internal/infrastructure/persistence/sqlite/queries/folders.sql +0 -25
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | -- name: GetAllFolders :many | |||
| 2 | - | SELECT * FROM favorite_folders ORDER BY position ASC; | |||
| 3 | - | ||||
| 4 | - | -- name: GetFolderByID :one | |||
| 5 | - | SELECT * FROM favorite_folders WHERE id = ? LIMIT 1; | |||
| 6 | - | ||||
| 7 | - | -- name: GetChildFolders :many | |||
| 8 | - | SELECT * FROM favorite_folders WHERE parent_id = ? ORDER BY position ASC; | |||
| 9 | - | ||||
| 10 | - | -- name: GetRootFolders :many | |||
| 11 | - | SELECT * FROM favorite_folders WHERE parent_id IS NULL ORDER BY position ASC; | |||
| 12 | - | ||||
| 13 | - | -- name: CreateFolder :one | |||
| 14 | - | INSERT INTO favorite_folders (name, icon, parent_id, position, created_at) | |||
| 15 | - | VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorite_folders), 0), CURRENT_TIMESTAMP) | |||
| 16 | - | RETURNING *; | |||
| 17 | - | ||||
| 18 | - | -- name: UpdateFolder :exec | |||
| 19 | - | UPDATE favorite_folders SET name = ?, icon = ? WHERE id = ?; | |||
| 20 | - | ||||
| 21 | - | -- name: UpdateFolderPosition :exec | |||
| 22 | - | UPDATE favorite_folders SET position = ? WHERE id = ?; | |||
| 23 | - | ||||
| 24 | - | -- name: DeleteFolder :exec | |||
| 25 | - | DELETE FROM favorite_folders WHERE id = ?; | |||
M internal/infrastructure/persistence/sqlite/queries/tags.sql +7 -1
| @@ -5,7 +5,7 @@ SELECT * FROM favorite_tags ORDER BY name ASC; | |||||
| 5 | SELECT * FROM favorite_tags WHERE id = ? LIMIT 1; | 5 | SELECT * FROM favorite_tags WHERE id = ? LIMIT 1; | ||
| 6 | 6 | ||||
| 7 | -- name: GetTagByName :one | 7 | -- name: GetTagByName :one | ||
| 8 | - | SELECT * FROM favorite_tags WHERE name = ? LIMIT 1; | 8 | + | SELECT * FROM favorite_tags WHERE lower(trim(name)) = lower(trim(?)) LIMIT 1; |
| 9 | 9 | ||||
| 10 | -- name: CreateTag :one | 10 | -- name: CreateTag :one | ||
| 11 | INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *; | 11 | INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *; | ||
| @@ -27,3 +27,9 @@ SELECT t.* FROM favorite_tags t | |||||
| 27 | INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id | 27 | INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id | ||
| 28 | WHERE fta.favorite_id = ? | 28 | WHERE fta.favorite_id = ? | ||
| 29 | ORDER BY t.name ASC; | 29 | ORDER BY t.name ASC; | ||
| 30 | + | ||||
| 31 | + | -- name: GetTagsForFavorites :many | |||
| 32 | + | SELECT sqlc.embed(t), fta.favorite_id FROM favorite_tags t | |||
| 33 | + | INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id | |||
| 34 | + | WHERE fta.favorite_id IN (sqlc.slice('favorite_ids')) | |||
| 35 | + | ORDER BY fta.favorite_id ASC, t.name ASC; | |||
M internal/infrastructure/persistence/sqlite/sqlc/favorites.sql.go +112 -91
| @@ -11,32 +11,36 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | const CreateFavorite = `-- name: CreateFavorite :one | 13 | const CreateFavorite = `-- name: CreateFavorite :one | ||
| 14 | - | INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at) | 14 | + | INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at) |
| 15 | - | VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) | 15 | + | VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) |
| 16 | - | RETURNING id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at | 16 | + | RETURNING id, url, title, favicon_url, shortcut_key, position, created_at, updated_at |
| 17 | ` | 17 | ` | ||
| 18 | 18 | ||||
| 19 | type CreateFavoriteParams struct { | 19 | type CreateFavoriteParams struct { | ||
| 20 | Url string `json:"url"` | 20 | Url string `json:"url"` | ||
| 21 | Title sql.NullString `json:"title"` | 21 | Title sql.NullString `json:"title"` | ||
| 22 | FaviconUrl sql.NullString `json:"favicon_url"` | 22 | FaviconUrl sql.NullString `json:"favicon_url"` | ||
| 23 | - | FolderID sql.NullInt64 `json:"folder_id"` | |||
| 24 | } | 23 | } | ||
| 25 | 24 | ||||
| 26 | - | func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) { | 25 | + | type CreateFavoriteRow struct { |
| 27 | - | row := q.db.QueryRowContext(ctx, CreateFavorite, | 26 | + | ID int64 `json:"id"` |
| 28 | - | arg.Url, | 27 | + | Url string `json:"url"` |
| 29 | - | arg.Title, | 28 | + | Title sql.NullString `json:"title"` |
| 30 | - | arg.FaviconUrl, | 29 | + | FaviconUrl sql.NullString `json:"favicon_url"` |
| 31 | - | arg.FolderID, | 30 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` |
| 32 | - | ) | 31 | + | Position int64 `json:"position"` |
| 33 | - | var i Favorite | 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 | |||
| 34 | err := row.Scan( | 39 | err := row.Scan( | ||
| 35 | &i.ID, | 40 | &i.ID, | ||
| 36 | &i.Url, | 41 | &i.Url, | ||
| 37 | &i.Title, | 42 | &i.Title, | ||
| 38 | &i.FaviconUrl, | 43 | &i.FaviconUrl, | ||
| 39 | - | &i.FolderID, | |||
| 40 | &i.ShortcutKey, | 44 | &i.ShortcutKey, | ||
| 41 | &i.Position, | 45 | &i.Position, | ||
| 42 | &i.CreatedAt, | 46 | &i.CreatedAt, | ||
| @@ -55,24 +59,36 @@ func (q *Queries) DeleteFavorite(ctx context.Context, id int64) error { | |||||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 57 | const GetAllFavorites = `-- name: GetAllFavorites :many | 61 | 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 | |||
| 59 | ` | 65 | ` | ||
| 60 | 66 | ||||
| 61 | - | func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) { | 67 | + | type GetAllFavoritesRow struct { |
| 68 | + | ID int64 `json:"id"` | |||
| 69 | + | Url string `json:"url"` | |||
| 70 | + | Title sql.NullString `json:"title"` | |||
| 71 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 72 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` | |||
| 73 | + | Position int64 `json:"position"` | |||
| 74 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 75 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (q *Queries) GetAllFavorites(ctx context.Context) ([]GetAllFavoritesRow, error) { | |||
| 62 | rows, err := q.db.QueryContext(ctx, GetAllFavorites) | 79 | rows, err := q.db.QueryContext(ctx, GetAllFavorites) | ||
| 63 | if err != nil { | 80 | if err != nil { | ||
| 64 | return nil, err | 81 | return nil, err | ||
| 65 | } | 82 | } | ||
| 66 | defer rows.Close() | 83 | defer rows.Close() | ||
| 67 | - | items := []Favorite{} | 84 | + | items := []GetAllFavoritesRow{} |
| 68 | for rows.Next() { | 85 | for rows.Next() { | ||
| 69 | - | var i Favorite | 86 | + | var i GetAllFavoritesRow |
| 70 | if err := rows.Scan( | 87 | if err := rows.Scan( | ||
| 71 | &i.ID, | 88 | &i.ID, | ||
| 72 | &i.Url, | 89 | &i.Url, | ||
| 73 | &i.Title, | 90 | &i.Title, | ||
| 74 | &i.FaviconUrl, | 91 | &i.FaviconUrl, | ||
| 75 | - | &i.FolderID, | |||
| 76 | &i.ShortcutKey, | 92 | &i.ShortcutKey, | ||
| 77 | &i.Position, | 93 | &i.Position, | ||
| 78 | &i.CreatedAt, | 94 | &i.CreatedAt, | ||
| @@ -92,18 +108,30 @@ func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) { | |||||
| 92 | } | 108 | } | ||
| 93 | 109 | ||||
| 94 | const GetFavoriteByID = `-- name: GetFavoriteByID :one | 110 | 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 | |||
| 96 | ` | 114 | ` | ||
| 97 | 115 | ||||
| 98 | - | func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) { | 116 | + | type GetFavoriteByIDRow struct { |
| 117 | + | ID int64 `json:"id"` | |||
| 118 | + | Url string `json:"url"` | |||
| 119 | + | Title sql.NullString `json:"title"` | |||
| 120 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 121 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` | |||
| 122 | + | Position int64 `json:"position"` | |||
| 123 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 124 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (GetFavoriteByIDRow, error) { | |||
| 99 | row := q.db.QueryRowContext(ctx, GetFavoriteByID, id) | 128 | row := q.db.QueryRowContext(ctx, GetFavoriteByID, id) | ||
| 100 | - | var i Favorite | 129 | + | var i GetFavoriteByIDRow |
| 101 | err := row.Scan( | 130 | err := row.Scan( | ||
| 102 | &i.ID, | 131 | &i.ID, | ||
| 103 | &i.Url, | 132 | &i.Url, | ||
| 104 | &i.Title, | 133 | &i.Title, | ||
| 105 | &i.FaviconUrl, | 134 | &i.FaviconUrl, | ||
| 106 | - | &i.FolderID, | |||
| 107 | &i.ShortcutKey, | 135 | &i.ShortcutKey, | ||
| 108 | &i.Position, | 136 | &i.Position, | ||
| 109 | &i.CreatedAt, | 137 | &i.CreatedAt, | ||
| @@ -113,18 +141,30 @@ func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, erro | |||||
| 113 | } | 141 | } | ||
| 114 | 142 | ||||
| 115 | const GetFavoriteByShortcut = `-- name: GetFavoriteByShortcut :one | 143 | 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 | |||
| 117 | ` | 147 | ` | ||
| 118 | 148 | ||||
| 119 | - | func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) { | 149 | + | type GetFavoriteByShortcutRow struct { |
| 150 | + | ID int64 `json:"id"` | |||
| 151 | + | Url string `json:"url"` | |||
| 152 | + | Title sql.NullString `json:"title"` | |||
| 153 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 154 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` | |||
| 155 | + | Position int64 `json:"position"` | |||
| 156 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 157 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (GetFavoriteByShortcutRow, error) { | |||
| 120 | row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey) | 161 | row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey) | ||
| 121 | - | var i Favorite | 162 | + | var i GetFavoriteByShortcutRow |
| 122 | err := row.Scan( | 163 | err := row.Scan( | ||
| 123 | &i.ID, | 164 | &i.ID, | ||
| 124 | &i.Url, | 165 | &i.Url, | ||
| 125 | &i.Title, | 166 | &i.Title, | ||
| 126 | &i.FaviconUrl, | 167 | &i.FaviconUrl, | ||
| 127 | - | &i.FolderID, | |||
| 128 | &i.ShortcutKey, | 168 | &i.ShortcutKey, | ||
| 129 | &i.Position, | 169 | &i.Position, | ||
| 130 | &i.CreatedAt, | 170 | &i.CreatedAt, | ||
| @@ -134,18 +174,30 @@ func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.Nul | |||||
| 134 | } | 174 | } | ||
| 135 | 175 | ||||
| 136 | const GetFavoriteByURL = `-- name: GetFavoriteByURL :one | 176 | 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 | |||
| 138 | ` | 180 | ` | ||
| 139 | 181 | ||||
| 140 | - | func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) { | 182 | + | type GetFavoriteByURLRow struct { |
| 183 | + | ID int64 `json:"id"` | |||
| 184 | + | Url string `json:"url"` | |||
| 185 | + | Title sql.NullString `json:"title"` | |||
| 186 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 187 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` | |||
| 188 | + | Position int64 `json:"position"` | |||
| 189 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 190 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (GetFavoriteByURLRow, error) { | |||
| 141 | row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url) | 194 | row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url) | ||
| 142 | - | var i Favorite | 195 | + | var i GetFavoriteByURLRow |
| 143 | err := row.Scan( | 196 | err := row.Scan( | ||
| 144 | &i.ID, | 197 | &i.ID, | ||
| 145 | &i.Url, | 198 | &i.Url, | ||
| 146 | &i.Title, | 199 | &i.Title, | ||
| 147 | &i.FaviconUrl, | 200 | &i.FaviconUrl, | ||
| 148 | - | &i.FolderID, | |||
| 149 | &i.ShortcutKey, | 201 | &i.ShortcutKey, | ||
| 150 | &i.Position, | 202 | &i.Position, | ||
| 151 | &i.CreatedAt, | 203 | &i.CreatedAt, | ||
| @@ -154,62 +206,39 @@ func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, e | |||||
| 154 | return i, err | 206 | return i, err | ||
| 155 | } | 207 | } | ||
| 156 | 208 | ||||
| 157 | - | const GetFavoritesByFolder = `-- name: GetFavoritesByFolder :many | 209 | + | const GetFavoritesByTag = `-- name: GetFavoritesByTag :many |
| 158 | - | SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id = ? ORDER BY position ASC | 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 | |||
| 159 | ` | 215 | ` | ||
| 160 | 216 | ||||
| 161 | - | func (q *Queries) GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) { | 217 | + | type GetFavoritesByTagRow struct { |
| 162 | - | rows, err := q.db.QueryContext(ctx, GetFavoritesByFolder, folderID) | 218 | + | ID int64 `json:"id"` |
| 163 | - | if err != nil { | 219 | + | Url string `json:"url"` |
| 164 | - | return nil, err | 220 | + | Title sql.NullString `json:"title"` |
| 165 | - | } | 221 | + | FaviconUrl sql.NullString `json:"favicon_url"` |
| 166 | - | defer rows.Close() | 222 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` |
| 167 | - | items := []Favorite{} | 223 | + | Position int64 `json:"position"` |
| 168 | - | for rows.Next() { | 224 | + | CreatedAt sql.NullTime `json:"created_at"` |
| 169 | - | var i Favorite | 225 | + | UpdatedAt sql.NullTime `json:"updated_at"` |
| 170 | - | if err := rows.Scan( | |||
| 171 | - | &i.ID, | |||
| 172 | - | &i.Url, | |||
| 173 | - | &i.Title, | |||
| 174 | - | &i.FaviconUrl, | |||
| 175 | - | &i.FolderID, | |||
| 176 | - | &i.ShortcutKey, | |||
| 177 | - | &i.Position, | |||
| 178 | - | &i.CreatedAt, | |||
| 179 | - | &i.UpdatedAt, | |||
| 180 | - | ); err != nil { | |||
| 181 | - | return nil, err | |||
| 182 | - | } | |||
| 183 | - | items = append(items, i) | |||
| 184 | - | } | |||
| 185 | - | if err := rows.Close(); err != nil { | |||
| 186 | - | return nil, err | |||
| 187 | - | } | |||
| 188 | - | if err := rows.Err(); err != nil { | |||
| 189 | - | return nil, err | |||
| 190 | - | } | |||
| 191 | - | return items, nil | |||
| 192 | } | 226 | } | ||
| 193 | 227 | ||||
| 194 | - | const GetFavoritesWithoutFolder = `-- name: GetFavoritesWithoutFolder :many | 228 | + | func (q *Queries) GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error) { |
| 195 | - | SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id IS NULL ORDER BY position ASC | 229 | + | rows, err := q.db.QueryContext(ctx, GetFavoritesByTag, tagID) |
| 196 | - | ` | |||
| 197 | - | ||||
| 198 | - | func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) { | |||
| 199 | - | rows, err := q.db.QueryContext(ctx, GetFavoritesWithoutFolder) | |||
| 200 | if err != nil { | 230 | if err != nil { | ||
| 201 | return nil, err | 231 | return nil, err | ||
| 202 | } | 232 | } | ||
| 203 | defer rows.Close() | 233 | defer rows.Close() | ||
| 204 | - | items := []Favorite{} | 234 | + | items := []GetFavoritesByTagRow{} |
| 205 | for rows.Next() { | 235 | for rows.Next() { | ||
| 206 | - | var i Favorite | 236 | + | var i GetFavoritesByTagRow |
| 207 | if err := rows.Scan( | 237 | if err := rows.Scan( | ||
| 208 | &i.ID, | 238 | &i.ID, | ||
| 209 | &i.Url, | 239 | &i.Url, | ||
| 210 | &i.Title, | 240 | &i.Title, | ||
| 211 | &i.FaviconUrl, | 241 | &i.FaviconUrl, | ||
| 212 | - | &i.FolderID, | |||
| 213 | &i.ShortcutKey, | 242 | &i.ShortcutKey, | ||
| 214 | &i.Position, | 243 | &i.Position, | ||
| 215 | &i.CreatedAt, | 244 | &i.CreatedAt, | ||
| @@ -228,20 +257,6 @@ func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, er | |||||
| 228 | return items, nil | 257 | return items, nil | ||
| 229 | } | 258 | } | ||
| 230 | 259 | ||||
| 231 | - | const SetFavoriteFolder = `-- name: SetFavoriteFolder :exec | |||
| 232 | - | UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? | |||
| 233 | - | ` | |||
| 234 | - | ||||
| 235 | - | type SetFavoriteFolderParams struct { | |||
| 236 | - | FolderID sql.NullInt64 `json:"folder_id"` | |||
| 237 | - | ID int64 `json:"id"` | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | func (q *Queries) SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error { | |||
| 241 | - | _, err := q.db.ExecContext(ctx, SetFavoriteFolder, arg.FolderID, arg.ID) | |||
| 242 | - | return err | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | const SetFavoriteShortcut = `-- name: SetFavoriteShortcut :exec | 260 | const SetFavoriteShortcut = `-- name: SetFavoriteShortcut :exec | ||
| 246 | UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? | 261 | UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? | ||
| 247 | ` | 262 | ` | ||
| @@ -258,18 +273,24 @@ func (q *Queries) SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortc | |||||
| 258 | 273 | ||||
| 259 | const UpdateFavorite = `-- name: UpdateFavorite :exec | 274 | const UpdateFavorite = `-- name: UpdateFavorite :exec | ||
| 260 | UPDATE favorites | 275 | UPDATE favorites | ||
| 261 | - | SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP | 276 | + | SET title = ?, favicon_url = ?, shortcut_key = ?, updated_at = CURRENT_TIMESTAMP |
| 262 | WHERE id = ? | 277 | WHERE id = ? | ||
| 263 | ` | 278 | ` | ||
| 264 | 279 | ||||
| 265 | type UpdateFavoriteParams struct { | 280 | type UpdateFavoriteParams struct { | ||
| 266 | - | Title sql.NullString `json:"title"` | 281 | + | Title sql.NullString `json:"title"` |
| 267 | - | FaviconUrl sql.NullString `json:"favicon_url"` | 282 | + | FaviconUrl sql.NullString `json:"favicon_url"` |
| 268 | - | ID int64 `json:"id"` | 283 | + | ShortcutKey sql.NullInt64 `json:"shortcut_key"` |
| 284 | + | ID int64 `json:"id"` | |||
| 269 | } | 285 | } | ||
| 270 | 286 | ||||
| 271 | func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error { | 287 | func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error { | ||
| 272 | - | _, err := q.db.ExecContext(ctx, UpdateFavorite, arg.Title, arg.FaviconUrl, arg.ID) | 288 | + | _, err := q.db.ExecContext(ctx, UpdateFavorite, |
| 289 | + | arg.Title, | |||
| 290 | + | arg.FaviconUrl, | |||
| 291 | + | arg.ShortcutKey, | |||
| 292 | + | arg.ID, | |||
| 293 | + | ) | |||
| 273 | return err | 294 | return err | ||
| 274 | } | 295 | } | ||
| 275 | 296 | ||||
D internal/infrastructure/persistence/sqlite/sqlc/folders.sql.go +0 -195
| @@ -1,195 +0,0 @@ | |||||
| 1 | - | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | - | // versions: | |||
| 3 | - | // sqlc v1.30.0 | |||
| 4 | - | // source: folders.sql | |||
| 5 | - | ||||
| 6 | - | package sqlc | |||
| 7 | - | ||||
| 8 | - | import ( | |||
| 9 | - | "context" | |||
| 10 | - | "database/sql" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | const CreateFolder = `-- name: CreateFolder :one | |||
| 14 | - | INSERT INTO favorite_folders (name, icon, parent_id, position, created_at) | |||
| 15 | - | VALUES (?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorite_folders), 0), CURRENT_TIMESTAMP) | |||
| 16 | - | RETURNING id, name, icon, parent_id, position, created_at | |||
| 17 | - | ` | |||
| 18 | - | ||||
| 19 | - | type CreateFolderParams struct { | |||
| 20 | - | Name string `json:"name"` | |||
| 21 | - | Icon sql.NullString `json:"icon"` | |||
| 22 | - | ParentID sql.NullInt64 `json:"parent_id"` | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func (q *Queries) CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) { | |||
| 26 | - | row := q.db.QueryRowContext(ctx, CreateFolder, arg.Name, arg.Icon, arg.ParentID) | |||
| 27 | - | var i FavoriteFolder | |||
| 28 | - | err := row.Scan( | |||
| 29 | - | &i.ID, | |||
| 30 | - | &i.Name, | |||
| 31 | - | &i.Icon, | |||
| 32 | - | &i.ParentID, | |||
| 33 | - | &i.Position, | |||
| 34 | - | &i.CreatedAt, | |||
| 35 | - | ) | |||
| 36 | - | return i, err | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | const DeleteFolder = `-- name: DeleteFolder :exec | |||
| 40 | - | DELETE FROM favorite_folders WHERE id = ? | |||
| 41 | - | ` | |||
| 42 | - | ||||
| 43 | - | func (q *Queries) DeleteFolder(ctx context.Context, id int64) error { | |||
| 44 | - | _, err := q.db.ExecContext(ctx, DeleteFolder, id) | |||
| 45 | - | return err | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | const GetAllFolders = `-- name: GetAllFolders :many | |||
| 49 | - | SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders ORDER BY position ASC | |||
| 50 | - | ` | |||
| 51 | - | ||||
| 52 | - | func (q *Queries) GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) { | |||
| 53 | - | rows, err := q.db.QueryContext(ctx, GetAllFolders) | |||
| 54 | - | if err != nil { | |||
| 55 | - | return nil, err | |||
| 56 | - | } | |||
| 57 | - | defer rows.Close() | |||
| 58 | - | items := []FavoriteFolder{} | |||
| 59 | - | for rows.Next() { | |||
| 60 | - | var i FavoriteFolder | |||
| 61 | - | if err := rows.Scan( | |||
| 62 | - | &i.ID, | |||
| 63 | - | &i.Name, | |||
| 64 | - | &i.Icon, | |||
| 65 | - | &i.ParentID, | |||
| 66 | - | &i.Position, | |||
| 67 | - | &i.CreatedAt, | |||
| 68 | - | ); err != nil { | |||
| 69 | - | return nil, err | |||
| 70 | - | } | |||
| 71 | - | items = append(items, i) | |||
| 72 | - | } | |||
| 73 | - | if err := rows.Close(); err != nil { | |||
| 74 | - | return nil, err | |||
| 75 | - | } | |||
| 76 | - | if err := rows.Err(); err != nil { | |||
| 77 | - | return nil, err | |||
| 78 | - | } | |||
| 79 | - | return items, nil | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | const GetChildFolders = `-- name: GetChildFolders :many | |||
| 83 | - | SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE parent_id = ? ORDER BY position ASC | |||
| 84 | - | ` | |||
| 85 | - | ||||
| 86 | - | func (q *Queries) GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) { | |||
| 87 | - | rows, err := q.db.QueryContext(ctx, GetChildFolders, parentID) | |||
| 88 | - | if err != nil { | |||
| 89 | - | return nil, err | |||
| 90 | - | } | |||
| 91 | - | defer rows.Close() | |||
| 92 | - | items := []FavoriteFolder{} | |||
| 93 | - | for rows.Next() { | |||
| 94 | - | var i FavoriteFolder | |||
| 95 | - | if err := rows.Scan( | |||
| 96 | - | &i.ID, | |||
| 97 | - | &i.Name, | |||
| 98 | - | &i.Icon, | |||
| 99 | - | &i.ParentID, | |||
| 100 | - | &i.Position, | |||
| 101 | - | &i.CreatedAt, | |||
| 102 | - | ); err != nil { | |||
| 103 | - | return nil, err | |||
| 104 | - | } | |||
| 105 | - | items = append(items, i) | |||
| 106 | - | } | |||
| 107 | - | if err := rows.Close(); err != nil { | |||
| 108 | - | return nil, err | |||
| 109 | - | } | |||
| 110 | - | if err := rows.Err(); err != nil { | |||
| 111 | - | return nil, err | |||
| 112 | - | } | |||
| 113 | - | return items, nil | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | const GetFolderByID = `-- name: GetFolderByID :one | |||
| 117 | - | SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE id = ? LIMIT 1 | |||
| 118 | - | ` | |||
| 119 | - | ||||
| 120 | - | func (q *Queries) GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error) { | |||
| 121 | - | row := q.db.QueryRowContext(ctx, GetFolderByID, id) | |||
| 122 | - | var i FavoriteFolder | |||
| 123 | - | err := row.Scan( | |||
| 124 | - | &i.ID, | |||
| 125 | - | &i.Name, | |||
| 126 | - | &i.Icon, | |||
| 127 | - | &i.ParentID, | |||
| 128 | - | &i.Position, | |||
| 129 | - | &i.CreatedAt, | |||
| 130 | - | ) | |||
| 131 | - | return i, err | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | const GetRootFolders = `-- name: GetRootFolders :many | |||
| 135 | - | SELECT id, name, icon, parent_id, position, created_at FROM favorite_folders WHERE parent_id IS NULL ORDER BY position ASC | |||
| 136 | - | ` | |||
| 137 | - | ||||
| 138 | - | func (q *Queries) GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) { | |||
| 139 | - | rows, err := q.db.QueryContext(ctx, GetRootFolders) | |||
| 140 | - | if err != nil { | |||
| 141 | - | return nil, err | |||
| 142 | - | } | |||
| 143 | - | defer rows.Close() | |||
| 144 | - | items := []FavoriteFolder{} | |||
| 145 | - | for rows.Next() { | |||
| 146 | - | var i FavoriteFolder | |||
| 147 | - | if err := rows.Scan( | |||
| 148 | - | &i.ID, | |||
| 149 | - | &i.Name, | |||
| 150 | - | &i.Icon, | |||
| 151 | - | &i.ParentID, | |||
| 152 | - | &i.Position, | |||
| 153 | - | &i.CreatedAt, | |||
| 154 | - | ); err != nil { | |||
| 155 | - | return nil, err | |||
| 156 | - | } | |||
| 157 | - | items = append(items, i) | |||
| 158 | - | } | |||
| 159 | - | if err := rows.Close(); err != nil { | |||
| 160 | - | return nil, err | |||
| 161 | - | } | |||
| 162 | - | if err := rows.Err(); err != nil { | |||
| 163 | - | return nil, err | |||
| 164 | - | } | |||
| 165 | - | return items, nil | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | const UpdateFolder = `-- name: UpdateFolder :exec | |||
| 169 | - | UPDATE favorite_folders SET name = ?, icon = ? WHERE id = ? | |||
| 170 | - | ` | |||
| 171 | - | ||||
| 172 | - | type UpdateFolderParams struct { | |||
| 173 | - | Name string `json:"name"` | |||
| 174 | - | Icon sql.NullString `json:"icon"` | |||
| 175 | - | ID int64 `json:"id"` | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | func (q *Queries) UpdateFolder(ctx context.Context, arg UpdateFolderParams) error { | |||
| 179 | - | _, err := q.db.ExecContext(ctx, UpdateFolder, arg.Name, arg.Icon, arg.ID) | |||
| 180 | - | return err | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | const UpdateFolderPosition = `-- name: UpdateFolderPosition :exec | |||
| 184 | - | UPDATE favorite_folders SET position = ? WHERE id = ? | |||
| 185 | - | ` | |||
| 186 | - | ||||
| 187 | - | type UpdateFolderPositionParams struct { | |||
| 188 | - | Position int64 `json:"position"` | |||
| 189 | - | ID int64 `json:"id"` | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | func (q *Queries) UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error { | |||
| 193 | - | _, err := q.db.ExecContext(ctx, UpdateFolderPosition, arg.Position, arg.ID) | |||
| 194 | - | return err | |||
| 195 | - | } | |||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +8 -17
| @@ -13,15 +13,13 @@ type Querier interface { | |||||
| 13 | AddToWhitelist(ctx context.Context, domain string) error | 13 | AddToWhitelist(ctx context.Context, domain string) error | ||
| 14 | AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error | 14 | AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error | ||
| 15 | CapVisitCount(ctx context.Context, arg CapVisitCountParams) error | 15 | CapVisitCount(ctx context.Context, arg CapVisitCountParams) error | ||
| 16 | - | CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) | 16 | + | CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (CreateFavoriteRow, error) |
| 17 | - | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | |||
| 18 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | ||
| 19 | DeleteAllHistory(ctx context.Context) error | 18 | DeleteAllHistory(ctx context.Context) error | ||
| 20 | // Deletes exited browser sessions older than the given cutoff time. | 19 | // Deletes exited browser sessions older than the given cutoff time. | ||
| 21 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | 20 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | ||
| 22 | DeleteFavicon(ctx context.Context, key string) error | 21 | DeleteFavicon(ctx context.Context, key string) error | ||
| 23 | DeleteFavorite(ctx context.Context, id int64) error | 22 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 24 | - | DeleteFolder(ctx context.Context, id int64) error | |||
| 25 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | 23 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | ||
| 26 | DeleteHistoryByID(ctx context.Context, id int64) error | 24 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 27 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 25 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| @@ -34,24 +32,20 @@ type Querier interface { | |||||
| 34 | DeleteTag(ctx context.Context, id int64) error | 32 | DeleteTag(ctx context.Context, id int64) error | ||
| 35 | DeleteZoomLevel(ctx context.Context, domain string) error | 33 | DeleteZoomLevel(ctx context.Context, domain string) error | ||
| 36 | GetActiveBrowserSession(ctx context.Context) (Session, error) | 34 | GetActiveBrowserSession(ctx context.Context) (Session, error) | ||
| 37 | - | GetAllFavorites(ctx context.Context) ([]Favorite, error) | 35 | + | GetAllFavorites(ctx context.Context) ([]GetAllFavoritesRow, error) |
| 38 | - | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | |||
| 39 | GetAllMostVisited(ctx context.Context) ([]History, error) | 36 | GetAllMostVisited(ctx context.Context) ([]History, error) | ||
| 40 | GetAllRecentHistory(ctx context.Context) ([]History, error) | 37 | GetAllRecentHistory(ctx context.Context) ([]History, error) | ||
| 41 | GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) | 38 | GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) | ||
| 42 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | 39 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | ||
| 43 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | 40 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | ||
| 44 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | 41 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | ||
| 45 | - | GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) | |||
| 46 | GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error) | 42 | GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error) | ||
| 47 | GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error) | 43 | GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error) | ||
| 48 | GetFavicon(ctx context.Context, key string) (Favicon, error) | 44 | GetFavicon(ctx context.Context, key string) (Favicon, error) | ||
| 49 | - | GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) | 45 | + | GetFavoriteByID(ctx context.Context, id int64) (GetFavoriteByIDRow, error) |
| 50 | - | GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) | 46 | + | GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (GetFavoriteByShortcutRow, error) |
| 51 | - | GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) | 47 | + | GetFavoriteByURL(ctx context.Context, url string) (GetFavoriteByURLRow, error) |
| 52 | - | GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) | 48 | + | GetFavoritesByTag(ctx context.Context, tagID int64) ([]GetFavoritesByTagRow, error) |
| 53 | - | GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) | |||
| 54 | - | GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error) | |||
| 55 | GetHistoryByURL(ctx context.Context, url string) (History, error) | 49 | GetHistoryByURL(ctx context.Context, url string) (History, error) | ||
| 56 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | 50 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | ||
| 57 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | 51 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | ||
| @@ -63,13 +57,13 @@ type Querier interface { | |||||
| 63 | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | 57 | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | ||
| 64 | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | 58 | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | ||
| 65 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 59 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| 66 | - | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |||
| 67 | GetSessionByID(ctx context.Context, id string) (Session, error) | 60 | GetSessionByID(ctx context.Context, id string) (Session, error) | ||
| 68 | GetSessionState(ctx context.Context, sessionID string) (SessionState, error) | 61 | GetSessionState(ctx context.Context, sessionID string) (SessionState, error) | ||
| 69 | GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) | 62 | GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) | ||
| 70 | GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | 63 | GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | ||
| 71 | - | GetTagByName(ctx context.Context, name string) (FavoriteTag, error) | 64 | + | GetTagByName(ctx context.Context, trim string) (FavoriteTag, error) |
| 72 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | 65 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | ||
| 66 | + | GetTagsForFavorites(ctx context.Context, favoriteIds []int64) ([]GetTagsForFavoritesRow, error) | |||
| 73 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | 67 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | ||
| 74 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | 68 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | ||
| 75 | IncrementVisitCount(ctx context.Context, url string) error | 69 | IncrementVisitCount(ctx context.Context, url string) error | ||
| @@ -86,15 +80,12 @@ type Querier interface { | |||||
| 86 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | 80 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | ||
| 87 | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | 81 | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | ||
| 88 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | 82 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | ||
| 89 | - | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | |||
| 90 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 83 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | ||
| 91 | SetPermission(ctx context.Context, arg SetPermissionParams) error | 84 | SetPermission(ctx context.Context, arg SetPermissionParams) error | ||
| 92 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | 85 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | ||
| 93 | UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error | 86 | UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error | ||
| 94 | UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error | 87 | UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error | ||
| 95 | UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error | 88 | UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error | ||
| 96 | - | UpdateFolder(ctx context.Context, arg UpdateFolderParams) error | |||
| 97 | - | UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error | |||
| 98 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | 89 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | ||
| 99 | UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error | 90 | UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error | ||
| 100 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | 91 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | ||
M internal/infrastructure/persistence/sqlite/sqlc/tags.sql.go +55 -3
| @@ -7,6 +7,7 @@ package sqlc | |||||
| 7 | 7 | ||||
| 8 | import ( | 8 | import ( | ||
| 9 | "context" | 9 | "context" | ||
| 10 | + | "strings" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec | 13 | const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec | ||
| @@ -102,11 +103,11 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | |||||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 104 | const GetTagByName = `-- name: GetTagByName :one | 105 | 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 |
| 106 | ` | 107 | ` | ||
| 107 | 108 | ||||
| 108 | - | func (q *Queries) GetTagByName(ctx context.Context, name string) (FavoriteTag, error) { | 109 | + | func (q *Queries) GetTagByName(ctx context.Context, trim string) (FavoriteTag, error) { |
| 109 | - | row := q.db.QueryRowContext(ctx, GetTagByName, name) | 110 | + | row := q.db.QueryRowContext(ctx, GetTagByName, trim) |
| 110 | var i FavoriteTag | 111 | var i FavoriteTag | ||
| 111 | err := row.Scan( | 112 | err := row.Scan( | ||
| 112 | &i.ID, | 113 | &i.ID, | ||
| @@ -152,6 +153,57 @@ func (q *Queries) GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]F | |||||
| 152 | return items, nil | 153 | return items, nil | ||
| 153 | } | 154 | } | ||
| 154 | 155 | ||||
| 156 | + | const GetTagsForFavorites = `-- name: GetTagsForFavorites :many | |||
| 157 | + | SELECT t.id, t.name, t.color, t.created_at, fta.favorite_id FROM favorite_tags t | |||
| 158 | + | INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id | |||
| 159 | + | WHERE fta.favorite_id IN (/*SLICE:favorite_ids*/?) | |||
| 160 | + | ORDER BY fta.favorite_id ASC, t.name ASC | |||
| 161 | + | ` | |||
| 162 | + | ||||
| 163 | + | type GetTagsForFavoritesRow struct { | |||
| 164 | + | FavoriteTag FavoriteTag `json:"favorite_tag"` | |||
| 165 | + | FavoriteID int64 `json:"favorite_id"` | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (q *Queries) GetTagsForFavorites(ctx context.Context, favoriteIds []int64) ([]GetTagsForFavoritesRow, error) { | |||
| 169 | + | query := GetTagsForFavorites | |||
| 170 | + | var queryParams []interface{} | |||
| 171 | + | if len(favoriteIds) > 0 { | |||
| 172 | + | for _, v := range favoriteIds { | |||
| 173 | + | queryParams = append(queryParams, v) | |||
| 174 | + | } | |||
| 175 | + | query = strings.Replace(query, "/*SLICE:favorite_ids*/?", strings.Repeat(",?", len(favoriteIds))[1:], 1) | |||
| 176 | + | } else { | |||
| 177 | + | query = strings.Replace(query, "/*SLICE:favorite_ids*/?", "NULL", 1) | |||
| 178 | + | } | |||
| 179 | + | rows, err := q.db.QueryContext(ctx, query, queryParams...) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return nil, err | |||
| 182 | + | } | |||
| 183 | + | defer rows.Close() | |||
| 184 | + | items := []GetTagsForFavoritesRow{} | |||
| 185 | + | for rows.Next() { | |||
| 186 | + | var i GetTagsForFavoritesRow | |||
| 187 | + | if err := rows.Scan( | |||
| 188 | + | &i.FavoriteTag.ID, | |||
| 189 | + | &i.FavoriteTag.Name, | |||
| 190 | + | &i.FavoriteTag.Color, | |||
| 191 | + | &i.FavoriteTag.CreatedAt, | |||
| 192 | + | &i.FavoriteID, | |||
| 193 | + | ); err != nil { | |||
| 194 | + | return nil, err | |||
| 195 | + | } | |||
| 196 | + | items = append(items, i) | |||
| 197 | + | } | |||
| 198 | + | if err := rows.Close(); err != nil { | |||
| 199 | + | return nil, err | |||
| 200 | + | } | |||
| 201 | + | if err := rows.Err(); err != nil { | |||
| 202 | + | return nil, err | |||
| 203 | + | } | |||
| 204 | + | return items, nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 155 | const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec | 207 | const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec | ||
| 156 | DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ? | 208 | DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ? | ||
| 157 | ` | 209 | ` | ||
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +2 -2
| @@ -25,8 +25,8 @@ func NewBrowserClient() *Client { | |||||
| 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | ||
| 26 | switch msgType { | 26 | switch msgType { | ||
| 27 | case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics", | 27 | case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics", | ||
| 28 | - | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | 28 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "tag_list", |
| 29 | - | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | 29 | + | "favorite_set_shortcut", |
| 30 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | 30 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | ||
| 31 | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | 31 | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | ||
| 32 | case "save_config": | 32 | case "save_config": | ||
M internal/infrastructure/systemviewsbridge/client.go +8 -90
| @@ -272,42 +272,27 @@ func favoriteCreatePayload(input dto.FavoriteCreateInput) any { | |||||
| 272 | URL string `json:"url"` | 272 | URL string `json:"url"` | ||
| 273 | Title string `json:"title"` | 273 | Title string `json:"title"` | ||
| 274 | FaviconURL string `json:"favicon_url"` | 274 | FaviconURL string `json:"favicon_url"` | ||
| 275 | - | FolderID *int64 `json:"folder_id"` | |||
| 276 | Tags []int64 `json:"tags"` | 275 | Tags []int64 `json:"tags"` | ||
| 277 | }{ | 276 | }{ | ||
| 278 | RequestID: nextRequestID(), | 277 | RequestID: nextRequestID(), | ||
| 279 | URL: input.URL, | 278 | URL: input.URL, | ||
| 280 | Title: input.Title, | 279 | Title: input.Title, | ||
| 281 | FaviconURL: input.FaviconURL, | 280 | FaviconURL: input.FaviconURL, | ||
| 282 | - | FolderID: folderIDPayload(input.FolderID), | |||
| 283 | Tags: tagIDPayloads(input.Tags), | 281 | Tags: tagIDPayloads(input.Tags), | ||
| 284 | } | 282 | } | ||
| 285 | } | 283 | } | ||
| 286 | 284 | ||||
| 287 | func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any { | 285 | func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any { | ||
| 288 | - | return struct { | 286 | + | payload := map[string]any{ |
| 289 | - | RequestID string `json:"requestId"` | 287 | + | "requestId": nextRequestID(), |
| 290 | - | ID int64 `json:"id"` | 288 | + | "id": int64(input.ID), |
| 291 | - | Title string `json:"title"` | 289 | + | "title": input.Title, |
| 292 | - | FaviconURL string `json:"favicon_url"` | 290 | + | "favicon_url": input.FaviconURL, |
| 293 | - | FolderID *int64 `json:"folder_id"` | |||
| 294 | - | ShortcutKey *int `json:"shortcut_key"` | |||
| 295 | - | }{ | |||
| 296 | - | RequestID: nextRequestID(), | |||
| 297 | - | ID: int64(input.ID), | |||
| 298 | - | Title: input.Title, | |||
| 299 | - | FaviconURL: input.FaviconURL, | |||
| 300 | - | FolderID: folderIDPayload(input.FolderID), | |||
| 301 | - | ShortcutKey: input.ShortcutKey, | |||
| 302 | } | 291 | } | ||
| 303 | - | } | 292 | + | if input.ShortcutKeySet { |
| 304 | - | 293 | + | payload["shortcut_key"] = input.ShortcutKey | |
| 305 | - | func folderIDPayload(id *entity.FolderID) *int64 { | |||
| 306 | - | if id == nil { | |||
| 307 | - | return nil | |||
| 308 | } | 294 | } | ||
| 309 | - | value := int64(*id) | 295 | + | return payload |
| 310 | - | return &value | |||
| 311 | } | 296 | } | ||
| 312 | 297 | ||||
| 313 | func tagIDPayloads(ids []entity.TagID) []int64 { | 298 | func tagIDPayloads(ids []entity.TagID) []int64 { | ||
| @@ -318,12 +303,6 @@ func tagIDPayloads(ids []entity.TagID) []int64 { | |||||
| 318 | return out | 303 | return out | ||
| 319 | } | 304 | } | ||
| 320 | 305 | ||||
| 321 | - | func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) { | |||
| 322 | - | return request[[]*entity.Folder](c, ctx, "folder_list", struct { | |||
| 323 | - | RequestID string `json:"requestId"` | |||
| 324 | - | }{RequestID: nextRequestID()}) | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | 306 | func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | ||
| 328 | return request[[]*entity.Tag](c, ctx, "tag_list", struct { | 307 | return request[[]*entity.Tag](c, ctx, "tag_list", struct { | ||
| 329 | RequestID string `json:"requestId"` | 308 | RequestID string `json:"requestId"` | ||
| @@ -342,67 +321,6 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey | |||||
| 342 | return err | 321 | return err | ||
| 343 | } | 322 | } | ||
| 344 | 323 | ||||
| 345 | - | func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error { | |||
| 346 | - | if favoriteID <= 0 { | |||
| 347 | - | return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID) | |||
| 348 | - | } | |||
| 349 | - | _, err := request[struct{}](c, ctx, "favorite_set_folder", struct { | |||
| 350 | - | RequestID string `json:"requestId"` | |||
| 351 | - | FavoriteID int64 `json:"favorite_id"` | |||
| 352 | - | FolderID *int64 `json:"folder_id"` | |||
| 353 | - | }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID}) | |||
| 354 | - | return err | |||
| 355 | - | } | |||
| 356 | - | ||||
| 357 | - | func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) { | |||
| 358 | - | name = strings.TrimSpace(name) | |||
| 359 | - | if name == "" { | |||
| 360 | - | return nil, fmt.Errorf("folder name is required") | |||
| 361 | - | } | |||
| 362 | - | var iconPtr *string | |||
| 363 | - | if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" { | |||
| 364 | - | iconPtr = &trimmedIcon | |||
| 365 | - | } | |||
| 366 | - | return request[*entity.Folder](c, ctx, "folder_create", struct { | |||
| 367 | - | RequestID string `json:"requestId"` | |||
| 368 | - | Name string `json:"name"` | |||
| 369 | - | Icon *string `json:"icon"` | |||
| 370 | - | ParentID *int64 `json:"parent_id,omitempty"` | |||
| 371 | - | }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID}) | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | - | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | |||
| 375 | - | if id <= 0 { | |||
| 376 | - | return fmt.Errorf("update folder id must be positive, got %d", id) | |||
| 377 | - | } | |||
| 378 | - | name = strings.TrimSpace(name) | |||
| 379 | - | if name == "" { | |||
| 380 | - | return fmt.Errorf("folder name is required") | |||
| 381 | - | } | |||
| 382 | - | var iconPtr *string | |||
| 383 | - | if trimmed := strings.TrimSpace(icon); trimmed != "" { | |||
| 384 | - | iconPtr = &trimmed | |||
| 385 | - | } | |||
| 386 | - | _, err := request[struct{}](c, ctx, "folder_update", struct { | |||
| 387 | - | RequestID string `json:"requestId"` | |||
| 388 | - | ID int64 `json:"id"` | |||
| 389 | - | Name string `json:"name"` | |||
| 390 | - | Icon *string `json:"icon"` | |||
| 391 | - | }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr}) | |||
| 392 | - | return err | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | |||
| 396 | - | if id <= 0 { | |||
| 397 | - | return fmt.Errorf("delete folder id must be positive, got %d", id) | |||
| 398 | - | } | |||
| 399 | - | _, err := request[struct{}](c, ctx, "folder_delete", struct { | |||
| 400 | - | RequestID string `json:"requestId"` | |||
| 401 | - | ID int64 `json:"id"` | |||
| 402 | - | }{RequestID: nextRequestID(), ID: id}) | |||
| 403 | - | return err | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | 324 | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | ||
| 407 | name = strings.TrimSpace(name) | 325 | name = strings.TrimSpace(name) | ||
| 408 | if name == "" { | 326 | if name == "" { | ||
M internal/infrastructure/systemviewsbridge/client_test.go +0 -139
| @@ -268,32 +268,6 @@ func TestClientListDecodesFavorites(t *testing.T) { | |||||
| 268 | } | 268 | } | ||
| 269 | } | 269 | } | ||
| 270 | 270 | ||||
| 271 | - | func TestClientListFoldersDecodesFolders(t *testing.T) { | |||
| 272 | - | t.Parallel() | |||
| 273 | - | ||||
| 274 | - | native := newTransportRecorder(t, true, []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)) | |||
| 275 | - | client := NewClient(native, nil) | |||
| 276 | - | ||||
| 277 | - | folders, err := client.ListFolders(context.Background()) | |||
| 278 | - | if err != nil { | |||
| 279 | - | t.Fatalf("ListFolders() error = %v", err) | |||
| 280 | - | } | |||
| 281 | - | if len(folders) != 1 { | |||
| 282 | - | t.Fatalf("ListFolders() len = %d, want 1", len(folders)) | |||
| 283 | - | } | |||
| 284 | - | if folders[0].Name != "Read Later" { | |||
| 285 | - | t.Fatalf("ListFolders() folder = %+v", folders[0]) | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | var msg port.WebUIMessage | |||
| 289 | - | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 290 | - | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 291 | - | } | |||
| 292 | - | if msg.Type != "folder_list" { | |||
| 293 | - | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list") | |||
| 294 | - | } | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | func TestClientListTagsDecodesTags(t *testing.T) { | 271 | func TestClientListTagsDecodesTags(t *testing.T) { | ||
| 298 | t.Parallel() | 272 | t.Parallel() | ||
| 299 | 273 | ||||
| @@ -470,61 +444,6 @@ func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | |||||
| 470 | } | 444 | } | ||
| 471 | } | 445 | } | ||
| 472 | 446 | ||||
| 473 | - | func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 474 | - | t.Parallel() | |||
| 475 | - | ||||
| 476 | - | parentID := int64(42) | |||
| 477 | - | transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)) | |||
| 478 | - | client := NewClient(transport, nil) | |||
| 479 | - | ||||
| 480 | - | folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID) | |||
| 481 | - | if err != nil { | |||
| 482 | - | t.Fatalf("CreateFolder() error = %v", err) | |||
| 483 | - | } | |||
| 484 | - | if folder == nil { | |||
| 485 | - | t.Fatal("CreateFolder() returned nil folder") | |||
| 486 | - | } | |||
| 487 | - | if folder.Name != "Nested" { | |||
| 488 | - | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | |||
| 489 | - | } | |||
| 490 | - | if folder.Icon != "📁" { | |||
| 491 | - | t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon) | |||
| 492 | - | } | |||
| 493 | - | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | |||
| 494 | - | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | |||
| 495 | - | } | |||
| 496 | - | ||||
| 497 | - | var msg port.WebUIMessage | |||
| 498 | - | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 499 | - | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 500 | - | } | |||
| 501 | - | if msg.Type != "folder_create" { | |||
| 502 | - | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create") | |||
| 503 | - | } | |||
| 504 | - | ||||
| 505 | - | var payload struct { | |||
| 506 | - | RequestID string `json:"requestId"` | |||
| 507 | - | Name string `json:"name"` | |||
| 508 | - | Icon *string `json:"icon"` | |||
| 509 | - | ParentID *int64 `json:"parent_id"` | |||
| 510 | - | } | |||
| 511 | - | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 512 | - | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 513 | - | } | |||
| 514 | - | if payload.ParentID == nil || *payload.ParentID != parentID { | |||
| 515 | - | t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID) | |||
| 516 | - | } | |||
| 517 | - | if payload.Name != "Nested" { | |||
| 518 | - | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | |||
| 519 | - | } | |||
| 520 | - | if payload.Icon == nil || *payload.Icon != "📁" { | |||
| 521 | - | t.Fatalf("sent icon = %#v, want 📁", payload.Icon) | |||
| 522 | - | } | |||
| 523 | - | if payload.RequestID == "" { | |||
| 524 | - | t.Fatal("sent requestId was empty") | |||
| 525 | - | } | |||
| 526 | - | } | |||
| 527 | - | ||||
| 528 | func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) { | 447 | func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) { | ||
| 529 | t.Parallel() | 448 | t.Parallel() | ||
| 530 | 449 | ||||
| @@ -555,59 +474,6 @@ func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) { | |||||
| 555 | } | 474 | } | ||
| 556 | } | 475 | } | ||
| 557 | 476 | ||||
| 558 | - | func TestClientUpdateFolderTrimsIcon(t *testing.T) { | |||
| 559 | - | t.Parallel() | |||
| 560 | - | ||||
| 561 | - | transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-18","success":true}`)) | |||
| 562 | - | client := NewClient(transport, nil) | |||
| 563 | - | ||||
| 564 | - | if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil { | |||
| 565 | - | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | - | var msg port.WebUIMessage | |||
| 569 | - | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 570 | - | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 571 | - | } | |||
| 572 | - | if msg.Type != "folder_update" { | |||
| 573 | - | t.Fatalf("sent type = %q, want folder_update", msg.Type) | |||
| 574 | - | } | |||
| 575 | - | var payload struct { | |||
| 576 | - | Icon *string `json:"icon"` | |||
| 577 | - | } | |||
| 578 | - | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 579 | - | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 580 | - | } | |||
| 581 | - | if payload.Icon == nil || *payload.Icon != "📚" { | |||
| 582 | - | t.Fatalf("sent icon = %#v, want 📚", payload.Icon) | |||
| 583 | - | } | |||
| 584 | - | } | |||
| 585 | - | ||||
| 586 | - | func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) { | |||
| 587 | - | t.Parallel() | |||
| 588 | - | ||||
| 589 | - | transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-19","success":true}`)) | |||
| 590 | - | client := NewClient(transport, nil) | |||
| 591 | - | ||||
| 592 | - | if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil { | |||
| 593 | - | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 594 | - | } | |||
| 595 | - | ||||
| 596 | - | var msg port.WebUIMessage | |||
| 597 | - | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 598 | - | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 599 | - | } | |||
| 600 | - | var payload struct { | |||
| 601 | - | Icon *string `json:"icon"` | |||
| 602 | - | } | |||
| 603 | - | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 604 | - | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 605 | - | } | |||
| 606 | - | if payload.Icon != nil { | |||
| 607 | - | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |||
| 608 | - | } | |||
| 609 | - | } | |||
| 610 | - | ||||
| 611 | func TestClientDeleteEntryRejectsInvalidID(t *testing.T) { | 477 | func TestClientDeleteEntryRejectsInvalidID(t *testing.T) { | ||
| 612 | t.Parallel() | 478 | t.Parallel() | ||
| 613 | 479 | ||||
| @@ -719,11 +585,6 @@ func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |||||
| 719 | }{ | 585 | }{ | ||
| 720 | {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }}, | 586 | {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }}, | ||
| 721 | {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }}, | 587 | {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }}, | ||
| 722 | - | {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }}, | |||
| 723 | - | {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }}, | |||
| 724 | - | {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }}, | |||
| 725 | - | {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }}, | |||
| 726 | - | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | |||
| 727 | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | 588 | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | ||
| 728 | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | 589 | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | ||
| 729 | {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }}, | 590 | {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }}, | ||
M internal/ui/component/omnibox.go +14 -9
| @@ -1753,20 +1753,25 @@ func (o *Omnibox) loadFavorites(query string, token uint64) { | |||||
| 1753 | return | 1753 | return | ||
| 1754 | } | 1754 | } | ||
| 1755 | 1755 | ||||
| 1756 | - | favorites := make([]Favorite, 0, len(results)) | 1756 | + | favorites := favoriteResultsForOmnibox(results) |
| 1757 | - | for i, r := range results { | |||
| 1758 | - | favorites = append(favorites, Favorite{ | |||
| 1759 | - | ID: int64(r.ID), | |||
| 1760 | - | URL: r.URL, | |||
| 1761 | - | Title: r.Title, | |||
| 1762 | - | Position: i, | |||
| 1763 | - | }) | |||
| 1764 | - | } | |||
| 1765 | 1757 | ||||
| 1766 | o.idleAddUpdateFavorites(favorites, query, token) | 1758 | o.idleAddUpdateFavorites(favorites, query, token) | ||
| 1767 | }() | 1759 | }() | ||
| 1768 | } | 1760 | } | ||
| 1769 | 1761 | ||||
| 1762 | + | func favoriteResultsForOmnibox(results []*entity.Favorite) []Favorite { | |||
| 1763 | + | favorites := make([]Favorite, 0, len(results)) | |||
| 1764 | + | for i, r := range results { | |||
| 1765 | + | favorites = append(favorites, Favorite{ | |||
| 1766 | + | ID: int64(r.ID), | |||
| 1767 | + | URL: r.URL, | |||
| 1768 | + | Title: r.Title, | |||
| 1769 | + | Position: i, | |||
| 1770 | + | }) | |||
| 1771 | + | } | |||
| 1772 | + | return favorites | |||
| 1773 | + | } | |||
| 1774 | + | ||||
| 1770 | func (o *Omnibox) loadBangSuggestions(query string, token uint64) { | 1775 | func (o *Omnibox) loadBangSuggestions(query string, token uint64) { | ||
| 1771 | if o.shortcutsUC == nil { | 1776 | if o.shortcutsUC == nil { | ||
| 1772 | return | 1777 | return | ||
A internal/ui/component/omnibox_favorite_search_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestOmniboxFavoriteSearchResultsAllowTagsPresent(t *testing.T) { | |||
| 11 | + | results := []*entity.Favorite{ | |||
| 12 | + | { | |||
| 13 | + | ID: 1, | |||
| 14 | + | URL: "https://example.com/docs", | |||
| 15 | + | Title: "Docs", | |||
| 16 | + | Tags: []entity.Tag{{ID: 7, Name: "Go"}}, | |||
| 17 | + | }, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | favorites := favoriteResultsForOmnibox(results) | |||
| 21 | + | ||||
| 22 | + | assert.Equal(t, []Favorite{ | |||
| 23 | + | { | |||
| 24 | + | ID: 1, | |||
| 25 | + | URL: "https://example.com/docs", | |||
| 26 | + | Title: "Docs", | |||
| 27 | + | Position: 0, | |||
| 28 | + | }, | |||
| 29 | + | }, favorites) | |||
| 30 | + | } | |||
M internal/ui/systemviews/app.go +45 -56
| @@ -24,44 +24,43 @@ type Dependencies struct { | |||||
| 24 | type App struct { | 24 | type App struct { | ||
| 25 | // mu protects rendered route state; actionMu protects worker channels. | 25 | // mu protects rendered route state; actionMu protects worker channels. | ||
| 26 | // The two locks must not be held at the same time. | 26 | // The two locks must not be held at the same time. | ||
| 27 | - | mu sync.Mutex | 27 | + | mu sync.Mutex |
| 28 | - | actionMu sync.Mutex | 28 | + | actionMu sync.Mutex |
| 29 | - | deps Dependencies | 29 | + | deps Dependencies |
| 30 | - | currentRoute Route | 30 | + | currentRoute Route |
| 31 | - | shellTheme shellTheme | 31 | + | shellTheme shellTheme |
| 32 | - | historyEntries []*entity.HistoryEntry | 32 | + | historyEntries []*entity.HistoryEntry |
| 33 | - | historyStats *entity.HistoryStats | 33 | + | historyStats *entity.HistoryStats |
| 34 | - | historyAnalytics *entity.HistoryAnalytics | 34 | + | historyAnalytics *entity.HistoryAnalytics |
| 35 | - | historyDomainStats []*entity.DomainStat | 35 | + | historyDomainStats []*entity.DomainStat |
| 36 | - | historyQuery string | 36 | + | historyQuery string |
| 37 | - | historyDomainFilter string | 37 | + | historyDomainFilter string |
| 38 | - | historyOffset int | 38 | + | historyOffset int |
| 39 | - | historyWindowBefore time.Time | 39 | + | historyWindowBefore time.Time |
| 40 | - | historyWindowAfter time.Time | 40 | + | historyWindowAfter time.Time |
| 41 | - | historyCursorID int64 | 41 | + | historyCursorID int64 |
| 42 | - | historyHasMore bool | 42 | + | historyHasMore bool |
| 43 | - | historyNotice string | 43 | + | historyNotice string |
| 44 | - | historyError string | 44 | + | historyError string |
| 45 | - | favorites []*entity.Favorite | 45 | + | favorites []*entity.Favorite |
| 46 | - | folders []*entity.Folder | 46 | + | tags []*entity.Tag |
| 47 | - | tags []*entity.Tag | 47 | + | favoriteTagFilter *entity.TagID |
| 48 | - | favoriteFolderFilter *entity.FolderID | 48 | + | favoriteUntaggedFilter bool |
| 49 | - | favoriteTagFilter *entity.TagID | 49 | + | favoritesNotice string |
| 50 | - | favoritesNotice string | 50 | + | favoritesError string |
| 51 | - | favoritesError string | 51 | + | config *dto.SystemviewConfigPayload |
| 52 | - | config *dto.SystemviewConfigPayload | 52 | + | keybindings port.KeybindingsConfig |
| 53 | - | keybindings port.KeybindingsConfig | 53 | + | configNotice string |
| 54 | - | configNotice string | 54 | + | configError string |
| 55 | - | configError string | 55 | + | renderedHTML string |
| 56 | - | renderedHTML string | 56 | + | renderGeneration uint64 |
| 57 | - | renderGeneration uint64 | 57 | + | closed bool |
| 58 | - | closed bool | 58 | + | actionQueue chan DOMAction |
| 59 | - | actionQueue chan DOMAction | 59 | + | actionErrorQueue chan error |
| 60 | - | actionErrorQueue chan error | 60 | + | actionCtx context.Context |
| 61 | - | actionCtx context.Context | 61 | + | actionCancel context.CancelFunc |
| 62 | - | actionCancel context.CancelFunc | 62 | + | actionClosed bool |
| 63 | - | actionClosed bool | 63 | + | actionWG sync.WaitGroup |
| 64 | - | actionWG sync.WaitGroup | |||
| 65 | } | 64 | } | ||
| 66 | 65 | ||||
| 67 | const ( | 66 | const ( | ||
| @@ -513,7 +512,6 @@ func routeSubtitle(route Route) string { | |||||
| 513 | 512 | ||||
| 514 | func (a *App) loadHistoryLoadingRoute() { | 513 | func (a *App) loadHistoryLoadingRoute() { | ||
| 515 | a.favorites = nil | 514 | a.favorites = nil | ||
| 516 | - | a.folders = nil | |||
| 517 | a.tags = nil | 515 | a.tags = nil | ||
| 518 | a.historyEntries = nil | 516 | a.historyEntries = nil | ||
| 519 | a.historyStats = nil | 517 | a.historyStats = nil | ||
| @@ -698,7 +696,6 @@ func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapsho | |||||
| 698 | 696 | ||||
| 699 | func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | 697 | func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | ||
| 700 | a.favorites = nil | 698 | a.favorites = nil | ||
| 701 | - | a.folders = nil | |||
| 702 | a.tags = nil | 699 | a.tags = nil | ||
| 703 | a.historyEntries = result.entries | 700 | a.historyEntries = result.entries | ||
| 704 | a.historyStats = result.stats | 701 | a.historyStats = result.stats | ||
| @@ -727,28 +724,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||||
| 727 | if err != nil { | 724 | if err != nil { | ||
| 728 | return err | 725 | return err | ||
| 729 | } | 726 | } | ||
| 730 | - | folders, err := a.deps.Favorites.ListFolders(ctx) | |||
| 731 | - | if err != nil { | |||
| 732 | - | return err | |||
| 733 | - | } | |||
| 734 | tags, err := a.deps.Favorites.ListTags(ctx) | 727 | tags, err := a.deps.Favorites.ListTags(ctx) | ||
| 735 | if err != nil { | 728 | if err != nil { | ||
| 736 | return err | 729 | return err | ||
| 737 | } | 730 | } | ||
| 738 | 731 | ||||
| 739 | a.historyEntries = nil | 732 | a.historyEntries = nil | ||
| 740 | - | favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter) | 733 | + | favorites = filterFavorites(favorites, a.favoriteTagFilter, a.favoriteUntaggedFilter) |
| 741 | a.favorites = favorites | 734 | a.favorites = favorites | ||
| 742 | - | a.folders = folders | |||
| 743 | a.tags = tags | 735 | a.tags = tags | ||
| 744 | data := favoritesRenderData{ | 736 | data := favoritesRenderData{ | ||
| 745 | - | Favorites: favorites, | 737 | + | Favorites: favorites, |
| 746 | - | Folders: folders, | 738 | + | Tags: tags, |
| 747 | - | Tags: tags, | 739 | + | TagFilter: a.favoriteTagFilter, |
| 748 | - | FolderFilter: a.favoriteFolderFilter, | 740 | + | UntaggedFilter: a.favoriteUntaggedFilter, |
| 749 | - | TagFilter: a.favoriteTagFilter, | 741 | + | Notice: a.favoritesNotice, |
| 750 | - | Notice: a.favoritesNotice, | 742 | + | Error: a.favoritesError, |
| 751 | - | Error: a.favoritesError, | |||
| 752 | } | 743 | } | ||
| 753 | a.renderedHTML = renderAppFrame(renderedPage{ | 744 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 754 | route: RouteFavorites, | 745 | route: RouteFavorites, | ||
| @@ -782,7 +773,6 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||||
| 782 | 773 | ||||
| 783 | a.historyEntries = nil | 774 | a.historyEntries = nil | ||
| 784 | a.favorites = nil | 775 | a.favorites = nil | ||
| 785 | - | a.folders = nil | |||
| 786 | a.tags = nil | 776 | a.tags = nil | ||
| 787 | a.config = &config | 777 | a.config = &config | ||
| 788 | a.keybindings = keybindings | 778 | a.keybindings = keybindings | ||
| @@ -835,10 +825,9 @@ func (a *App) resetRouteState() { | |||||
| 835 | a.historyNotice = "" | 825 | a.historyNotice = "" | ||
| 836 | a.historyError = "" | 826 | a.historyError = "" | ||
| 837 | a.favorites = nil | 827 | a.favorites = nil | ||
| 838 | - | a.folders = nil | |||
| 839 | a.tags = nil | 828 | a.tags = nil | ||
| 840 | - | a.favoriteFolderFilter = nil | |||
| 841 | a.favoriteTagFilter = nil | 829 | a.favoriteTagFilter = nil | ||
| 830 | + | a.favoriteUntaggedFilter = false | |||
| 842 | a.favoritesNotice = "" | 831 | a.favoritesNotice = "" | ||
| 843 | a.favoritesError = "" | 832 | a.favoritesError = "" | ||
| 844 | a.config = nil | 833 | a.config = nil | ||
M internal/ui/systemviews/app_test.go +14 -67
| @@ -517,7 +517,6 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 517 | 517 | ||||
| 518 | favorites := &recordingFavoritesService{ | 518 | favorites := &recordingFavoritesService{ | ||
| 519 | favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}}, | 519 | favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}}, | ||
| 520 | - | folders: []*entity.Folder{{Name: "Read Later"}}, | |||
| 521 | tags: []*entity.Tag{{Name: "Go"}}, | 520 | tags: []*entity.Tag{{Name: "Go"}}, | ||
| 522 | } | 521 | } | ||
| 523 | 522 | ||||
| @@ -529,14 +528,11 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 529 | require.NoError(t, app.LoadInitial(context.Background())) | 528 | require.NoError(t, app.LoadInitial(context.Background())) | ||
| 530 | assert.Equal(t, RouteFavorites, app.CurrentRoute()) | 529 | assert.Equal(t, RouteFavorites, app.CurrentRoute()) | ||
| 531 | assert.Len(t, app.favorites, 1) | 530 | assert.Len(t, app.favorites, 1) | ||
| 532 | - | assert.Len(t, app.folders, 1) | |||
| 533 | assert.Len(t, app.tags, 1) | 531 | assert.Len(t, app.tags, 1) | ||
| 534 | assert.Contains(t, app.renderedHTML, "Favorites") | 532 | assert.Contains(t, app.renderedHTML, "Favorites") | ||
| 535 | assert.Contains(t, app.renderedHTML, "Example") | 533 | assert.Contains(t, app.renderedHTML, "Example") | ||
| 536 | - | assert.Contains(t, app.renderedHTML, "Read Later") | |||
| 537 | assert.Contains(t, app.renderedHTML, "Go") | 534 | assert.Contains(t, app.renderedHTML, "Go") | ||
| 538 | assert.True(t, favorites.calledList) | 535 | assert.True(t, favorites.calledList) | ||
| 539 | - | assert.True(t, favorites.calledFolders) | |||
| 540 | assert.True(t, favorites.calledTags) | 536 | assert.True(t, favorites.calledTags) | ||
| 541 | 537 | ||||
| 542 | // Shell frame present, no full document wrapper. | 538 | // Shell frame present, no full document wrapper. | ||
| @@ -553,11 +549,9 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 553 | func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | 549 | func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | ||
| 554 | t.Parallel() | 550 | t.Parallel() | ||
| 555 | 551 | ||||
| 556 | - | folderID := entity.FolderID(1) | |||
| 557 | shortcut := 3 | 552 | shortcut := 3 | ||
| 558 | favorites := &recordingFavoritesService{ | 553 | favorites := &recordingFavoritesService{ | ||
| 559 | - | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}}, | 554 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", ShortcutKey: &shortcut}}, |
| 560 | - | folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}}, | |||
| 561 | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | 555 | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | ||
| 562 | } | 556 | } | ||
| 563 | 557 | ||||
| @@ -567,16 +561,16 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |||||
| 567 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`) | 561 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`) | ||
| 568 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`) | 562 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`) | ||
| 569 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`) | 563 | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`) | ||
| 570 | - | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`) | 564 | + | assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.create"`) |
| 571 | - | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`) | 565 | + | assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.update"`) |
| 572 | - | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`) | 566 | + | assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.delete"`) |
| 573 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`) | 567 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`) | ||
| 574 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | 568 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | ||
| 575 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | 569 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | ||
| 576 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | 570 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | ||
| 571 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.filterUntagged"`) | |||
| 577 | assert.Contains(t, app.renderedHTML, `name="tags"`) | 572 | assert.Contains(t, app.renderedHTML, `name="tags"`) | ||
| 578 | assert.Contains(t, app.renderedHTML, `value="7"`) | 573 | assert.Contains(t, app.renderedHTML, `value="7"`) | ||
| 579 | - | assert.Contains(t, app.renderedHTML, "Read Later") | |||
| 580 | assert.Contains(t, app.renderedHTML, "Go") | 574 | assert.Contains(t, app.renderedHTML, "Go") | ||
| 581 | assert.Contains(t, app.renderedHTML, "Shortcut 3") | 575 | assert.Contains(t, app.renderedHTML, "Shortcut 3") | ||
| 582 | } | 576 | } | ||
| @@ -585,18 +579,15 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||||
| 585 | dom := &recordingDOM{} | 579 | dom := &recordingDOM{} | ||
| 586 | favorites := &recordingFavoritesService{ | 580 | favorites := &recordingFavoritesService{ | ||
| 587 | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}}, | 581 | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}}, | ||
| 588 | - | folders: []*entity.Folder{{ID: 5, Name: "Read Later"}}, | |||
| 589 | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | 582 | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | ||
| 590 | } | 583 | } | ||
| 591 | app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"}) | 584 | app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"}) | ||
| 592 | 585 | ||||
| 593 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 586 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 594 | Action: favoriteActionCreate, | 587 | Action: favoriteActionCreate, | ||
| 595 | - | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"}, | 588 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "tags": "7"}, |
| 596 | })) | 589 | })) | ||
| 597 | - | require.NotNil(t, favorites.createdFavorite.FolderID) | |||
| 598 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | 590 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | ||
| 599 | - | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |||
| 600 | assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags) | 591 | assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags) | ||
| 601 | assert.Contains(t, dom.HTML(), "Added favorite New") | 592 | assert.Contains(t, dom.HTML(), "Added favorite New") | ||
| 602 | 593 | ||||
| @@ -606,6 +597,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||||
| 606 | })) | 597 | })) | ||
| 607 | assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID) | 598 | assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID) | ||
| 608 | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | 599 | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | ||
| 600 | + | assert.True(t, favorites.updatedFavorite.ShortcutKeySet) | |||
| 609 | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | 601 | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | ||
| 610 | assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey) | 602 | assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey) | ||
| 611 | assert.Contains(t, dom.HTML(), "Saved favorite Updated") | 603 | assert.Contains(t, dom.HTML(), "Saved favorite Updated") | ||
| @@ -617,29 +609,13 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||||
| 617 | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | 609 | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | ||
| 618 | assert.Equal(t, int64(7), favorites.assignedTagID) | 610 | assert.Equal(t, int64(7), favorites.assignedTagID) | ||
| 619 | 611 | ||||
| 620 | - | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 621 | - | Action: favoriteActionFilterFolder, | |||
| 622 | - | Data: map[string]string{"folderId": "5"}, | |||
| 623 | - | })) | |||
| 624 | - | require.NotNil(t, app.favoriteFolderFilter) | |||
| 625 | - | assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter) | |||
| 626 | - | assert.Nil(t, app.favoriteTagFilter) | |||
| 627 | - | ||||
| 628 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 612 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 629 | Action: favoriteActionFilterTag, | 613 | Action: favoriteActionFilterTag, | ||
| 630 | Data: map[string]string{"tagId": "7"}, | 614 | Data: map[string]string{"tagId": "7"}, | ||
| 631 | })) | 615 | })) | ||
| 632 | require.NotNil(t, app.favoriteTagFilter) | 616 | require.NotNil(t, app.favoriteTagFilter) | ||
| 633 | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | 617 | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | ||
| 634 | - | assert.Nil(t, app.favoriteFolderFilter) | 618 | + | assert.False(t, app.favoriteUntaggedFilter) |
| 635 | - | ||||
| 636 | - | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 637 | - | Action: folderActionCreate, | |||
| 638 | - | Data: map[string]string{"name": "Reading", "icon": "📚"}, | |||
| 639 | - | })) | |||
| 640 | - | assert.Equal(t, "Reading", favorites.createdFolder) | |||
| 641 | - | assert.Equal(t, "📚", favorites.createdFolderIcon) | |||
| 642 | - | assert.Contains(t, dom.HTML(), "Created folder 📚 Reading") | |||
| 643 | 619 | ||||
| 644 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 620 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 645 | Action: favoriteActionDelete, | 621 | Action: favoriteActionDelete, | ||
| @@ -1199,20 +1175,14 @@ func (s *recordingHistoryService) DeleteDomain(_ context.Context, domain string) | |||||
| 1199 | 1175 | ||||
| 1200 | // Recording fixture to capture favorites state for stateful render assertions. | 1176 | // Recording fixture to capture favorites state for stateful render assertions. | ||
| 1201 | type recordingFavoritesService struct { | 1177 | type recordingFavoritesService struct { | ||
| 1202 | - | calledList bool | 1178 | + | calledList bool |
| 1203 | - | calledFolders bool | 1179 | + | calledTags bool |
| 1204 | - | calledTags bool | 1180 | + | favorites []*entity.Favorite |
| 1205 | - | favorites []*entity.Favorite | 1181 | + | tags []*entity.Tag |
| 1206 | - | folders []*entity.Folder | |||
| 1207 | - | tags []*entity.Tag | |||
| 1208 | 1182 | ||||
| 1209 | createdFavorite dto.FavoriteCreateInput | 1183 | createdFavorite dto.FavoriteCreateInput | ||
| 1210 | updatedFavorite dto.FavoriteUpdateInput | 1184 | updatedFavorite dto.FavoriteUpdateInput | ||
| 1211 | deletedFavorite int64 | 1185 | deletedFavorite int64 | ||
| 1212 | - | createdFolder string | |||
| 1213 | - | createdFolderIcon string | |||
| 1214 | - | updatedFolderID int64 | |||
| 1215 | - | deletedFolderID int64 | |||
| 1216 | createdTag string | 1186 | createdTag string | ||
| 1217 | updatedTagID int64 | 1187 | updatedTagID int64 | ||
| 1218 | deletedTagID int64 | 1188 | deletedTagID int64 | ||
| @@ -1227,11 +1197,6 @@ func (s *recordingFavoritesService) List(context.Context) ([]*entity.Favorite, e | |||||
| 1227 | return s.favorites, nil | 1197 | return s.favorites, nil | ||
| 1228 | } | 1198 | } | ||
| 1229 | 1199 | ||||
| 1230 | - | func (s *recordingFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) { | |||
| 1231 | - | s.calledFolders = true | |||
| 1232 | - | return s.folders, nil | |||
| 1233 | - | } | |||
| 1234 | - | ||||
| 1235 | func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) { | 1200 | func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) { | ||
| 1236 | s.calledTags = true | 1201 | s.calledTags = true | ||
| 1237 | return s.tags, nil | 1202 | return s.tags, nil | ||
| @@ -1239,12 +1204,12 @@ func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, er | |||||
| 1239 | 1204 | ||||
| 1240 | func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | 1205 | func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | ||
| 1241 | s.createdFavorite = input | 1206 | 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 |
| 1243 | } | 1208 | } | ||
| 1244 | 1209 | ||||
| 1245 | func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | 1210 | func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | ||
| 1246 | s.updatedFavorite = input | 1211 | 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 |
| 1248 | } | 1213 | } | ||
| 1249 | 1214 | ||||
| 1250 | func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error { | 1215 | func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error { | ||
| @@ -1254,24 +1219,6 @@ func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) | |||||
| 1254 | 1219 | ||||
| 1255 | func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | 1220 | func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | ||
| 1256 | 1221 | ||||
| 1257 | - | func (s *recordingFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | |||
| 1258 | - | ||||
| 1259 | - | func (s *recordingFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) { | |||
| 1260 | - | s.createdFolder = name | |||
| 1261 | - | s.createdFolderIcon = icon | |||
| 1262 | - | return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil | |||
| 1263 | - | } | |||
| 1264 | - | ||||
| 1265 | - | func (s *recordingFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | |||
| 1266 | - | s.updatedFolderID = id | |||
| 1267 | - | return nil | |||
| 1268 | - | } | |||
| 1269 | - | ||||
| 1270 | - | func (s *recordingFavoritesService) DeleteFolder(_ context.Context, id int64) error { | |||
| 1271 | - | s.deletedFolderID = id | |||
| 1272 | - | return nil | |||
| 1273 | - | } | |||
| 1274 | - | ||||
| 1275 | func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) { | 1222 | func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) { | ||
| 1276 | s.createdTag = name | 1223 | s.createdTag = name | ||
| 1277 | return &entity.Tag{ID: 55, Name: name, Color: color}, nil | 1224 | return &entity.Tag{ID: 55, Name: name, Color: color}, nil | ||
M internal/ui/systemviews/favorite_actions.go +25 -99
| @@ -12,20 +12,17 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | - | favoriteActionCreate = "favorite.create" | 15 | + | favoriteActionCreate = "favorite.create" |
| 16 | - | favoriteActionUpdate = "favorite.update" | 16 | + | favoriteActionUpdate = "favorite.update" |
| 17 | - | favoriteActionDelete = "favorite.delete" | 17 | + | favoriteActionDelete = "favorite.delete" |
| 18 | - | favoriteActionFilterFolder = "favorite.filterFolder" | 18 | + | favoriteActionFilterTag = "favorite.filterTag" |
| 19 | - | favoriteActionFilterTag = "favorite.filterTag" | 19 | + | favoriteActionFilterUntagged = "favorite.filterUntagged" |
| 20 | - | favoriteActionClearFilters = "favorite.clearFilters" | 20 | + | favoriteActionClearFilters = "favorite.clearFilters" |
| 21 | - | folderActionCreate = "folder.create" | 21 | + | tagActionCreate = "tag.create" |
| 22 | - | folderActionUpdate = "folder.update" | 22 | + | tagActionUpdate = "tag.update" |
| 23 | - | folderActionDelete = "folder.delete" | 23 | + | tagActionDelete = "tag.delete" |
| 24 | - | tagActionCreate = "tag.create" | 24 | + | tagActionAssign = "tag.assign" |
| 25 | - | tagActionUpdate = "tag.update" | 25 | + | tagActionRemove = "tag.remove" |
| 26 | - | tagActionDelete = "tag.delete" | |||
| 27 | - | tagActionAssign = "tag.assign" | |||
| 28 | - | tagActionRemove = "tag.remove" | |||
| 29 | ) | 26 | ) | ||
| 30 | 27 | ||||
| 31 | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | 28 | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | ||
| @@ -36,10 +33,6 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 36 | data := event.Data | 33 | data := event.Data | ||
| 37 | switch event.Action { | 34 | switch event.Action { | ||
| 38 | case favoriteActionCreate: | 35 | case favoriteActionCreate: | ||
| 39 | - | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 40 | - | if err != nil { | |||
| 41 | - | return err | |||
| 42 | - | } | |||
| 43 | favoriteURL, err := validateFavoriteURL(data["url"]) | 36 | favoriteURL, err := validateFavoriteURL(data["url"]) | ||
| 44 | if err != nil { | 37 | if err != nil { | ||
| 45 | return err | 38 | return err | ||
| @@ -49,10 +42,9 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 49 | return err | 42 | return err | ||
| 50 | } | 43 | } | ||
| 51 | favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{ | 44 | favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{ | ||
| 52 | - | URL: favoriteURL, | 45 | + | URL: favoriteURL, |
| 53 | - | Title: strings.TrimSpace(data["title"]), | 46 | + | Title: strings.TrimSpace(data["title"]), |
| 54 | - | FolderID: folderID, | 47 | + | Tags: tags, |
| 55 | - | Tags: tags, | |||
| 56 | }) | 48 | }) | ||
| 57 | if err != nil { | 49 | if err != nil { | ||
| 58 | return err | 50 | return err | ||
| @@ -63,20 +55,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 63 | if err != nil { | 55 | if err != nil { | ||
| 64 | return err | 56 | return err | ||
| 65 | } | 57 | } | ||
| 66 | - | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 67 | - | if err != nil { | |||
| 68 | - | return err | |||
| 69 | - | } | |||
| 70 | shortcut, err := optionalShortcut(data["shortcut_key"]) | 58 | shortcut, err := optionalShortcut(data["shortcut_key"]) | ||
| 71 | if err != nil { | 59 | if err != nil { | ||
| 72 | return err | 60 | return err | ||
| 73 | } | 61 | } | ||
| 74 | favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | 62 | favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | ||
| 75 | - | ID: entity.FavoriteID(id), | 63 | + | ID: entity.FavoriteID(id), |
| 76 | - | Title: strings.TrimSpace(data["title"]), | 64 | + | Title: strings.TrimSpace(data["title"]), |
| 77 | - | FaviconURL: strings.TrimSpace(data["favicon_url"]), | 65 | + | FaviconURL: strings.TrimSpace(data["favicon_url"]), |
| 78 | - | FolderID: folderID, | 66 | + | ShortcutKey: shortcut, |
| 79 | - | ShortcutKey: shortcut, | 67 | + | ShortcutKeySet: true, |
| 80 | }) | 68 | }) | ||
| 81 | if err != nil { | 69 | if err != nil { | ||
| 82 | return err | 70 | return err | ||
| @@ -91,14 +79,6 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 91 | return err | 79 | return err | ||
| 92 | } | 80 | } | ||
| 93 | a.favoritesNotice = "Deleted favorite" | 81 | a.favoritesNotice = "Deleted favorite" | ||
| 94 | - | case favoriteActionFilterFolder: | |||
| 95 | - | folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id")) | |||
| 96 | - | if err != nil { | |||
| 97 | - | return err | |||
| 98 | - | } | |||
| 99 | - | a.favoriteFolderFilter = folderID | |||
| 100 | - | a.favoriteTagFilter = nil | |||
| 101 | - | a.favoritesNotice = "" | |||
| 102 | case favoriteActionFilterTag: | 82 | case favoriteActionFilterTag: | ||
| 103 | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | 83 | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | ||
| 104 | if err != nil { | 84 | if err != nil { | ||
| @@ -106,45 +86,16 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 106 | } | 86 | } | ||
| 107 | id := entity.TagID(tagID) | 87 | id := entity.TagID(tagID) | ||
| 108 | a.favoriteTagFilter = &id | 88 | 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 | |||
| 110 | a.favoritesNotice = "" | 94 | a.favoritesNotice = "" | ||
| 111 | case favoriteActionClearFilters: | 95 | case favoriteActionClearFilters: | ||
| 112 | - | a.favoriteFolderFilter = nil | |||
| 113 | a.favoriteTagFilter = nil | 96 | a.favoriteTagFilter = nil | ||
| 97 | + | a.favoriteUntaggedFilter = false | |||
| 114 | a.favoritesNotice = "" | 98 | a.favoritesNotice = "" | ||
| 115 | - | case folderActionCreate: | |||
| 116 | - | name := strings.TrimSpace(data["name"]) | |||
| 117 | - | if name == "" { | |||
| 118 | - | return fmt.Errorf("folder name is required") | |||
| 119 | - | } | |||
| 120 | - | folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil) | |||
| 121 | - | if err != nil { | |||
| 122 | - | return err | |||
| 123 | - | } | |||
| 124 | - | a.favoritesNotice = "Created folder " + folderDisplayName(folder) | |||
| 125 | - | case folderActionUpdate: | |||
| 126 | - | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 127 | - | if err != nil { | |||
| 128 | - | return err | |||
| 129 | - | } | |||
| 130 | - | name := strings.TrimSpace(data["name"]) | |||
| 131 | - | if name == "" { | |||
| 132 | - | return fmt.Errorf("folder name is required") | |||
| 133 | - | } | |||
| 134 | - | if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil { | |||
| 135 | - | return err | |||
| 136 | - | } | |||
| 137 | - | a.favoritesNotice = "Saved folder " + name | |||
| 138 | - | case folderActionDelete: | |||
| 139 | - | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 140 | - | if err != nil { | |||
| 141 | - | return err | |||
| 142 | - | } | |||
| 143 | - | if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil { | |||
| 144 | - | return err | |||
| 145 | - | } | |||
| 146 | - | a.favoriteFolderFilter = nil | |||
| 147 | - | a.favoritesNotice = "Deleted folder" | |||
| 148 | case tagActionCreate: | 99 | case tagActionCreate: | ||
| 149 | name := strings.TrimSpace(data["name"]) | 100 | name := strings.TrimSpace(data["name"]) | ||
| 150 | if name == "" { | 101 | if name == "" { | ||
| @@ -272,31 +223,6 @@ func optionalTagIDs(raw string) ([]entity.TagID, error) { | |||||
| 272 | return ids, nil | 223 | return ids, nil | ||
| 273 | } | 224 | } | ||
| 274 | 225 | ||||
| 275 | - | // optionalFolderID returns nil for empty or "root" input, meaning no folder constraint; | |||
| 276 | - | // otherwise it parses a positive folder id into a non-nil *entity.FolderID. | |||
| 277 | - | func optionalFolderID(raw string) (*entity.FolderID, error) { | |||
| 278 | - | raw = strings.TrimSpace(raw) | |||
| 279 | - | if raw == "" || raw == "root" { | |||
| 280 | - | return nil, nil | |||
| 281 | - | } | |||
| 282 | - | id, err := parsePositiveInt64(raw, "folder id") | |||
| 283 | - | if err != nil { | |||
| 284 | - | return nil, err | |||
| 285 | - | } | |||
| 286 | - | folderID := entity.FolderID(id) | |||
| 287 | - | return &folderID, nil | |||
| 288 | - | } | |||
| 289 | - | ||||
| 290 | - | // filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter | |||
| 291 | - | // favorites in the root folder; other inputs delegate to optionalFolderID. | |||
| 292 | - | func filterFolderID(raw string) (*entity.FolderID, error) { | |||
| 293 | - | if strings.TrimSpace(raw) == "root" { | |||
| 294 | - | root := entity.FolderID(0) | |||
| 295 | - | return &root, nil | |||
| 296 | - | } | |||
| 297 | - | return optionalFolderID(raw) | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. | 226 | // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. | ||
| 301 | func optionalShortcut(raw string) (*int, error) { | 227 | func optionalShortcut(raw string) (*int, error) { | ||
| 302 | raw = strings.TrimSpace(raw) | 228 | raw = strings.TrimSpace(raw) | ||
M internal/ui/systemviews/favorites.templ +8 -64
| @@ -19,22 +19,7 @@ templ FavoritesView(data favoritesRenderData) { | |||||
| 19 | <ul class="sv-list"> | 19 | <ul class="sv-list"> | ||
| 20 | for _, favorite := range data.Favorites { | 20 | for _, favorite := range data.Favorites { | ||
| 21 | if favorite != nil { | 21 | if favorite != nil { | ||
| 22 | - | @FavoriteItem(favorite, data.Folders, data.Tags) | 22 | + | @FavoriteItem(favorite, data.Tags) |
| 23 | - | } | |||
| 24 | - | } | |||
| 25 | - | </ul> | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | @Section("sv-folders-list", "Folders") { | |||
| 29 | - | @Meta(foldersSummary(data)) | |||
| 30 | - | @FolderCreateForm() | |||
| 31 | - | if len(data.Folders) == 0 { | |||
| 32 | - | @EmptyState("No folders") | |||
| 33 | - | } else { | |||
| 34 | - | <ul class="sv-list"> | |||
| 35 | - | for _, folder := range data.Folders { | |||
| 36 | - | if folder != nil { | |||
| 37 | - | @FolderItem(folder) | |||
| 38 | } | 23 | } | ||
| 39 | } | 24 | } | ||
| 40 | </ul> | 25 | </ul> | ||
| @@ -61,23 +46,14 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 61 | <form class="sv-card-form" data-sv-action="favorite.create"> | 46 | <form class="sv-card-form" data-sv-action="favorite.create"> | ||
| 62 | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | 47 | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | ||
| 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | 48 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | ||
| 64 | - | @FolderSelect("folder_id", data.Folders, nil, "Folder") | |||
| 65 | @FavoriteCreateTagPicker(data.Tags) | 49 | @FavoriteCreateTagPicker(data.Tags) | ||
| 66 | <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button> | 50 | <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button> | ||
| 67 | </form> | 51 | </form> | ||
| 68 | <div class="sv-filter-panel"> | 52 | <div class="sv-filter-panel"> | ||
| 69 | - | <div class="sv-button-row"> | |||
| 70 | - | <span class="sv-meta">Folders</span> | |||
| 71 | - | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | |||
| 72 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> | |||
| 73 | - | for _, folder := range data.Folders { | |||
| 74 | - | if folder != nil { | |||
| 75 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | </div> | |||
| 79 | <div class="sv-button-row"> | 53 | <div class="sv-button-row"> | ||
| 80 | <span class="sv-meta">Tags</span> | 54 | <span class="sv-meta">Tags</span> | ||
| 55 | + | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | |||
| 56 | + | <button type="button" class={ favoriteFilterClass(favoriteUntaggedFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteUntaggedFilterActive(data)) } data-sv-action="favorite.filterUntagged">Untagged</button> | |||
| 81 | for _, tag := range data.Tags { | 57 | for _, tag := range data.Tags { | ||
| 82 | if tag != nil { | 58 | if tag != nil { | ||
| 83 | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | 59 | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | ||
| @@ -108,15 +84,15 @@ templ FavoriteCreateTagPicker(tags []*entity.Tag) { | |||||
| 108 | } | 84 | } | ||
| 109 | } | 85 | } | ||
| 110 | 86 | ||||
| 111 | - | templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) { | 87 | + | templ FavoriteItem(favorite *entity.Favorite, tags []*entity.Tag) { |
| 112 | <li class="sv-list-row"> | 88 | <li class="sv-list-row"> | ||
| 113 | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | 89 | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | ||
| 114 | <div class="sv-favorite-header"> | 90 | <div class="sv-favorite-header"> | ||
| 115 | <div> | 91 | <div> | ||
| 116 | <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a> | 92 | <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a> | ||
| 117 | <p class="sv-history-url">{ favorite.URL }</p> | 93 | <p class="sv-history-url">{ favorite.URL }</p> | ||
| 118 | - | if favoriteMetaText(favorite, folders) != "" { | 94 | + | if favoriteMetaText(favorite) != "" { |
| 119 | - | <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p> | 95 | + | <p class="sv-meta">{ favoriteMetaText(favorite) }</p> |
| 120 | } | 96 | } | ||
| 121 | </div> | 97 | </div> | ||
| 122 | <div class="sv-history-row-actions"> | 98 | <div class="sv-history-row-actions"> | ||
| @@ -124,18 +100,17 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []* | |||||
| 124 | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button> | 100 | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button> | ||
| 125 | </div> | 101 | </div> | ||
| 126 | </div> | 102 | </div> | ||
| 127 | - | @FavoriteEditForm(favorite, folders) | 103 | + | @FavoriteEditForm(favorite) |
| 128 | @FavoriteTagControls(favorite, tags) | 104 | @FavoriteTagControls(favorite, tags) | ||
| 129 | </article> | 105 | </article> | ||
| 130 | </li> | 106 | </li> | ||
| 131 | } | 107 | } | ||
| 132 | 108 | ||||
| 133 | - | templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) { | 109 | + | templ FavoriteEditForm(favorite *entity.Favorite) { |
| 134 | <form class="sv-inline-form" data-sv-action="favorite.update"> | 110 | <form class="sv-inline-form" data-sv-action="favorite.update"> | ||
| 135 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/> | 111 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/> | ||
| 136 | <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label> | 112 | <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label> | ||
| 137 | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | 113 | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | ||
| 138 | - | @FolderSelect("folder_id", folders, favorite.FolderID, "Folder") | |||
| 139 | @ShortcutSelect(favorite.ShortcutKey) | 114 | @ShortcutSelect(favorite.ShortcutKey) | ||
| 140 | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | 115 | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | ||
| 141 | </form> | 116 | </form> | ||
| @@ -156,26 +131,6 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) { | |||||
| 156 | } | 131 | } | ||
| 157 | } | 132 | } | ||
| 158 | 133 | ||||
| 159 | - | templ FolderCreateForm() { | |||
| 160 | - | <form class="sv-inline-form" data-sv-action="folder.create"> | |||
| 161 | - | <label><span>Name</span><input name="name" type="text" required/></label> | |||
| 162 | - | <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label> | |||
| 163 | - | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button> | |||
| 164 | - | </form> | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | templ FolderItem(folder *entity.Folder) { | |||
| 168 | - | <li class="sv-list-row"> | |||
| 169 | - | <form class="sv-inline-form" data-sv-action="folder.update"> | |||
| 170 | - | <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/> | |||
| 171 | - | <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label> | |||
| 172 | - | <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label> | |||
| 173 | - | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 174 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button> | |||
| 175 | - | </form> | |||
| 176 | - | </li> | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | templ TagCreateForm() { | 134 | templ TagCreateForm() { | ||
| 180 | <form class="sv-inline-form" data-sv-action="tag.create"> | 135 | <form class="sv-inline-form" data-sv-action="tag.create"> | ||
| 181 | <label><span>Name</span><input name="name" type="text" required/></label> | 136 | <label><span>Name</span><input name="name" type="text" required/></label> | ||
| @@ -197,17 +152,6 @@ templ TagItem(tag *entity.Tag) { | |||||
| 197 | </li> | 152 | </li> | ||
| 198 | } | 153 | } | ||
| 199 | 154 | ||||
| 200 | - | templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) { | |||
| 201 | - | <label><span>{ label }</span><select name={ name }> | |||
| 202 | - | <option value="">Unfiled</option> | |||
| 203 | - | for _, folder := range folders { | |||
| 204 | - | if folder != nil { | |||
| 205 | - | <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option> | |||
| 206 | - | } | |||
| 207 | - | } | |||
| 208 | - | </select></label> | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | templ ShortcutSelect(selected *int) { | 155 | templ ShortcutSelect(selected *int) { | ||
| 212 | <label><span>Shortcut</span><select name="shortcut_key"> | 156 | <label><span>Shortcut</span><select name="shortcut_key"> | ||
| 213 | <option value="">None</option> | 157 | <option value="">None</option> | ||
M internal/ui/systemviews/favorites_templ.go +245 -616 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +19 -62
| @@ -10,13 +10,12 @@ import ( | |||||
| 10 | const defaultTagColor = "#808080" | 10 | const defaultTagColor = "#808080" | ||
| 11 | 11 | ||||
| 12 | type favoritesRenderData struct { | 12 | type favoritesRenderData struct { | ||
| 13 | - | Favorites []*entity.Favorite | 13 | + | Favorites []*entity.Favorite |
| 14 | - | Folders []*entity.Folder | 14 | + | Tags []*entity.Tag |
| 15 | - | Tags []*entity.Tag | 15 | + | TagFilter *entity.TagID |
| 16 | - | FolderFilter *entity.FolderID | 16 | + | UntaggedFilter bool |
| 17 | - | TagFilter *entity.TagID | 17 | + | Notice string |
| 18 | - | Notice string | 18 | + | Error string |
| 19 | - | Error string | |||
| 20 | } | 19 | } | ||
| 21 | 20 | ||||
| 22 | func favoritesHTML(data favoritesRenderData) string { | 21 | func favoritesHTML(data favoritesRenderData) string { | ||
| @@ -25,7 +24,7 @@ func favoritesHTML(data favoritesRenderData) string { | |||||
| 25 | 24 | ||||
| 26 | func favoritesDocumentTitle(data favoritesRenderData) string { | 25 | func favoritesDocumentTitle(data favoritesRenderData) string { | ||
| 27 | count := len(data.Favorites) | 26 | count := len(data.Favorites) | ||
| 28 | - | if data.FolderFilter != nil || data.TagFilter != nil { | 27 | + | if data.TagFilter != nil || data.UntaggedFilter { |
| 29 | if count == 1 { | 28 | if count == 1 { | ||
| 30 | return "Favorites — 1 filtered bookmark" | 29 | return "Favorites — 1 filtered bookmark" | ||
| 31 | } | 30 | } | ||
| @@ -38,11 +37,7 @@ func favoritesDocumentTitle(data favoritesRenderData) string { | |||||
| 38 | } | 37 | } | ||
| 39 | 38 | ||||
| 40 | func favoritesSummary(data favoritesRenderData) string { | 39 | func favoritesSummary(data favoritesRenderData) string { | ||
| 41 | - | return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | 40 | + | return fmt.Sprintf("%d favorites · %d tags", len(data.Favorites), len(data.Tags)) |
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func foldersSummary(data favoritesRenderData) string { | |||
| 45 | - | return fmt.Sprintf("%d folders", len(data.Folders)) | |||
| 46 | } | 41 | } | ||
| 47 | 42 | ||||
| 48 | func tagsSummary(data favoritesRenderData) string { | 43 | func tagsSummary(data favoritesRenderData) string { | ||
| @@ -57,12 +52,12 @@ func favoriteFilterClass(active bool) string { | |||||
| 57 | return classes | 52 | return classes | ||
| 58 | } | 53 | } | ||
| 59 | 54 | ||||
| 60 | - | func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool { | 55 | + | func favoriteUntaggedFilterActive(data favoritesRenderData) bool { |
| 61 | - | return data.FolderFilter != nil && *data.FolderFilter == folderID | 56 | + | return data.UntaggedFilter |
| 62 | } | 57 | } | ||
| 63 | 58 | ||||
| 64 | func favoriteAllFilterActive(data favoritesRenderData) bool { | 59 | func favoriteAllFilterActive(data favoritesRenderData) bool { | ||
| 65 | - | return data.FolderFilter == nil && data.TagFilter == nil | 60 | + | return data.TagFilter == nil && !data.UntaggedFilter |
| 66 | } | 61 | } | ||
| 67 | 62 | ||||
| 68 | func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool { | 63 | func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool { | ||
| @@ -80,18 +75,11 @@ func favoriteItemLabel(favorite *entity.Favorite) string { | |||||
| 80 | return label | 75 | return label | ||
| 81 | } | 76 | } | ||
| 82 | 77 | ||||
| 83 | - | func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string { | 78 | + | func favoriteMetaText(favorite *entity.Favorite) string { |
| 84 | - | if favorite == nil { | 79 | + | if favorite == nil || favorite.ShortcutKey == nil { |
| 85 | return "" | 80 | return "" | ||
| 86 | } | 81 | } | ||
| 87 | - | parts := make([]string, 0, 2) | 82 | + | return fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey) |
| 88 | - | if favorite.ShortcutKey != nil { | |||
| 89 | - | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | |||
| 90 | - | } | |||
| 91 | - | if name := folderNameForID(folders, favorite.FolderID); name != "" { | |||
| 92 | - | parts = append(parts, "Folder "+name) | |||
| 93 | - | } | |||
| 94 | - | return strings.Join(parts, " · ") | |||
| 95 | } | 83 | } | ||
| 96 | 84 | ||||
| 97 | func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string { | 85 | func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string { | ||
| @@ -136,38 +124,12 @@ func safeTagColor(raw string) string { | |||||
| 136 | return value | 124 | return value | ||
| 137 | } | 125 | } | ||
| 138 | 126 | ||||
| 139 | - | func folderDisplayName(folder *entity.Folder) string { | |||
| 140 | - | if folder == nil { | |||
| 141 | - | return "" | |||
| 142 | - | } | |||
| 143 | - | if strings.TrimSpace(folder.Icon) != "" { | |||
| 144 | - | return folder.Icon + " " + folder.Name | |||
| 145 | - | } | |||
| 146 | - | return folder.Name | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string { | |||
| 150 | - | if id == nil { | |||
| 151 | - | return "Unfiled" | |||
| 152 | - | } | |||
| 153 | - | for _, folder := range folders { | |||
| 154 | - | if folder != nil && folder.ID == *id { | |||
| 155 | - | return folderDisplayName(folder) | |||
| 156 | - | } | |||
| 157 | - | } | |||
| 158 | - | return "Unknown" | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool { | |||
| 162 | - | return selected != nil && *selected == folderID | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | func shortcutSelected(selected *int, value int) bool { | 127 | func shortcutSelected(selected *int, value int) bool { | ||
| 166 | return selected != nil && *selected == value | 128 | return selected != nil && *selected == value | ||
| 167 | } | 129 | } | ||
| 168 | 130 | ||||
| 169 | - | func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite { | 131 | + | func filterFavorites(favorites []*entity.Favorite, tagID *entity.TagID, untagged bool) []*entity.Favorite { |
| 170 | - | if folderID == nil && tagID == nil { | 132 | + | if tagID == nil && !untagged { |
| 171 | return favorites | 133 | return favorites | ||
| 172 | } | 134 | } | ||
| 173 | out := make([]*entity.Favorite, 0, len(favorites)) | 135 | out := make([]*entity.Favorite, 0, len(favorites)) | ||
| @@ -175,16 +137,11 @@ func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, ta | |||||
| 175 | if favorite == nil { | 137 | if favorite == nil { | ||
| 176 | continue | 138 | continue | ||
| 177 | } | 139 | } | ||
| 178 | - | if folderID != nil { | 140 | + | if untagged { |
| 179 | - | if *folderID == 0 { | 141 | + | if len(favorite.Tags) != 0 { |
| 180 | - | if favorite.FolderID != nil { | |||
| 181 | - | continue | |||
| 182 | - | } | |||
| 183 | - | } else if favorite.FolderID == nil || *favorite.FolderID != *folderID { | |||
| 184 | continue | 142 | continue | ||
| 185 | } | 143 | } | ||
| 186 | - | } | 144 | + | } else if tagID != nil && !favorite.HasTag(*tagID) { |
| 187 | - | if tagID != nil && !favorite.HasTag(*tagID) { | |||
| 188 | continue | 145 | continue | ||
| 189 | } | 146 | } | ||
| 190 | out = append(out, favorite) | 147 | out = append(out, favorite) | ||