diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..3bccfb9 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Vet + run: go vet ./... + + - name: Format check + run: | + diff=$(gofmt -l .) + if [ -n "$diff" ]; then + echo "Files need formatting:" + echo "$diff" + exit 1 + fi + + - name: Test + run: go test -race -count=1 ./... + + - name: Build + run: go build -trimpath -o nib . diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..99e1df0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,27 @@ +# Code Hardening — Senior Dev Audit Fixes + +## Phase 1: Quick Wins (safety + correctness) +- [x] Cap API list limit at 200 +- [x] Fix markdown XSS — add DOMPurify to sanitize marked output +- [x] Add missing DB indexes (deleted_at, modified_at) via v4 migration +- [x] Fix v2 migration error handling (swallowed ALTER TABLE errors) +- [x] Fix ~/.nib directory permissions (0o755 → 0o700) + +## Phase 2: CI Pipeline +- [x] Gitea Actions workflow: test + lint on PR + +## Phase 3: context.Context in Store +- [x] Thread context.Context through all Store methods +- [x] Use context in API handlers (from r.Context()) +- [x] Use context in CLI commands (cobra context) + +## Phase 4: cmd/ Tests +- [x] Test add command +- [x] Test ls command +- [x] Test promote/demote commands +- [x] Test delete command +- [x] Test absorb command + +## Phase 5: Backup/Export +- [x] nib export — dump entities to JSON +- [x] nib backup — safe SQLite backup (handles WAL) diff --git a/cmd/absorb.go b/cmd/absorb.go index acba7f7..5446d64 100644 --- a/cmd/absorb.go +++ b/cmd/absorb.go @@ -19,19 +19,19 @@ func init() { rootCmd.AddCommand(absorbCmd) } -func runAbsorb(_ *cobra.Command, args []string) error { +func runAbsorb(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - targetID, err := store.Resolve(args[0]) + targetID, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } - sourceID, err := store.Resolve(args[1]) + sourceID, err := store.Resolve(cmd.Context(), args[1]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[1]) } @@ -40,7 +40,7 @@ func runAbsorb(_ *cobra.Command, args []string) error { return fmt.Errorf("target and source must be different entities") } - if err := store.Absorb(targetID, sourceID); err != nil { + if err := store.Absorb(cmd.Context(), targetID, sourceID); err != nil { if err == db.ErrTargetCrystallized { return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first", display.FormatID(targetID)) diff --git a/cmd/add.go b/cmd/add.go index f95bc51..b7fe321 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -17,7 +17,7 @@ var addCmd = &cobra.Command{ RunE: runAdd, } -func runAdd(_ *cobra.Command, args []string) error { +func runAdd(cmd *cobra.Command, args []string) error { input := strings.Join(args, " ") parsed, err := parse.Parse(input) @@ -47,7 +47,7 @@ func runAdd(_ *cobra.Command, args []string) error { e.CardType = &ct } - if err := store.Create(e); err != nil { + if err := store.Create(cmd.Context(), e); err != nil { return err } diff --git a/cmd/backup.go b/cmd/backup.go new file mode 100644 index 0000000..a39bd02 --- /dev/null +++ b/cmd/backup.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +var backupCmd = &cobra.Command{ + Use: "backup [path]", + Short: "create a safe backup of the database", + Long: "Creates an atomic backup using VACUUM INTO. Safe with WAL mode — no need to stop the server.", + Args: cobra.MaximumNArgs(1), + RunE: runBackup, +} + +func init() { + rootCmd.AddCommand(backupCmd) +} + +func runBackup(cmd *cobra.Command, args []string) error { + srcPath, err := db.DefaultPath() + if err != nil { + return err + } + + dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405")) + if len(args) > 0 { + dst = args[0] + } + + store, err := db.Open(srcPath) + if err != nil { + return err + } + defer store.Close() + + if err := store.Backup(dst); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + fmt.Printf("backed up to %s\n", dst) + return nil +} diff --git a/cmd/cards.go b/cmd/cards.go index c612159..03e3a2f 100644 --- a/cmd/cards.go +++ b/cmd/cards.go @@ -26,7 +26,7 @@ func init() { rootCmd.AddCommand(cardsCmd) } -func runCards(_ *cobra.Command, _ []string) error { +func runCards(cmd *cobra.Command, _ []string) error { store, err := openStore() if err != nil { return err @@ -49,7 +49,7 @@ func runCards(_ *cobra.Command, _ []string) error { p.CardTypeFilter = &ct } - entities, err := store.List(p) + entities, err := store.List(cmd.Context(), p) if err != nil { return err } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go new file mode 100644 index 0000000..2d6f9e6 --- /dev/null +++ b/cmd/cmd_test.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +func testStore(t *testing.T) *db.Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + t.Setenv("NIB_DB", dbPath) + store, err := db.Open(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func newCmd() *cobra.Command { + c := &cobra.Command{} + c.SetContext(context.Background()) + return c +} + +func captureOutput(t *testing.T, fn func()) string { + t.Helper() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + defer rootCmd.SetOut(nil) + fn() + return buf.String() +} + +func seedEntity(t *testing.T, store *db.Store, body string, glyph db.Glyph) *db.Entity { + t.Helper() + e := &db.Entity{Body: body, Glyph: glyph, Tags: []string{}} + if err := store.Create(context.Background(), e); err != nil { + t.Fatal(err) + } + return e +} + +func TestRunAdd(t *testing.T) { + testStore(t) + + err := runAdd(newCmd(), []string{"hello", "world"}) + if err != nil { + t.Fatalf("runAdd: %v", err) + } +} + +func TestRunAddWithGlyph(t *testing.T) { + testStore(t) + + err := runAdd(newCmd(), []string{"-", "buy", "milk", "#errands"}) + if err != nil { + t.Fatalf("runAdd todo: %v", err) + } +} + +func TestRunAddWithTimeAnchor(t *testing.T) { + testStore(t) + + err := runAdd(newCmd(), []string{"@", "dentist", "@14:00"}) + if err != nil { + t.Fatalf("runAdd event: %v", err) + } +} + +func TestRunDelete(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "to delete", db.GlyphNote) + store.Close() + + err := runDelete(newCmd(), []string{e.ID}) + if err != nil { + t.Fatalf("runDelete soft: %v", err) + } + + err = runDelete(newCmd(), []string{e.ID}) + if err != nil { + t.Fatalf("runDelete hard: %v", err) + } +} + +func TestRunDeleteNotFound(t *testing.T) { + testStore(t) + + err := runDelete(newCmd(), []string{"nonexistent"}) + if err == nil { + t.Fatal("expected error for nonexistent id") + } + if !strings.Contains(err.Error(), "not_found") { + t.Fatalf("expected not_found error, got: %v", err) + } +} + +func TestRunPromote(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "reusable snippet", db.GlyphNote) + store.Close() + + err := runPromote(newCmd(), []string{e.ID, "snippet"}) + if err != nil { + t.Fatalf("runPromote: %v", err) + } +} + +func TestRunPromoteAlreadyPromoted(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "already a card", db.GlyphNote) + store.Promote(context.Background(), e.ID, db.CardSnippet, nil) + store.Close() + + err := runPromote(newCmd(), []string{e.ID, "snippet"}) + if err == nil { + t.Fatal("expected error for already promoted") + } +} + +func TestRunDemote(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "demote me", db.GlyphNote) + store.Promote(context.Background(), e.ID, db.CardSnippet, nil) + store.Close() + + err := runDemote(newCmd(), []string{e.ID}) + if err != nil { + t.Fatalf("runDemote: %v", err) + } +} + +func TestRunDemoteAlreadyFluid(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "already fluid", db.GlyphNote) + store.Close() + + err := runDemote(newCmd(), []string{e.ID}) + if err == nil { + t.Fatal("expected error for already fluid") + } +} + +func TestRunAbsorb(t *testing.T) { + store := testStore(t) + target := seedEntity(t, store, "target body", db.GlyphNote) + source := seedEntity(t, store, "source body", db.GlyphNote) + store.Close() + + err := runAbsorb(newCmd(), []string{target.ID, source.ID}) + if err != nil { + t.Fatalf("runAbsorb: %v", err) + } +} + +func TestRunAbsorbSameEntity(t *testing.T) { + store := testStore(t) + e := seedEntity(t, store, "same entity", db.GlyphNote) + store.Close() + + err := runAbsorb(newCmd(), []string{e.ID, e.ID}) + if err == nil { + t.Fatal("expected error for same entity absorb") + } +} + +func TestRunAbsorbCrystallizedTarget(t *testing.T) { + store := testStore(t) + target := seedEntity(t, store, "crystallized", db.GlyphNote) + source := seedEntity(t, store, "source", db.GlyphNote) + store.Promote(context.Background(), target.ID, db.CardSnippet, nil) + store.Close() + + err := runAbsorb(newCmd(), []string{target.ID, source.ID}) + if err == nil { + t.Fatal("expected error for crystallized target") + } +} + +func TestRunLs(t *testing.T) { + store := testStore(t) + seedEntity(t, store, "recent note", db.GlyphNote) + store.Close() + + lsTag = "" + lsDate = "" + lsMonth = "" + lsFrom = "" + lsTo = "" + lsLimit = 0 + lsAll = false + + err := runLs(newCmd(), nil) + if err != nil { + t.Fatalf("runLs: %v", err) + } +} + +func TestRunLsEmpty(t *testing.T) { + testStore(t) + + lsTag = "" + lsDate = "" + lsMonth = "" + lsFrom = "" + lsTo = "" + lsLimit = 0 + lsAll = false + + err := runLs(newCmd(), nil) + if err != nil { + t.Fatalf("runLs empty: %v", err) + } +} + +func TestRunExport(t *testing.T) { + store := testStore(t) + seedEntity(t, store, "export me", db.GlyphNote) + seedEntity(t, store, "export me too", db.GlyphTodo) + store.Close() + + outFile := filepath.Join(t.TempDir(), "export.json") + exportOutput = outFile + + err := runExport(newCmd(), nil) + if err != nil { + t.Fatalf("runExport: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read export: %v", err) + } + + var entities []exportEntity + if err := json.Unmarshal(data, &entities); err != nil { + t.Fatalf("unmarshal export: %v", err) + } + + if len(entities) != 2 { + t.Fatalf("expected 2 entities, got %d", len(entities)) + } +} + +func TestRunBackup(t *testing.T) { + store := testStore(t) + seedEntity(t, store, "backup me", db.GlyphNote) + store.Close() + + dst := filepath.Join(t.TempDir(), "backup.db") + err := runBackup(newCmd(), []string{dst}) + if err != nil { + t.Fatalf("runBackup: %v", err) + } + + info, err := os.Stat(dst) + if err != nil { + t.Fatalf("backup file missing: %v", err) + } + if info.Size() == 0 { + t.Fatal("backup file is empty") + } + + backed, err := db.Open(dst) + if err != nil { + t.Fatalf("open backup: %v", err) + } + defer backed.Close() + + entities, err := backed.List(context.Background(), db.DefaultListParams()) + if err != nil { + t.Fatalf("list backup: %v", err) + } + if len(entities) != 1 { + t.Fatalf("expected 1 entity in backup, got %d", len(entities)) + } +} diff --git a/cmd/copy.go b/cmd/copy.go index a7b3001..41ba0be 100644 --- a/cmd/copy.go +++ b/cmd/copy.go @@ -19,19 +19,19 @@ func init() { rootCmd.AddCommand(copyCmd) } -func runCopy(_ *cobra.Command, args []string) error { +func runCopy(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - id, err := store.Resolve(args[0]) + id, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } - e, err := store.Get(id) + e, err := store.Get(cmd.Context(), id) if err != nil { return err } @@ -40,7 +40,7 @@ func runCopy(_ *cobra.Command, args []string) error { return fmt.Errorf("clipboard: %w", err) } - if err := store.IncrementUse(id); err != nil { + if err := store.IncrementUse(cmd.Context(), id); err != nil { return err } diff --git a/cmd/delete.go b/cmd/delete.go index 24eecbf..11336b1 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -19,19 +19,19 @@ func init() { rootCmd.AddCommand(deleteCmd) } -func runDelete(_ *cobra.Command, args []string) error { +func runDelete(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - id, err := store.Resolve(args[0]) + id, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } - result, err := store.SoftDelete(id) + result, err := store.SoftDelete(cmd.Context(), id) if err != nil { return err } diff --git a/cmd/demo.go b/cmd/demo.go index ca3bf50..e2e2c5d 100644 --- a/cmd/demo.go +++ b/cmd/demo.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" @@ -35,7 +36,7 @@ type demoEntity struct { Tags []string `json:"tags"` } -func runDemo(_ *cobra.Command, _ []string) error { +func runDemo(cmd *cobra.Command, _ []string) error { tmpDir, err := os.MkdirTemp("", "nib-demo-*") if err != nil { return err @@ -48,7 +49,7 @@ func runDemo(_ *cobra.Command, _ []string) error { return err } - if err := seedDemo(store); err != nil { + if err := seedDemo(cmd.Context(), store); err != nil { store.Close() return fmt.Errorf("seed demo data: %w", err) } @@ -58,7 +59,7 @@ func runDemo(_ *cobra.Command, _ []string) error { return runServe(nil, nil) } -func seedDemo(store *db.Store) error { +func seedDemo(ctx context.Context, store *db.Store) error { data, err := findDemoFile() if err != nil { return err @@ -94,19 +95,19 @@ func seedDemo(store *db.Store) error { e.CompletedAt = &t } - if err := store.Create(e); err != nil { + if err := store.Create(ctx, e); err != nil { return fmt.Errorf("entity %d: %w", i, err) } if entry.CardType != nil { ct := db.CardType(*entry.CardType) - if err := store.Promote(e.ID, ct, entry.CardData); err != nil { + if err := store.Promote(ctx, e.ID, ct, entry.CardData); err != nil { return fmt.Errorf("promote entity %d: %w", i, err) } } if entry.Deleted { - store.SoftDelete(e.ID) + store.SoftDelete(ctx, e.ID) } } diff --git a/cmd/demote.go b/cmd/demote.go index 18fe1cb..64ec693 100644 --- a/cmd/demote.go +++ b/cmd/demote.go @@ -19,19 +19,19 @@ func init() { rootCmd.AddCommand(demoteCmd) } -func runDemote(_ *cobra.Command, args []string) error { +func runDemote(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - id, err := store.Resolve(args[0]) + id, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } - if err := store.Demote(id); err != nil { + if err := store.Demote(cmd.Context(), id); err != nil { if err == db.ErrAlreadyFluid { return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id)) } diff --git a/cmd/edit.go b/cmd/edit.go index ade00c9..f66a3be 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -21,19 +21,19 @@ func init() { rootCmd.AddCommand(editCmd) } -func runEdit(_ *cobra.Command, args []string) error { +func runEdit(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - id, err := store.Resolve(args[0]) + id, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } - e, err := store.Get(id) + e, err := store.Get(cmd.Context(), id) if err != nil { return err } @@ -55,11 +55,11 @@ func runEdit(_ *cobra.Command, args []string) error { editor = "vi" } - cmd := exec.Command(editor, tmpfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + editorCmd := exec.Command(editor, tmpfile.Name()) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + if err := editorCmd.Run(); err != nil { return fmt.Errorf("editor: %w", err) } @@ -74,7 +74,7 @@ func runEdit(_ *cobra.Command, args []string) error { return nil } - if err := store.Update(id, &db.EntityUpdate{Body: &body}); err != nil { + if err := store.Update(cmd.Context(), id, &db.EntityUpdate{Body: &body}); err != nil { return err } diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..14d3703 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +var exportOutput string + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "dump all entities to JSON", + RunE: runExport, +} + +func init() { + exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout") + rootCmd.AddCommand(exportCmd) +} + +type exportEntity struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` + Body string `json:"body"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Glyph string `json:"glyph"` + TimeAnchor *string `json:"time_anchor,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + Pinned bool `json:"pinned"` + DeletedAt *string `json:"deleted_at,omitempty"` + Tags []string `json:"tags"` + CardType *string `json:"card_type,omitempty"` + CardData *string `json:"card_data,omitempty"` + UseCount int `json:"use_count"` + LastUsedAt *string `json:"last_used_at,omitempty"` +} + +func runExport(cmd *cobra.Command, _ []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + ctx := cmd.Context() + + p := db.DefaultListParams() + p.IncludeDeleted = true + p.Limit = 10000 + + entities, err := store.List(ctx, p) + if err != nil { + return err + } + + out := make([]exportEntity, len(entities)) + for i, e := range entities { + out[i] = exportEntity{ + ID: e.ID, + CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + ModifiedAt: e.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"), + Body: e.Body, + Title: e.Title, + Glyph: string(e.Glyph), + TimeAnchor: e.TimeAnchor, + Pinned: e.Pinned, + Tags: e.Tags, + CardData: e.CardData, + UseCount: e.UseCount, + } + if e.Description != nil { + out[i].Description = e.Description + } + if e.CompletedAt != nil { + s := e.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].CompletedAt = &s + } + if e.DeletedAt != nil { + s := e.DeletedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].DeletedAt = &s + } + if e.CardType != nil { + s := string(*e.CardType) + out[i].CardType = &s + } + if e.LastUsedAt != nil { + s := e.LastUsedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].LastUsedAt = &s + } + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + + if exportOutput != "" { + if err := os.WriteFile(exportOutput, data, 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "exported %d entities to %s\n", len(out), exportOutput) + return nil + } + + fmt.Println(string(data)) + return nil +} diff --git a/cmd/ls.go b/cmd/ls.go index 3e6f995..8fd38a5 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -36,7 +36,7 @@ func init() { lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities") } -func runLs(_ *cobra.Command, _ []string) error { +func runLs(cmd *cobra.Command, _ []string) error { store, err := openStore() if err != nil { return err @@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error { p.Since = &since } - entities, err := store.List(p) + entities, err := store.List(cmd.Context(), p) if err != nil { return err } diff --git a/cmd/promote.go b/cmd/promote.go index 7b99927..0580059 100644 --- a/cmd/promote.go +++ b/cmd/promote.go @@ -20,14 +20,14 @@ func init() { rootCmd.AddCommand(promoteCmd) } -func runPromote(_ *cobra.Command, args []string) error { +func runPromote(cmd *cobra.Command, args []string) error { store, err := openStore() if err != nil { return err } defer store.Close() - id, err := store.Resolve(args[0]) + id, err := store.Resolve(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("not_found — no entity with id %s", args[0]) } @@ -40,14 +40,14 @@ func runPromote(_ *cobra.Command, args []string) error { cardType = db.CardType(args[1]) } - e, err := store.Get(id) + e, err := store.Get(cmd.Context(), id) if err != nil { return err } cd := carddata.GenerateCardData(cardType, e.Body) - if err := store.Promote(id, cardType, cd); err != nil { + if err := store.Promote(cmd.Context(), id, cardType, cd); err != nil { if err == db.ErrAlreadyPromoted { return fmt.Errorf("invalid_promote — entity %s is already a %s", display.FormatID(id), *e.CardType) diff --git a/internal/api/entities.go b/internal/api/entities.go index b4058d4..9a0fb36 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -92,6 +92,9 @@ func listEntities(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer") return } + if limit > 200 { + limit = 200 + } p.Limit = limit } if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { @@ -106,13 +109,13 @@ func listEntities(store *db.Store) http.HandlerFunc { p.Limit = 50 } - total, err := store.Count(p) + total, err := store.Count(r.Context(), p) if err != nil { writeInternalError(w, err) return } - entities, err := store.List(p) + entities, err := store.List(r.Context(), p) if err != nil { writeInternalError(w, err) return @@ -174,7 +177,7 @@ func createEntity(store *db.Store) http.HandlerFunc { e.CardData = req.CardData } - if err := store.Create(e); err != nil { + if err := store.Create(r.Context(), e); err != nil { if err == db.ErrInvalidCardData { writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON") return @@ -190,7 +193,7 @@ func createEntity(store *db.Store) http.HandlerFunc { func getEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) @@ -240,7 +243,7 @@ func updateEntity(store *db.Store) http.HandlerFunc { u.CardType = &ct } - if err := store.Update(id, u); err != nil { + if err := store.Update(r.Context(), id, u); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return @@ -253,7 +256,7 @@ func updateEntity(store *db.Store) http.HandlerFunc { return } - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { writeInternalError(w, err) return @@ -269,7 +272,7 @@ type DeleteResponse struct { func deleteEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - result, err := store.SoftDelete(id) + result, err := store.SoftDelete(r.Context(), id) if err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) @@ -304,7 +307,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc { return } - if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil { + if err := store.Promote(r.Context(), id, db.CardType(req.CardType), req.CardData); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return @@ -321,7 +324,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc { return } - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { writeInternalError(w, err) return @@ -334,7 +337,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if err := store.Demote(id); err != nil { + if err := store.Demote(r.Context(), id); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return @@ -347,7 +350,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc { return } - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { writeInternalError(w, err) return @@ -378,7 +381,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc { return } - if err := store.Absorb(id, req.SourceID); err != nil { + if err := store.Absorb(r.Context(), id, req.SourceID); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "target or source entity not found") return @@ -391,7 +394,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc { return } - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { writeInternalError(w, err) return @@ -404,7 +407,7 @@ func useEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if err := store.IncrementUse(id); err != nil { + if err := store.IncrementUse(r.Context(), id); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return @@ -413,7 +416,7 @@ func useEntity(store *db.Store) http.HandlerFunc { return } - e, err := store.Get(id) + e, err := store.Get(r.Context(), id) if err != nil { writeInternalError(w, err) return diff --git a/internal/api/tags.go b/internal/api/tags.go index 5a51d23..f463e3a 100644 --- a/internal/api/tags.go +++ b/internal/api/tags.go @@ -14,7 +14,7 @@ type TagResponse struct { func listTags(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cardsOnly := r.URL.Query().Get("cards_only") == "true" - tags, err := store.ListTags(cardsOnly) + tags, err := store.ListTags(r.Context(), cardsOnly) if err != nil { writeInternalError(w, err) return diff --git a/internal/db/db.go b/internal/db/db.go index 982e072..efb7040 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -51,7 +51,12 @@ func (s *Store) Close() error { return s.db.Close() } -const currentSchema = 3 +func (s *Store) Backup(dst string) error { + _, err := s.db.Exec("VACUUM INTO ?", dst) + return err +} + +const currentSchema = 4 var migrations = []func(db *sql.DB) error{ // v1: initial schema @@ -92,8 +97,12 @@ var migrations = []func(db *sql.DB) error{ // v2: add title and description columns func(db *sql.DB) error { - db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) - db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil { + return fmt.Errorf("add title column: %w", err) + } + if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil { + return fmt.Errorf("add description column: %w", err) + } return nil }, @@ -166,6 +175,19 @@ var migrations = []func(db *sql.DB) error{ return tx.Commit() }, + + // v4: add indexes for common query filters + func(db *sql.DB) error { + for _, idx := range []string{ + `CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`, + `CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`, + } { + if _, err := db.Exec(idx); err != nil { + return fmt.Errorf("create index: %w", err) + } + } + return nil + }, } func (s *Store) migrate() error { @@ -200,7 +222,7 @@ func DefaultPath() (string, error) { return "", err } dir := filepath.Join(home, ".nib") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } return filepath.Join(dir, "nib.db"), nil diff --git a/internal/db/entities.go b/internal/db/entities.go index 5954b66..b27509e 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "encoding/json" "fmt" @@ -104,7 +105,7 @@ type EntityUpdate struct { Tags *[]string } -func (s *Store) Create(e *Entity) error { +func (s *Store) Create(ctx context.Context, e *Entity) error { if e.CardData != nil && !json.Valid([]byte(*e.CardData)) { return ErrInvalidCardData } @@ -116,13 +117,13 @@ func (s *Store) Create(e *Entity) error { e.Tags = []string{} } - tx, err := s.db.Begin() + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` INSERT INTO entities (id, created_at, modified_at, body, title, description, glyph, time_anchor, completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) @@ -147,18 +148,18 @@ func (s *Store) Create(e *Entity) error { return err } - if err := insertTags(tx, e.ID, e.Tags); err != nil { + if err := insertTags(ctx, tx, e.ID, e.Tags); err != nil { return err } return tx.Commit() } -func (s *Store) Get(id string) (*Entity, error) { +func (s *Store) Get(ctx context.Context, id string) (*Entity, error) { e := &Entity{} row := newEntityRow() - err := s.db.QueryRow(` + err := s.db.QueryRowContext(ctx, ` SELECT id, created_at, modified_at, body, title, description, glyph, time_anchor, completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at @@ -174,7 +175,7 @@ func (s *Store) Get(id string) (*Entity, error) { return nil, fmt.Errorf("scan entity %s: %w", id, err) } - tags, err := s.loadTags(id) + tags, err := s.loadTags(ctx, id) if err != nil { return nil, err } @@ -229,15 +230,15 @@ func listWhere(params ListParams) (string, []any) { return clause, args } -func (s *Store) Count(params ListParams) (int, error) { +func (s *Store) Count(ctx context.Context, params ListParams) (int, error) { whereClause, args := listWhere(params) query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause) var count int - err := s.db.QueryRow(query, args...).Scan(&count) + err := s.db.QueryRowContext(ctx, query, args...).Scan(&count) return count, err } -func (s *Store) List(params ListParams) ([]*Entity, error) { +func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) { whereClause, args := listWhere(params) orderCol := "e.created_at" @@ -275,7 +276,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { args = append(args, limit, params.Offset) - rows, err := s.db.Query(query, args...) + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } @@ -297,20 +298,20 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { return nil, err } - if err := s.batchLoadTags(entities); err != nil { + if err := s.batchLoadTags(ctx, entities); err != nil { return nil, err } return entities, nil } -func (s *Store) Update(id string, u *EntityUpdate) error { - existing, err := s.Get(id) +func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error { + existing, err := s.Get(ctx, id) if err != nil { return err } - tx, err := s.db.Begin() + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } @@ -369,15 +370,15 @@ func (s *Store) Update(id string, u *EntityUpdate) error { args = append(args, existing.ID) query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", ")) - if _, err := tx.Exec(query, args...); err != nil { + if _, err := tx.ExecContext(ctx, query, args...); err != nil { return err } if u.Tags != nil { - if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil { return err } - if err := insertTags(tx, existing.ID, *u.Tags); err != nil { + if err := insertTags(ctx, tx, existing.ID, *u.Tags); err != nil { return err } } @@ -385,8 +386,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error { return tx.Commit() } -func (s *Store) Promote(id string, cardType CardType, cardData *string) error { - e, err := s.Get(id) +func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error { + e, err := s.Get(ctx, id) if err != nil { return err } @@ -402,15 +403,15 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error { dataVal = *cardData } - _, err = s.db.Exec(` + _, err = s.db.ExecContext(ctx, ` UPDATE entities SET card_type = ?, card_data = ?, modified_at = ? WHERE id = ?`, string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id) return err } -func (s *Store) Demote(id string) error { - e, err := s.Get(id) +func (s *Store) Demote(ctx context.Context, id string) error { + e, err := s.Get(ctx, id) if err != nil { return err } @@ -418,7 +419,7 @@ func (s *Store) Demote(id string) error { return ErrAlreadyFluid } - _, err = s.db.Exec(` + _, err = s.db.ExecContext(ctx, ` UPDATE entities SET card_type = NULL, card_data = NULL, use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`, @@ -433,9 +434,9 @@ const ( DeletedHard ) -func (s *Store) SoftDelete(id string) (DeleteResult, error) { +func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) { var deletedAt sql.NullString - err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt) + err := s.db.QueryRowContext(ctx, "SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt) if err == sql.ErrNoRows { return 0, ErrNotFound } @@ -444,21 +445,21 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) { } if deletedAt.Valid { - _, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id) + _, err = s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id) return DeletedHard, err } - _, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", + _, err = s.db.ExecContext(ctx, "UPDATE entities SET deleted_at = ? WHERE id = ?", time.Now().UTC().Format(time.RFC3339), id) return DeletedSoft, err } -func (s *Store) Absorb(targetID, sourceID string) error { - target, err := s.Get(targetID) +func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error { + target, err := s.Get(ctx, targetID) if err != nil { return err } - source, err := s.Get(sourceID) + source, err := s.Get(ctx, sourceID) if err != nil { return err } @@ -467,7 +468,7 @@ func (s *Store) Absorb(targetID, sourceID string) error { return ErrTargetCrystallized } - tx, err := s.db.Begin() + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } @@ -476,7 +477,7 @@ func (s *Store) Absorb(targetID, sourceID string) error { now := time.Now().UTC().Format(time.RFC3339) merged := target.Body + "\n" + source.Body - if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?", + if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?", merged, now, targetID); err != nil { return err } @@ -487,7 +488,7 @@ func (s *Store) Absorb(targetID, sourceID string) error { } for _, t := range source.Tags { if !seen[t] { - if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", + if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", targetID, t); err != nil { return err } @@ -495,7 +496,7 @@ func (s *Store) Absorb(targetID, sourceID string) error { } if source.CardType != nil { - if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL, + if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL, use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`, now, sourceID); err != nil { return err @@ -503,7 +504,7 @@ func (s *Store) Absorb(targetID, sourceID string) error { } absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]" - if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?", + if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?", absorbNote, now, now, sourceID); err != nil { return err } @@ -511,8 +512,8 @@ func (s *Store) Absorb(targetID, sourceID string) error { return tx.Commit() } -func (s *Store) IncrementUse(id string) error { - res, err := s.db.Exec(` +func (s *Store) IncrementUse(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, ` UPDATE entities SET use_count = use_count + 1, last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) @@ -526,8 +527,8 @@ func (s *Store) IncrementUse(id string) error { return nil } -func (s *Store) Resolve(prefix string) (string, error) { - rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%") +func (s *Store) Resolve(ctx context.Context, prefix string) (string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT id FROM entities WHERE id LIKE ?", prefix+"%") if err != nil { return "", err } @@ -593,9 +594,7 @@ func (r *entityRow) apply(e *Entity) error { return nil } -// helpers - -func (s *Store) batchLoadTags(entities []*Entity) error { +func (s *Store) batchLoadTags(ctx context.Context, entities []*Entity) error { if len(entities) == 0 { return nil } @@ -615,7 +614,7 @@ func (s *Store) batchLoadTags(entities []*Entity) error { strings.Join(placeholders, ","), ) - rows, err := s.db.Query(query, args...) + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return err } @@ -633,8 +632,8 @@ func (s *Store) batchLoadTags(entities []*Entity) error { return rows.Err() } -func (s *Store) loadTags(entityID string) ([]string, error) { - rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID) +func (s *Store) loadTags(ctx context.Context, entityID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID) if err != nil { return nil, err } @@ -657,9 +656,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) { return tags, nil } -func insertTags(tx *sql.Tx, entityID string, tags []string) error { +func insertTags(ctx context.Context, tx *sql.Tx, entityID string, tags []string) error { for _, tag := range tags { - if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", + if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", entityID, tag); err != nil { return err } diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 8817edc..b68023d 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -1,6 +1,7 @@ package db import ( + "context" "testing" "time" ) @@ -11,15 +12,16 @@ func ptr[T any](v T) *T { func TestCreate_Note(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "hello world", Glyph: GlyphNote} - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } if e.ID == "" { t.Fatal("ID not set") } - got, err := s.Get(e.ID) + got, err := s.Get(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -33,12 +35,13 @@ func TestCreate_Note(t *testing.T) { func TestCreate_TodoWithTimeAnchor(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")} - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } - got, err := s.Get(e.ID) + got, err := s.Get(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -49,12 +52,13 @@ func TestCreate_TodoWithTimeAnchor(t *testing.T) { func TestCreate_WithTags(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}} - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } - got, err := s.Get(e.ID) + got, err := s.Get(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -65,13 +69,14 @@ func TestCreate_WithTags(t *testing.T) { func TestCreate_WithCardType(t *testing.T) { s := testStore(t) + ctx := context.Background() ct := CardSnippet e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } - got, err := s.Get(e.ID) + got, err := s.Get(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -82,7 +87,7 @@ func TestCreate_WithCardType(t *testing.T) { func TestGet_NotFound(t *testing.T) { s := testStore(t) - _, err := s.Get("01NONEXISTENT0000000000000") + _, err := s.Get(context.Background(), "01NONEXISTENT0000000000000") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } @@ -90,11 +95,12 @@ func TestGet_NotFound(t *testing.T) { func TestList_DefaultParams(t *testing.T) { s := testStore(t) + ctx := context.Background() for i := 0; i < 3; i++ { - s.Create(&Entity{Body: "note", Glyph: GlyphNote}) + s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote}) } - entities, err := s.List(DefaultListParams()) + entities, err := s.List(ctx, DefaultListParams()) if err != nil { t.Fatal(err) } @@ -109,15 +115,16 @@ func TestList_DefaultParams(t *testing.T) { func TestList_FilterByTag(t *testing.T) { s := testStore(t) - s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}}) - s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}}) - s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}}) + ctx := context.Background() + s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}}) + s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}}) + s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}}) p := DefaultListParams() tag := "ops" p.Tag = &tag - entities, err := s.List(p) + entities, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -128,13 +135,14 @@ func TestList_FilterByTag(t *testing.T) { func TestList_FilterByDate(t *testing.T) { s := testStore(t) - s.Create(&Entity{Body: "today", Glyph: GlyphNote}) + ctx := context.Background() + s.Create(ctx, &Entity{Body: "today", Glyph: GlyphNote}) p := DefaultListParams() date := time.Now().UTC().Format("2006-01-02") p.Date = &date - entities, err := s.List(p) + entities, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -144,7 +152,7 @@ func TestList_FilterByDate(t *testing.T) { otherDate := "2020-01-01" p.Date = &otherDate - entities, err = s.List(p) + entities, err = s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -155,13 +163,14 @@ func TestList_FilterByDate(t *testing.T) { func TestList_CardsOnly(t *testing.T) { s := testStore(t) - s.Create(&Entity{Body: "fluid", Glyph: GlyphNote}) + ctx := context.Background() + s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote}) ct := CardSnippet - s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct}) + s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, CardType: &ct}) p := DefaultListParams() p.CardsOnly = true - entities, err := s.List(p) + entities, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -175,12 +184,13 @@ func TestList_CardsOnly(t *testing.T) { func TestList_IncludeDeleted(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "doomed", Glyph: GlyphNote} - s.Create(e) - s.SoftDelete(e.ID) + s.Create(ctx, e) + s.SoftDelete(ctx, e.ID) p := DefaultListParams() - entities, err := s.List(p) + entities, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -189,7 +199,7 @@ func TestList_IncludeDeleted(t *testing.T) { } p.IncludeDeleted = true - entities, err = s.List(p) + entities, err = s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -200,17 +210,18 @@ func TestList_IncludeDeleted(t *testing.T) { func TestList_SortByUseCount(t *testing.T) { s := testStore(t) + ctx := context.Background() ct := CardSnippet e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct} e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct} - s.Create(e1) - s.Create(e2) - s.IncrementUse(e2.ID) - s.IncrementUse(e2.ID) + s.Create(ctx, e1) + s.Create(ctx, e2) + s.IncrementUse(ctx, e2.ID) + s.IncrementUse(ctx, e2.ID) p := DefaultListParams() p.Sort = "use_count" - entities, err := s.List(p) + entities, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -221,14 +232,15 @@ func TestList_SortByUseCount(t *testing.T) { func TestList_Pagination(t *testing.T) { s := testStore(t) + ctx := context.Background() for i := 0; i < 10; i++ { - s.Create(&Entity{Body: "note", Glyph: GlyphNote}) + s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote}) } p := DefaultListParams() p.Limit = 3 p.Offset = 0 - page1, err := s.List(p) + page1, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -237,7 +249,7 @@ func TestList_Pagination(t *testing.T) { } p.Offset = 3 - page2, err := s.List(p) + page2, err := s.List(ctx, p) if err != nil { t.Fatal(err) } @@ -251,16 +263,17 @@ func TestList_Pagination(t *testing.T) { func TestUpdate_Body(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "old", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) time.Sleep(1100 * time.Millisecond) newBody := "new" - if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil { + if err := s.Update(ctx, e.ID, &EntityUpdate{Body: &newBody}); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.Body != "new" { t.Errorf("body not updated: %q", got.Body) } @@ -271,15 +284,16 @@ func TestUpdate_Body(t *testing.T) { func TestUpdate_Tags(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}} - s.Create(e) + s.Create(ctx, e) newTags := []string{"new1", "new2"} - if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil { + if err := s.Update(ctx, e.ID, &EntityUpdate{Tags: &newTags}); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if len(got.Tags) != 2 { t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags) } @@ -287,14 +301,15 @@ func TestUpdate_Tags(t *testing.T) { func TestPromote_Success(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "trick", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - if err := s.Promote(e.ID, CardSnippet, nil); err != nil { + if err := s.Promote(ctx, e.ID, CardSnippet, nil); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.CardType == nil || *got.CardType != CardSnippet { t.Errorf("expected snippet, got %v", got.CardType) } @@ -302,26 +317,28 @@ func TestPromote_Success(t *testing.T) { func TestPromote_AlreadyPromoted(t *testing.T) { s := testStore(t) + ctx := context.Background() ct := CardSnippet e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} - s.Create(e) + s.Create(ctx, e) - if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted { + if err := s.Promote(ctx, e.ID, CardTemplate, nil); err != ErrAlreadyPromoted { t.Errorf("expected ErrAlreadyPromoted, got %v", err) } } func TestDemote_Success(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "trick", Glyph: GlyphNote} - s.Create(e) - s.Promote(e.ID, CardSnippet, nil) + s.Create(ctx, e) + s.Promote(ctx, e.ID, CardSnippet, nil) - if err := s.Demote(e.ID); err != nil { + if err := s.Demote(ctx, e.ID); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.CardType != nil { t.Errorf("expected nil card_type, got %v", got.CardType) } @@ -332,20 +349,22 @@ func TestDemote_Success(t *testing.T) { func TestDemote_AlreadyFluid(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "trick", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - if err := s.Demote(e.ID); err != ErrAlreadyFluid { + if err := s.Demote(ctx, e.ID); err != ErrAlreadyFluid { t.Errorf("expected ErrAlreadyFluid, got %v", err) } } func TestSoftDelete_First(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "doomed", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - result, err := s.SoftDelete(e.ID) + result, err := s.SoftDelete(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -353,7 +372,7 @@ func TestSoftDelete_First(t *testing.T) { t.Errorf("expected DeletedSoft, got %d", result) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.DeletedAt == nil { t.Error("expected deleted_at to be set") } @@ -361,11 +380,12 @@ func TestSoftDelete_First(t *testing.T) { func TestSoftDelete_Second(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "doomed", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - s.SoftDelete(e.ID) - result, err := s.SoftDelete(e.ID) + s.SoftDelete(ctx, e.ID) + result, err := s.SoftDelete(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -373,7 +393,7 @@ func TestSoftDelete_Second(t *testing.T) { t.Errorf("expected DeletedHard, got %d", result) } - _, err = s.Get(e.ID) + _, err = s.Get(ctx, e.ID) if err != ErrNotFound { t.Errorf("expected ErrNotFound after hard delete, got %v", err) } @@ -381,7 +401,7 @@ func TestSoftDelete_Second(t *testing.T) { func TestSoftDelete_NotFound(t *testing.T) { s := testStore(t) - _, err := s.SoftDelete("01NONEXISTENT0000000000000") + _, err := s.SoftDelete(context.Background(), "01NONEXISTENT0000000000000") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } @@ -389,15 +409,16 @@ func TestSoftDelete_NotFound(t *testing.T) { func TestIncrementUse(t *testing.T) { s := testStore(t) + ctx := context.Background() ct := CardSnippet e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} - s.Create(e) + s.Create(ctx, e) - if err := s.IncrementUse(e.ID); err != nil { + if err := s.IncrementUse(ctx, e.ID); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.UseCount != 1 { t.Errorf("expected use_count=1, got %d", got.UseCount) } @@ -408,10 +429,11 @@ func TestIncrementUse(t *testing.T) { func TestResolve_FullID(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "test", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - got, err := s.Resolve(e.ID) + got, err := s.Resolve(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -422,10 +444,11 @@ func TestResolve_FullID(t *testing.T) { func TestResolve_Prefix(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "test", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) - got, err := s.Resolve(e.ID[:6]) + got, err := s.Resolve(ctx, e.ID[:6]) if err != nil { t.Fatal(err) } @@ -436,7 +459,7 @@ func TestResolve_Prefix(t *testing.T) { func TestResolve_NotFound(t *testing.T) { s := testStore(t) - _, err := s.Resolve("ZZZZZZZZZ") + _, err := s.Resolve(context.Background(), "ZZZZZZZZZ") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } @@ -444,24 +467,25 @@ func TestResolve_NotFound(t *testing.T) { func TestAbsorb_SourceIsCard(t *testing.T) { s := testStore(t) + ctx := context.Background() target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}} - s.Create(target) + s.Create(ctx, target) source := &Entity{Body: "source", Glyph: GlyphNote} - s.Create(source) - s.Promote(source.ID, CardSnippet, nil) - s.IncrementUse(source.ID) + s.Create(ctx, source) + s.Promote(ctx, source.ID, CardSnippet, nil) + s.IncrementUse(ctx, source.ID) - if err := s.Absorb(target.ID, source.ID); err != nil { + if err := s.Absorb(ctx, target.ID, source.ID); err != nil { t.Fatal(err) } - got, _ := s.Get(target.ID) + got, _ := s.Get(ctx, target.ID) if got.Body != "target\nsource" { t.Errorf("merged body: %q", got.Body) } - src, _ := s.Get(source.ID) + src, _ := s.Get(ctx, source.ID) if src.CardType != nil { t.Error("source card_type should be cleared after absorb") } @@ -475,6 +499,7 @@ func TestAbsorb_SourceIsCard(t *testing.T) { func TestCreate_WithTitleAndDescription(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{ Body: "body text", Title: ptr("nginx trick"), @@ -482,11 +507,11 @@ func TestCreate_WithTitleAndDescription(t *testing.T) { Glyph: GlyphNote, Tags: []string{"ops"}, } - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } - got, err := s.Get(e.ID) + got, err := s.Get(ctx, e.ID) if err != nil { t.Fatal(err) } @@ -503,12 +528,13 @@ func TestCreate_WithTitleAndDescription(t *testing.T) { func TestCreate_WithoutTitle(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "just body", Glyph: GlyphNote} - if err := s.Create(e); err != nil { + if err := s.Create(ctx, e); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.Title != nil { t.Errorf("expected nil title, got %v", got.Title) } @@ -519,15 +545,16 @@ func TestCreate_WithoutTitle(t *testing.T) { func TestUpdate_Title(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "body", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) newTitle := "new title" - if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil { + if err := s.Update(ctx, e.ID, &EntityUpdate{Title: &newTitle}); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.Title == nil || *got.Title != "new title" { t.Errorf("title: got %v", got.Title) } @@ -535,15 +562,16 @@ func TestUpdate_Title(t *testing.T) { func TestUpdate_Description(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "body", Glyph: GlyphNote} - s.Create(e) + s.Create(ctx, e) newDesc := "new desc" - if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil { + if err := s.Update(ctx, e.ID, &EntityUpdate{Description: &newDesc}); err != nil { t.Fatal(err) } - got, _ := s.Get(e.ID) + got, _ := s.Get(ctx, e.ID) if got.Description == nil || *got.Description != "new desc" { t.Errorf("description: got %v", got.Description) } @@ -551,16 +579,17 @@ func TestUpdate_Description(t *testing.T) { func TestAbsorb_PreservesTargetTitle(t *testing.T) { s := testStore(t) + ctx := context.Background() target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote} source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote} - s.Create(target) - s.Create(source) + s.Create(ctx, target) + s.Create(ctx, source) - if err := s.Absorb(target.ID, source.ID); err != nil { + if err := s.Absorb(ctx, target.ID, source.ID); err != nil { t.Fatal(err) } - got, _ := s.Get(target.ID) + got, _ := s.Get(ctx, target.ID) if got.Title == nil || *got.Title != "target title" { t.Errorf("target title should be preserved, got %v", got.Title) } diff --git a/internal/db/tags.go b/internal/db/tags.go index bcff174..306037f 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -1,20 +1,22 @@ package db +import "context" + type TagCount struct { Tag string Count int } -func (s *Store) ListTags(cardsOnly bool) ([]TagCount, error) { +func (s *Store) ListTags(ctx context.Context, cardsOnly bool) ([]TagCount, error) { where := "WHERE e.deleted_at IS NULL" if cardsOnly { where += " AND e.card_type IS NOT NULL" } - rows, err := s.db.Query(` + rows, err := s.db.QueryContext(ctx, ` SELECT t.tag, COUNT(*) as cnt FROM entity_tags t JOIN entities e ON t.entity_id = e.id - ` + where + ` + `+where+` GROUP BY t.tag ORDER BY t.tag`) if err != nil { diff --git a/internal/db/tags_test.go b/internal/db/tags_test.go index d28d0aa..f343bd7 100644 --- a/internal/db/tags_test.go +++ b/internal/db/tags_test.go @@ -1,10 +1,13 @@ package db -import "testing" +import ( + "context" + "testing" +) func TestListTags_Empty(t *testing.T) { s := testStore(t) - tags, err := s.ListTags(false) + tags, err := s.ListTags(context.Background(), false) if err != nil { t.Fatal(err) } @@ -15,11 +18,12 @@ func TestListTags_Empty(t *testing.T) { func TestListTags_Counts(t *testing.T) { s := testStore(t) - s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}) - s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}}) - s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}}) + ctx := context.Background() + s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}) + s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}}) + s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}}) - tags, err := s.ListTags(false) + tags, err := s.ListTags(ctx, false) if err != nil { t.Fatal(err) } @@ -44,13 +48,14 @@ func TestListTags_Counts(t *testing.T) { func TestListTags_ExcludesDeleted(t *testing.T) { s := testStore(t) + ctx := context.Background() e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}} - s.Create(e) - s.SoftDelete(e.ID) + s.Create(ctx, e) + s.SoftDelete(ctx, e.ID) - s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}}) + s.Create(ctx, &Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}}) - tags, err := s.ListTags(false) + tags, err := s.ListTags(ctx, false) if err != nil { t.Fatal(err) } @@ -64,12 +69,13 @@ func TestListTags_ExcludesDeleted(t *testing.T) { func TestListTags_CardsOnly(t *testing.T) { s := testStore(t) - s.Create(&Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}}) + ctx := context.Background() + s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}}) ct := CardSnippet - s.Create(&Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct}) + s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct}) - all, err := s.ListTags(false) + all, err := s.ListTags(ctx, false) if err != nil { t.Fatal(err) } @@ -77,7 +83,7 @@ func TestListTags_CardsOnly(t *testing.T) { t.Fatalf("all tags: expected 3, got %d", len(all)) } - cards, err := s.ListTags(true) + cards, err := s.ListTags(ctx, true) if err != nil { t.Fatal(err) } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index a233f0b..f6b64c1 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -1,6 +1,7 @@ package tui import ( + "context" "os" "os/exec" "strings" @@ -82,7 +83,7 @@ type errMsg struct { func loadEntities(store *db.Store, params db.ListParams) tea.Cmd { return func() tea.Msg { - entities, err := store.List(params) + entities, err := store.List(context.Background(), params) if err != nil { return errMsg{err} } @@ -92,7 +93,7 @@ func loadEntities(store *db.Store, params db.ListParams) tea.Cmd { func createEntity(store *db.Store, e *db.Entity) tea.Cmd { return func() tea.Msg { - if err := store.Create(e); err != nil { + if err := store.Create(context.Background(), e); err != nil { return errMsg{err} } return entityCreatedMsg{e} @@ -101,7 +102,7 @@ func createEntity(store *db.Store, e *db.Entity) tea.Cmd { func deleteEntity(store *db.Store, id string) tea.Cmd { return func() tea.Msg { - if _, err := store.SoftDelete(id); err != nil { + if _, err := store.SoftDelete(context.Background(), id); err != nil { return errMsg{err} } return entityDeletedMsg{id} @@ -118,10 +119,10 @@ func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd { update = db.EntityUpdate{ClearCompleted: true} } - if err := store.Update(e.ID, &update); err != nil { + if err := store.Update(context.Background(), e.ID, &update); err != nil { return errMsg{err} } - updated, err := store.Get(e.ID) + updated, err := store.Get(context.Background(), e.ID) if err != nil { return errMsg{err} } @@ -137,10 +138,10 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd { return func() tea.Msg { newPinned := !e.Pinned update := db.EntityUpdate{Pinned: &newPinned} - if err := store.Update(e.ID, &update); err != nil { + if err := store.Update(context.Background(), e.ID, &update); err != nil { return errMsg{err} } - updated, err := store.Get(e.ID) + updated, err := store.Get(context.Background(), e.ID) if err != nil { return errMsg{err} } @@ -155,7 +156,7 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd { func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd { return func() tea.Msg { cd := carddata.GenerateCardData(ct, body) - if err := store.Promote(id, ct, cd); err != nil { + if err := store.Promote(context.Background(), id, ct, cd); err != nil { return errMsg{err} } return entityPromotedMsg{id, ct} @@ -164,7 +165,7 @@ func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea. func demoteEntity(store *db.Store, id string) tea.Cmd { return func() tea.Msg { - if err := store.Demote(id); err != nil { + if err := store.Demote(context.Background(), id); err != nil { return errMsg{err} } return entityDemotedMsg{id} @@ -176,7 +177,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd { if err := clipboard.WriteAll(e.Body); err != nil { return errMsg{err} } - if err := store.IncrementUse(e.ID); err != nil { + if err := store.IncrementUse(context.Background(), e.ID); err != nil { return errMsg{err} } return entityCopiedMsg{} @@ -185,7 +186,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd { func loadTags(store *db.Store) tea.Cmd { return func() tea.Msg { - tags, err := store.ListTags(false) + tags, err := store.ListTags(context.Background(), false) if err != nil { return errMsg{err} } @@ -195,7 +196,7 @@ func loadTags(store *db.Store) tea.Cmd { func loadRailTags(store *db.Store) tea.Cmd { return func() tea.Msg { - tags, err := store.ListTags(false) + tags, err := store.ListTags(context.Background(), false) if err != nil { return errMsg{err} } @@ -243,7 +244,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { } update := db.EntityUpdate{Body: &newBody} - if updateErr := store.Update(e.ID, &update); updateErr != nil { + if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil { return editorFinishedMsg{updateErr} } @@ -253,7 +254,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd { return func() tea.Msg { - entities, err := store.List(db.DefaultListParams()) + entities, err := store.List(context.Background(), db.DefaultListParams()) if err != nil { return errMsg{err} } @@ -263,7 +264,7 @@ func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd { func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { return func() tea.Msg { - if err := store.Absorb(targetID, sourceID); err != nil { + if err := store.Absorb(context.Background(), targetID, sourceID); err != nil { return errMsg{err} } return entityAbsorbedMsg{targetID} @@ -273,7 +274,7 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd { return func() tea.Msg { update := db.EntityUpdate{CardData: &stepsJSON} - if err := store.Update(entityID, &update); err != nil { + if err := store.Update(context.Background(), entityID, &update); err != nil { return errMsg{err} } return stepsPersistedMsg{} @@ -285,7 +286,7 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { if err := clipboard.WriteAll(resolved); err != nil { return errMsg{err} } - if err := store.IncrementUse(entityID); err != nil { + if err := store.IncrementUse(context.Background(), entityID); err != nil { return errMsg{err} } return templateCopiedMsg{} @@ -300,7 +301,7 @@ func clearStatusAfter(d time.Duration, seq int) tea.Cmd { func loadStaleEntities(store *db.Store) tea.Cmd { return func() tea.Msg { - entities, err := store.List(staleParams()) + entities, err := store.List(context.Background(), staleParams()) if err != nil { return errMsg{err} } @@ -310,7 +311,7 @@ func loadStaleEntities(store *db.Store) tea.Cmd { func stumbleDismiss(store *db.Store, id string) tea.Cmd { return func() tea.Msg { - if _, err := store.SoftDelete(id); err != nil { + if _, err := store.SoftDelete(context.Background(), id); err != nil { return errMsg{err} } return stumbleActionMsg{"dismissed"} @@ -321,7 +322,7 @@ func stumblePin(store *db.Store, id string) tea.Cmd { return func() tea.Msg { pinned := true update := db.EntityUpdate{Pinned: &pinned} - if err := store.Update(id, &update); err != nil { + if err := store.Update(context.Background(), id, &update); err != nil { return errMsg{err} } return stumbleActionMsg{"pinned"} diff --git a/internal/tui/model.go b/internal/tui/model.go index abdf619..3430af3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "strings" "time" @@ -1095,7 +1096,7 @@ func (m model) reloadDetail(id string) tea.Cmd { return tea.Batch( loadEntities(m.store, m.listParams()), func() tea.Msg { - e, err := m.store.Get(id) + e, err := m.store.Get(context.Background(), id) if err != nil { return errMsg{err} } diff --git a/web/app.js b/web/app.js index ade4b6b..71d738b 100644 --- a/web/app.js +++ b/web/app.js @@ -1946,7 +1946,8 @@ function renderMd(s) { if (!s) return ''; if (typeof marked === 'undefined') return escHtml(s); - return marked.parse(s, { breaks: true }); + const html = marked.parse(s, { breaks: true }); + return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : escHtml(s); } function isSafeUrl(url) { diff --git a/web/index.html b/web/index.html index cfa9da4..872e1b7 100644 --- a/web/index.html +++ b/web/index.html @@ -97,6 +97,7 @@ +