detached d088d27

feat(infrastructure): add SQLite persistence layer with migrations and generated queries

commit
d088d278d9ba6a9033ae57f9c13fcbff640aee4a
author
bnema · · 2025-12-09 21:32 UTC
parent
8523928
signature
unsigned
25 files · +2155 -0
A internal/infrastructure/persistence/sqlite/connection.go +84 -0
@@ -0,0 +1,84 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver (pure Go)
12+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite WASM binary
13+)
14+
15+// NewConnection creates a new SQLite database connection with optimized settings.
16+// It creates the database directory if it doesn't exist and applies performance pragmas.
17+func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
18+ log := logging.FromContext(ctx)
19+
20+ if dbPath == "" {
21+ return nil, fmt.Errorf("database path cannot be empty")
22+ }
23+
24+ // Ensure database directory exists
25+ if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil {
26+ return nil, fmt.Errorf("failed to create database directory: %w", err)
27+ }
28+
29+ // Open database connection
30+ db, err := sql.Open("sqlite3", dbPath)
31+ if err != nil {
32+ return nil, fmt.Errorf("failed to open database: %w", err)
33+ }
34+
35+ // Test the connection
36+ if err := db.Ping(); err != nil {
37+ _ = db.Close()
38+ return nil, fmt.Errorf("failed to connect to database: %w", err)
39+ }
40+
41+ // Apply performance pragmas
42+ if err := applyPragmas(db); err != nil {
43+ _ = db.Close()
44+ return nil, err
45+ }
46+
47+ // Run migrations
48+ if err := RunMigrations(ctx, db); err != nil {
49+ _ = db.Close()
50+ return nil, fmt.Errorf("failed to run migrations: %w", err)
51+ }
52+
53+ log.Info().Str("path", dbPath).Msg("database connection established")
54+
55+ return db, nil
56+}
57+
58+// applyPragmas configures SQLite for optimal performance.
59+func applyPragmas(db *sql.DB) error {
60+ pragmas := []string{
61+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
62+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
63+ "PRAGMA cache_size = -64000", // 64MB cache
64+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
65+ "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O
66+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
67+ }
68+
69+ for _, pragma := range pragmas {
70+ if _, err := db.Exec(pragma); err != nil {
71+ return fmt.Errorf("failed to set pragma %q: %w", pragma, err)
72+ }
73+ }
74+
75+ return nil
76+}
77+
78+// Close closes the database connection gracefully.
79+func Close(db *sql.DB) error {
80+ if db == nil {
81+ return nil
82+ }
83+ return db.Close()
84+}
@@ -0,0 +1,84 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver (pure Go)
12+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite WASM binary
13+)
14+
15+// NewConnection creates a new SQLite database connection with optimized settings.
16+// It creates the database directory if it doesn't exist and applies performance pragmas.
17+func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
18+ log := logging.FromContext(ctx)
19+
20+ if dbPath == "" {
21+ return nil, fmt.Errorf("database path cannot be empty")
22+ }
23+
24+ // Ensure database directory exists
25+ if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil {
26+ return nil, fmt.Errorf("failed to create database directory: %w", err)
27+ }
28+
29+ // Open database connection
30+ db, err := sql.Open("sqlite3", dbPath)
31+ if err != nil {
32+ return nil, fmt.Errorf("failed to open database: %w", err)
33+ }
34+
35+ // Test the connection
36+ if err := db.Ping(); err != nil {
37+ _ = db.Close()
38+ return nil, fmt.Errorf("failed to connect to database: %w", err)
39+ }
40+
41+ // Apply performance pragmas
42+ if err := applyPragmas(db); err != nil {
43+ _ = db.Close()
44+ return nil, err
45+ }
46+
47+ // Run migrations
48+ if err := RunMigrations(ctx, db); err != nil {
49+ _ = db.Close()
50+ return nil, fmt.Errorf("failed to run migrations: %w", err)
51+ }
52+
53+ log.Info().Str("path", dbPath).Msg("database connection established")
54+
55+ return db, nil
56+}
57+
58+// applyPragmas configures SQLite for optimal performance.
59+func applyPragmas(db *sql.DB) error {
60+ pragmas := []string{
61+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
62+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
63+ "PRAGMA cache_size = -64000", // 64MB cache
64+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
65+ "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O
66+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
67+ }
68+
69+ for _, pragma := range pragmas {
70+ if _, err := db.Exec(pragma); err != nil {
71+ return fmt.Errorf("failed to set pragma %q: %w", pragma, err)
72+ }
73+ }
74+
75+ return nil
76+}
77+
78+// Close closes the database connection gracefully.
79+func Close(db *sql.DB) error {
80+ if db == nil {
81+ return nil
82+ }
83+ return db.Close()
84+}
A internal/infrastructure/persistence/sqlite/favorite_repo.go +361 -0
@@ -0,0 +1,361 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ==================== Favorite Repository ====================
14+
15+type favoriteRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+// NewFavoriteRepository creates a new SQLite-backed favorite repository.
20+func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
21+ return &favoriteRepo{queries: sqlc.New(db)}
22+}
23+
24+func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
25+ log := logging.FromContext(ctx)
26+ log.Debug().Str("url", fav.URL).Msg("saving favorite")
27+
28+ var folderID sql.NullInt64
29+ if fav.FolderID != nil {
30+ folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
31+ }
32+
33+ row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
34+ Url: fav.URL,
35+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
36+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
37+ FolderID: folderID,
38+ })
39+ if err != nil {
40+ return err
41+ }
42+ fav.ID = entity.FavoriteID(row.ID)
43+ return nil
44+}
45+
46+func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
47+ row, err := r.queries.GetFavoriteByID(ctx, int64(id))
48+ if err != nil {
49+ if err == sql.ErrNoRows {
50+ return nil, nil
51+ }
52+ return nil, err
53+ }
54+ return favoriteFromRow(row), nil
55+}
56+
57+func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
58+ row, err := r.queries.GetFavoriteByURL(ctx, url)
59+ if err != nil {
60+ if err == sql.ErrNoRows {
61+ return nil, nil
62+ }
63+ return nil, err
64+ }
65+ return favoriteFromRow(row), nil
66+}
67+
68+func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
69+ rows, err := r.queries.GetAllFavorites(ctx)
70+ if err != nil {
71+ return nil, err
72+ }
73+ return favoritesFromRows(rows), nil
74+}
75+
76+func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
77+ var rows []sqlc.Favorite
78+ var err error
79+
80+ if folderID == nil {
81+ rows, err = r.queries.GetFavoritesWithoutFolder(ctx)
82+ } else {
83+ rows, err = r.queries.GetFavoritesByFolder(ctx, sql.NullInt64{Int64: int64(*folderID), Valid: true})
84+ }
85+ if err != nil {
86+ return nil, err
87+ }
88+ return favoritesFromRows(rows), nil
89+}
90+
91+func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
92+ row, err := r.queries.GetFavoriteByShortcut(ctx, sql.NullInt64{Int64: int64(key), Valid: true})
93+ if err != nil {
94+ if err == sql.ErrNoRows {
95+ return nil, nil
96+ }
97+ return nil, err
98+ }
99+ return favoriteFromRow(row), nil
100+}
101+
102+func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
103+ return r.queries.UpdateFavoritePosition(ctx, sqlc.UpdateFavoritePositionParams{
104+ Position: int64(position),
105+ ID: int64(id),
106+ })
107+}
108+
109+func (r *favoriteRepo) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
110+ var fid sql.NullInt64
111+ if folderID != nil {
112+ fid = sql.NullInt64{Int64: int64(*folderID), Valid: true}
113+ }
114+ return r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{
115+ FolderID: fid,
116+ ID: int64(id),
117+ })
118+}
119+
120+func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
121+ var k sql.NullInt64
122+ if key != nil {
123+ k = sql.NullInt64{Int64: int64(*key), Valid: true}
124+ }
125+ return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
126+ ShortcutKey: k,
127+ ID: int64(id),
128+ })
129+}
130+
131+func (r *favoriteRepo) Delete(ctx context.Context, id entity.FavoriteID) error {
132+ return r.queries.DeleteFavorite(ctx, int64(id))
133+}
134+
135+func favoriteFromRow(row sqlc.Favorite) *entity.Favorite {
136+ fav := &entity.Favorite{
137+ ID: entity.FavoriteID(row.ID),
138+ URL: row.Url,
139+ Title: row.Title.String,
140+ FaviconURL: row.FaviconUrl.String,
141+ Position: int(row.Position),
142+ CreatedAt: row.CreatedAt.Time,
143+ UpdatedAt: row.UpdatedAt.Time,
144+ }
145+ if row.FolderID.Valid {
146+ fid := entity.FolderID(row.FolderID.Int64)
147+ fav.FolderID = &fid
148+ }
149+ if row.ShortcutKey.Valid {
150+ key := int(row.ShortcutKey.Int64)
151+ fav.ShortcutKey = &key
152+ }
153+ return fav
154+}
155+
156+func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite {
157+ favorites := make([]*entity.Favorite, len(rows))
158+ for i, row := range rows {
159+ favorites[i] = favoriteFromRow(row)
160+ }
161+ return favorites
162+}
163+
164+// ==================== Folder Repository ====================
165+
166+type folderRepo struct {
167+ queries *sqlc.Queries
168+}
169+
170+// NewFolderRepository creates a new SQLite-backed folder repository.
171+func NewFolderRepository(db *sql.DB) repository.FolderRepository {
172+ return &folderRepo{queries: sqlc.New(db)}
173+}
174+
175+func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
176+ log := logging.FromContext(ctx)
177+ log.Debug().Str("name", folder.Name).Msg("saving folder")
178+
179+ var parentID sql.NullInt64
180+ if folder.ParentID != nil {
181+ parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
182+ }
183+
184+ row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
185+ Name: folder.Name,
186+ Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
187+ ParentID: parentID,
188+ })
189+ if err != nil {
190+ return err
191+ }
192+ folder.ID = entity.FolderID(row.ID)
193+ return nil
194+}
195+
196+func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
197+ row, err := r.queries.GetFolderByID(ctx, int64(id))
198+ if err != nil {
199+ if err == sql.ErrNoRows {
200+ return nil, nil
201+ }
202+ return nil, err
203+ }
204+ return folderFromRow(row), nil
205+}
206+
207+func (r *folderRepo) GetAll(ctx context.Context) ([]*entity.Folder, error) {
208+ rows, err := r.queries.GetAllFolders(ctx)
209+ if err != nil {
210+ return nil, err
211+ }
212+ return foldersFromRows(rows), nil
213+}
214+
215+func (r *folderRepo) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
216+ var rows []sqlc.FavoriteFolder
217+ var err error
218+
219+ if parentID == nil {
220+ rows, err = r.queries.GetRootFolders(ctx)
221+ } else {
222+ rows, err = r.queries.GetChildFolders(ctx, sql.NullInt64{Int64: int64(*parentID), Valid: true})
223+ }
224+ if err != nil {
225+ return nil, err
226+ }
227+ return foldersFromRows(rows), nil
228+}
229+
230+func (r *folderRepo) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
231+ return r.queries.UpdateFolderPosition(ctx, sqlc.UpdateFolderPositionParams{
232+ Position: int64(position),
233+ ID: int64(id),
234+ })
235+}
236+
237+func (r *folderRepo) Delete(ctx context.Context, id entity.FolderID) error {
238+ return r.queries.DeleteFolder(ctx, int64(id))
239+}
240+
241+func folderFromRow(row sqlc.FavoriteFolder) *entity.Folder {
242+ folder := &entity.Folder{
243+ ID: entity.FolderID(row.ID),
244+ Name: row.Name,
245+ Icon: row.Icon.String,
246+ Position: int(row.Position),
247+ CreatedAt: row.CreatedAt.Time,
248+ }
249+ if row.ParentID.Valid {
250+ pid := entity.FolderID(row.ParentID.Int64)
251+ folder.ParentID = &pid
252+ }
253+ return folder
254+}
255+
256+func foldersFromRows(rows []sqlc.FavoriteFolder) []*entity.Folder {
257+ folders := make([]*entity.Folder, len(rows))
258+ for i, row := range rows {
259+ folders[i] = folderFromRow(row)
260+ }
261+ return folders
262+}
263+
264+// ==================== Tag Repository ====================
265+
266+type tagRepo struct {
267+ queries *sqlc.Queries
268+}
269+
270+// NewTagRepository creates a new SQLite-backed tag repository.
271+func NewTagRepository(db *sql.DB) repository.TagRepository {
272+ return &tagRepo{queries: sqlc.New(db)}
273+}
274+
275+func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
276+ log := logging.FromContext(ctx)
277+ log.Debug().Str("name", tag.Name).Msg("saving tag")
278+
279+ row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{
280+ Name: tag.Name,
281+ Color: tag.Color,
282+ })
283+ if err != nil {
284+ return err
285+ }
286+ tag.ID = entity.TagID(row.ID)
287+ return nil
288+}
289+
290+func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
291+ row, err := r.queries.GetTagByID(ctx, int64(id))
292+ if err != nil {
293+ if err == sql.ErrNoRows {
294+ return nil, nil
295+ }
296+ return nil, err
297+ }
298+ return tagFromRow(row), nil
299+}
300+
301+func (r *tagRepo) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
302+ row, err := r.queries.GetTagByName(ctx, name)
303+ if err != nil {
304+ if err == sql.ErrNoRows {
305+ return nil, nil
306+ }
307+ return nil, err
308+ }
309+ return tagFromRow(row), nil
310+}
311+
312+func (r *tagRepo) GetAll(ctx context.Context) ([]*entity.Tag, error) {
313+ rows, err := r.queries.GetAllTags(ctx)
314+ if err != nil {
315+ return nil, err
316+ }
317+ return tagsFromRows(rows), nil
318+}
319+
320+func (r *tagRepo) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
321+ return r.queries.AssignTagToFavorite(ctx, sqlc.AssignTagToFavoriteParams{
322+ FavoriteID: int64(favID),
323+ TagID: int64(tagID),
324+ })
325+}
326+
327+func (r *tagRepo) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
328+ return r.queries.RemoveTagFromFavorite(ctx, sqlc.RemoveTagFromFavoriteParams{
329+ FavoriteID: int64(favID),
330+ TagID: int64(tagID),
331+ })
332+}
333+
334+func (r *tagRepo) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
335+ rows, err := r.queries.GetTagsForFavorite(ctx, int64(favID))
336+ if err != nil {
337+ return nil, err
338+ }
339+ return tagsFromRows(rows), nil
340+}
341+
342+func (r *tagRepo) Delete(ctx context.Context, id entity.TagID) error {
343+ return r.queries.DeleteTag(ctx, int64(id))
344+}
345+
346+func tagFromRow(row sqlc.FavoriteTag) *entity.Tag {
347+ return &entity.Tag{
348+ ID: entity.TagID(row.ID),
349+ Name: row.Name,
350+ Color: row.Color,
351+ CreatedAt: row.CreatedAt.Time,
352+ }
353+}
354+
355+func tagsFromRows(rows []sqlc.FavoriteTag) []*entity.Tag {
356+ tags := make([]*entity.Tag, len(rows))
357+ for i, row := range rows {
358+ tags[i] = tagFromRow(row)
359+ }
360+ return tags
361+}
@@ -0,0 +1,361 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ==================== Favorite Repository ====================
14+
15+type favoriteRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+// NewFavoriteRepository creates a new SQLite-backed favorite repository.
20+func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
21+ return &favoriteRepo{queries: sqlc.New(db)}
22+}
23+
24+func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
25+ log := logging.FromContext(ctx)
26+ log.Debug().Str("url", fav.URL).Msg("saving favorite")
27+
28+ var folderID sql.NullInt64
29+ if fav.FolderID != nil {
30+ folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
31+ }
32+
33+ row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
34+ Url: fav.URL,
35+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
36+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
37+ FolderID: folderID,
38+ })
39+ if err != nil {
40+ return err
41+ }
42+ fav.ID = entity.FavoriteID(row.ID)
43+ return nil
44+}
45+
46+func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
47+ row, err := r.queries.GetFavoriteByID(ctx, int64(id))
48+ if err != nil {
49+ if err == sql.ErrNoRows {
50+ return nil, nil
51+ }
52+ return nil, err
53+ }
54+ return favoriteFromRow(row), nil
55+}
56+
57+func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
58+ row, err := r.queries.GetFavoriteByURL(ctx, url)
59+ if err != nil {
60+ if err == sql.ErrNoRows {
61+ return nil, nil
62+ }
63+ return nil, err
64+ }
65+ return favoriteFromRow(row), nil
66+}
67+
68+func (r *favoriteRepo) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
69+ rows, err := r.queries.GetAllFavorites(ctx)
70+ if err != nil {
71+ return nil, err
72+ }
73+ return favoritesFromRows(rows), nil
74+}
75+
76+func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
77+ var rows []sqlc.Favorite
78+ var err error
79+
80+ if folderID == nil {
81+ rows, err = r.queries.GetFavoritesWithoutFolder(ctx)
82+ } else {
83+ rows, err = r.queries.GetFavoritesByFolder(ctx, sql.NullInt64{Int64: int64(*folderID), Valid: true})
84+ }
85+ if err != nil {
86+ return nil, err
87+ }
88+ return favoritesFromRows(rows), nil
89+}
90+
91+func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
92+ row, err := r.queries.GetFavoriteByShortcut(ctx, sql.NullInt64{Int64: int64(key), Valid: true})
93+ if err != nil {
94+ if err == sql.ErrNoRows {
95+ return nil, nil
96+ }
97+ return nil, err
98+ }
99+ return favoriteFromRow(row), nil
100+}
101+
102+func (r *favoriteRepo) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
103+ return r.queries.UpdateFavoritePosition(ctx, sqlc.UpdateFavoritePositionParams{
104+ Position: int64(position),
105+ ID: int64(id),
106+ })
107+}
108+
109+func (r *favoriteRepo) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
110+ var fid sql.NullInt64
111+ if folderID != nil {
112+ fid = sql.NullInt64{Int64: int64(*folderID), Valid: true}
113+ }
114+ return r.queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{
115+ FolderID: fid,
116+ ID: int64(id),
117+ })
118+}
119+
120+func (r *favoriteRepo) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
121+ var k sql.NullInt64
122+ if key != nil {
123+ k = sql.NullInt64{Int64: int64(*key), Valid: true}
124+ }
125+ return r.queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
126+ ShortcutKey: k,
127+ ID: int64(id),
128+ })
129+}
130+
131+func (r *favoriteRepo) Delete(ctx context.Context, id entity.FavoriteID) error {
132+ return r.queries.DeleteFavorite(ctx, int64(id))
133+}
134+
135+func favoriteFromRow(row sqlc.Favorite) *entity.Favorite {
136+ fav := &entity.Favorite{
137+ ID: entity.FavoriteID(row.ID),
138+ URL: row.Url,
139+ Title: row.Title.String,
140+ FaviconURL: row.FaviconUrl.String,
141+ Position: int(row.Position),
142+ CreatedAt: row.CreatedAt.Time,
143+ UpdatedAt: row.UpdatedAt.Time,
144+ }
145+ if row.FolderID.Valid {
146+ fid := entity.FolderID(row.FolderID.Int64)
147+ fav.FolderID = &fid
148+ }
149+ if row.ShortcutKey.Valid {
150+ key := int(row.ShortcutKey.Int64)
151+ fav.ShortcutKey = &key
152+ }
153+ return fav
154+}
155+
156+func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite {
157+ favorites := make([]*entity.Favorite, len(rows))
158+ for i, row := range rows {
159+ favorites[i] = favoriteFromRow(row)
160+ }
161+ return favorites
162+}
163+
164+// ==================== Folder Repository ====================
165+
166+type folderRepo struct {
167+ queries *sqlc.Queries
168+}
169+
170+// NewFolderRepository creates a new SQLite-backed folder repository.
171+func NewFolderRepository(db *sql.DB) repository.FolderRepository {
172+ return &folderRepo{queries: sqlc.New(db)}
173+}
174+
175+func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
176+ log := logging.FromContext(ctx)
177+ log.Debug().Str("name", folder.Name).Msg("saving folder")
178+
179+ var parentID sql.NullInt64
180+ if folder.ParentID != nil {
181+ parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
182+ }
183+
184+ row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
185+ Name: folder.Name,
186+ Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
187+ ParentID: parentID,
188+ })
189+ if err != nil {
190+ return err
191+ }
192+ folder.ID = entity.FolderID(row.ID)
193+ return nil
194+}
195+
196+func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
197+ row, err := r.queries.GetFolderByID(ctx, int64(id))
198+ if err != nil {
199+ if err == sql.ErrNoRows {
200+ return nil, nil
201+ }
202+ return nil, err
203+ }
204+ return folderFromRow(row), nil
205+}
206+
207+func (r *folderRepo) GetAll(ctx context.Context) ([]*entity.Folder, error) {
208+ rows, err := r.queries.GetAllFolders(ctx)
209+ if err != nil {
210+ return nil, err
211+ }
212+ return foldersFromRows(rows), nil
213+}
214+
215+func (r *folderRepo) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
216+ var rows []sqlc.FavoriteFolder
217+ var err error
218+
219+ if parentID == nil {
220+ rows, err = r.queries.GetRootFolders(ctx)
221+ } else {
222+ rows, err = r.queries.GetChildFolders(ctx, sql.NullInt64{Int64: int64(*parentID), Valid: true})
223+ }
224+ if err != nil {
225+ return nil, err
226+ }
227+ return foldersFromRows(rows), nil
228+}
229+
230+func (r *folderRepo) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
231+ return r.queries.UpdateFolderPosition(ctx, sqlc.UpdateFolderPositionParams{
232+ Position: int64(position),
233+ ID: int64(id),
234+ })
235+}
236+
237+func (r *folderRepo) Delete(ctx context.Context, id entity.FolderID) error {
238+ return r.queries.DeleteFolder(ctx, int64(id))
239+}
240+
241+func folderFromRow(row sqlc.FavoriteFolder) *entity.Folder {
242+ folder := &entity.Folder{
243+ ID: entity.FolderID(row.ID),
244+ Name: row.Name,
245+ Icon: row.Icon.String,
246+ Position: int(row.Position),
247+ CreatedAt: row.CreatedAt.Time,
248+ }
249+ if row.ParentID.Valid {
250+ pid := entity.FolderID(row.ParentID.Int64)
251+ folder.ParentID = &pid
252+ }
253+ return folder
254+}
255+
256+func foldersFromRows(rows []sqlc.FavoriteFolder) []*entity.Folder {
257+ folders := make([]*entity.Folder, len(rows))
258+ for i, row := range rows {
259+ folders[i] = folderFromRow(row)
260+ }
261+ return folders
262+}
263+
264+// ==================== Tag Repository ====================
265+
266+type tagRepo struct {
267+ queries *sqlc.Queries
268+}
269+
270+// NewTagRepository creates a new SQLite-backed tag repository.
271+func NewTagRepository(db *sql.DB) repository.TagRepository {
272+ return &tagRepo{queries: sqlc.New(db)}
273+}
274+
275+func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
276+ log := logging.FromContext(ctx)
277+ log.Debug().Str("name", tag.Name).Msg("saving tag")
278+
279+ row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{
280+ Name: tag.Name,
281+ Color: tag.Color,
282+ })
283+ if err != nil {
284+ return err
285+ }
286+ tag.ID = entity.TagID(row.ID)
287+ return nil
288+}
289+
290+func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
291+ row, err := r.queries.GetTagByID(ctx, int64(id))
292+ if err != nil {
293+ if err == sql.ErrNoRows {
294+ return nil, nil
295+ }
296+ return nil, err
297+ }
298+ return tagFromRow(row), nil
299+}
300+
301+func (r *tagRepo) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
302+ row, err := r.queries.GetTagByName(ctx, name)
303+ if err != nil {
304+ if err == sql.ErrNoRows {
305+ return nil, nil
306+ }
307+ return nil, err
308+ }
309+ return tagFromRow(row), nil
310+}
311+
312+func (r *tagRepo) GetAll(ctx context.Context) ([]*entity.Tag, error) {
313+ rows, err := r.queries.GetAllTags(ctx)
314+ if err != nil {
315+ return nil, err
316+ }
317+ return tagsFromRows(rows), nil
318+}
319+
320+func (r *tagRepo) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
321+ return r.queries.AssignTagToFavorite(ctx, sqlc.AssignTagToFavoriteParams{
322+ FavoriteID: int64(favID),
323+ TagID: int64(tagID),
324+ })
325+}
326+
327+func (r *tagRepo) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
328+ return r.queries.RemoveTagFromFavorite(ctx, sqlc.RemoveTagFromFavoriteParams{
329+ FavoriteID: int64(favID),
330+ TagID: int64(tagID),
331+ })
332+}
333+
334+func (r *tagRepo) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
335+ rows, err := r.queries.GetTagsForFavorite(ctx, int64(favID))
336+ if err != nil {
337+ return nil, err
338+ }
339+ return tagsFromRows(rows), nil
340+}
341+
342+func (r *tagRepo) Delete(ctx context.Context, id entity.TagID) error {
343+ return r.queries.DeleteTag(ctx, int64(id))
344+}
345+
346+func tagFromRow(row sqlc.FavoriteTag) *entity.Tag {
347+ return &entity.Tag{
348+ ID: entity.TagID(row.ID),
349+ Name: row.Name,
350+ Color: row.Color,
351+ CreatedAt: row.CreatedAt.Time,
352+ }
353+}
354+
355+func tagsFromRows(rows []sqlc.FavoriteTag) []*entity.Tag {
356+ tags := make([]*entity.Tag, len(rows))
357+ for i, row := range rows {
358+ tags[i] = tagFromRow(row)
359+ }
360+ return tags
361+}
A internal/infrastructure/persistence/sqlite/filter_repo.go +42 -0
@@ -0,0 +1,42 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+type contentWhitelistRepo struct {
13+ queries *sqlc.Queries
14+}
15+
16+// NewContentWhitelistRepository creates a new SQLite-backed content whitelist repository.
17+func NewContentWhitelistRepository(db *sql.DB) repository.ContentWhitelistRepository {
18+ return &contentWhitelistRepo{queries: sqlc.New(db)}
19+}
20+
21+func (r *contentWhitelistRepo) Add(ctx context.Context, domain string) error {
22+ log := logging.FromContext(ctx)
23+ log.Debug().Str("domain", domain).Msg("adding domain to whitelist")
24+
25+ return r.queries.AddToWhitelist(ctx, domain)
26+}
27+
28+func (r *contentWhitelistRepo) Remove(ctx context.Context, domain string) error {
29+ return r.queries.RemoveFromWhitelist(ctx, domain)
30+}
31+
32+func (r *contentWhitelistRepo) Contains(ctx context.Context, domain string) (bool, error) {
33+ count, err := r.queries.IsWhitelisted(ctx, domain)
34+ if err != nil {
35+ return false, err
36+ }
37+ return count > 0, nil
38+}
39+
40+func (r *contentWhitelistRepo) GetAll(ctx context.Context) ([]string, error) {
41+ return r.queries.GetAllWhitelistedDomains(ctx)
42+}
@@ -0,0 +1,42 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+type contentWhitelistRepo struct {
13+ queries *sqlc.Queries
14+}
15+
16+// NewContentWhitelistRepository creates a new SQLite-backed content whitelist repository.
17+func NewContentWhitelistRepository(db *sql.DB) repository.ContentWhitelistRepository {
18+ return &contentWhitelistRepo{queries: sqlc.New(db)}
19+}
20+
21+func (r *contentWhitelistRepo) Add(ctx context.Context, domain string) error {
22+ log := logging.FromContext(ctx)
23+ log.Debug().Str("domain", domain).Msg("adding domain to whitelist")
24+
25+ return r.queries.AddToWhitelist(ctx, domain)
26+}
27+
28+func (r *contentWhitelistRepo) Remove(ctx context.Context, domain string) error {
29+ return r.queries.RemoveFromWhitelist(ctx, domain)
30+}
31+
32+func (r *contentWhitelistRepo) Contains(ctx context.Context, domain string) (bool, error) {
33+ count, err := r.queries.IsWhitelisted(ctx, domain)
34+ if err != nil {
35+ return false, err
36+ }
37+ return count > 0, nil
38+}
39+
40+func (r *contentWhitelistRepo) GetAll(ctx context.Context) ([]string, error) {
41+ return r.queries.GetAllWhitelistedDomains(ctx)
42+}
A internal/infrastructure/persistence/sqlite/history_repo.go +107 -0
@@ -0,0 +1,107 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+type historyRepo struct {
15+ queries *sqlc.Queries
16+}
17+
18+// NewHistoryRepository creates a new SQLite-backed history repository.
19+func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
20+ return &historyRepo{queries: sqlc.New(db)}
21+}
22+
23+func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {
24+ log := logging.FromContext(ctx)
25+ log.Debug().Str("url", entry.URL).Msg("saving history entry")
26+
27+ return r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
28+ Url: entry.URL,
29+ Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
30+ FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
31+ })
32+}
33+
34+func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
35+ row, err := r.queries.GetHistoryByURL(ctx, url)
36+ if err != nil {
37+ if err == sql.ErrNoRows {
38+ return nil, nil
39+ }
40+ return nil, err
41+ }
42+ return historyFromRow(row), nil
43+}
44+
45+func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
46+ rows, err := r.queries.SearchHistory(ctx, sqlc.SearchHistoryParams{
47+ Column1: sql.NullString{String: query, Valid: true},
48+ Column2: sql.NullString{String: query, Valid: true},
49+ Limit: int64(limit),
50+ })
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ matches := make([]entity.HistoryMatch, len(rows))
56+ for i, row := range rows {
57+ matches[i] = entity.HistoryMatch{
58+ Entry: historyFromRow(row),
59+ Score: 1.0, // Basic score, could be enhanced with FTS ranking
60+ }
61+ }
62+ return matches, nil
63+}
64+
65+func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
66+ rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
67+ Limit: int64(limit),
68+ Offset: int64(offset),
69+ })
70+ if err != nil {
71+ return nil, err
72+ }
73+
74+ entries := make([]*entity.HistoryEntry, len(rows))
75+ for i, row := range rows {
76+ entries[i] = historyFromRow(row)
77+ }
78+ return entries, nil
79+}
80+
81+func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
82+ return r.queries.IncrementVisitCount(ctx, url)
83+}
84+
85+func (r *historyRepo) Delete(ctx context.Context, id int64) error {
86+ return r.queries.DeleteHistoryByID(ctx, id)
87+}
88+
89+func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) error {
90+ return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
91+}
92+
93+func (r *historyRepo) DeleteAll(ctx context.Context) error {
94+ return r.queries.DeleteAllHistory(ctx)
95+}
96+
97+func historyFromRow(row sqlc.History) *entity.HistoryEntry {
98+ return &entity.HistoryEntry{
99+ ID: row.ID,
100+ URL: row.Url,
101+ Title: row.Title.String,
102+ FaviconURL: row.FaviconUrl.String,
103+ VisitCount: row.VisitCount.Int64,
104+ LastVisited: row.LastVisited.Time,
105+ CreatedAt: row.CreatedAt.Time,
106+ }
107+}
@@ -0,0 +1,107 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+type historyRepo struct {
15+ queries *sqlc.Queries
16+}
17+
18+// NewHistoryRepository creates a new SQLite-backed history repository.
19+func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
20+ return &historyRepo{queries: sqlc.New(db)}
21+}
22+
23+func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {
24+ log := logging.FromContext(ctx)
25+ log.Debug().Str("url", entry.URL).Msg("saving history entry")
26+
27+ return r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
28+ Url: entry.URL,
29+ Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
30+ FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
31+ })
32+}
33+
34+func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
35+ row, err := r.queries.GetHistoryByURL(ctx, url)
36+ if err != nil {
37+ if err == sql.ErrNoRows {
38+ return nil, nil
39+ }
40+ return nil, err
41+ }
42+ return historyFromRow(row), nil
43+}
44+
45+func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
46+ rows, err := r.queries.SearchHistory(ctx, sqlc.SearchHistoryParams{
47+ Column1: sql.NullString{String: query, Valid: true},
48+ Column2: sql.NullString{String: query, Valid: true},
49+ Limit: int64(limit),
50+ })
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ matches := make([]entity.HistoryMatch, len(rows))
56+ for i, row := range rows {
57+ matches[i] = entity.HistoryMatch{
58+ Entry: historyFromRow(row),
59+ Score: 1.0, // Basic score, could be enhanced with FTS ranking
60+ }
61+ }
62+ return matches, nil
63+}
64+
65+func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
66+ rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
67+ Limit: int64(limit),
68+ Offset: int64(offset),
69+ })
70+ if err != nil {
71+ return nil, err
72+ }
73+
74+ entries := make([]*entity.HistoryEntry, len(rows))
75+ for i, row := range rows {
76+ entries[i] = historyFromRow(row)
77+ }
78+ return entries, nil
79+}
80+
81+func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
82+ return r.queries.IncrementVisitCount(ctx, url)
83+}
84+
85+func (r *historyRepo) Delete(ctx context.Context, id int64) error {
86+ return r.queries.DeleteHistoryByID(ctx, id)
87+}
88+
89+func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) error {
90+ return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
91+}
92+
93+func (r *historyRepo) DeleteAll(ctx context.Context) error {
94+ return r.queries.DeleteAllHistory(ctx)
95+}
96+
97+func historyFromRow(row sqlc.History) *entity.HistoryEntry {
98+ return &entity.HistoryEntry{
99+ ID: row.ID,
100+ URL: row.Url,
101+ Title: row.Title.String,
102+ FaviconURL: row.FaviconUrl.String,
103+ VisitCount: row.VisitCount.Int64,
104+ LastVisited: row.LastVisited.Time,
105+ CreatedAt: row.CreatedAt.Time,
106+ }
107+}
A internal/infrastructure/persistence/sqlite/migrations.go +68 -0
@@ -0,0 +1,68 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "embed"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/pressly/goose/v3"
11+)
12+
13+//go:embed migrations/*.sql
14+var embedMigrations embed.FS
15+
16+// RunMigrations applies all pending migrations to the database.
17+// Uses goose with embedded SQL files for automatic schema management.
18+func RunMigrations(ctx context.Context, db *sql.DB) error {
19+ log := logging.FromContext(ctx)
20+
21+ // Configure goose
22+ goose.SetBaseFS(embedMigrations)
23+
24+ if err := goose.SetDialect("sqlite3"); err != nil {
25+ return fmt.Errorf("failed to set goose dialect: %w", err)
26+ }
27+
28+ // Disable verbose logging from goose
29+ goose.SetLogger(goose.NopLogger())
30+
31+ // Get current version before migration
32+ currentVersion, err := goose.GetDBVersion(db)
33+ if err != nil {
34+ log.Debug().Err(err).Msg("could not get current db version (may be new database)")
35+ currentVersion = 0
36+ }
37+
38+ // Run migrations
39+ if err := goose.Up(db, "migrations"); err != nil {
40+ return fmt.Errorf("failed to run migrations: %w", err)
41+ }
42+
43+ // Get new version after migration
44+ newVersion, err := goose.GetDBVersion(db)
45+ if err != nil {
46+ return fmt.Errorf("failed to get db version after migration: %w", err)
47+ }
48+
49+ if newVersion > currentVersion {
50+ log.Info().
51+ Int64("from_version", currentVersion).
52+ Int64("to_version", newVersion).
53+ Msg("database migrations applied")
54+ } else {
55+ log.Debug().Int64("version", newVersion).Msg("database schema up to date")
56+ }
57+
58+ return nil
59+}
60+
61+// GetMigrationStatus returns the current migration version.
62+func GetMigrationStatus(db *sql.DB) (int64, error) {
63+ goose.SetBaseFS(embedMigrations)
64+ if err := goose.SetDialect("sqlite3"); err != nil {
65+ return 0, err
66+ }
67+ return goose.GetDBVersion(db)
68+}
@@ -0,0 +1,68 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "embed"
7+ "fmt"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/pressly/goose/v3"
11+)
12+
13+//go:embed migrations/*.sql
14+var embedMigrations embed.FS
15+
16+// RunMigrations applies all pending migrations to the database.
17+// Uses goose with embedded SQL files for automatic schema management.
18+func RunMigrations(ctx context.Context, db *sql.DB) error {
19+ log := logging.FromContext(ctx)
20+
21+ // Configure goose
22+ goose.SetBaseFS(embedMigrations)
23+
24+ if err := goose.SetDialect("sqlite3"); err != nil {
25+ return fmt.Errorf("failed to set goose dialect: %w", err)
26+ }
27+
28+ // Disable verbose logging from goose
29+ goose.SetLogger(goose.NopLogger())
30+
31+ // Get current version before migration
32+ currentVersion, err := goose.GetDBVersion(db)
33+ if err != nil {
34+ log.Debug().Err(err).Msg("could not get current db version (may be new database)")
35+ currentVersion = 0
36+ }
37+
38+ // Run migrations
39+ if err := goose.Up(db, "migrations"); err != nil {
40+ return fmt.Errorf("failed to run migrations: %w", err)
41+ }
42+
43+ // Get new version after migration
44+ newVersion, err := goose.GetDBVersion(db)
45+ if err != nil {
46+ return fmt.Errorf("failed to get db version after migration: %w", err)
47+ }
48+
49+ if newVersion > currentVersion {
50+ log.Info().
51+ Int64("from_version", currentVersion).
52+ Int64("to_version", newVersion).
53+ Msg("database migrations applied")
54+ } else {
55+ log.Debug().Int64("version", newVersion).Msg("database schema up to date")
56+ }
57+
58+ return nil
59+}
60+
61+// GetMigrationStatus returns the current migration version.
62+func GetMigrationStatus(db *sql.DB) (int64, error) {
63+ goose.SetBaseFS(embedMigrations)
64+ if err := goose.SetDialect("sqlite3"); err != nil {
65+ return 0, err
66+ }
67+ return goose.GetDBVersion(db)
68+}
A internal/infrastructure/persistence/sqlite/migrations/001_history.sql +45 -0
@@ -0,0 +1,45 @@
1+-- +goose Up
2+-- History tracking for visited URLs
3+
4+CREATE TABLE IF NOT EXISTS history (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ favicon_url TEXT,
9+ visit_count INTEGER DEFAULT 1,
10+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
11+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
15+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
16+CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited ON history(visit_count DESC, last_visited DESC);
17+
18+-- FTS5 for history search
19+CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
20+ url,
21+ title,
22+ content='history',
23+ content_rowid='id'
24+);
25+
26+-- Triggers to keep FTS in sync
27+CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
28+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
29+END;
30+
31+CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
32+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
33+END;
34+
35+CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
36+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
37+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
38+END;
39+
40+-- +goose Down
41+DROP TRIGGER IF EXISTS history_fts_update;
42+DROP TRIGGER IF EXISTS history_fts_delete;
43+DROP TRIGGER IF EXISTS history_fts_insert;
44+DROP TABLE IF EXISTS history_fts;
45+DROP TABLE IF EXISTS history;
@@ -0,0 +1,45 @@
1+-- +goose Up
2+-- History tracking for visited URLs
3+
4+CREATE TABLE IF NOT EXISTS history (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ favicon_url TEXT,
9+ visit_count INTEGER DEFAULT 1,
10+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
11+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
15+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
16+CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited ON history(visit_count DESC, last_visited DESC);
17+
18+-- FTS5 for history search
19+CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
20+ url,
21+ title,
22+ content='history',
23+ content_rowid='id'
24+);
25+
26+-- Triggers to keep FTS in sync
27+CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
28+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
29+END;
30+
31+CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
32+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
33+END;
34+
35+CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
36+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
37+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
38+END;
39+
40+-- +goose Down
41+DROP TRIGGER IF EXISTS history_fts_update;
42+DROP TRIGGER IF EXISTS history_fts_delete;
43+DROP TRIGGER IF EXISTS history_fts_insert;
44+DROP TABLE IF EXISTS history_fts;
45+DROP TABLE IF EXISTS history;
A internal/infrastructure/persistence/sqlite/migrations/002_zoom_levels.sql +13 -0
@@ -0,0 +1,13 @@
1+-- +goose Up
2+-- Per-domain zoom levels with constraints
3+
4+CREATE TABLE IF NOT EXISTS zoom_levels (
5+ domain TEXT PRIMARY KEY,
6+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.25 AND zoom_factor <= 5.0),
7+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
8+);
9+
10+CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
11+
12+-- +goose Down
13+DROP TABLE IF EXISTS zoom_levels;
@@ -0,0 +1,13 @@
1+-- +goose Up
2+-- Per-domain zoom levels with constraints
3+
4+CREATE TABLE IF NOT EXISTS zoom_levels (
5+ domain TEXT PRIMARY KEY,
6+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.25 AND zoom_factor <= 5.0),
7+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
8+);
9+
10+CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
11+
12+-- +goose Down
13+DROP TABLE IF EXISTS zoom_levels;
A internal/infrastructure/persistence/sqlite/migrations/003_favorites.sql +59 -0
@@ -0,0 +1,59 @@
1+-- +goose Up
2+-- Favorites (bookmarks) with folders, tags, and shortcuts
3+
4+-- Folders for organizing favorites
5+CREATE TABLE IF NOT EXISTS favorite_folders (
6+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7+ name TEXT NOT NULL,
8+ icon TEXT,
9+ parent_id INTEGER REFERENCES favorite_folders(id) ON DELETE CASCADE,
10+ position INTEGER NOT NULL DEFAULT 0,
11+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+CREATE INDEX IF NOT EXISTS idx_favorite_folders_position ON favorite_folders(position);
15+CREATE INDEX IF NOT EXISTS idx_favorite_folders_parent ON favorite_folders(parent_id);
16+
17+-- Tags for labeling favorites
18+CREATE TABLE IF NOT EXISTS favorite_tags (
19+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20+ name TEXT NOT NULL UNIQUE,
21+ color TEXT NOT NULL DEFAULT '#808080',
22+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
23+);
24+
25+-- Favorites table
26+CREATE TABLE IF NOT EXISTS favorites (
27+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28+ url TEXT NOT NULL UNIQUE,
29+ title TEXT,
30+ favicon_url TEXT,
31+ folder_id INTEGER REFERENCES favorite_folders(id) ON DELETE SET NULL,
32+ shortcut_key INTEGER CHECK (shortcut_key BETWEEN 1 AND 9),
33+ position INTEGER NOT NULL DEFAULT 0,
34+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
35+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36+);
37+
38+CREATE INDEX IF NOT EXISTS idx_favorites_position ON favorites(position);
39+CREATE INDEX IF NOT EXISTS idx_favorites_url ON favorites(url);
40+CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);
41+CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_shortcut ON favorites(shortcut_key) WHERE shortcut_key IS NOT NULL;
42+
43+-- Tag assignments (junction table)
44+CREATE TABLE IF NOT EXISTS favorite_tag_assignments (
45+ favorite_id INTEGER NOT NULL,
46+ tag_id INTEGER NOT NULL,
47+ PRIMARY KEY (favorite_id, tag_id),
48+ FOREIGN KEY (favorite_id) REFERENCES favorites(id) ON DELETE CASCADE,
49+ FOREIGN KEY (tag_id) REFERENCES favorite_tags(id) ON DELETE CASCADE
50+);
51+
52+CREATE INDEX IF NOT EXISTS idx_fta_favorite ON favorite_tag_assignments(favorite_id);
53+CREATE INDEX IF NOT EXISTS idx_fta_tag ON favorite_tag_assignments(tag_id);
54+
55+-- +goose Down
56+DROP TABLE IF EXISTS favorite_tag_assignments;
57+DROP TABLE IF EXISTS favorites;
58+DROP TABLE IF EXISTS favorite_tags;
59+DROP TABLE IF EXISTS favorite_folders;
@@ -0,0 +1,59 @@
1+-- +goose Up
2+-- Favorites (bookmarks) with folders, tags, and shortcuts
3+
4+-- Folders for organizing favorites
5+CREATE TABLE IF NOT EXISTS favorite_folders (
6+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7+ name TEXT NOT NULL,
8+ icon TEXT,
9+ parent_id INTEGER REFERENCES favorite_folders(id) ON DELETE CASCADE,
10+ position INTEGER NOT NULL DEFAULT 0,
11+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+CREATE INDEX IF NOT EXISTS idx_favorite_folders_position ON favorite_folders(position);
15+CREATE INDEX IF NOT EXISTS idx_favorite_folders_parent ON favorite_folders(parent_id);
16+
17+-- Tags for labeling favorites
18+CREATE TABLE IF NOT EXISTS favorite_tags (
19+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20+ name TEXT NOT NULL UNIQUE,
21+ color TEXT NOT NULL DEFAULT '#808080',
22+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
23+);
24+
25+-- Favorites table
26+CREATE TABLE IF NOT EXISTS favorites (
27+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28+ url TEXT NOT NULL UNIQUE,
29+ title TEXT,
30+ favicon_url TEXT,
31+ folder_id INTEGER REFERENCES favorite_folders(id) ON DELETE SET NULL,
32+ shortcut_key INTEGER CHECK (shortcut_key BETWEEN 1 AND 9),
33+ position INTEGER NOT NULL DEFAULT 0,
34+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
35+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36+);
37+
38+CREATE INDEX IF NOT EXISTS idx_favorites_position ON favorites(position);
39+CREATE INDEX IF NOT EXISTS idx_favorites_url ON favorites(url);
40+CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder_id);
41+CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_shortcut ON favorites(shortcut_key) WHERE shortcut_key IS NOT NULL;
42+
43+-- Tag assignments (junction table)
44+CREATE TABLE IF NOT EXISTS favorite_tag_assignments (
45+ favorite_id INTEGER NOT NULL,
46+ tag_id INTEGER NOT NULL,
47+ PRIMARY KEY (favorite_id, tag_id),
48+ FOREIGN KEY (favorite_id) REFERENCES favorites(id) ON DELETE CASCADE,
49+ FOREIGN KEY (tag_id) REFERENCES favorite_tags(id) ON DELETE CASCADE
50+);
51+
52+CREATE INDEX IF NOT EXISTS idx_fta_favorite ON favorite_tag_assignments(favorite_id);
53+CREATE INDEX IF NOT EXISTS idx_fta_tag ON favorite_tag_assignments(tag_id);
54+
55+-- +goose Down
56+DROP TABLE IF EXISTS favorite_tag_assignments;
57+DROP TABLE IF EXISTS favorites;
58+DROP TABLE IF EXISTS favorite_tags;
59+DROP TABLE IF EXISTS favorite_folders;
A internal/infrastructure/persistence/sqlite/migrations/004_content_whitelist.sql +13 -0
@@ -0,0 +1,13 @@
1+-- +goose Up
2+-- Content filter whitelist - domains that bypass content filtering
3+
4+CREATE TABLE IF NOT EXISTS content_whitelist (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ domain TEXT NOT NULL UNIQUE,
7+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
8+);
9+
10+CREATE INDEX IF NOT EXISTS idx_content_whitelist_domain ON content_whitelist(domain);
11+
12+-- +goose Down
13+DROP TABLE IF EXISTS content_whitelist;
@@ -0,0 +1,13 @@
1+-- +goose Up
2+-- Content filter whitelist - domains that bypass content filtering
3+
4+CREATE TABLE IF NOT EXISTS content_whitelist (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ domain TEXT NOT NULL UNIQUE,
7+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
8+);
9+
10+CREATE INDEX IF NOT EXISTS idx_content_whitelist_domain ON content_whitelist(domain);
11+
12+-- +goose Down
13+DROP TABLE IF EXISTS content_whitelist;
A internal/infrastructure/persistence/sqlite/queries/favorites.sql +39 -0
@@ -0,0 +1,39 @@
1+-- name: GetAllFavorites :many
2+SELECT * FROM favorites ORDER BY position ASC;
3+
4+-- name: GetFavoriteByID :one
5+SELECT * FROM favorites WHERE id = ? LIMIT 1;
6+
7+-- name: GetFavoriteByURL :one
8+SELECT * FROM favorites WHERE url = ? LIMIT 1;
9+
10+-- name: GetFavoritesByFolder :many
11+SELECT * FROM favorites WHERE folder_id = ? ORDER BY position ASC;
12+
13+-- name: GetFavoritesWithoutFolder :many
14+SELECT * FROM favorites WHERE folder_id IS NULL ORDER BY position ASC;
15+
16+-- name: GetFavoriteByShortcut :one
17+SELECT * FROM favorites WHERE shortcut_key = ? LIMIT 1;
18+
19+-- name: CreateFavorite :one
20+INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
21+VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
22+RETURNING *;
23+
24+-- name: UpdateFavorite :exec
25+UPDATE favorites
26+SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
27+WHERE id = ?;
28+
29+-- name: UpdateFavoritePosition :exec
30+UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
31+
32+-- name: SetFavoriteFolder :exec
33+UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
34+
35+-- name: SetFavoriteShortcut :exec
36+UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
37+
38+-- name: DeleteFavorite :exec
39+DELETE FROM favorites WHERE id = ?;
@@ -0,0 +1,39 @@
1+-- name: GetAllFavorites :many
2+SELECT * FROM favorites ORDER BY position ASC;
3+
4+-- name: GetFavoriteByID :one
5+SELECT * FROM favorites WHERE id = ? LIMIT 1;
6+
7+-- name: GetFavoriteByURL :one
8+SELECT * FROM favorites WHERE url = ? LIMIT 1;
9+
10+-- name: GetFavoritesByFolder :many
11+SELECT * FROM favorites WHERE folder_id = ? ORDER BY position ASC;
12+
13+-- name: GetFavoritesWithoutFolder :many
14+SELECT * FROM favorites WHERE folder_id IS NULL ORDER BY position ASC;
15+
16+-- name: GetFavoriteByShortcut :one
17+SELECT * FROM favorites WHERE shortcut_key = ? LIMIT 1;
18+
19+-- name: CreateFavorite :one
20+INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
21+VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
22+RETURNING *;
23+
24+-- name: UpdateFavorite :exec
25+UPDATE favorites
26+SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
27+WHERE id = ?;
28+
29+-- name: UpdateFavoritePosition :exec
30+UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
31+
32+-- name: SetFavoriteFolder :exec
33+UPDATE favorites SET folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
34+
35+-- name: SetFavoriteShortcut :exec
36+UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;
37+
38+-- name: DeleteFavorite :exec
39+DELETE FROM favorites WHERE id = ?;
A internal/infrastructure/persistence/sqlite/queries/folders.sql +25 -0
@@ -0,0 +1,25 @@
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 = ?;
@@ -0,0 +1,25 @@
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 = ?;
A internal/infrastructure/persistence/sqlite/queries/history.sql +35 -0
@@ -0,0 +1,35 @@
1+-- name: GetRecentHistory :many
2+SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
3+
4+-- name: GetHistoryByURL :one
5+SELECT * FROM history WHERE url = ? LIMIT 1;
6+
7+-- name: UpsertHistory :exec
8+INSERT INTO history (url, title, favicon_url)
9+VALUES (?, ?, ?)
10+ON CONFLICT(url)
11+DO UPDATE SET
12+ visit_count = visit_count + 1,
13+ last_visited = CURRENT_TIMESTAMP,
14+ title = COALESCE(EXCLUDED.title, history.title),
15+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url);
16+
17+-- name: IncrementVisitCount :exec
18+UPDATE history
19+SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
20+WHERE url = ?;
21+
22+-- name: SearchHistory :many
23+SELECT * FROM history
24+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
25+ORDER BY visit_count DESC, last_visited DESC
26+LIMIT ?;
27+
28+-- name: DeleteHistoryByID :exec
29+DELETE FROM history WHERE id = ?;
30+
31+-- name: DeleteHistoryOlderThan :exec
32+DELETE FROM history WHERE last_visited < ?;
33+
34+-- name: DeleteAllHistory :exec
35+DELETE FROM history;
@@ -0,0 +1,35 @@
1+-- name: GetRecentHistory :many
2+SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
3+
4+-- name: GetHistoryByURL :one
5+SELECT * FROM history WHERE url = ? LIMIT 1;
6+
7+-- name: UpsertHistory :exec
8+INSERT INTO history (url, title, favicon_url)
9+VALUES (?, ?, ?)
10+ON CONFLICT(url)
11+DO UPDATE SET
12+ visit_count = visit_count + 1,
13+ last_visited = CURRENT_TIMESTAMP,
14+ title = COALESCE(EXCLUDED.title, history.title),
15+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url);
16+
17+-- name: IncrementVisitCount :exec
18+UPDATE history
19+SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
20+WHERE url = ?;
21+
22+-- name: SearchHistory :many
23+SELECT * FROM history
24+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
25+ORDER BY visit_count DESC, last_visited DESC
26+LIMIT ?;
27+
28+-- name: DeleteHistoryByID :exec
29+DELETE FROM history WHERE id = ?;
30+
31+-- name: DeleteHistoryOlderThan :exec
32+DELETE FROM history WHERE last_visited < ?;
33+
34+-- name: DeleteAllHistory :exec
35+DELETE FROM history;
A internal/infrastructure/persistence/sqlite/queries/tags.sql +29 -0
@@ -0,0 +1,29 @@
1+-- name: GetAllTags :many
2+SELECT * FROM favorite_tags ORDER BY name ASC;
3+
4+-- name: GetTagByID :one
5+SELECT * FROM favorite_tags WHERE id = ? LIMIT 1;
6+
7+-- name: GetTagByName :one
8+SELECT * FROM favorite_tags WHERE name = ? LIMIT 1;
9+
10+-- name: CreateTag :one
11+INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *;
12+
13+-- name: UpdateTag :exec
14+UPDATE favorite_tags SET name = ?, color = ? WHERE id = ?;
15+
16+-- name: DeleteTag :exec
17+DELETE FROM favorite_tags WHERE id = ?;
18+
19+-- name: AssignTagToFavorite :exec
20+INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id) VALUES (?, ?);
21+
22+-- name: RemoveTagFromFavorite :exec
23+DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?;
24+
25+-- name: GetTagsForFavorite :many
26+SELECT t.* FROM favorite_tags t
27+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
28+WHERE fta.favorite_id = ?
29+ORDER BY t.name ASC;
@@ -0,0 +1,29 @@
1+-- name: GetAllTags :many
2+SELECT * FROM favorite_tags ORDER BY name ASC;
3+
4+-- name: GetTagByID :one
5+SELECT * FROM favorite_tags WHERE id = ? LIMIT 1;
6+
7+-- name: GetTagByName :one
8+SELECT * FROM favorite_tags WHERE name = ? LIMIT 1;
9+
10+-- name: CreateTag :one
11+INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING *;
12+
13+-- name: UpdateTag :exec
14+UPDATE favorite_tags SET name = ?, color = ? WHERE id = ?;
15+
16+-- name: DeleteTag :exec
17+DELETE FROM favorite_tags WHERE id = ?;
18+
19+-- name: AssignTagToFavorite :exec
20+INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id) VALUES (?, ?);
21+
22+-- name: RemoveTagFromFavorite :exec
23+DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?;
24+
25+-- name: GetTagsForFavorite :many
26+SELECT t.* FROM favorite_tags t
27+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
28+WHERE fta.favorite_id = ?
29+ORDER BY t.name ASC;
A internal/infrastructure/persistence/sqlite/queries/whitelist.sql +11 -0
@@ -0,0 +1,11 @@
1+-- name: GetAllWhitelistedDomains :many
2+SELECT domain FROM content_whitelist ORDER BY domain;
3+
4+-- name: AddToWhitelist :exec
5+INSERT OR IGNORE INTO content_whitelist (domain, created_at) VALUES (?, CURRENT_TIMESTAMP);
6+
7+-- name: RemoveFromWhitelist :exec
8+DELETE FROM content_whitelist WHERE domain = ?;
9+
10+-- name: IsWhitelisted :one
11+SELECT COUNT(*) FROM content_whitelist WHERE domain = ? LIMIT 1;
@@ -0,0 +1,11 @@
1+-- name: GetAllWhitelistedDomains :many
2+SELECT domain FROM content_whitelist ORDER BY domain;
3+
4+-- name: AddToWhitelist :exec
5+INSERT OR IGNORE INTO content_whitelist (domain, created_at) VALUES (?, CURRENT_TIMESTAMP);
6+
7+-- name: RemoveFromWhitelist :exec
8+DELETE FROM content_whitelist WHERE domain = ?;
9+
10+-- name: IsWhitelisted :one
11+SELECT COUNT(*) FROM content_whitelist WHERE domain = ? LIMIT 1;
A internal/infrastructure/persistence/sqlite/queries/zoom.sql +15 -0
@@ -0,0 +1,15 @@
1+-- name: GetZoomLevel :one
2+SELECT * FROM zoom_levels WHERE domain = ? LIMIT 1;
3+
4+-- name: SetZoomLevel :exec
5+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
6+VALUES (?, ?, CURRENT_TIMESTAMP)
7+ON CONFLICT(domain) DO UPDATE SET
8+ zoom_factor = excluded.zoom_factor,
9+ updated_at = CURRENT_TIMESTAMP;
10+
11+-- name: DeleteZoomLevel :exec
12+DELETE FROM zoom_levels WHERE domain = ?;
13+
14+-- name: ListZoomLevels :many
15+SELECT * FROM zoom_levels ORDER BY updated_at DESC;
@@ -0,0 +1,15 @@
1+-- name: GetZoomLevel :one
2+SELECT * FROM zoom_levels WHERE domain = ? LIMIT 1;
3+
4+-- name: SetZoomLevel :exec
5+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
6+VALUES (?, ?, CURRENT_TIMESTAMP)
7+ON CONFLICT(domain) DO UPDATE SET
8+ zoom_factor = excluded.zoom_factor,
9+ updated_at = CURRENT_TIMESTAMP;
10+
11+-- name: DeleteZoomLevel :exec
12+DELETE FROM zoom_levels WHERE domain = ?;
13+
14+-- name: ListZoomLevels :many
15+SELECT * FROM zoom_levels ORDER BY updated_at DESC;
A internal/infrastructure/persistence/sqlite/sqlc/db.go +31 -0
@@ -0,0 +1,31 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type DBTX interface {
13+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14+ PrepareContext(context.Context, string) (*sql.Stmt, error)
15+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17+}
18+
19+func New(db DBTX) *Queries {
20+ return &Queries{db: db}
21+}
22+
23+type Queries struct {
24+ db DBTX
25+}
26+
27+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28+ return &Queries{
29+ db: tx,
30+ }
31+}
@@ -0,0 +1,31 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type DBTX interface {
13+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14+ PrepareContext(context.Context, string) (*sql.Stmt, error)
15+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17+}
18+
19+func New(db DBTX) *Queries {
20+ return &Queries{db: db}
21+}
22+
23+type Queries struct {
24+ db DBTX
25+}
26+
27+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28+ return &Queries{
29+ db: tx,
30+ }
31+}
A internal/infrastructure/persistence/sqlite/sqlc/favorites.sql.go +288 -0
@@ -0,0 +1,288 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: favorites.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const CreateFavorite = `-- name: CreateFavorite :one
14+INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
15+VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
16+RETURNING id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at
17+`
18+
19+type CreateFavoriteParams struct {
20+ Url string `json:"url"`
21+ Title sql.NullString `json:"title"`
22+ FaviconUrl sql.NullString `json:"favicon_url"`
23+ FolderID sql.NullInt64 `json:"folder_id"`
24+}
25+
26+func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) {
27+ row := q.db.QueryRowContext(ctx, CreateFavorite,
28+ arg.Url,
29+ arg.Title,
30+ arg.FaviconUrl,
31+ arg.FolderID,
32+ )
33+ var i Favorite
34+ err := row.Scan(
35+ &i.ID,
36+ &i.Url,
37+ &i.Title,
38+ &i.FaviconUrl,
39+ &i.FolderID,
40+ &i.ShortcutKey,
41+ &i.Position,
42+ &i.CreatedAt,
43+ &i.UpdatedAt,
44+ )
45+ return i, err
46+}
47+
48+const DeleteFavorite = `-- name: DeleteFavorite :exec
49+DELETE FROM favorites WHERE id = ?
50+`
51+
52+func (q *Queries) DeleteFavorite(ctx context.Context, id int64) error {
53+ _, err := q.db.ExecContext(ctx, DeleteFavorite, id)
54+ return err
55+}
56+
57+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
59+`
60+
61+func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
62+ rows, err := q.db.QueryContext(ctx, GetAllFavorites)
63+ if err != nil {
64+ return nil, err
65+ }
66+ defer rows.Close()
67+ items := []Favorite{}
68+ for rows.Next() {
69+ var i Favorite
70+ if err := rows.Scan(
71+ &i.ID,
72+ &i.Url,
73+ &i.Title,
74+ &i.FaviconUrl,
75+ &i.FolderID,
76+ &i.ShortcutKey,
77+ &i.Position,
78+ &i.CreatedAt,
79+ &i.UpdatedAt,
80+ ); err != nil {
81+ return nil, err
82+ }
83+ items = append(items, i)
84+ }
85+ if err := rows.Close(); err != nil {
86+ return nil, err
87+ }
88+ if err := rows.Err(); err != nil {
89+ return nil, err
90+ }
91+ return items, nil
92+}
93+
94+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
96+`
97+
98+func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) {
99+ row := q.db.QueryRowContext(ctx, GetFavoriteByID, id)
100+ var i Favorite
101+ err := row.Scan(
102+ &i.ID,
103+ &i.Url,
104+ &i.Title,
105+ &i.FaviconUrl,
106+ &i.FolderID,
107+ &i.ShortcutKey,
108+ &i.Position,
109+ &i.CreatedAt,
110+ &i.UpdatedAt,
111+ )
112+ return i, err
113+}
114+
115+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
117+`
118+
119+func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) {
120+ row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey)
121+ var i Favorite
122+ err := row.Scan(
123+ &i.ID,
124+ &i.Url,
125+ &i.Title,
126+ &i.FaviconUrl,
127+ &i.FolderID,
128+ &i.ShortcutKey,
129+ &i.Position,
130+ &i.CreatedAt,
131+ &i.UpdatedAt,
132+ )
133+ return i, err
134+}
135+
136+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
138+`
139+
140+func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) {
141+ row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
142+ var i Favorite
143+ err := row.Scan(
144+ &i.ID,
145+ &i.Url,
146+ &i.Title,
147+ &i.FaviconUrl,
148+ &i.FolderID,
149+ &i.ShortcutKey,
150+ &i.Position,
151+ &i.CreatedAt,
152+ &i.UpdatedAt,
153+ )
154+ return i, err
155+}
156+
157+const GetFavoritesByFolder = `-- name: GetFavoritesByFolder :many
158+SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id = ? ORDER BY position ASC
159+`
160+
161+func (q *Queries) GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) {
162+ rows, err := q.db.QueryContext(ctx, GetFavoritesByFolder, folderID)
163+ if err != nil {
164+ return nil, err
165+ }
166+ defer rows.Close()
167+ items := []Favorite{}
168+ for rows.Next() {
169+ var i Favorite
170+ if err := rows.Scan(
171+ &i.ID,
172+ &i.Url,
173+ &i.Title,
174+ &i.FaviconUrl,
175+ &i.FolderID,
176+ &i.ShortcutKey,
177+ &i.Position,
178+ &i.CreatedAt,
179+ &i.UpdatedAt,
180+ ); err != nil {
181+ return nil, err
182+ }
183+ items = append(items, i)
184+ }
185+ if err := rows.Close(); err != nil {
186+ return nil, err
187+ }
188+ if err := rows.Err(); err != nil {
189+ return nil, err
190+ }
191+ return items, nil
192+}
193+
194+const GetFavoritesWithoutFolder = `-- name: GetFavoritesWithoutFolder :many
195+SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id IS NULL ORDER BY position ASC
196+`
197+
198+func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) {
199+ rows, err := q.db.QueryContext(ctx, GetFavoritesWithoutFolder)
200+ if err != nil {
201+ return nil, err
202+ }
203+ defer rows.Close()
204+ items := []Favorite{}
205+ for rows.Next() {
206+ var i Favorite
207+ if err := rows.Scan(
208+ &i.ID,
209+ &i.Url,
210+ &i.Title,
211+ &i.FaviconUrl,
212+ &i.FolderID,
213+ &i.ShortcutKey,
214+ &i.Position,
215+ &i.CreatedAt,
216+ &i.UpdatedAt,
217+ ); err != nil {
218+ return nil, err
219+ }
220+ items = append(items, i)
221+ }
222+ if err := rows.Close(); err != nil {
223+ return nil, err
224+ }
225+ if err := rows.Err(); err != nil {
226+ return nil, err
227+ }
228+ return items, nil
229+}
230+
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
246+UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
247+`
248+
249+type SetFavoriteShortcutParams struct {
250+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
251+ ID int64 `json:"id"`
252+}
253+
254+func (q *Queries) SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error {
255+ _, err := q.db.ExecContext(ctx, SetFavoriteShortcut, arg.ShortcutKey, arg.ID)
256+ return err
257+}
258+
259+const UpdateFavorite = `-- name: UpdateFavorite :exec
260+UPDATE favorites
261+SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
262+WHERE id = ?
263+`
264+
265+type UpdateFavoriteParams struct {
266+ Title sql.NullString `json:"title"`
267+ FaviconUrl sql.NullString `json:"favicon_url"`
268+ ID int64 `json:"id"`
269+}
270+
271+func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error {
272+ _, err := q.db.ExecContext(ctx, UpdateFavorite, arg.Title, arg.FaviconUrl, arg.ID)
273+ return err
274+}
275+
276+const UpdateFavoritePosition = `-- name: UpdateFavoritePosition :exec
277+UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
278+`
279+
280+type UpdateFavoritePositionParams struct {
281+ Position int64 `json:"position"`
282+ ID int64 `json:"id"`
283+}
284+
285+func (q *Queries) UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error {
286+ _, err := q.db.ExecContext(ctx, UpdateFavoritePosition, arg.Position, arg.ID)
287+ return err
288+}
@@ -0,0 +1,288 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: favorites.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const CreateFavorite = `-- name: CreateFavorite :one
14+INSERT INTO favorites (url, title, favicon_url, folder_id, position, created_at, updated_at)
15+VALUES (?, ?, ?, ?, COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
16+RETURNING id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at
17+`
18+
19+type CreateFavoriteParams struct {
20+ Url string `json:"url"`
21+ Title sql.NullString `json:"title"`
22+ FaviconUrl sql.NullString `json:"favicon_url"`
23+ FolderID sql.NullInt64 `json:"folder_id"`
24+}
25+
26+func (q *Queries) CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error) {
27+ row := q.db.QueryRowContext(ctx, CreateFavorite,
28+ arg.Url,
29+ arg.Title,
30+ arg.FaviconUrl,
31+ arg.FolderID,
32+ )
33+ var i Favorite
34+ err := row.Scan(
35+ &i.ID,
36+ &i.Url,
37+ &i.Title,
38+ &i.FaviconUrl,
39+ &i.FolderID,
40+ &i.ShortcutKey,
41+ &i.Position,
42+ &i.CreatedAt,
43+ &i.UpdatedAt,
44+ )
45+ return i, err
46+}
47+
48+const DeleteFavorite = `-- name: DeleteFavorite :exec
49+DELETE FROM favorites WHERE id = ?
50+`
51+
52+func (q *Queries) DeleteFavorite(ctx context.Context, id int64) error {
53+ _, err := q.db.ExecContext(ctx, DeleteFavorite, id)
54+ return err
55+}
56+
57+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
59+`
60+
61+func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) {
62+ rows, err := q.db.QueryContext(ctx, GetAllFavorites)
63+ if err != nil {
64+ return nil, err
65+ }
66+ defer rows.Close()
67+ items := []Favorite{}
68+ for rows.Next() {
69+ var i Favorite
70+ if err := rows.Scan(
71+ &i.ID,
72+ &i.Url,
73+ &i.Title,
74+ &i.FaviconUrl,
75+ &i.FolderID,
76+ &i.ShortcutKey,
77+ &i.Position,
78+ &i.CreatedAt,
79+ &i.UpdatedAt,
80+ ); err != nil {
81+ return nil, err
82+ }
83+ items = append(items, i)
84+ }
85+ if err := rows.Close(); err != nil {
86+ return nil, err
87+ }
88+ if err := rows.Err(); err != nil {
89+ return nil, err
90+ }
91+ return items, nil
92+}
93+
94+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
96+`
97+
98+func (q *Queries) GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) {
99+ row := q.db.QueryRowContext(ctx, GetFavoriteByID, id)
100+ var i Favorite
101+ err := row.Scan(
102+ &i.ID,
103+ &i.Url,
104+ &i.Title,
105+ &i.FaviconUrl,
106+ &i.FolderID,
107+ &i.ShortcutKey,
108+ &i.Position,
109+ &i.CreatedAt,
110+ &i.UpdatedAt,
111+ )
112+ return i, err
113+}
114+
115+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
117+`
118+
119+func (q *Queries) GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) {
120+ row := q.db.QueryRowContext(ctx, GetFavoriteByShortcut, shortcutKey)
121+ var i Favorite
122+ err := row.Scan(
123+ &i.ID,
124+ &i.Url,
125+ &i.Title,
126+ &i.FaviconUrl,
127+ &i.FolderID,
128+ &i.ShortcutKey,
129+ &i.Position,
130+ &i.CreatedAt,
131+ &i.UpdatedAt,
132+ )
133+ return i, err
134+}
135+
136+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
138+`
139+
140+func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) {
141+ row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url)
142+ var i Favorite
143+ err := row.Scan(
144+ &i.ID,
145+ &i.Url,
146+ &i.Title,
147+ &i.FaviconUrl,
148+ &i.FolderID,
149+ &i.ShortcutKey,
150+ &i.Position,
151+ &i.CreatedAt,
152+ &i.UpdatedAt,
153+ )
154+ return i, err
155+}
156+
157+const GetFavoritesByFolder = `-- name: GetFavoritesByFolder :many
158+SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id = ? ORDER BY position ASC
159+`
160+
161+func (q *Queries) GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error) {
162+ rows, err := q.db.QueryContext(ctx, GetFavoritesByFolder, folderID)
163+ if err != nil {
164+ return nil, err
165+ }
166+ defer rows.Close()
167+ items := []Favorite{}
168+ for rows.Next() {
169+ var i Favorite
170+ if err := rows.Scan(
171+ &i.ID,
172+ &i.Url,
173+ &i.Title,
174+ &i.FaviconUrl,
175+ &i.FolderID,
176+ &i.ShortcutKey,
177+ &i.Position,
178+ &i.CreatedAt,
179+ &i.UpdatedAt,
180+ ); err != nil {
181+ return nil, err
182+ }
183+ items = append(items, i)
184+ }
185+ if err := rows.Close(); err != nil {
186+ return nil, err
187+ }
188+ if err := rows.Err(); err != nil {
189+ return nil, err
190+ }
191+ return items, nil
192+}
193+
194+const GetFavoritesWithoutFolder = `-- name: GetFavoritesWithoutFolder :many
195+SELECT id, url, title, favicon_url, folder_id, shortcut_key, position, created_at, updated_at FROM favorites WHERE folder_id IS NULL ORDER BY position ASC
196+`
197+
198+func (q *Queries) GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error) {
199+ rows, err := q.db.QueryContext(ctx, GetFavoritesWithoutFolder)
200+ if err != nil {
201+ return nil, err
202+ }
203+ defer rows.Close()
204+ items := []Favorite{}
205+ for rows.Next() {
206+ var i Favorite
207+ if err := rows.Scan(
208+ &i.ID,
209+ &i.Url,
210+ &i.Title,
211+ &i.FaviconUrl,
212+ &i.FolderID,
213+ &i.ShortcutKey,
214+ &i.Position,
215+ &i.CreatedAt,
216+ &i.UpdatedAt,
217+ ); err != nil {
218+ return nil, err
219+ }
220+ items = append(items, i)
221+ }
222+ if err := rows.Close(); err != nil {
223+ return nil, err
224+ }
225+ if err := rows.Err(); err != nil {
226+ return nil, err
227+ }
228+ return items, nil
229+}
230+
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
246+UPDATE favorites SET shortcut_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
247+`
248+
249+type SetFavoriteShortcutParams struct {
250+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
251+ ID int64 `json:"id"`
252+}
253+
254+func (q *Queries) SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error {
255+ _, err := q.db.ExecContext(ctx, SetFavoriteShortcut, arg.ShortcutKey, arg.ID)
256+ return err
257+}
258+
259+const UpdateFavorite = `-- name: UpdateFavorite :exec
260+UPDATE favorites
261+SET title = ?, favicon_url = ?, updated_at = CURRENT_TIMESTAMP
262+WHERE id = ?
263+`
264+
265+type UpdateFavoriteParams struct {
266+ Title sql.NullString `json:"title"`
267+ FaviconUrl sql.NullString `json:"favicon_url"`
268+ ID int64 `json:"id"`
269+}
270+
271+func (q *Queries) UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error {
272+ _, err := q.db.ExecContext(ctx, UpdateFavorite, arg.Title, arg.FaviconUrl, arg.ID)
273+ return err
274+}
275+
276+const UpdateFavoritePosition = `-- name: UpdateFavoritePosition :exec
277+UPDATE favorites SET position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
278+`
279+
280+type UpdateFavoritePositionParams struct {
281+ Position int64 `json:"position"`
282+ ID int64 `json:"id"`
283+}
284+
285+func (q *Queries) UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error {
286+ _, err := q.db.ExecContext(ctx, UpdateFavoritePosition, arg.Position, arg.ID)
287+ return err
288+}
A internal/infrastructure/persistence/sqlite/sqlc/folders.sql.go +195 -0
@@ -0,0 +1,195 @@
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+}
@@ -0,0 +1,195 @@
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+}
A internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +174 -0
@@ -0,0 +1,174 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: history.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const DeleteAllHistory = `-- name: DeleteAllHistory :exec
14+DELETE FROM history
15+`
16+
17+func (q *Queries) DeleteAllHistory(ctx context.Context) error {
18+ _, err := q.db.ExecContext(ctx, DeleteAllHistory)
19+ return err
20+}
21+
22+const DeleteHistoryByID = `-- name: DeleteHistoryByID :exec
23+DELETE FROM history WHERE id = ?
24+`
25+
26+func (q *Queries) DeleteHistoryByID(ctx context.Context, id int64) error {
27+ _, err := q.db.ExecContext(ctx, DeleteHistoryByID, id)
28+ return err
29+}
30+
31+const DeleteHistoryOlderThan = `-- name: DeleteHistoryOlderThan :exec
32+DELETE FROM history WHERE last_visited < ?
33+`
34+
35+func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error {
36+ _, err := q.db.ExecContext(ctx, DeleteHistoryOlderThan, lastVisited)
37+ return err
38+}
39+
40+const GetHistoryByURL = `-- name: GetHistoryByURL :one
41+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
42+`
43+
44+func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {
45+ row := q.db.QueryRowContext(ctx, GetHistoryByURL, url)
46+ var i History
47+ err := row.Scan(
48+ &i.ID,
49+ &i.Url,
50+ &i.Title,
51+ &i.FaviconUrl,
52+ &i.VisitCount,
53+ &i.LastVisited,
54+ &i.CreatedAt,
55+ )
56+ return i, err
57+}
58+
59+const GetRecentHistory = `-- name: GetRecentHistory :many
60+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
61+`
62+
63+type GetRecentHistoryParams struct {
64+ Limit int64 `json:"limit"`
65+ Offset int64 `json:"offset"`
66+}
67+
68+func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) {
69+ rows, err := q.db.QueryContext(ctx, GetRecentHistory, arg.Limit, arg.Offset)
70+ if err != nil {
71+ return nil, err
72+ }
73+ defer rows.Close()
74+ items := []History{}
75+ for rows.Next() {
76+ var i History
77+ if err := rows.Scan(
78+ &i.ID,
79+ &i.Url,
80+ &i.Title,
81+ &i.FaviconUrl,
82+ &i.VisitCount,
83+ &i.LastVisited,
84+ &i.CreatedAt,
85+ ); err != nil {
86+ return nil, err
87+ }
88+ items = append(items, i)
89+ }
90+ if err := rows.Close(); err != nil {
91+ return nil, err
92+ }
93+ if err := rows.Err(); err != nil {
94+ return nil, err
95+ }
96+ return items, nil
97+}
98+
99+const IncrementVisitCount = `-- name: IncrementVisitCount :exec
100+UPDATE history
101+SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
102+WHERE url = ?
103+`
104+
105+func (q *Queries) IncrementVisitCount(ctx context.Context, url string) error {
106+ _, err := q.db.ExecContext(ctx, IncrementVisitCount, url)
107+ return err
108+}
109+
110+const SearchHistory = `-- name: SearchHistory :many
111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
112+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
113+ORDER BY visit_count DESC, last_visited DESC
114+LIMIT ?
115+`
116+
117+type SearchHistoryParams struct {
118+ Column1 sql.NullString `json:"column_1"`
119+ Column2 sql.NullString `json:"column_2"`
120+ Limit int64 `json:"limit"`
121+}
122+
123+func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) {
124+ rows, err := q.db.QueryContext(ctx, SearchHistory, arg.Column1, arg.Column2, arg.Limit)
125+ if err != nil {
126+ return nil, err
127+ }
128+ defer rows.Close()
129+ items := []History{}
130+ for rows.Next() {
131+ var i History
132+ if err := rows.Scan(
133+ &i.ID,
134+ &i.Url,
135+ &i.Title,
136+ &i.FaviconUrl,
137+ &i.VisitCount,
138+ &i.LastVisited,
139+ &i.CreatedAt,
140+ ); err != nil {
141+ return nil, err
142+ }
143+ items = append(items, i)
144+ }
145+ if err := rows.Close(); err != nil {
146+ return nil, err
147+ }
148+ if err := rows.Err(); err != nil {
149+ return nil, err
150+ }
151+ return items, nil
152+}
153+
154+const UpsertHistory = `-- name: UpsertHistory :exec
155+INSERT INTO history (url, title, favicon_url)
156+VALUES (?, ?, ?)
157+ON CONFLICT(url)
158+DO UPDATE SET
159+ visit_count = visit_count + 1,
160+ last_visited = CURRENT_TIMESTAMP,
161+ title = COALESCE(EXCLUDED.title, history.title),
162+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url)
163+`
164+
165+type UpsertHistoryParams struct {
166+ Url string `json:"url"`
167+ Title sql.NullString `json:"title"`
168+ FaviconUrl sql.NullString `json:"favicon_url"`
169+}
170+
171+func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {
172+ _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl)
173+ return err
174+}
@@ -0,0 +1,174 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: history.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const DeleteAllHistory = `-- name: DeleteAllHistory :exec
14+DELETE FROM history
15+`
16+
17+func (q *Queries) DeleteAllHistory(ctx context.Context) error {
18+ _, err := q.db.ExecContext(ctx, DeleteAllHistory)
19+ return err
20+}
21+
22+const DeleteHistoryByID = `-- name: DeleteHistoryByID :exec
23+DELETE FROM history WHERE id = ?
24+`
25+
26+func (q *Queries) DeleteHistoryByID(ctx context.Context, id int64) error {
27+ _, err := q.db.ExecContext(ctx, DeleteHistoryByID, id)
28+ return err
29+}
30+
31+const DeleteHistoryOlderThan = `-- name: DeleteHistoryOlderThan :exec
32+DELETE FROM history WHERE last_visited < ?
33+`
34+
35+func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error {
36+ _, err := q.db.ExecContext(ctx, DeleteHistoryOlderThan, lastVisited)
37+ return err
38+}
39+
40+const GetHistoryByURL = `-- name: GetHistoryByURL :one
41+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
42+`
43+
44+func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {
45+ row := q.db.QueryRowContext(ctx, GetHistoryByURL, url)
46+ var i History
47+ err := row.Scan(
48+ &i.ID,
49+ &i.Url,
50+ &i.Title,
51+ &i.FaviconUrl,
52+ &i.VisitCount,
53+ &i.LastVisited,
54+ &i.CreatedAt,
55+ )
56+ return i, err
57+}
58+
59+const GetRecentHistory = `-- name: GetRecentHistory :many
60+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
61+`
62+
63+type GetRecentHistoryParams struct {
64+ Limit int64 `json:"limit"`
65+ Offset int64 `json:"offset"`
66+}
67+
68+func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) {
69+ rows, err := q.db.QueryContext(ctx, GetRecentHistory, arg.Limit, arg.Offset)
70+ if err != nil {
71+ return nil, err
72+ }
73+ defer rows.Close()
74+ items := []History{}
75+ for rows.Next() {
76+ var i History
77+ if err := rows.Scan(
78+ &i.ID,
79+ &i.Url,
80+ &i.Title,
81+ &i.FaviconUrl,
82+ &i.VisitCount,
83+ &i.LastVisited,
84+ &i.CreatedAt,
85+ ); err != nil {
86+ return nil, err
87+ }
88+ items = append(items, i)
89+ }
90+ if err := rows.Close(); err != nil {
91+ return nil, err
92+ }
93+ if err := rows.Err(); err != nil {
94+ return nil, err
95+ }
96+ return items, nil
97+}
98+
99+const IncrementVisitCount = `-- name: IncrementVisitCount :exec
100+UPDATE history
101+SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
102+WHERE url = ?
103+`
104+
105+func (q *Queries) IncrementVisitCount(ctx context.Context, url string) error {
106+ _, err := q.db.ExecContext(ctx, IncrementVisitCount, url)
107+ return err
108+}
109+
110+const SearchHistory = `-- name: SearchHistory :many
111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
112+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
113+ORDER BY visit_count DESC, last_visited DESC
114+LIMIT ?
115+`
116+
117+type SearchHistoryParams struct {
118+ Column1 sql.NullString `json:"column_1"`
119+ Column2 sql.NullString `json:"column_2"`
120+ Limit int64 `json:"limit"`
121+}
122+
123+func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) {
124+ rows, err := q.db.QueryContext(ctx, SearchHistory, arg.Column1, arg.Column2, arg.Limit)
125+ if err != nil {
126+ return nil, err
127+ }
128+ defer rows.Close()
129+ items := []History{}
130+ for rows.Next() {
131+ var i History
132+ if err := rows.Scan(
133+ &i.ID,
134+ &i.Url,
135+ &i.Title,
136+ &i.FaviconUrl,
137+ &i.VisitCount,
138+ &i.LastVisited,
139+ &i.CreatedAt,
140+ ); err != nil {
141+ return nil, err
142+ }
143+ items = append(items, i)
144+ }
145+ if err := rows.Close(); err != nil {
146+ return nil, err
147+ }
148+ if err := rows.Err(); err != nil {
149+ return nil, err
150+ }
151+ return items, nil
152+}
153+
154+const UpsertHistory = `-- name: UpsertHistory :exec
155+INSERT INTO history (url, title, favicon_url)
156+VALUES (?, ?, ?)
157+ON CONFLICT(url)
158+DO UPDATE SET
159+ visit_count = visit_count + 1,
160+ last_visited = CURRENT_TIMESTAMP,
161+ title = COALESCE(EXCLUDED.title, history.title),
162+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url)
163+`
164+
165+type UpsertHistoryParams struct {
166+ Url string `json:"url"`
167+ Title sql.NullString `json:"title"`
168+ FaviconUrl sql.NullString `json:"favicon_url"`
169+}
170+
171+func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {
172+ _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl)
173+ return err
174+}
A internal/infrastructure/persistence/sqlite/sqlc/models.go +69 -0
@@ -0,0 +1,69 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "database/sql"
9+)
10+
11+type ContentWhitelist struct {
12+ ID int64 `json:"id"`
13+ Domain string `json:"domain"`
14+ CreatedAt sql.NullTime `json:"created_at"`
15+}
16+
17+type Favorite struct {
18+ ID int64 `json:"id"`
19+ Url string `json:"url"`
20+ Title sql.NullString `json:"title"`
21+ FaviconUrl sql.NullString `json:"favicon_url"`
22+ FolderID sql.NullInt64 `json:"folder_id"`
23+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
24+ Position int64 `json:"position"`
25+ CreatedAt sql.NullTime `json:"created_at"`
26+ UpdatedAt sql.NullTime `json:"updated_at"`
27+}
28+
29+type FavoriteFolder struct {
30+ ID int64 `json:"id"`
31+ Name string `json:"name"`
32+ Icon sql.NullString `json:"icon"`
33+ ParentID sql.NullInt64 `json:"parent_id"`
34+ Position int64 `json:"position"`
35+ CreatedAt sql.NullTime `json:"created_at"`
36+}
37+
38+type FavoriteTag struct {
39+ ID int64 `json:"id"`
40+ Name string `json:"name"`
41+ Color string `json:"color"`
42+ CreatedAt sql.NullTime `json:"created_at"`
43+}
44+
45+type FavoriteTagAssignment struct {
46+ FavoriteID int64 `json:"favorite_id"`
47+ TagID int64 `json:"tag_id"`
48+}
49+
50+type History struct {
51+ ID int64 `json:"id"`
52+ Url string `json:"url"`
53+ Title sql.NullString `json:"title"`
54+ FaviconUrl sql.NullString `json:"favicon_url"`
55+ VisitCount sql.NullInt64 `json:"visit_count"`
56+ LastVisited sql.NullTime `json:"last_visited"`
57+ CreatedAt sql.NullTime `json:"created_at"`
58+}
59+
60+type HistoryFt struct {
61+ Url string `json:"url"`
62+ Title string `json:"title"`
63+}
64+
65+type ZoomLevel struct {
66+ Domain string `json:"domain"`
67+ ZoomFactor float64 `json:"zoom_factor"`
68+ UpdatedAt sql.NullTime `json:"updated_at"`
69+}
@@ -0,0 +1,69 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "database/sql"
9+)
10+
11+type ContentWhitelist struct {
12+ ID int64 `json:"id"`
13+ Domain string `json:"domain"`
14+ CreatedAt sql.NullTime `json:"created_at"`
15+}
16+
17+type Favorite struct {
18+ ID int64 `json:"id"`
19+ Url string `json:"url"`
20+ Title sql.NullString `json:"title"`
21+ FaviconUrl sql.NullString `json:"favicon_url"`
22+ FolderID sql.NullInt64 `json:"folder_id"`
23+ ShortcutKey sql.NullInt64 `json:"shortcut_key"`
24+ Position int64 `json:"position"`
25+ CreatedAt sql.NullTime `json:"created_at"`
26+ UpdatedAt sql.NullTime `json:"updated_at"`
27+}
28+
29+type FavoriteFolder struct {
30+ ID int64 `json:"id"`
31+ Name string `json:"name"`
32+ Icon sql.NullString `json:"icon"`
33+ ParentID sql.NullInt64 `json:"parent_id"`
34+ Position int64 `json:"position"`
35+ CreatedAt sql.NullTime `json:"created_at"`
36+}
37+
38+type FavoriteTag struct {
39+ ID int64 `json:"id"`
40+ Name string `json:"name"`
41+ Color string `json:"color"`
42+ CreatedAt sql.NullTime `json:"created_at"`
43+}
44+
45+type FavoriteTagAssignment struct {
46+ FavoriteID int64 `json:"favorite_id"`
47+ TagID int64 `json:"tag_id"`
48+}
49+
50+type History struct {
51+ ID int64 `json:"id"`
52+ Url string `json:"url"`
53+ Title sql.NullString `json:"title"`
54+ FaviconUrl sql.NullString `json:"favicon_url"`
55+ VisitCount sql.NullInt64 `json:"visit_count"`
56+ LastVisited sql.NullTime `json:"last_visited"`
57+ CreatedAt sql.NullTime `json:"created_at"`
58+}
59+
60+type HistoryFt struct {
61+ Url string `json:"url"`
62+ Title string `json:"title"`
63+}
64+
65+type ZoomLevel struct {
66+ Domain string `json:"domain"`
67+ ZoomFactor float64 `json:"zoom_factor"`
68+ UpdatedAt sql.NullTime `json:"updated_at"`
69+}
A internal/infrastructure/persistence/sqlite/sqlc/querier.go +60 -0
@@ -0,0 +1,60 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type Querier interface {
13+ AddToWhitelist(ctx context.Context, domain string) error
14+ AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
15+ CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)
16+ CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
17+ CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
18+ DeleteAllHistory(ctx context.Context) error
19+ DeleteFavorite(ctx context.Context, id int64) error
20+ DeleteFolder(ctx context.Context, id int64) error
21+ DeleteHistoryByID(ctx context.Context, id int64) error
22+ DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
23+ DeleteTag(ctx context.Context, id int64) error
24+ DeleteZoomLevel(ctx context.Context, domain string) error
25+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
26+ GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
27+ GetAllTags(ctx context.Context) ([]FavoriteTag, error)
28+ GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
29+ GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
30+ GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
31+ GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
32+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
33+ GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error)
34+ GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
35+ GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
36+ GetHistoryByURL(ctx context.Context, url string) (History, error)
37+ GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
38+ GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
39+ GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
40+ GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
41+ GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
42+ GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
43+ IncrementVisitCount(ctx context.Context, url string) error
44+ IsWhitelisted(ctx context.Context, domain string) (int64, error)
45+ ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
46+ RemoveFromWhitelist(ctx context.Context, domain string) error
47+ RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
48+ SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
49+ SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
50+ SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
51+ SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
52+ UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
53+ UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
54+ UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
55+ UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
56+ UpdateTag(ctx context.Context, arg UpdateTagParams) error
57+ UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
58+}
59+
60+var _ Querier = (*Queries)(nil)
@@ -0,0 +1,60 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package sqlc
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type Querier interface {
13+ AddToWhitelist(ctx context.Context, domain string) error
14+ AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error
15+ CreateFavorite(ctx context.Context, arg CreateFavoriteParams) (Favorite, error)
16+ CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
17+ CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
18+ DeleteAllHistory(ctx context.Context) error
19+ DeleteFavorite(ctx context.Context, id int64) error
20+ DeleteFolder(ctx context.Context, id int64) error
21+ DeleteHistoryByID(ctx context.Context, id int64) error
22+ DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
23+ DeleteTag(ctx context.Context, id int64) error
24+ DeleteZoomLevel(ctx context.Context, domain string) error
25+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
26+ GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
27+ GetAllTags(ctx context.Context) ([]FavoriteTag, error)
28+ GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
29+ GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
30+ GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
31+ GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
32+ GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
33+ GetFavoritesByFolder(ctx context.Context, folderID sql.NullInt64) ([]Favorite, error)
34+ GetFavoritesWithoutFolder(ctx context.Context) ([]Favorite, error)
35+ GetFolderByID(ctx context.Context, id int64) (FavoriteFolder, error)
36+ GetHistoryByURL(ctx context.Context, url string) (History, error)
37+ GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
38+ GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
39+ GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
40+ GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
41+ GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
42+ GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
43+ IncrementVisitCount(ctx context.Context, url string) error
44+ IsWhitelisted(ctx context.Context, domain string) (int64, error)
45+ ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
46+ RemoveFromWhitelist(ctx context.Context, domain string) error
47+ RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
48+ SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
49+ SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
50+ SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
51+ SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
52+ UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
53+ UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
54+ UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
55+ UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
56+ UpdateTag(ctx context.Context, arg UpdateTagParams) error
57+ UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
58+}
59+
60+var _ Querier = (*Queries)(nil)
A internal/infrastructure/persistence/sqlite/sqlc/tags.sql.go +182 -0
@@ -0,0 +1,182 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: tags.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec
13+INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id) VALUES (?, ?)
14+`
15+
16+type AssignTagToFavoriteParams struct {
17+ FavoriteID int64 `json:"favorite_id"`
18+ TagID int64 `json:"tag_id"`
19+}
20+
21+func (q *Queries) AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error {
22+ _, err := q.db.ExecContext(ctx, AssignTagToFavorite, arg.FavoriteID, arg.TagID)
23+ return err
24+}
25+
26+const CreateTag = `-- name: CreateTag :one
27+INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING id, name, color, created_at
28+`
29+
30+type CreateTagParams struct {
31+ Name string `json:"name"`
32+ Color string `json:"color"`
33+}
34+
35+func (q *Queries) CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) {
36+ row := q.db.QueryRowContext(ctx, CreateTag, arg.Name, arg.Color)
37+ var i FavoriteTag
38+ err := row.Scan(
39+ &i.ID,
40+ &i.Name,
41+ &i.Color,
42+ &i.CreatedAt,
43+ )
44+ return i, err
45+}
46+
47+const DeleteTag = `-- name: DeleteTag :exec
48+DELETE FROM favorite_tags WHERE id = ?
49+`
50+
51+func (q *Queries) DeleteTag(ctx context.Context, id int64) error {
52+ _, err := q.db.ExecContext(ctx, DeleteTag, id)
53+ return err
54+}
55+
56+const GetAllTags = `-- name: GetAllTags :many
57+SELECT id, name, color, created_at FROM favorite_tags ORDER BY name ASC
58+`
59+
60+func (q *Queries) GetAllTags(ctx context.Context) ([]FavoriteTag, error) {
61+ rows, err := q.db.QueryContext(ctx, GetAllTags)
62+ if err != nil {
63+ return nil, err
64+ }
65+ defer rows.Close()
66+ items := []FavoriteTag{}
67+ for rows.Next() {
68+ var i FavoriteTag
69+ if err := rows.Scan(
70+ &i.ID,
71+ &i.Name,
72+ &i.Color,
73+ &i.CreatedAt,
74+ ); err != nil {
75+ return nil, err
76+ }
77+ items = append(items, i)
78+ }
79+ if err := rows.Close(); err != nil {
80+ return nil, err
81+ }
82+ if err := rows.Err(); err != nil {
83+ return nil, err
84+ }
85+ return items, nil
86+}
87+
88+const GetTagByID = `-- name: GetTagByID :one
89+SELECT id, name, color, created_at FROM favorite_tags WHERE id = ? LIMIT 1
90+`
91+
92+func (q *Queries) GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) {
93+ row := q.db.QueryRowContext(ctx, GetTagByID, id)
94+ var i FavoriteTag
95+ err := row.Scan(
96+ &i.ID,
97+ &i.Name,
98+ &i.Color,
99+ &i.CreatedAt,
100+ )
101+ return i, err
102+}
103+
104+const GetTagByName = `-- name: GetTagByName :one
105+SELECT id, name, color, created_at FROM favorite_tags WHERE name = ? LIMIT 1
106+`
107+
108+func (q *Queries) GetTagByName(ctx context.Context, name string) (FavoriteTag, error) {
109+ row := q.db.QueryRowContext(ctx, GetTagByName, name)
110+ var i FavoriteTag
111+ err := row.Scan(
112+ &i.ID,
113+ &i.Name,
114+ &i.Color,
115+ &i.CreatedAt,
116+ )
117+ return i, err
118+}
119+
120+const GetTagsForFavorite = `-- name: GetTagsForFavorite :many
121+SELECT t.id, t.name, t.color, t.created_at FROM favorite_tags t
122+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
123+WHERE fta.favorite_id = ?
124+ORDER BY t.name ASC
125+`
126+
127+func (q *Queries) GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) {
128+ rows, err := q.db.QueryContext(ctx, GetTagsForFavorite, favoriteID)
129+ if err != nil {
130+ return nil, err
131+ }
132+ defer rows.Close()
133+ items := []FavoriteTag{}
134+ for rows.Next() {
135+ var i FavoriteTag
136+ if err := rows.Scan(
137+ &i.ID,
138+ &i.Name,
139+ &i.Color,
140+ &i.CreatedAt,
141+ ); err != nil {
142+ return nil, err
143+ }
144+ items = append(items, i)
145+ }
146+ if err := rows.Close(); err != nil {
147+ return nil, err
148+ }
149+ if err := rows.Err(); err != nil {
150+ return nil, err
151+ }
152+ return items, nil
153+}
154+
155+const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec
156+DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?
157+`
158+
159+type RemoveTagFromFavoriteParams struct {
160+ FavoriteID int64 `json:"favorite_id"`
161+ TagID int64 `json:"tag_id"`
162+}
163+
164+func (q *Queries) RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error {
165+ _, err := q.db.ExecContext(ctx, RemoveTagFromFavorite, arg.FavoriteID, arg.TagID)
166+ return err
167+}
168+
169+const UpdateTag = `-- name: UpdateTag :exec
170+UPDATE favorite_tags SET name = ?, color = ? WHERE id = ?
171+`
172+
173+type UpdateTagParams struct {
174+ Name string `json:"name"`
175+ Color string `json:"color"`
176+ ID int64 `json:"id"`
177+}
178+
179+func (q *Queries) UpdateTag(ctx context.Context, arg UpdateTagParams) error {
180+ _, err := q.db.ExecContext(ctx, UpdateTag, arg.Name, arg.Color, arg.ID)
181+ return err
182+}
@@ -0,0 +1,182 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: tags.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const AssignTagToFavorite = `-- name: AssignTagToFavorite :exec
13+INSERT OR IGNORE INTO favorite_tag_assignments (favorite_id, tag_id) VALUES (?, ?)
14+`
15+
16+type AssignTagToFavoriteParams struct {
17+ FavoriteID int64 `json:"favorite_id"`
18+ TagID int64 `json:"tag_id"`
19+}
20+
21+func (q *Queries) AssignTagToFavorite(ctx context.Context, arg AssignTagToFavoriteParams) error {
22+ _, err := q.db.ExecContext(ctx, AssignTagToFavorite, arg.FavoriteID, arg.TagID)
23+ return err
24+}
25+
26+const CreateTag = `-- name: CreateTag :one
27+INSERT INTO favorite_tags (name, color) VALUES (?, ?) RETURNING id, name, color, created_at
28+`
29+
30+type CreateTagParams struct {
31+ Name string `json:"name"`
32+ Color string `json:"color"`
33+}
34+
35+func (q *Queries) CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) {
36+ row := q.db.QueryRowContext(ctx, CreateTag, arg.Name, arg.Color)
37+ var i FavoriteTag
38+ err := row.Scan(
39+ &i.ID,
40+ &i.Name,
41+ &i.Color,
42+ &i.CreatedAt,
43+ )
44+ return i, err
45+}
46+
47+const DeleteTag = `-- name: DeleteTag :exec
48+DELETE FROM favorite_tags WHERE id = ?
49+`
50+
51+func (q *Queries) DeleteTag(ctx context.Context, id int64) error {
52+ _, err := q.db.ExecContext(ctx, DeleteTag, id)
53+ return err
54+}
55+
56+const GetAllTags = `-- name: GetAllTags :many
57+SELECT id, name, color, created_at FROM favorite_tags ORDER BY name ASC
58+`
59+
60+func (q *Queries) GetAllTags(ctx context.Context) ([]FavoriteTag, error) {
61+ rows, err := q.db.QueryContext(ctx, GetAllTags)
62+ if err != nil {
63+ return nil, err
64+ }
65+ defer rows.Close()
66+ items := []FavoriteTag{}
67+ for rows.Next() {
68+ var i FavoriteTag
69+ if err := rows.Scan(
70+ &i.ID,
71+ &i.Name,
72+ &i.Color,
73+ &i.CreatedAt,
74+ ); err != nil {
75+ return nil, err
76+ }
77+ items = append(items, i)
78+ }
79+ if err := rows.Close(); err != nil {
80+ return nil, err
81+ }
82+ if err := rows.Err(); err != nil {
83+ return nil, err
84+ }
85+ return items, nil
86+}
87+
88+const GetTagByID = `-- name: GetTagByID :one
89+SELECT id, name, color, created_at FROM favorite_tags WHERE id = ? LIMIT 1
90+`
91+
92+func (q *Queries) GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) {
93+ row := q.db.QueryRowContext(ctx, GetTagByID, id)
94+ var i FavoriteTag
95+ err := row.Scan(
96+ &i.ID,
97+ &i.Name,
98+ &i.Color,
99+ &i.CreatedAt,
100+ )
101+ return i, err
102+}
103+
104+const GetTagByName = `-- name: GetTagByName :one
105+SELECT id, name, color, created_at FROM favorite_tags WHERE name = ? LIMIT 1
106+`
107+
108+func (q *Queries) GetTagByName(ctx context.Context, name string) (FavoriteTag, error) {
109+ row := q.db.QueryRowContext(ctx, GetTagByName, name)
110+ var i FavoriteTag
111+ err := row.Scan(
112+ &i.ID,
113+ &i.Name,
114+ &i.Color,
115+ &i.CreatedAt,
116+ )
117+ return i, err
118+}
119+
120+const GetTagsForFavorite = `-- name: GetTagsForFavorite :many
121+SELECT t.id, t.name, t.color, t.created_at FROM favorite_tags t
122+INNER JOIN favorite_tag_assignments fta ON t.id = fta.tag_id
123+WHERE fta.favorite_id = ?
124+ORDER BY t.name ASC
125+`
126+
127+func (q *Queries) GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) {
128+ rows, err := q.db.QueryContext(ctx, GetTagsForFavorite, favoriteID)
129+ if err != nil {
130+ return nil, err
131+ }
132+ defer rows.Close()
133+ items := []FavoriteTag{}
134+ for rows.Next() {
135+ var i FavoriteTag
136+ if err := rows.Scan(
137+ &i.ID,
138+ &i.Name,
139+ &i.Color,
140+ &i.CreatedAt,
141+ ); err != nil {
142+ return nil, err
143+ }
144+ items = append(items, i)
145+ }
146+ if err := rows.Close(); err != nil {
147+ return nil, err
148+ }
149+ if err := rows.Err(); err != nil {
150+ return nil, err
151+ }
152+ return items, nil
153+}
154+
155+const RemoveTagFromFavorite = `-- name: RemoveTagFromFavorite :exec
156+DELETE FROM favorite_tag_assignments WHERE favorite_id = ? AND tag_id = ?
157+`
158+
159+type RemoveTagFromFavoriteParams struct {
160+ FavoriteID int64 `json:"favorite_id"`
161+ TagID int64 `json:"tag_id"`
162+}
163+
164+func (q *Queries) RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error {
165+ _, err := q.db.ExecContext(ctx, RemoveTagFromFavorite, arg.FavoriteID, arg.TagID)
166+ return err
167+}
168+
169+const UpdateTag = `-- name: UpdateTag :exec
170+UPDATE favorite_tags SET name = ?, color = ? WHERE id = ?
171+`
172+
173+type UpdateTagParams struct {
174+ Name string `json:"name"`
175+ Color string `json:"color"`
176+ ID int64 `json:"id"`
177+}
178+
179+func (q *Queries) UpdateTag(ctx context.Context, arg UpdateTagParams) error {
180+ _, err := q.db.ExecContext(ctx, UpdateTag, arg.Name, arg.Color, arg.ID)
181+ return err
182+}
A internal/infrastructure/persistence/sqlite/sqlc/whitelist.sql.go +66 -0
@@ -0,0 +1,66 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: whitelist.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const AddToWhitelist = `-- name: AddToWhitelist :exec
13+INSERT OR IGNORE INTO content_whitelist (domain, created_at) VALUES (?, CURRENT_TIMESTAMP)
14+`
15+
16+func (q *Queries) AddToWhitelist(ctx context.Context, domain string) error {
17+ _, err := q.db.ExecContext(ctx, AddToWhitelist, domain)
18+ return err
19+}
20+
21+const GetAllWhitelistedDomains = `-- name: GetAllWhitelistedDomains :many
22+SELECT domain FROM content_whitelist ORDER BY domain
23+`
24+
25+func (q *Queries) GetAllWhitelistedDomains(ctx context.Context) ([]string, error) {
26+ rows, err := q.db.QueryContext(ctx, GetAllWhitelistedDomains)
27+ if err != nil {
28+ return nil, err
29+ }
30+ defer rows.Close()
31+ items := []string{}
32+ for rows.Next() {
33+ var domain string
34+ if err := rows.Scan(&domain); err != nil {
35+ return nil, err
36+ }
37+ items = append(items, domain)
38+ }
39+ if err := rows.Close(); err != nil {
40+ return nil, err
41+ }
42+ if err := rows.Err(); err != nil {
43+ return nil, err
44+ }
45+ return items, nil
46+}
47+
48+const IsWhitelisted = `-- name: IsWhitelisted :one
49+SELECT COUNT(*) FROM content_whitelist WHERE domain = ? LIMIT 1
50+`
51+
52+func (q *Queries) IsWhitelisted(ctx context.Context, domain string) (int64, error) {
53+ row := q.db.QueryRowContext(ctx, IsWhitelisted, domain)
54+ var count int64
55+ err := row.Scan(&count)
56+ return count, err
57+}
58+
59+const RemoveFromWhitelist = `-- name: RemoveFromWhitelist :exec
60+DELETE FROM content_whitelist WHERE domain = ?
61+`
62+
63+func (q *Queries) RemoveFromWhitelist(ctx context.Context, domain string) error {
64+ _, err := q.db.ExecContext(ctx, RemoveFromWhitelist, domain)
65+ return err
66+}
@@ -0,0 +1,66 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: whitelist.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const AddToWhitelist = `-- name: AddToWhitelist :exec
13+INSERT OR IGNORE INTO content_whitelist (domain, created_at) VALUES (?, CURRENT_TIMESTAMP)
14+`
15+
16+func (q *Queries) AddToWhitelist(ctx context.Context, domain string) error {
17+ _, err := q.db.ExecContext(ctx, AddToWhitelist, domain)
18+ return err
19+}
20+
21+const GetAllWhitelistedDomains = `-- name: GetAllWhitelistedDomains :many
22+SELECT domain FROM content_whitelist ORDER BY domain
23+`
24+
25+func (q *Queries) GetAllWhitelistedDomains(ctx context.Context) ([]string, error) {
26+ rows, err := q.db.QueryContext(ctx, GetAllWhitelistedDomains)
27+ if err != nil {
28+ return nil, err
29+ }
30+ defer rows.Close()
31+ items := []string{}
32+ for rows.Next() {
33+ var domain string
34+ if err := rows.Scan(&domain); err != nil {
35+ return nil, err
36+ }
37+ items = append(items, domain)
38+ }
39+ if err := rows.Close(); err != nil {
40+ return nil, err
41+ }
42+ if err := rows.Err(); err != nil {
43+ return nil, err
44+ }
45+ return items, nil
46+}
47+
48+const IsWhitelisted = `-- name: IsWhitelisted :one
49+SELECT COUNT(*) FROM content_whitelist WHERE domain = ? LIMIT 1
50+`
51+
52+func (q *Queries) IsWhitelisted(ctx context.Context, domain string) (int64, error) {
53+ row := q.db.QueryRowContext(ctx, IsWhitelisted, domain)
54+ var count int64
55+ err := row.Scan(&count)
56+ return count, err
57+}
58+
59+const RemoveFromWhitelist = `-- name: RemoveFromWhitelist :exec
60+DELETE FROM content_whitelist WHERE domain = ?
61+`
62+
63+func (q *Queries) RemoveFromWhitelist(ctx context.Context, domain string) error {
64+ _, err := q.db.ExecContext(ctx, RemoveFromWhitelist, domain)
65+ return err
66+}
A internal/infrastructure/persistence/sqlite/sqlc/zoom.sql.go +75 -0
@@ -0,0 +1,75 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: zoom.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const DeleteZoomLevel = `-- name: DeleteZoomLevel :exec
13+DELETE FROM zoom_levels WHERE domain = ?
14+`
15+
16+func (q *Queries) DeleteZoomLevel(ctx context.Context, domain string) error {
17+ _, err := q.db.ExecContext(ctx, DeleteZoomLevel, domain)
18+ return err
19+}
20+
21+const GetZoomLevel = `-- name: GetZoomLevel :one
22+SELECT domain, zoom_factor, updated_at FROM zoom_levels WHERE domain = ? LIMIT 1
23+`
24+
25+func (q *Queries) GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) {
26+ row := q.db.QueryRowContext(ctx, GetZoomLevel, domain)
27+ var i ZoomLevel
28+ err := row.Scan(&i.Domain, &i.ZoomFactor, &i.UpdatedAt)
29+ return i, err
30+}
31+
32+const ListZoomLevels = `-- name: ListZoomLevels :many
33+SELECT domain, zoom_factor, updated_at FROM zoom_levels ORDER BY updated_at DESC
34+`
35+
36+func (q *Queries) ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) {
37+ rows, err := q.db.QueryContext(ctx, ListZoomLevels)
38+ if err != nil {
39+ return nil, err
40+ }
41+ defer rows.Close()
42+ items := []ZoomLevel{}
43+ for rows.Next() {
44+ var i ZoomLevel
45+ if err := rows.Scan(&i.Domain, &i.ZoomFactor, &i.UpdatedAt); err != nil {
46+ return nil, err
47+ }
48+ items = append(items, i)
49+ }
50+ if err := rows.Close(); err != nil {
51+ return nil, err
52+ }
53+ if err := rows.Err(); err != nil {
54+ return nil, err
55+ }
56+ return items, nil
57+}
58+
59+const SetZoomLevel = `-- name: SetZoomLevel :exec
60+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
61+VALUES (?, ?, CURRENT_TIMESTAMP)
62+ON CONFLICT(domain) DO UPDATE SET
63+ zoom_factor = excluded.zoom_factor,
64+ updated_at = CURRENT_TIMESTAMP
65+`
66+
67+type SetZoomLevelParams struct {
68+ Domain string `json:"domain"`
69+ ZoomFactor float64 `json:"zoom_factor"`
70+}
71+
72+func (q *Queries) SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error {
73+ _, err := q.db.ExecContext(ctx, SetZoomLevel, arg.Domain, arg.ZoomFactor)
74+ return err
75+}
@@ -0,0 +1,75 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: zoom.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+)
11+
12+const DeleteZoomLevel = `-- name: DeleteZoomLevel :exec
13+DELETE FROM zoom_levels WHERE domain = ?
14+`
15+
16+func (q *Queries) DeleteZoomLevel(ctx context.Context, domain string) error {
17+ _, err := q.db.ExecContext(ctx, DeleteZoomLevel, domain)
18+ return err
19+}
20+
21+const GetZoomLevel = `-- name: GetZoomLevel :one
22+SELECT domain, zoom_factor, updated_at FROM zoom_levels WHERE domain = ? LIMIT 1
23+`
24+
25+func (q *Queries) GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) {
26+ row := q.db.QueryRowContext(ctx, GetZoomLevel, domain)
27+ var i ZoomLevel
28+ err := row.Scan(&i.Domain, &i.ZoomFactor, &i.UpdatedAt)
29+ return i, err
30+}
31+
32+const ListZoomLevels = `-- name: ListZoomLevels :many
33+SELECT domain, zoom_factor, updated_at FROM zoom_levels ORDER BY updated_at DESC
34+`
35+
36+func (q *Queries) ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) {
37+ rows, err := q.db.QueryContext(ctx, ListZoomLevels)
38+ if err != nil {
39+ return nil, err
40+ }
41+ defer rows.Close()
42+ items := []ZoomLevel{}
43+ for rows.Next() {
44+ var i ZoomLevel
45+ if err := rows.Scan(&i.Domain, &i.ZoomFactor, &i.UpdatedAt); err != nil {
46+ return nil, err
47+ }
48+ items = append(items, i)
49+ }
50+ if err := rows.Close(); err != nil {
51+ return nil, err
52+ }
53+ if err := rows.Err(); err != nil {
54+ return nil, err
55+ }
56+ return items, nil
57+}
58+
59+const SetZoomLevel = `-- name: SetZoomLevel :exec
60+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
61+VALUES (?, ?, CURRENT_TIMESTAMP)
62+ON CONFLICT(domain) DO UPDATE SET
63+ zoom_factor = excluded.zoom_factor,
64+ updated_at = CURRENT_TIMESTAMP
65+`
66+
67+type SetZoomLevelParams struct {
68+ Domain string `json:"domain"`
69+ ZoomFactor float64 `json:"zoom_factor"`
70+}
71+
72+func (q *Queries) SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error {
73+ _, err := q.db.ExecContext(ctx, SetZoomLevel, arg.Domain, arg.ZoomFactor)
74+ return err
75+}
A internal/infrastructure/persistence/sqlite/zoom_repo.go +69 -0
@@ -0,0 +1,69 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+type zoomRepo struct {
14+ queries *sqlc.Queries
15+}
16+
17+// NewZoomRepository creates a new SQLite-backed zoom repository.
18+func NewZoomRepository(db *sql.DB) repository.ZoomRepository {
19+ return &zoomRepo{queries: sqlc.New(db)}
20+}
21+
22+func (r *zoomRepo) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
23+ log := logging.FromContext(ctx)
24+ log.Debug().Str("domain", domain).Msg("getting zoom level")
25+
26+ row, err := r.queries.GetZoomLevel(ctx, domain)
27+ if err != nil {
28+ if err == sql.ErrNoRows {
29+ return nil, nil
30+ }
31+ return nil, err
32+ }
33+ return zoomFromRow(row), nil
34+}
35+
36+func (r *zoomRepo) Set(ctx context.Context, level *entity.ZoomLevel) error {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("domain", level.Domain).Float64("factor", level.ZoomFactor).Msg("setting zoom level")
39+
40+ return r.queries.SetZoomLevel(ctx, sqlc.SetZoomLevelParams{
41+ Domain: level.Domain,
42+ ZoomFactor: level.ZoomFactor,
43+ })
44+}
45+
46+func (r *zoomRepo) Delete(ctx context.Context, domain string) error {
47+ return r.queries.DeleteZoomLevel(ctx, domain)
48+}
49+
50+func (r *zoomRepo) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
51+ rows, err := r.queries.ListZoomLevels(ctx)
52+ if err != nil {
53+ return nil, err
54+ }
55+
56+ levels := make([]*entity.ZoomLevel, len(rows))
57+ for i, row := range rows {
58+ levels[i] = zoomFromRow(row)
59+ }
60+ return levels, nil
61+}
62+
63+func zoomFromRow(row sqlc.ZoomLevel) *entity.ZoomLevel {
64+ return &entity.ZoomLevel{
65+ Domain: row.Domain,
66+ ZoomFactor: row.ZoomFactor,
67+ UpdatedAt: row.UpdatedAt.Time,
68+ }
69+}
@@ -0,0 +1,69 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+type zoomRepo struct {
14+ queries *sqlc.Queries
15+}
16+
17+// NewZoomRepository creates a new SQLite-backed zoom repository.
18+func NewZoomRepository(db *sql.DB) repository.ZoomRepository {
19+ return &zoomRepo{queries: sqlc.New(db)}
20+}
21+
22+func (r *zoomRepo) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
23+ log := logging.FromContext(ctx)
24+ log.Debug().Str("domain", domain).Msg("getting zoom level")
25+
26+ row, err := r.queries.GetZoomLevel(ctx, domain)
27+ if err != nil {
28+ if err == sql.ErrNoRows {
29+ return nil, nil
30+ }
31+ return nil, err
32+ }
33+ return zoomFromRow(row), nil
34+}
35+
36+func (r *zoomRepo) Set(ctx context.Context, level *entity.ZoomLevel) error {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("domain", level.Domain).Float64("factor", level.ZoomFactor).Msg("setting zoom level")
39+
40+ return r.queries.SetZoomLevel(ctx, sqlc.SetZoomLevelParams{
41+ Domain: level.Domain,
42+ ZoomFactor: level.ZoomFactor,
43+ })
44+}
45+
46+func (r *zoomRepo) Delete(ctx context.Context, domain string) error {
47+ return r.queries.DeleteZoomLevel(ctx, domain)
48+}
49+
50+func (r *zoomRepo) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
51+ rows, err := r.queries.ListZoomLevels(ctx)
52+ if err != nil {
53+ return nil, err
54+ }
55+
56+ levels := make([]*entity.ZoomLevel, len(rows))
57+ for i, row := range rows {
58+ levels[i] = zoomFromRow(row)
59+ }
60+ return levels, nil
61+}
62+
63+func zoomFromRow(row sqlc.ZoomLevel) *entity.ZoomLevel {
64+ return &entity.ZoomLevel{
65+ Domain: row.Domain,
66+ ZoomFactor: row.ZoomFactor,
67+ UpdatedAt: row.UpdatedAt.Time,
68+ }
69+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close