From 3f5cf0d673149ff69d3298952287eb959cd06dc0 Mon Sep 17 00:00:00 2001 From: adminoo Date: Tue, 3 Feb 2026 09:15:29 +0100 Subject: [PATCH] feat: sqlite storage draft --- cmd/main.go | 30 ++++- go.mod | 15 ++- go.sum | 55 +++++++- internal/scanner/handler.go | 5 +- internal/scanner/scanner.go | 41 +++--- internal/storage/sqlite.go | 217 ++++++++++++++++++++++++++++++++ internal/storage/sqlite_test.go | 159 +++++++++++++++++++++++ 7 files changed, 501 insertions(+), 21 deletions(-) create mode 100644 internal/storage/sqlite.go create mode 100644 internal/storage/sqlite_test.go diff --git a/cmd/main.go b/cmd/main.go index d88054f..5ef808b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,8 @@ import ( "flag" "log" "net/http" + "os" + "path/filepath" "donniemarko/internal/render" "donniemarko/internal/scanner" @@ -18,6 +20,7 @@ func main() { var help bool rootFolder := flag.String("root", ".", "Root folder to serve files from") listenAddr := flag.String("addr", "localhost:5555", "Address to listen on") + dbPath := flag.String("db", "", "SQLite database path (empty uses ~/.local/share/donniemarko/notes.db)") flag.BoolVar(&help, "help", false, "display this program usage") flag.Parse() @@ -27,7 +30,32 @@ func main() { } // Initialize storage - noteStorage := storage.NewNoteStorage() + var noteStorage storage.Storage + var sqliteStorage *storage.SQLiteStorage + resolvedDBPath := *dbPath + if resolvedDBPath == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("failed to resolve home directory: %v", err) + } + resolvedDBPath = filepath.Join(homeDir, ".local", "share", "donniemarko", "notes.db") + } + + if err := os.MkdirAll(filepath.Dir(resolvedDBPath), 0o700); err != nil { + log.Fatalf("failed to create database directory: %v", err) + } + + var err error + sqliteStorage, err = storage.NewSQLiteStorage(resolvedDBPath) + if err != nil { + log.Fatalf("failed to open sqlite db: %v", err) + } + defer func() { + if err := sqliteStorage.Close(); err != nil { + log.Printf("failed to close sqlite db: %v", err) + } + }() + noteStorage = sqliteStorage // Initialize scanner monitor := scanner.NewScanner(*rootFolder) diff --git a/go.mod b/go.mod index 879cf6d..95e2c69 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,18 @@ toolchain go1.24.12 require ( github.com/russross/blackfriday/v2 v2.1.0 - golang.org/x/net v0.49.0 + modernc.org/sqlite v1.44.3 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.40.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 188c718..2f1e22d 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/scanner/handler.go b/internal/scanner/handler.go index 7e1f145..545a230 100644 --- a/internal/scanner/handler.go +++ b/internal/scanner/handler.go @@ -5,8 +5,8 @@ import ( "donniemarko/internal/note" "donniemarko/internal/storage" - "os" "log" + "os" ) type NotesHandler struct { @@ -55,6 +55,7 @@ func ParseNoteFile(path string) (*note.Note, error) { id := note.GenerateNoteID(path) nn := note.NewNote() nn.ID = id + nn.Path = path nn.Content = string(content) nn.Title = note.ExtractTitle(nn.Content) // Use filename as title if no heading found @@ -62,6 +63,6 @@ func ParseNoteFile(path string) (*note.Note, error) { nn.Title = filepath.Base(path) } nn.UpdatedAt = fileInfo.ModTime() - nn.CreatedAt = fileInfo.ModTime() + nn.Size = fileInfo.Size() return nn, nil } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 35de5eb..f65903b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -98,6 +98,31 @@ func (s *ScannerService) Monitor(ctx context.Context) error { ticker := time.NewTicker(s.Interval) defer ticker.Stop() + applyChanges := func(changes []Change) { + for _, change := range changes { + var err error + switch change.Type { + case Created: + err = s.handler.HandleCreate(change.Path) + case Modified: + err = s.handler.HandleModify(change.Path) + case Deleted: + err = s.handler.HandleDelete(change.Path) + } + + if err != nil { + log.Printf("handler error for %s: %v", change.Path, err) + } + } + } + + changes, err := s.Scan() + if err != nil { + log.Printf("scan error: %v", err) + } else { + applyChanges(changes) + } + for { select { case <-ticker.C: @@ -107,21 +132,7 @@ func (s *ScannerService) Monitor(ctx context.Context) error { continue } - for _, change := range changes { - var err error - switch change.Type { - case Created: - err = s.handler.HandleCreate(change.Path) - case Modified: - err = s.handler.HandleModify(change.Path) - case Deleted: - err = s.handler.HandleDelete(change.Path) - } - - if err != nil { - log.Printf("handler error for %s: %v", change.Path, err) - } - } + applyChanges(changes) case <-ctx.Done(): return ctx.Err() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..a28c89c --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,217 @@ +package storage + +import ( + "database/sql" + "fmt" + "time" + + "donniemarko/internal/note" + + _ "modernc.org/sqlite" +) + +type SQLiteStorage struct { + db *sql.DB +} + +const sqliteSchema = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + size INTEGER NOT NULL, + published INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at); +CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path); +` + +func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + if _, err := db.Exec(sqliteSchema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("init schema: %w", err) + } + + return &SQLiteStorage{db: db}, nil +} + +func (s *SQLiteStorage) Close() error { + if s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *SQLiteStorage) GetAll() []*note.Note { + rows, err := s.db.Query(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + `) + if err != nil { + return []*note.Note{} + } + defer rows.Close() + + var notes []*note.Note + for rows.Next() { + n, err := scanNote(rows) + if err != nil { + continue + } + notes = append(notes, n) + } + + return notes +} + +func (s *SQLiteStorage) Get(id string) (*note.Note, error) { + row := s.db.QueryRow(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + WHERE id = ? + `, id) + + n, err := scanNote(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("No note with id '%s'", id) + } + return nil, err + } + + return n, nil +} + +func (s *SQLiteStorage) Create(n *note.Note) error { + _, err := s.db.Exec(` + INSERT INTO notes (id, path, title, content, updated_at, size, published) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + n.ID, + n.Path, + n.Title, + n.Content, + toUnix(n.UpdatedAt), + n.Size, + boolToInt(n.Published), + ) + if err != nil { + return fmt.Errorf("insert note: %w", err) + } + + return nil +} + +func (s *SQLiteStorage) Delete(id string) { + _, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id) +} + +func (s *SQLiteStorage) Update(id string, n *note.Note) { + _, _ = s.db.Exec(` + UPDATE notes + SET path = ?, title = ?, content = ?, updated_at = ?, size = ?, published = ? + WHERE id = ? + `, + n.Path, + n.Title, + n.Content, + toUnix(n.UpdatedAt), + n.Size, + boolToInt(n.Published), + id, + ) +} + +func (s *SQLiteStorage) Search(query string) []*note.Note { + pattern := "%" + query + "%" + rows, err := s.db.Query(` + SELECT id, path, title, content, updated_at, size, published + FROM notes + WHERE lower(content) LIKE lower(?) + `, pattern) + if err != nil { + return []*note.Note{} + } + defer rows.Close() + + var notes []*note.Note + for rows.Next() { + n, err := scanNote(rows) + if err != nil { + continue + } + notes = append(notes, n) + } + + return notes +} + +func (s *SQLiteStorage) Count() int { + row := s.db.QueryRow(`SELECT COUNT(*) FROM notes`) + var count int + if err := row.Scan(&count); err != nil { + return 0 + } + return count +} + +type scanner interface { + Scan(dest ...interface{}) error +} + +func scanNote(row scanner) (*note.Note, error) { + var ( + id string + path string + title string + content string + updated int64 + size int64 + published int + ) + + if err := row.Scan(&id, &path, &title, &content, &updated, &size, &published); err != nil { + return nil, err + } + + return ¬e.Note{ + ID: id, + Path: path, + Title: title, + Content: content, + UpdatedAt: fromUnix(updated), + Size: size, + Published: published != 0, + }, nil +} + +func toUnix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UTC().Unix() +} + +func fromUnix(v int64) time.Time { + if v == 0 { + return time.Time{} + } + return time.Unix(v, 0).UTC() +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go new file mode 100644 index 0000000..bfee33c --- /dev/null +++ b/internal/storage/sqlite_test.go @@ -0,0 +1,159 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" + + "donniemarko/internal/note" +) + +func newSQLiteStorage(t *testing.T) *SQLiteStorage { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "notes.db") + st, err := NewSQLiteStorage(dbPath) + if err != nil { + t.Fatalf("new sqlite storage: %v", err) + } + t.Cleanup(func() { + _ = st.Close() + }) + + return st +} + +func sampleNote(id, path, title, content string, tstamp time.Time) *note.Note { + return ¬e.Note{ + ID: id, + Path: path, + Title: title, + Content: content, + UpdatedAt: tstamp.Add(2 * time.Hour), + Size: int64(len(content)), + Published: true, + } +} + +func TestSQLiteStorage_CreateGet(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + n := sampleNote("n1", "notes/alpha.md", "Alpha", "# Alpha", ts) + + if err := st.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + + got, err := st.Get("n1") + if err != nil { + t.Fatalf("get note: %v", err) + } + + if got.Title != n.Title || got.Path != n.Path || got.Content != n.Content { + t.Fatalf("unexpected note fields: %+v", got) + } + if !got.UpdatedAt.Equal(n.UpdatedAt) || got.Size != n.Size { + t.Fatalf("unexpected time fields: %+v", got) + } + if !got.Published { + t.Fatalf("expected published to be true") + } +} + +func TestSQLiteStorage_GetAll(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + all := st.GetAll() + if len(all) != 2 { + t.Fatalf("expected 2 notes, got %d", len(all)) + } +} + +func TestSQLiteStorage_Update(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts) + if err := st.Create(n); err != nil { + t.Fatalf("create note: %v", err) + } + + updated := sampleNote("n1", "notes/alpha.md", "Alpha Updated", "two", ts.Add(24*time.Hour)) + st.Update("n1", updated) + + got, err := st.Get("n1") + if err != nil { + t.Fatalf("get note: %v", err) + } + if got.Title != "Alpha Updated" || got.Content != "two" { + t.Fatalf("update did not persist: %+v", got) + } +} + +func TestSQLiteStorage_Delete(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + st.Delete("n1") + + if st.Count() != 0 { + t.Fatalf("expected count 0 after delete") + } + + if _, err := st.Get("n1"); err == nil { + t.Fatalf("expected error for missing note") + } +} + +func TestSQLiteStorage_Search(t *testing.T) { + st := newSQLiteStorage(t) + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "Rust tips", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "Golang tutorial", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + results := st.Search("rust") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].ID != "n1" { + t.Fatalf("expected rust match to be n1") + } +} + +func TestSQLiteStorage_Count(t *testing.T) { + st := newSQLiteStorage(t) + + if st.Count() != 0 { + t.Fatalf("expected empty count to be 0") + } + + ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC) + if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + if st.Count() != 2 { + t.Fatalf("expected count 2") + } +}