Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9ecc4c1f7 | |||
| 8426c2fbc1 | |||
| 1e58433936 | |||
| d24df8432f | |||
| e22e040688 | |||
| 29bd7d3dc6 | |||
| a9da5c1765 | |||
| b9b3f99be9 | |||
| cae651302a | |||
| 8fc686ec6d | |||
| 564039112a | |||
| eea59b3f3c | |||
| ceb29fdd7b | |||
| 2152baeb4f | |||
| 33f6d99ba7 | |||
| d715b053e7 | |||
| 50b80f4407 | |||
| 8663beeb96 | |||
| 1ac4196547 | |||
| a96c1a52f4 | |||
| db1dc135d2 | |||
| 7d1e0f895c | |||
| 82bc6e7ba1 | |||
| 533e086ffb | |||
| 989aa86679 | |||
| 3eb778f31b | |||
| 98fdae1e3a | |||
| a567b2ce73 | |||
| 388ae88d4a | |||
| 60705463c1 | |||
| b5b7f6b6ee | |||
| 3f57531995 | |||
| a2dac64d1f | |||
| 3daa5a2e11 | |||
| c26e2d2022 | |||
| cb10d1e93d | |||
| e20fae3543 | |||
| 4e0ac8402f | |||
| e2d0f3e997 |
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
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 -count=1 ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -trimpath -o nib .
|
||||
@@ -0,0 +1,70 @@
|
||||
# Code Review Fixes
|
||||
|
||||
## Phase 1: Critical — Concurrency & Data Safety
|
||||
|
||||
### 1. ULID entropy not goroutine-safe
|
||||
- [x] Wrap MonotonicEntropy in LockedMonotonicReader (`internal/ulid/ulid.go`)
|
||||
- [x] Add concurrent uniqueness test (100 goroutines)
|
||||
- [x] Verify `go test -race ./internal/ulid/...`
|
||||
|
||||
### 2. Migration PRAGMA bug
|
||||
- [x] Move `PRAGMA foreign_keys = OFF` before transaction in v3 migration (`internal/db/db.go`)
|
||||
- [x] Re-enable after commit
|
||||
- [x] Remove dead `currentSchema` constant while here
|
||||
|
||||
### 3. LIKE wildcard injection in link resolution
|
||||
- [x] Escape `%` and `_` in link text before LIKE query (`internal/db/links.go`)
|
||||
- [x] Add `ESCAPE '\'` clause
|
||||
- [ ] Test with `[[%]]` and `[[_]]` link text
|
||||
|
||||
## Phase 2: High — Security & API Hygiene
|
||||
|
||||
### 4. No auth warning for non-localhost binding
|
||||
- [x] Print loud warning when `--host != 127.0.0.1` and no auth configured (`cmd/serve.go`)
|
||||
- [ ] Consider `--no-auth` flag requirement for non-localhost
|
||||
|
||||
### 5. writeJSON ignores encoder errors
|
||||
- [x] Log error from `json.Encoder.Encode()` in `writeJSON` (`internal/api/helpers.go`)
|
||||
|
||||
### 6. DELETE endpoint semantics
|
||||
- [x] Add `?permanent=true` query param for hard delete (`internal/api/entities.go`)
|
||||
- [x] Add `HardDelete` store method (`internal/db/entities.go`)
|
||||
- [ ] Update frontend and CLI to match
|
||||
- [x] Keep backward compat: double-delete still works without param
|
||||
|
||||
## Phase 3: Medium — Frontend Robustness
|
||||
|
||||
### 7. No resp.ok checks on fetch calls
|
||||
- [x] Add `checkedJSON()` wrapper with error extraction (`web/app.js`)
|
||||
- [x] All API methods use `checkedJSON(resp)` instead of `resp.json()`
|
||||
- [ ] Surface API errors to user via notification/toast
|
||||
|
||||
### 8. Inconsistent event.stopPropagation
|
||||
- [x] Add stopPropagation to all renderStreamPeek action buttons
|
||||
- [x] Add stopPropagation to all renderCardPeek action buttons and inline section buttons
|
||||
|
||||
### 9. Duplicate section rendering
|
||||
- [x] Extract `renderCardSections()` shared function (`web/app.js`)
|
||||
- [x] Refactor `renderInlineDetail` to use shared function
|
||||
- [x] Refactor `renderCardPeek` to use shared function
|
||||
|
||||
## Phase 4: Medium — Data Integrity
|
||||
|
||||
### 10. Absorb discards source title/description
|
||||
- [x] If target has no title, inherit from source (`internal/db/entities.go`)
|
||||
- [x] If target has no description, inherit from source
|
||||
- [ ] Test title/description preservation
|
||||
|
||||
## Phase 5: Low — Housekeeping & UX
|
||||
|
||||
### 11. Backup path collision
|
||||
- [x] Use millisecond-precision timestamps (`cmd/backup.go`)
|
||||
|
||||
### 12. spaHandler path safety
|
||||
- [x] Add explicit `path.Clean` in spaHandler (`internal/api/router.go`)
|
||||
|
||||
### 13. Focus-peek escape hatch
|
||||
- [ ] Add visible close/back button in focus-peek mode (`web/app.js`)
|
||||
|
||||
### 14. Hard delete confirmation
|
||||
- [ ] Add confirmation step or undo toast before permanent delete (`web/app.js`)
|
||||
+4
-4
@@ -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))
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.000"))
|
||||
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
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+286
@@ -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))
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
+7
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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))
|
||||
}
|
||||
|
||||
+9
-9
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/export"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
exportOutput string
|
||||
exportFormat string
|
||||
exportTag string
|
||||
exportTitle string
|
||||
)
|
||||
|
||||
var exportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "export entities to JSON or HTML card deck",
|
||||
RunE: runExport,
|
||||
}
|
||||
|
||||
func init() {
|
||||
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
|
||||
exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "output format: json or html")
|
||||
exportCmd.Flags().StringVarP(&exportTag, "tag", "t", "", "filter by tag (used as deck name for HTML)")
|
||||
exportCmd.Flags().StringVar(&exportTitle, "title", "", "deck title for HTML export")
|
||||
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()
|
||||
|
||||
p := db.DefaultListParams()
|
||||
p.Limit = 10000
|
||||
|
||||
if exportTag != "" {
|
||||
p.Tag = &exportTag
|
||||
}
|
||||
|
||||
switch exportFormat {
|
||||
case "html":
|
||||
return runHTMLExport(cmd, store, p)
|
||||
case "json":
|
||||
p.IncludeDeleted = true
|
||||
return runJSONExport(cmd, store, p)
|
||||
default:
|
||||
return fmt.Errorf("unknown format %q (use json or html)", exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func runHTMLExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||
p.CardsOnly = true
|
||||
|
||||
entities, err := store.List(cmd.Context(), p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := exportTitle
|
||||
if title == "" && exportTag != "" {
|
||||
title = "#" + exportTag
|
||||
}
|
||||
if title == "" {
|
||||
title = "nib cards"
|
||||
}
|
||||
|
||||
if exportOutput != "" {
|
||||
f, err := os.Create(exportOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := export.RenderHTML(f, entities, title); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d cards to %s\n", len(entities), exportOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
return export.RenderHTML(cmd.OutOrStdout(), entities, title)
|
||||
}
|
||||
|
||||
func runJSONExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||
entities, err := store.List(cmd.Context(), 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
@@ -90,6 +90,9 @@ func runServe(_ *cobra.Command, _ []string) error {
|
||||
if serveDev {
|
||||
fmt.Println(" CORS enabled (dev mode)")
|
||||
}
|
||||
if serveHost != "127.0.0.1" && serveHost != "localhost" && serveHost != "::1" {
|
||||
fmt.Fprintln(os.Stderr, " WARNING: binding to non-localhost with no authentication — API is open to the network")
|
||||
}
|
||||
|
||||
var listenErr error
|
||||
if useTLS {
|
||||
|
||||
@@ -6,7 +6,7 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -14,33 +14,45 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/glamour v1.0.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
@@ -23,6 +33,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
@@ -33,6 +45,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
@@ -41,12 +55,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
@@ -56,6 +75,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -65,21 +86,34 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
|
||||
+32
-15
@@ -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,21 @@ 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)
|
||||
|
||||
if r.URL.Query().Get("permanent") == "true" {
|
||||
if err := store.HardDelete(r.Context(), id); err != nil {
|
||||
if err == db.ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, DeleteResponse{Result: "hard"})
|
||||
return
|
||||
}
|
||||
|
||||
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 +321,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 +338,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 +351,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 +364,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 +395,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 +408,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 +421,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 +430,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
|
||||
|
||||
@@ -38,7 +38,9 @@ type EntityResponse struct {
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
|
||||
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
p := path.Clean(r.URL.Path)
|
||||
if p == "/" || path.Ext(p) == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
|
||||
@@ -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
|
||||
|
||||
+49
-11
@@ -51,7 +51,10 @@ 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
|
||||
}
|
||||
|
||||
var migrations = []func(db *sql.DB) error{
|
||||
// v1: initial schema
|
||||
@@ -92,24 +95,29 @@ 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
|
||||
},
|
||||
|
||||
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||
func(db *sql.DB) error {
|
||||
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("migrate fk off: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Disable FK checks during rebuild to avoid dangling references
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("migrate fk off: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||
return fmt.Errorf("migrate rename: %w", err)
|
||||
}
|
||||
@@ -160,11 +168,41 @@ var migrations = []func(db *sql.DB) error{
|
||||
return fmt.Errorf("migrate tags drop: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("migrate fk on: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
// v5: add entity_links table for wiki-links
|
||||
func(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE entity_links (
|
||||
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
|
||||
link_text TEXT NOT NULL,
|
||||
PRIMARY KEY (from_id, link_text)
|
||||
);
|
||||
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
|
||||
`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -200,7 +238,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
|
||||
|
||||
+91
-49
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -71,6 +72,7 @@ type ListParams struct {
|
||||
From *string
|
||||
To *string
|
||||
Since *time.Time
|
||||
ModifiedBefore *time.Time
|
||||
CardsOnly bool
|
||||
IncludeDeleted bool
|
||||
CardTypeFilter *CardType
|
||||
@@ -103,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
|
||||
}
|
||||
@@ -115,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)
|
||||
@@ -146,18 +148,22 @@ 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
|
||||
}
|
||||
|
||||
if err := syncLinks(ctx, tx, s, e.ID, e.Body); 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
|
||||
@@ -173,7 +179,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
|
||||
}
|
||||
@@ -216,6 +222,10 @@ func listWhere(params ListParams) (string, []any) {
|
||||
where = append(where, "e.card_type = ?")
|
||||
args = append(args, string(*params.CardTypeFilter))
|
||||
}
|
||||
if params.ModifiedBefore != nil {
|
||||
where = append(where, "e.modified_at < ?")
|
||||
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
@@ -224,21 +234,23 @@ 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"
|
||||
switch params.Sort {
|
||||
case "use_count":
|
||||
orderCol = "e.use_count"
|
||||
case "modified_at":
|
||||
orderCol = "e.modified_at"
|
||||
case "created_at", "":
|
||||
orderCol = "e.created_at"
|
||||
default:
|
||||
@@ -268,7 +280,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
|
||||
}
|
||||
@@ -290,20 +302,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
|
||||
}
|
||||
@@ -362,15 +374,21 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
if u.Body != nil {
|
||||
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -378,8 +396,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
|
||||
}
|
||||
@@ -395,15 +413,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
|
||||
}
|
||||
@@ -411,7 +429,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 = ?`,
|
||||
@@ -426,9 +444,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
|
||||
}
|
||||
@@ -437,21 +455,33 @@ 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) HardDelete(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := s.Get(sourceID)
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -460,7 +490,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
|
||||
}
|
||||
@@ -469,8 +499,18 @@ 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 = ?",
|
||||
merged, now, targetID); err != nil {
|
||||
title := target.Title
|
||||
if title == nil {
|
||||
title = source.Title
|
||||
}
|
||||
desc := target.Description
|
||||
if desc == nil {
|
||||
desc = source.Description
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE entities SET body = ?, title = ?, description = ?, modified_at = ? WHERE id = ?",
|
||||
merged, title, desc, now, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -480,15 +520,19 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -496,7 +540,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
|
||||
}
|
||||
@@ -504,8 +548,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)
|
||||
@@ -519,8 +563,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
|
||||
}
|
||||
@@ -586,9 +630,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
|
||||
}
|
||||
@@ -608,7 +650,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
|
||||
}
|
||||
@@ -626,8 +668,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
|
||||
}
|
||||
@@ -650,9 +692,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
|
||||
}
|
||||
|
||||
+116
-87
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/lerko/nib/internal/link"
|
||||
)
|
||||
|
||||
type Backlink struct {
|
||||
EntityID string
|
||||
Title *string
|
||||
Body string
|
||||
LinkText string
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
||||
lower := strings.ToLower(linkText)
|
||||
|
||||
var id string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT id FROM entities
|
||||
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
|
||||
if err == nil {
|
||||
return &id
|
||||
}
|
||||
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT id FROM entities
|
||||
WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
|
||||
if err == nil {
|
||||
return &id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
linkTexts := link.ExtractLinks(body)
|
||||
for _, lt := range linkTexts {
|
||||
toID := s.resolveLink(ctx, tx, lt, entityID)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
|
||||
entityID, toID, lt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT e.id, e.title, e.body, el.link_text
|
||||
FROM entity_links el
|
||||
JOIN entities e ON e.id = el.from_id
|
||||
WHERE el.to_id = ? AND e.deleted_at IS NULL
|
||||
ORDER BY e.created_at DESC`, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var backlinks []Backlink
|
||||
for rows.Next() {
|
||||
var bl Backlink
|
||||
var title sql.NullString
|
||||
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if title.Valid {
|
||||
bl.Title = &title.String
|
||||
}
|
||||
backlinks = append(backlinks, bl)
|
||||
}
|
||||
return backlinks, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyncLinks_OnCreate(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
target := &Entity{Body: "nginx proxy config", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := &Entity{Body: "see [[nginx proxy config]] for setup", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 1 {
|
||||
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
|
||||
}
|
||||
if backlinks[0].EntityID != source.ID {
|
||||
t.Errorf("backlink entity = %s, want %s", backlinks[0].EntityID, source.ID)
|
||||
}
|
||||
if backlinks[0].LinkText != "nginx proxy config" {
|
||||
t.Errorf("link text = %q, want %q", backlinks[0].LinkText, "nginx proxy config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_TitleMatch(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
title := "deploy checklist"
|
||||
target := &Entity{Body: "steps to deploy", Title: &title, Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := &Entity{Body: "follow [[deploy checklist]]", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 1 {
|
||||
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_TitlePriority(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
title := "nginx config"
|
||||
titled := &Entity{Body: "some body", Title: &title, Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, titled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bodyMatch := &Entity{Body: "nginx config details", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, bodyMatch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := &Entity{Body: "see [[nginx config]]", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err := s.LoadBacklinks(ctx, titled.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 1 {
|
||||
t.Fatalf("title match should win, got %d backlinks on titled entity", len(backlinks))
|
||||
}
|
||||
|
||||
bodyBacklinks, err := s.LoadBacklinks(ctx, bodyMatch.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bodyBacklinks) != 0 {
|
||||
t.Fatalf("body match entity should have 0 backlinks, got %d", len(bodyBacklinks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_Unresolved(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
source := &Entity{Body: "see [[nonexistent entry]]", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", source.ID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 link row (unresolved), got %d", count)
|
||||
}
|
||||
|
||||
var toID *string
|
||||
err = s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", source.ID).Scan(&toID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if toID != nil {
|
||||
t.Errorf("expected NULL to_id for unresolved link, got %v", *toID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_OnUpdate(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
target := &Entity{Body: "original target", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := &Entity{Body: "no links yet", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newBody := "now has [[original target]]"
|
||||
if err := s.Update(ctx, source.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 1 {
|
||||
t.Fatalf("expected 1 backlink after update, got %d", len(backlinks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_SelfLinkUnresolved(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := &Entity{Body: "I reference [[I reference]]", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var toID *string
|
||||
err := s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", e.ID).Scan(&toID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if toID != nil {
|
||||
t.Fatalf("self-matching link should be unresolved (NULL to_id), got %v", *toID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_NoLinks(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
e := &Entity{Body: "plain text no links", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", e.ID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected 0 links, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
target := &Entity{Body: "target entry", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := &Entity{Body: "see [[target entry]]", Glyph: "note", Tags: []string{}}
|
||||
if err := s.Create(ctx, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err := s.LoadBacklinks(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 1 {
|
||||
t.Fatalf("expected 1 backlink before delete, got %d", len(backlinks))
|
||||
}
|
||||
|
||||
if _, err := s.SoftDelete(ctx, source.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backlinks, err = s.LoadBacklinks(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backlinks) != 0 {
|
||||
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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 {
|
||||
|
||||
+20
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateHTML string
|
||||
|
||||
type TemplateSlot struct {
|
||||
Name string `json:"name"`
|
||||
Default string `json:"default"`
|
||||
}
|
||||
|
||||
type CheckStep struct {
|
||||
Text string `json:"text"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type DecisionData struct {
|
||||
Chose string `json:"chose"`
|
||||
Why string `json:"why"`
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
type CardView struct {
|
||||
ID string
|
||||
Glyph string
|
||||
CardType string
|
||||
Title string
|
||||
Body template.HTML
|
||||
Description template.HTML
|
||||
SearchText string
|
||||
Pinned bool
|
||||
Tags []string
|
||||
UseCount int
|
||||
LinkURL string
|
||||
Slots []TemplateSlot
|
||||
TemplateBody string
|
||||
Steps []CheckStep
|
||||
Progress int
|
||||
Decision DecisionData
|
||||
}
|
||||
|
||||
type DeckView struct {
|
||||
Title string
|
||||
Count int
|
||||
ExportedAt string
|
||||
Types []string
|
||||
Cards []CardView
|
||||
}
|
||||
|
||||
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
|
||||
tmpl, err := template.New("deck").Parse(templateHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var types []string
|
||||
var cards []CardView
|
||||
|
||||
for _, e := range entities {
|
||||
if e.CardType == nil {
|
||||
continue
|
||||
}
|
||||
ct := string(*e.CardType)
|
||||
if !seen[ct] {
|
||||
seen[ct] = true
|
||||
types = append(types, ct)
|
||||
}
|
||||
cards = append(cards, buildCardView(e))
|
||||
}
|
||||
|
||||
data := DeckView{
|
||||
Title: title,
|
||||
Count: len(cards),
|
||||
ExportedAt: time.Now().Format("Jan 2, 2006"),
|
||||
Types: types,
|
||||
Cards: cards,
|
||||
}
|
||||
|
||||
return tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func buildCardView(e *db.Entity) CardView {
|
||||
ct := ""
|
||||
if e.CardType != nil {
|
||||
ct = string(*e.CardType)
|
||||
}
|
||||
|
||||
title := ""
|
||||
if e.Title != nil {
|
||||
title = *e.Title
|
||||
}
|
||||
|
||||
body := e.Body
|
||||
desc := ""
|
||||
if e.Description != nil {
|
||||
desc = *e.Description
|
||||
}
|
||||
|
||||
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
|
||||
|
||||
cv := CardView{
|
||||
ID: e.ID,
|
||||
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
|
||||
CardType: ct,
|
||||
Title: title,
|
||||
Body: template.HTML(template.HTMLEscapeString(body)),
|
||||
SearchText: search,
|
||||
Pinned: e.Pinned,
|
||||
Tags: e.Tags,
|
||||
UseCount: e.UseCount,
|
||||
}
|
||||
|
||||
if desc != "" {
|
||||
cv.Description = template.HTML(template.HTMLEscapeString(desc))
|
||||
}
|
||||
|
||||
if e.CardData != nil {
|
||||
parseCardData(&cv, ct, *e.CardData, body)
|
||||
}
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
func parseCardData(cv *CardView, ct string, raw string, body string) {
|
||||
switch ct {
|
||||
case "snippet":
|
||||
// body is the snippet content, already set
|
||||
|
||||
case "template":
|
||||
var data struct {
|
||||
Slots []TemplateSlot `json:"slots"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.Slots = data.Slots
|
||||
}
|
||||
cv.TemplateBody = body
|
||||
|
||||
case "checklist":
|
||||
var data struct {
|
||||
Steps []CheckStep `json:"steps"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.Steps = data.Steps
|
||||
done := 0
|
||||
for _, s := range cv.Steps {
|
||||
if s.Done {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if len(cv.Steps) > 0 {
|
||||
cv.Progress = (done * 100) / len(cv.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
case "decision":
|
||||
var dec DecisionData
|
||||
if json.Unmarshal([]byte(raw), &dec) == nil {
|
||||
cv.Decision = dec
|
||||
}
|
||||
|
||||
case "link":
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.LinkURL = data.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
|
||||
:root{
|
||||
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
|
||||
--border:#2e2e2c;--border-focus:#4a4a47;
|
||||
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
|
||||
--accent:#c4a46c;--accent2:#a68a52;
|
||||
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
|
||||
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
|
||||
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
|
||||
--card-radius:12px;
|
||||
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||
}
|
||||
|
||||
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
font-family:var(--sans);color:var(--text);background:var(--bg);
|
||||
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
.deck-header{
|
||||
position:sticky;top:0;z-index:10;
|
||||
background:var(--bg);border-bottom:1px solid var(--border);
|
||||
padding:16px 20px 12px;
|
||||
}
|
||||
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
|
||||
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
|
||||
|
||||
.filter-bar{
|
||||
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
}
|
||||
.filter-bar::-webkit-scrollbar{display:none}
|
||||
.filter-chip{
|
||||
flex-shrink:0;padding:6px 14px;border-radius:20px;
|
||||
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text2);cursor:pointer;
|
||||
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||
|
||||
.search-wrap{padding:8px 20px 4px}
|
||||
.search-input{
|
||||
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text);font-size:0.9rem;
|
||||
font-family:var(--sans);outline:none;transition:border-color 0.15s;
|
||||
}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--text3)}
|
||||
|
||||
.card-list{padding:8px 16px}
|
||||
|
||||
.card{
|
||||
background:var(--surface);border:1px solid var(--border);
|
||||
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
|
||||
transition:border-color 0.15s;position:relative;
|
||||
}
|
||||
.card:active{border-color:var(--border-focus)}
|
||||
.card.pinned{border-left:3px solid var(--accent)}
|
||||
|
||||
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||
.card-glyph{
|
||||
font-size:1.1rem;width:28px;height:28px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:var(--surface2);border-radius:6px;flex-shrink:0;
|
||||
}
|
||||
.card-type{
|
||||
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
|
||||
color:var(--text3);
|
||||
}
|
||||
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
|
||||
|
||||
.card-title{
|
||||
font-size:0.95rem;font-weight:600;color:var(--text);
|
||||
line-height:1.4;margin-bottom:6px;
|
||||
}
|
||||
.card-body{
|
||||
font-size:0.85rem;color:var(--text2);line-height:1.55;
|
||||
white-space:pre-wrap;word-break:break-word;
|
||||
}
|
||||
|
||||
.card-body code{
|
||||
font-family:var(--mono);font-size:0.8rem;
|
||||
background:var(--surface2);padding:2px 5px;border-radius:4px;
|
||||
}
|
||||
|
||||
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||
.tag{
|
||||
font-size:0.7rem;color:var(--accent);background:var(--surface2);
|
||||
padding:3px 8px;border-radius:6px;
|
||||
}
|
||||
|
||||
/* snippet */
|
||||
.snippet-block{
|
||||
background:var(--bg);border:1px solid var(--border);border-radius:8px;
|
||||
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
|
||||
}
|
||||
.snippet-block pre{
|
||||
font-family:var(--mono);font-size:0.8rem;color:var(--text);
|
||||
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
|
||||
}
|
||||
.copy-btn{
|
||||
position:absolute;top:8px;right:8px;
|
||||
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text2);font-size:0.7rem;
|
||||
cursor:pointer;transition:all 0.15s;
|
||||
}
|
||||
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||
|
||||
/* checklist */
|
||||
.checklist{margin-top:8px;list-style:none}
|
||||
.checklist li{
|
||||
display:flex;align-items:flex-start;gap:10px;
|
||||
padding:8px 0;border-bottom:1px solid var(--border);
|
||||
font-size:0.85rem;color:var(--text2);
|
||||
}
|
||||
.checklist li:last-child{border-bottom:none}
|
||||
.check-box{
|
||||
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
|
||||
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:all 0.15s;margin-top:1px;
|
||||
}
|
||||
.check-box.checked{background:var(--green);border-color:var(--green)}
|
||||
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
|
||||
.check-text.done{text-decoration:line-through;color:var(--text3)}
|
||||
.progress-bar{
|
||||
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
|
||||
}
|
||||
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
|
||||
|
||||
/* template */
|
||||
.template-field{margin-top:8px}
|
||||
.template-field label{
|
||||
display:block;font-size:0.7rem;font-weight:600;
|
||||
text-transform:uppercase;letter-spacing:0.05em;
|
||||
color:var(--text3);margin-bottom:4px;
|
||||
}
|
||||
.template-field input{
|
||||
width:100%;padding:8px 12px;border-radius:8px;
|
||||
border:1px solid var(--border);background:var(--surface2);
|
||||
color:var(--text);font-size:0.85rem;font-family:var(--sans);
|
||||
outline:none;transition:border-color 0.15s;
|
||||
}
|
||||
.template-field input:focus{border-color:var(--accent)}
|
||||
.template-output{margin-top:10px}
|
||||
.template-copy-btn{
|
||||
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
|
||||
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
|
||||
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
|
||||
}
|
||||
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
|
||||
|
||||
/* decision */
|
||||
.decision-grid{margin-top:8px}
|
||||
.decision-row{
|
||||
padding:8px 0;border-bottom:1px solid var(--border);
|
||||
}
|
||||
.decision-row:last-child{border-bottom:none}
|
||||
.decision-label{
|
||||
font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
|
||||
}
|
||||
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
|
||||
.decision-rejected{color:var(--red);font-style:italic}
|
||||
|
||||
/* link */
|
||||
.link-target{
|
||||
display:flex;align-items:center;gap:10px;margin-top:8px;
|
||||
padding:12px;background:var(--bg);border:1px solid var(--border);
|
||||
border-radius:8px;text-decoration:none;color:var(--text);
|
||||
transition:border-color 0.15s;
|
||||
}
|
||||
.link-target:active{border-color:var(--accent)}
|
||||
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
|
||||
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
|
||||
|
||||
.empty-state{
|
||||
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
|
||||
}
|
||||
|
||||
@media(min-width:640px){
|
||||
.card-list{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
|
||||
gap:10px;padding:8px 20px;
|
||||
}
|
||||
.card{margin-bottom:0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="deck-header">
|
||||
<div class="deck-title">{{.Title}}</div>
|
||||
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" placeholder="search cards…" id="search">
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" id="filters">
|
||||
<button class="filter-chip active" data-type="all">All</button>
|
||||
{{range .Types}}
|
||||
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-list" id="cards">
|
||||
{{range .Cards}}
|
||||
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
|
||||
<div class="card-top">
|
||||
<span class="card-glyph">{{.Glyph}}</span>
|
||||
<span class="card-type">{{.CardType}}</span>
|
||||
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
|
||||
</div>
|
||||
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
|
||||
|
||||
{{if eq .CardType "snippet"}}
|
||||
<div class="snippet-block">
|
||||
<button class="copy-btn" onclick="copyText(this)">copy</button>
|
||||
<pre>{{.Body}}</pre>
|
||||
</div>
|
||||
{{else if eq .CardType "checklist"}}
|
||||
<div class="card-body">{{.Description}}</div>
|
||||
<ul class="checklist" data-card-id="{{.ID}}">
|
||||
{{range $i, $step := .Steps}}
|
||||
<li>
|
||||
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
|
||||
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
|
||||
{{else if eq .CardType "template"}}
|
||||
<div class="card-body">{{.Description}}</div>
|
||||
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
|
||||
{{range .Slots}}
|
||||
<div class="template-field">
|
||||
<label>{{.Name}}</label>
|
||||
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="template-output">
|
||||
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else if eq .CardType "decision"}}
|
||||
<div class="decision-grid">
|
||||
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
|
||||
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
|
||||
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
|
||||
</div>
|
||||
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
|
||||
{{else if eq .CardType "link"}}
|
||||
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
|
||||
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
|
||||
<span class="link-url">{{.LinkURL}}</span>
|
||||
<span class="link-arrow">↗</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="card-body">{{.Body}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Tags}}
|
||||
<div class="card-tags">
|
||||
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const cards=document.querySelectorAll('.card');
|
||||
const chips=document.querySelectorAll('.filter-chip');
|
||||
const search=document.getElementById('search');
|
||||
const empty=document.getElementById('empty');
|
||||
let activeType='all';
|
||||
|
||||
function applyFilters(){
|
||||
const q=search.value.toLowerCase().trim();
|
||||
let visible=0;
|
||||
cards.forEach(c=>{
|
||||
const typeMatch=activeType==='all'||c.dataset.type===activeType;
|
||||
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
|
||||
c.style.display=(typeMatch&&searchMatch)?'':'none';
|
||||
if(typeMatch&&searchMatch)visible++;
|
||||
});
|
||||
empty.style.display=visible?'none':'block';
|
||||
}
|
||||
|
||||
chips.forEach(chip=>{
|
||||
chip.addEventListener('click',()=>{
|
||||
chips.forEach(c=>c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
activeType=chip.dataset.type;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
search.addEventListener('input',applyFilters);
|
||||
})();
|
||||
|
||||
function copyText(btn){
|
||||
const pre=btn.parentElement.querySelector('pre');
|
||||
navigator.clipboard.writeText(pre.textContent).then(()=>{
|
||||
btn.textContent='copied!';
|
||||
setTimeout(()=>btn.textContent='copy',1500);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCheck(box){
|
||||
box.classList.toggle('checked');
|
||||
const span=box.nextElementSibling;
|
||||
span.classList.toggle('done');
|
||||
const list=box.closest('.checklist');
|
||||
const boxes=list.querySelectorAll('.check-box');
|
||||
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
|
||||
const bar=list.nextElementSibling.querySelector('.progress-fill');
|
||||
bar.style.width=Math.round((done/boxes.length)*100)+'%';
|
||||
}
|
||||
|
||||
function updateTemplate(input){
|
||||
const form=input.closest('.template-form');
|
||||
const tmpl=form.dataset.template;
|
||||
let filled=tmpl;
|
||||
form.querySelectorAll('input[data-slot]').forEach(inp=>{
|
||||
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
|
||||
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
|
||||
});
|
||||
form._filled=filled;
|
||||
}
|
||||
|
||||
function copyTemplate(btn){
|
||||
const form=btn.closest('.template-form');
|
||||
const text=form._filled||form.dataset.template;
|
||||
navigator.clipboard.writeText(text).then(()=>{
|
||||
btn.textContent='copied!';
|
||||
setTimeout(()=>btn.textContent='copy filled template',1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||
|
||||
func ExtractLinks(body string) []string {
|
||||
matches := linkRe.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var result []string
|
||||
for _, m := range matches {
|
||||
text := strings.TrimSpace(m[1])
|
||||
if text == "" || seen[text] {
|
||||
continue
|
||||
}
|
||||
seen[text] = true
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package link
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{"no links", "plain text with no links", nil},
|
||||
{"single link", "see [[nginx config]] for details", []string{"nginx config"}},
|
||||
{"multiple links", "see [[nginx config]] and [[deploy steps]]", []string{"nginx config", "deploy steps"}},
|
||||
{"duplicate deduped", "[[foo]] then [[foo]] again", []string{"foo"}},
|
||||
{"empty brackets", "empty [[ ]] ignored", nil},
|
||||
{"just brackets no content", "[[]] empty", nil},
|
||||
{"link with special chars", "see [[deploy: staging (v2)]]", []string{"deploy: staging (v2)"}},
|
||||
{"link in markdown", "# heading\n\nsee [[my note]] for info", []string{"my note"}},
|
||||
{"adjacent links", "[[one]][[two]]", []string{"one", "two"}},
|
||||
{"partial brackets ignored", "not a [link] or [[incomplete", nil},
|
||||
{"link with hash", "see [[#ops channel]]", []string{"#ops channel"}},
|
||||
{"multiline body", "line one [[link one]]\nline two [[link two]]", []string{"link one", "link two"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractLinks(tt.body)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
@@ -17,6 +18,9 @@ type Result struct {
|
||||
CardSuffix *string
|
||||
Pin bool
|
||||
Query bool
|
||||
QueryDateFrom *string
|
||||
QueryDateTo *string
|
||||
QueryCardType *string
|
||||
}
|
||||
|
||||
var validCardTypes = map[string]string{
|
||||
@@ -66,13 +70,50 @@ func Parse(input string) (*Result, error) {
|
||||
r.Glyph = ""
|
||||
tokens := strings.Fields(remaining)
|
||||
var bodyParts []string
|
||||
now := time.Now()
|
||||
for _, tok := range tokens {
|
||||
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
|
||||
tag := strings.ToLower(tok[1:])
|
||||
r.FilterTags = append(r.FilterTags, tag)
|
||||
case tok == "@today":
|
||||
d := now.Format("2006-01-02")
|
||||
r.QueryDateFrom = &d
|
||||
r.QueryDateTo = &d
|
||||
case tok == "@yesterday":
|
||||
d := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
r.QueryDateFrom = &d
|
||||
r.QueryDateTo = &d
|
||||
case tok == "@week":
|
||||
d := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||
r.QueryDateFrom = &d
|
||||
case tok == "@month":
|
||||
d := now.AddDate(0, -1, 0).Format("2006-01-02")
|
||||
r.QueryDateFrom = &d
|
||||
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
|
||||
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||
r.QueryDateTo = &d
|
||||
} else {
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
|
||||
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||
r.QueryDateFrom = &d
|
||||
} else {
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||
suffix := tok[1:]
|
||||
if ct, ok := validCardTypes[suffix]; ok {
|
||||
r.QueryCardType = &ct
|
||||
} else {
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
default:
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
}
|
||||
r.Body = strings.Join(bodyParts, " ")
|
||||
return r, nil
|
||||
|
||||
@@ -158,6 +158,67 @@ func TestParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueryComposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantBody string
|
||||
wantTags []string
|
||||
wantDateFrom bool
|
||||
wantDateTo bool
|
||||
wantCardType *string
|
||||
}{
|
||||
{"today", "?@today", "", nil, true, true, nil},
|
||||
{"yesterday", "?@yesterday", "", nil, true, true, nil},
|
||||
{"week", "?@week", "", nil, true, false, nil},
|
||||
{"month", "?@month", "", nil, true, false, nil},
|
||||
{"newer than", "?<7d", "", nil, true, false, nil},
|
||||
{"older than", "?>30d", "", nil, false, true, nil},
|
||||
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
|
||||
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
|
||||
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
|
||||
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
|
||||
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
|
||||
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
|
||||
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
|
||||
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
|
||||
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got.Query {
|
||||
t.Fatal("expected Query=true")
|
||||
}
|
||||
if got.Body != tt.wantBody {
|
||||
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
|
||||
}
|
||||
if !tagsEq(got.FilterTags, tt.wantTags) {
|
||||
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
|
||||
}
|
||||
if tt.wantDateFrom && got.QueryDateFrom == nil {
|
||||
t.Error("expected QueryDateFrom to be set")
|
||||
}
|
||||
if !tt.wantDateFrom && got.QueryDateFrom != nil {
|
||||
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
|
||||
}
|
||||
if tt.wantDateTo && got.QueryDateTo == nil {
|
||||
t.Error("expected QueryDateTo to be set")
|
||||
}
|
||||
if !tt.wantDateTo && got.QueryDateTo != nil {
|
||||
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
|
||||
}
|
||||
if !ptrEq(got.QueryCardType, tt.wantCardType) {
|
||||
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptrEq(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
|
||||
@@ -108,12 +108,14 @@ func (a absorbModel) visibleCount() int {
|
||||
|
||||
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||
body = body[:idx]
|
||||
}
|
||||
|
||||
var tags string
|
||||
if len(e.Tags) > 0 {
|
||||
@@ -125,11 +127,12 @@ func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||
tags = " " + strings.Join(tagParts, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
line := fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||
body = truncate(body, maxWidth-overhead)
|
||||
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||
}
|
||||
|
||||
return line
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
const maxSuggestions = 5
|
||||
|
||||
type autocompleteModel struct {
|
||||
suggestions []string
|
||||
cursor int
|
||||
active bool
|
||||
prefix string
|
||||
tokenStart int
|
||||
tokenEnd int
|
||||
}
|
||||
|
||||
func (a *autocompleteModel) moveUp() {
|
||||
if a.cursor > 0 {
|
||||
a.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
func (a *autocompleteModel) moveDown() {
|
||||
if a.cursor < len(a.suggestions)-1 {
|
||||
a.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
func (a autocompleteModel) selected() string {
|
||||
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
|
||||
return ""
|
||||
}
|
||||
return a.suggestions[a.cursor]
|
||||
}
|
||||
|
||||
func (a autocompleteModel) visibleCount() int {
|
||||
if len(a.suggestions) > maxSuggestions {
|
||||
return maxSuggestions
|
||||
}
|
||||
return len(a.suggestions)
|
||||
}
|
||||
|
||||
func (a autocompleteModel) view(width int) string {
|
||||
if !a.active || len(a.suggestions) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
n := a.visibleCount()
|
||||
for i := 0; i < n; i++ {
|
||||
tag := "#" + a.suggestions[i]
|
||||
if i == a.cursor {
|
||||
b.WriteString(acSelectedStyle.Render(" › " + tag))
|
||||
} else {
|
||||
b.WriteString(acItemStyle.Render(" " + tag))
|
||||
}
|
||||
if i < n-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(a.suggestions) > maxSuggestions {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(acItemStyle.Render(" …"))
|
||||
}
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Width(min(30, width)).
|
||||
Render(b.String())
|
||||
return box
|
||||
}
|
||||
|
||||
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
|
||||
if cursorPos > len(val) {
|
||||
cursorPos = len(val)
|
||||
}
|
||||
|
||||
start := cursorPos
|
||||
for start > 0 && val[start-1] != ' ' {
|
||||
start--
|
||||
}
|
||||
|
||||
if start >= len(val) || val[start] != '#' {
|
||||
return 0, 0, "", false
|
||||
}
|
||||
|
||||
end := cursorPos
|
||||
for end < len(val) && val[end] != ' ' {
|
||||
end++
|
||||
}
|
||||
|
||||
prefix = strings.ToLower(val[start+1 : cursorPos])
|
||||
return start, end, prefix, true
|
||||
}
|
||||
|
||||
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
prefix = strings.ToLower(prefix)
|
||||
var result []string
|
||||
for _, tc := range tags {
|
||||
lower := strings.ToLower(tc.Tag)
|
||||
if strings.HasPrefix(lower, prefix) && lower != prefix {
|
||||
result = append(result, tc.Tag)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
func TestTagTokenAtCursor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val string
|
||||
cursor int
|
||||
wantStart int
|
||||
wantEnd int
|
||||
wantPfx string
|
||||
wantOk bool
|
||||
}{
|
||||
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
|
||||
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
|
||||
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
|
||||
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
|
||||
{"not in tag", "hello world", 5, 0, 0, "", false},
|
||||
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
|
||||
{"cursor at hash", "#ops", 1, 0, 4, "", true},
|
||||
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
|
||||
{"empty string", "", 0, 0, 0, "", false},
|
||||
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
|
||||
if ok != tt.wantOk {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if start != tt.wantStart || end != tt.wantEnd {
|
||||
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
|
||||
}
|
||||
if pfx != tt.wantPfx {
|
||||
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterTagSuggestions(t *testing.T) {
|
||||
tags := []db.TagCount{
|
||||
{Tag: "ops", Count: 5},
|
||||
{Tag: "ops-deploy", Count: 3},
|
||||
{Tag: "infra", Count: 2},
|
||||
{Tag: "ops-team", Count: 1},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
want []string
|
||||
}{
|
||||
{"empty prefix", "", nil},
|
||||
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
|
||||
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
|
||||
{"no match", "zzz", nil},
|
||||
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
|
||||
{"single match", "inf", []string{"infra"}},
|
||||
{"full match excluded", "infra", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := filterTagSuggestions(tags, tt.prefix)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+117
-10
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type cardGroup struct {
|
||||
label string
|
||||
start int
|
||||
count int
|
||||
}
|
||||
|
||||
type cardsModel struct {
|
||||
entities []*db.Entity
|
||||
filtered []*db.Entity
|
||||
groups []cardGroup
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
@@ -91,10 +98,17 @@ func (c *cardsModel) setIntent(i intent) {
|
||||
}
|
||||
|
||||
func (c *cardsModel) applyFilter() {
|
||||
c.filtered = nil
|
||||
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
|
||||
if c.cursor >= len(c.filtered) {
|
||||
c.cursor = max(0, len(c.filtered)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
|
||||
if intentFilter != intentAll {
|
||||
var pinned, rest []*db.Entity
|
||||
for _, e := range c.entities {
|
||||
if !matchesIntent(e, c.intent) {
|
||||
for _, e := range entities {
|
||||
if !matchesIntent(e, intentFilter) {
|
||||
continue
|
||||
}
|
||||
if e.Pinned {
|
||||
@@ -103,10 +117,48 @@ func (c *cardsModel) applyFilter() {
|
||||
rest = append(rest, e)
|
||||
}
|
||||
}
|
||||
c.filtered = append(pinned, rest...)
|
||||
if c.cursor >= len(c.filtered) {
|
||||
c.cursor = max(0, len(c.filtered)-1)
|
||||
return append(pinned, rest...), nil
|
||||
}
|
||||
|
||||
var pinned, grab, read, fill []*db.Entity
|
||||
for _, e := range entities {
|
||||
if e.Pinned {
|
||||
pinned = append(pinned, e)
|
||||
} else {
|
||||
switch {
|
||||
case matchesIntent(e, intentGrab):
|
||||
grab = append(grab, e)
|
||||
case matchesIntent(e, intentRead):
|
||||
read = append(read, e)
|
||||
case matchesIntent(e, intentFill):
|
||||
fill = append(fill, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filtered []*db.Entity
|
||||
var groups []cardGroup
|
||||
for _, bucket := range []struct {
|
||||
label string
|
||||
entities []*db.Entity
|
||||
}{
|
||||
{"pinned", pinned},
|
||||
{"grab", grab},
|
||||
{"read", read},
|
||||
{"fill", fill},
|
||||
} {
|
||||
if len(bucket.entities) == 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, cardGroup{
|
||||
label: bucket.label,
|
||||
start: len(filtered),
|
||||
count: len(bucket.entities),
|
||||
})
|
||||
filtered = append(filtered, bucket.entities...)
|
||||
}
|
||||
|
||||
return filtered, groups
|
||||
}
|
||||
|
||||
func (c *cardsModel) setSize(width, height int) {
|
||||
@@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string {
|
||||
if len(c.filtered) == 0 {
|
||||
return statusStyle.Render("no cards")
|
||||
}
|
||||
if len(c.groups) > 0 {
|
||||
return c.groupedView(width)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
visible := c.visibleCount()
|
||||
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c cardsModel) groupedView(width int) string {
|
||||
entityWidth := width - 4 - dateGutterWidth
|
||||
|
||||
type displayLine struct {
|
||||
text string
|
||||
entityIdx int
|
||||
}
|
||||
|
||||
var lines []displayLine
|
||||
for _, g := range c.groups {
|
||||
for i := 0; i < g.count; i++ {
|
||||
eIdx := g.start + i
|
||||
var gutter string
|
||||
if i == 0 {
|
||||
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||
} else {
|
||||
gutter = gutterStyle.Render(" │ ")
|
||||
}
|
||||
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
|
||||
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
|
||||
}
|
||||
}
|
||||
|
||||
visible := c.visibleCount()
|
||||
offset := c.offset
|
||||
if c.cursor < offset {
|
||||
offset = c.cursor
|
||||
}
|
||||
if c.cursor >= offset+visible {
|
||||
offset = c.cursor - visible + 1
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
end := min(offset+visible, len(lines))
|
||||
for i := offset; i < end; i++ {
|
||||
dl := lines[i]
|
||||
if dl.entityIdx == c.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(dl.text))
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c cardsModel) visibleCount() int {
|
||||
if c.height <= 0 {
|
||||
return 20
|
||||
@@ -197,12 +301,14 @@ func (c cardsModel) visibleCount() int {
|
||||
|
||||
func renderCard(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||
body = body[:idx]
|
||||
}
|
||||
|
||||
affordance := detectAffordance(e)
|
||||
affordStr := ""
|
||||
@@ -231,11 +337,12 @@ func renderCard(e *db.Entity, maxWidth int) string {
|
||||
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
|
||||
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-30)
|
||||
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
|
||||
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||
body = truncate(body, maxWidth-overhead)
|
||||
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||
}
|
||||
|
||||
return line
|
||||
|
||||
+94
-16
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -54,10 +55,28 @@ type stepsPersistedMsg struct{}
|
||||
|
||||
type templateCopiedMsg struct{}
|
||||
|
||||
type backlinksLoadedMsg struct {
|
||||
backlinks []db.Backlink
|
||||
}
|
||||
|
||||
type tagsLoadedMsg struct {
|
||||
tags []db.TagCount
|
||||
}
|
||||
|
||||
type railTagsLoadedMsg struct {
|
||||
tags []db.TagCount
|
||||
}
|
||||
|
||||
type staleEntitiesLoadedMsg struct {
|
||||
entities []*db.Entity
|
||||
}
|
||||
|
||||
type stumbleActionMsg struct {
|
||||
action string
|
||||
}
|
||||
|
||||
type statusClearMsg struct{ seq int }
|
||||
|
||||
type editorFinishedMsg struct {
|
||||
err error
|
||||
}
|
||||
@@ -68,7 +87,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}
|
||||
}
|
||||
@@ -78,7 +97,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}
|
||||
@@ -87,7 +106,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}
|
||||
@@ -104,10 +123,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}
|
||||
}
|
||||
@@ -123,10 +142,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}
|
||||
}
|
||||
@@ -141,7 +160,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}
|
||||
@@ -150,7 +169,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}
|
||||
@@ -162,7 +181,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{}
|
||||
@@ -171,7 +190,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}
|
||||
}
|
||||
@@ -179,8 +198,31 @@ func loadTags(store *db.Store) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return backlinksLoadedMsg{backlinks}
|
||||
}
|
||||
}
|
||||
|
||||
func loadRailTags(store *db.Store) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
tags, err := store.ListTags(context.Background(), false)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return railTagsLoadedMsg{tags}
|
||||
}
|
||||
}
|
||||
|
||||
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
||||
editorEnv := os.Getenv("EDITOR")
|
||||
if editorEnv == "" {
|
||||
editorEnv = os.Getenv("VISUAL")
|
||||
}
|
||||
if editorEnv == "" {
|
||||
editorEnv = "vi"
|
||||
}
|
||||
@@ -216,7 +258,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}
|
||||
}
|
||||
|
||||
@@ -226,7 +268,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}
|
||||
}
|
||||
@@ -236,7 +278,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}
|
||||
@@ -246,7 +288,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{}
|
||||
@@ -258,9 +300,45 @@ 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{}
|
||||
}
|
||||
}
|
||||
|
||||
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
||||
return tea.Tick(d, func(time.Time) tea.Msg {
|
||||
return statusClearMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
func loadStaleEntities(store *db.Store) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entities, err := store.List(context.Background(), staleParams())
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return staleEntitiesLoadedMsg{entities}
|
||||
}
|
||||
}
|
||||
|
||||
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return stumbleActionMsg{"dismissed"}
|
||||
}
|
||||
}
|
||||
|
||||
func stumblePin(store *db.Store, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
pinned := true
|
||||
update := db.EntityUpdate{Pinned: &pinned}
|
||||
if err := store.Update(context.Background(), id, &update); err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return stumbleActionMsg{"pinned"}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-3
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
|
||||
type detailModel struct {
|
||||
entity *db.Entity
|
||||
backlinks []db.Backlink
|
||||
scroll int
|
||||
height int
|
||||
width int
|
||||
@@ -35,6 +37,7 @@ func newDetailModel() detailModel {
|
||||
|
||||
func (d *detailModel) setEntity(e *db.Entity) {
|
||||
d.entity = e
|
||||
d.backlinks = nil
|
||||
d.scroll = 0
|
||||
d.mode = detailPreview
|
||||
}
|
||||
@@ -61,6 +64,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
||||
}
|
||||
case "down", "j":
|
||||
d.scroll++
|
||||
case "pgdown", "ctrl+d":
|
||||
d.scroll += d.height
|
||||
case "pgup", "ctrl+u":
|
||||
d.scroll -= d.height
|
||||
if d.scroll < 0 {
|
||||
d.scroll = 0
|
||||
}
|
||||
case "home", "g":
|
||||
d.scroll = 0
|
||||
case "end", "G":
|
||||
d.scroll = 1<<31 - 1
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -98,7 +112,20 @@ func (d detailModel) previewView(width int) string {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(detailBodyStyle.Render(e.Body))
|
||||
bodyWidth := width - 4
|
||||
if bodyWidth < 20 {
|
||||
bodyWidth = 20
|
||||
}
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath(glamourStyle()),
|
||||
glamour.WithWordWrap(bodyWidth),
|
||||
)
|
||||
rendered, err := r.Render(e.Body)
|
||||
if err != nil {
|
||||
rendered = e.Body
|
||||
}
|
||||
rendered = strings.TrimRight(rendered, "\n")
|
||||
b.WriteString(detailBodyStyle.Render(rendered))
|
||||
b.WriteString("\n")
|
||||
|
||||
if e.CardType != nil {
|
||||
@@ -119,6 +146,25 @@ func (d detailModel) previewView(width int) string {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(d.backlinks) > 0 {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
|
||||
b.WriteString("\n")
|
||||
for _, bl := range d.backlinks {
|
||||
label := bl.Body
|
||||
if bl.Title != nil {
|
||||
label = *bl.Title
|
||||
} else if len(label) > 40 {
|
||||
label = label[:40] + "…"
|
||||
}
|
||||
line := " " + backlinkStyle.Render(label)
|
||||
if bl.LinkText != "" {
|
||||
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||
if e.ModifiedAt != e.CreatedAt {
|
||||
@@ -142,8 +188,24 @@ func (d detailModel) previewView(width int) string {
|
||||
b.WriteString(idStyle.Render(meta))
|
||||
|
||||
lines := strings.Split(b.String(), "\n")
|
||||
if d.scroll > 0 && d.scroll < len(lines) {
|
||||
lines = lines[d.scroll:]
|
||||
totalLines := len(lines)
|
||||
|
||||
maxScroll := totalLines - d.height
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
scroll := d.scroll
|
||||
if scroll > maxScroll {
|
||||
scroll = maxScroll
|
||||
}
|
||||
|
||||
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
|
||||
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
|
||||
lines[0] = lines[0] + indicator
|
||||
}
|
||||
|
||||
if scroll > 0 && scroll < totalLines {
|
||||
lines = lines[scroll:]
|
||||
}
|
||||
if d.height > 0 && len(lines) > d.height {
|
||||
lines = lines[:d.height]
|
||||
|
||||
+34
-3
@@ -7,27 +7,50 @@ func renderHelp(width, height int) string {
|
||||
title string
|
||||
binds [][2]string
|
||||
}{
|
||||
{"Focus", [][2]string{
|
||||
{"tab", "toggle capture ↔ list"},
|
||||
{"esc", "back / clear filter / to capture"},
|
||||
{"a", "focus capture bar"},
|
||||
{"h", "focus tag rail (from list)"},
|
||||
{"l", "focus detail (split view)"},
|
||||
{"ctrl+b", "toggle tag rail"},
|
||||
}},
|
||||
{"Capture Bar", [][2]string{
|
||||
{"enter", "submit (or browse if empty)"},
|
||||
{"?…", "search (type ?query)"},
|
||||
{"#…", "tag (autocomplete with tab)"},
|
||||
{"-", "todo prefix"},
|
||||
{"@", "event prefix"},
|
||||
{"!", "reminder prefix"},
|
||||
}},
|
||||
{"Query Operators", [][2]string{
|
||||
{"?text", "substring search"},
|
||||
{"?#tag1 #tag2", "filter by tags (AND)"},
|
||||
{"?@today @week", "date filter (@yesterday @month)"},
|
||||
{"?<7d >30d", "newer/older than N days"},
|
||||
{"?^snippet", "card type filter"},
|
||||
}},
|
||||
{"Navigation", [][2]string{
|
||||
{"j/k ↑/↓", "move cursor"},
|
||||
{"g/G home/end", "top / bottom"},
|
||||
{"pgup/pgdn", "page up / down"},
|
||||
{"enter", "view detail"},
|
||||
{"esc", "back / clear filter"},
|
||||
}},
|
||||
{"Views", [][2]string{
|
||||
{"1", "stream view"},
|
||||
{"2", "cards view"},
|
||||
{"s", "cycle sort (cards)"},
|
||||
{"tab", "cycle intent (cards)"},
|
||||
{"i", "cycle intent (cards)"},
|
||||
{"T", "cycle theme"},
|
||||
}},
|
||||
{"Actions", [][2]string{
|
||||
{"a", "add entity (or ?query to search)"},
|
||||
{"d", "delete (with confirm)"},
|
||||
{"x", "toggle todo completion"},
|
||||
{"!", "toggle pin"},
|
||||
{"#", "filter by tag"},
|
||||
{"m", "absorb (merge into target)"},
|
||||
{"p", "promote to card"},
|
||||
{"S", "stumble (resurface stale entries)"},
|
||||
}},
|
||||
{"Detail View", [][2]string{
|
||||
{"p", "promote to card"},
|
||||
@@ -38,6 +61,14 @@ func renderHelp(width, height int) string {
|
||||
{"r", "run checklist"},
|
||||
{"f", "fill template"},
|
||||
}},
|
||||
{"Stumble", [][2]string{
|
||||
{"n / →", "skip to next"},
|
||||
{"d", "dismiss (soft delete)"},
|
||||
{"!", "pin"},
|
||||
{"p", "promote to card"},
|
||||
{"m", "absorb"},
|
||||
{"esc", "exit"},
|
||||
}},
|
||||
{"Run Mode", [][2]string{
|
||||
{"j/k", "move between steps"},
|
||||
{"space", "toggle step"},
|
||||
|
||||
+38
-37
@@ -15,11 +15,13 @@ type inputResult struct {
|
||||
query bool
|
||||
body string
|
||||
tags []string
|
||||
dateFrom *string
|
||||
dateTo *string
|
||||
cardType *db.CardType
|
||||
}
|
||||
|
||||
type inputModel struct {
|
||||
ti textinput.Model
|
||||
active bool
|
||||
preview *parse.Result
|
||||
}
|
||||
|
||||
@@ -31,15 +33,8 @@ func newInputModel() inputModel {
|
||||
return inputModel{ti: ti}
|
||||
}
|
||||
|
||||
func (i *inputModel) focus() {
|
||||
i.active = true
|
||||
i.ti.Focus()
|
||||
}
|
||||
|
||||
func (i *inputModel) reset() {
|
||||
i.active = false
|
||||
func (i *inputModel) clearText() {
|
||||
i.ti.SetValue("")
|
||||
i.ti.Blur()
|
||||
i.preview = nil
|
||||
}
|
||||
|
||||
@@ -55,11 +50,18 @@ func (i inputModel) submit() *inputResult {
|
||||
}
|
||||
|
||||
if parsed.Query {
|
||||
return &inputResult{
|
||||
r := &inputResult{
|
||||
query: true,
|
||||
body: parsed.Body,
|
||||
tags: parsed.FilterTags,
|
||||
dateFrom: parsed.QueryDateFrom,
|
||||
dateTo: parsed.QueryDateTo,
|
||||
}
|
||||
if parsed.QueryCardType != nil {
|
||||
ct := db.CardType(*parsed.QueryCardType)
|
||||
r.cardType = &ct
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
e := &db.Entity{
|
||||
@@ -101,21 +103,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||
return i
|
||||
}
|
||||
|
||||
func (i inputModel) view(width int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(i.ti.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(i.renderPreview(width))
|
||||
return b.String()
|
||||
func (i inputModel) viewBar(width int, focused bool) string {
|
||||
tiView := i.ti.View()
|
||||
if focused {
|
||||
return tiView
|
||||
}
|
||||
val := i.ti.Value()
|
||||
if val != "" {
|
||||
return hintDescStyle.Render("› " + val)
|
||||
}
|
||||
return hintDescStyle.Render("› capture a thought…")
|
||||
}
|
||||
|
||||
func (i inputModel) renderPreview(width int) string {
|
||||
func (i inputModel) previewText() string {
|
||||
if i.preview == nil {
|
||||
return drawerPreviewStyle.Render("")
|
||||
return ""
|
||||
}
|
||||
|
||||
p := i.preview
|
||||
@@ -128,7 +130,16 @@ func (i inputModel) renderPreview(width int) string {
|
||||
for _, t := range p.FilterTags {
|
||||
q += " #" + t
|
||||
}
|
||||
return drawerPreviewStyle.Render("search: " + q)
|
||||
if p.QueryDateFrom != nil {
|
||||
q += " from:" + *p.QueryDateFrom
|
||||
}
|
||||
if p.QueryDateTo != nil {
|
||||
q += " to:" + *p.QueryDateTo
|
||||
}
|
||||
if p.QueryCardType != nil {
|
||||
q += " ^" + *p.QueryCardType
|
||||
}
|
||||
return "search: " + q
|
||||
}
|
||||
|
||||
glyph := glyphForParsed(p.Glyph)
|
||||
@@ -140,22 +151,16 @@ func (i inputModel) renderPreview(width int) string {
|
||||
var parts []string
|
||||
parts = append(parts, glyph, body)
|
||||
for _, t := range p.Tags {
|
||||
parts = append(parts, tagStyle.Render("#"+t))
|
||||
parts = append(parts, "#"+t)
|
||||
}
|
||||
if p.Pin {
|
||||
parts = append(parts, pinnedStyle.Render("•"))
|
||||
parts = append(parts, "•")
|
||||
}
|
||||
if p.CardSuffix != nil {
|
||||
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
|
||||
parts = append(parts, *p.CardSuffix)
|
||||
}
|
||||
|
||||
line := strings.Join(parts, " ")
|
||||
maxW := width - 4
|
||||
if maxW > 0 && len(stripAnsi(line)) > maxW {
|
||||
line = truncate(line, maxW)
|
||||
}
|
||||
|
||||
return drawerPreviewStyle.Render(line)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func glyphForParsed(glyph string) string {
|
||||
@@ -170,7 +175,3 @@ func glyphForParsed(glyph string) string {
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
func drawerLines() int {
|
||||
return 3
|
||||
}
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@ type keyMap struct {
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Back key.Binding
|
||||
Add key.Binding
|
||||
Capture key.Binding
|
||||
Delete key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
@@ -31,6 +31,10 @@ type keyMap struct {
|
||||
Fill key.Binding
|
||||
FocusLeft key.Binding
|
||||
FocusRight key.Binding
|
||||
Tab key.Binding
|
||||
ToggleRail key.Binding
|
||||
Stumble key.Binding
|
||||
Theme key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
@@ -38,7 +42,7 @@ var keys = keyMap{
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
|
||||
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
|
||||
Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
|
||||
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
@@ -56,10 +60,14 @@ var keys = keyMap{
|
||||
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
|
||||
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
||||
Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
|
||||
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
||||
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
|
||||
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
|
||||
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
|
||||
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
|
||||
}
|
||||
|
||||
+10
-9
@@ -204,23 +204,23 @@ func renderEntity(e *db.Entity, maxWidth int) string {
|
||||
}
|
||||
glyph := style.Render(glyphStr)
|
||||
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||
body = body[:idx]
|
||||
}
|
||||
|
||||
var extras []string
|
||||
if e.Pinned {
|
||||
extras = append(extras, pinnedStyle.Render("•"))
|
||||
}
|
||||
if len(e.Tags) > 0 {
|
||||
tagParts := make([]string, len(e.Tags))
|
||||
for i, t := range e.Tags {
|
||||
tagParts[i] = tagStyle.Render("#" + t)
|
||||
limit := min(2, len(e.Tags))
|
||||
for _, t := range e.Tags[:limit] {
|
||||
extras = append(extras, tagStyle.Render("#"+t))
|
||||
}
|
||||
extras = append(extras, strings.Join(tagParts, " "))
|
||||
}
|
||||
|
||||
extraStr := ""
|
||||
@@ -228,11 +228,12 @@ func renderEntity(e *db.Entity, maxWidth int) string {
|
||||
extraStr = " " + strings.Join(extras, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
|
||||
line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
|
||||
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||
body = truncate(body, maxWidth-overhead)
|
||||
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||
}
|
||||
|
||||
return line
|
||||
|
||||
+461
-131
@@ -1,8 +1,10 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -10,16 +12,18 @@ import (
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
const statusTimeout = 2 * time.Second
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
stateList viewState = iota
|
||||
stateDetail
|
||||
stateInput
|
||||
stateTagFilter
|
||||
stateConfirm
|
||||
statePromote
|
||||
stateAbsorb
|
||||
stateStumble
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
@@ -62,7 +66,9 @@ func (s cardsSort) next() cardsSort {
|
||||
type focusPane int
|
||||
|
||||
const (
|
||||
focusList focusPane = iota
|
||||
focusCapture focusPane = iota
|
||||
focusTagRail
|
||||
focusList
|
||||
focusDetail
|
||||
)
|
||||
|
||||
@@ -80,36 +86,66 @@ type model struct {
|
||||
filter filterModel
|
||||
promote promoteModel
|
||||
absorb absorbModel
|
||||
tagRail tagRailModel
|
||||
stumble stumbleModel
|
||||
showHelp bool
|
||||
autocomplete autocompleteModel
|
||||
|
||||
focus focusPane
|
||||
splitDetail bool
|
||||
showTagRail bool
|
||||
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
searchQuery string
|
||||
searchTags []string
|
||||
queryDateFrom *string
|
||||
queryDateTo *string
|
||||
queryCardType *db.CardType
|
||||
|
||||
status string
|
||||
statusSeq int
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(store *db.Store) model {
|
||||
loadTheme()
|
||||
applyTheme()
|
||||
inp := newInputModel()
|
||||
inp.ti.Focus()
|
||||
return model{
|
||||
store: store,
|
||||
state: stateList,
|
||||
mode: modeStream,
|
||||
focus: focusCapture,
|
||||
showTagRail: true,
|
||||
list: newListModel(),
|
||||
cards: newCardsModel(),
|
||||
detail: newDetailModel(),
|
||||
input: newInputModel(),
|
||||
input: inp,
|
||||
filter: newFilterModel(),
|
||||
tagRail: newTagRailModel(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) setFocus(f focusPane) tea.Cmd {
|
||||
m.focus = f
|
||||
if f == focusCapture {
|
||||
return m.input.ti.Focus()
|
||||
}
|
||||
m.input.ti.Blur()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) setStatus(msg string) tea.Cmd {
|
||||
m.statusSeq++
|
||||
m.status = msg
|
||||
return clearStatusAfter(statusTimeout, m.statusSeq)
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return loadEntities(m.store, m.listParams())
|
||||
return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
|
||||
}
|
||||
|
||||
func (m model) listParams() db.ListParams {
|
||||
@@ -117,6 +153,15 @@ func (m model) listParams() db.ListParams {
|
||||
if m.filterTag != "" {
|
||||
p.Tag = &m.filterTag
|
||||
}
|
||||
if m.queryDateFrom != nil {
|
||||
p.From = m.queryDateFrom
|
||||
}
|
||||
if m.queryDateTo != nil {
|
||||
p.To = m.queryDateTo
|
||||
}
|
||||
if m.queryCardType != nil {
|
||||
p.CardTypeFilter = m.queryCardType
|
||||
}
|
||||
if m.mode == modeCards {
|
||||
p.CardsOnly = true
|
||||
switch m.cardsSort {
|
||||
@@ -135,25 +180,19 @@ func (m model) listParams() db.ListParams {
|
||||
}
|
||||
|
||||
func (m model) hasSearch() bool {
|
||||
return m.searchQuery != "" || len(m.searchTags) > 0
|
||||
return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
|
||||
}
|
||||
|
||||
func (m *model) applySearch() {
|
||||
if m.mode == modeCards {
|
||||
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||
m.cards.filtered = nil
|
||||
var pinned, rest []*db.Entity
|
||||
for _, e := range filtered {
|
||||
if !matchesIntent(e, m.cards.intent) {
|
||||
continue
|
||||
}
|
||||
if e.Pinned {
|
||||
pinned = append(pinned, e)
|
||||
} else {
|
||||
rest = append(rest, e)
|
||||
searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||
var intentFiltered []*db.Entity
|
||||
for _, e := range searchFiltered {
|
||||
if matchesIntent(e, m.cards.intent) {
|
||||
intentFiltered = append(intentFiltered, e)
|
||||
}
|
||||
}
|
||||
m.cards.filtered = append(pinned, rest...)
|
||||
m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent)
|
||||
if m.cards.cursor >= len(m.cards.filtered) {
|
||||
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
||||
}
|
||||
@@ -172,6 +211,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.splitDetail = false
|
||||
m.focus = focusList
|
||||
}
|
||||
if !m.railVisible() && m.focus == focusTagRail {
|
||||
m.focus = focusList
|
||||
}
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
|
||||
@@ -187,42 +229,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
case railTagsLoadedMsg:
|
||||
m.tagRail.setTags(msg.tags)
|
||||
m.tagRail.activeTag = m.filterTag
|
||||
return m, nil
|
||||
|
||||
case entityCreatedMsg:
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
m.status = "created"
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
m.input.clearText()
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
|
||||
|
||||
case entityDeletedMsg:
|
||||
m.status = "deleted"
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
|
||||
|
||||
case entityUpdatedMsg:
|
||||
m.status = msg.action
|
||||
if m.state == stateDetail {
|
||||
m.detail.setEntity(msg.entity)
|
||||
}
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
|
||||
|
||||
case entityPromotedMsg:
|
||||
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
||||
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||
m.stumble.advance()
|
||||
m.state = stateStumble
|
||||
} else {
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
|
||||
|
||||
case entityDemotedMsg:
|
||||
m.status = "demoted → fluid"
|
||||
return m, m.reloadDetail(msg.id)
|
||||
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
|
||||
|
||||
case entityCopiedMsg:
|
||||
m.status = "copied"
|
||||
return m, nil
|
||||
return m, m.setStatus("copied")
|
||||
|
||||
case entityAbsorbedMsg:
|
||||
m.status = "absorbed"
|
||||
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||
m.stumble.advance()
|
||||
m.state = stateStumble
|
||||
} else {
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
|
||||
|
||||
case absorbSourcesLoadedMsg:
|
||||
m.absorb = newAbsorbModel(msg.targetID)
|
||||
@@ -232,27 +280,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case stepsPersistedMsg:
|
||||
m.status = "steps saved"
|
||||
m.detail.mode = detailPreview
|
||||
return m, m.reloadDetail(m.detail.entity.ID)
|
||||
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved"))
|
||||
|
||||
case templateCopiedMsg:
|
||||
m.status = "copied resolved"
|
||||
m.detail.mode = detailPreview
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
|
||||
|
||||
case backlinksLoadedMsg:
|
||||
if m.detail.entity != nil {
|
||||
m.detail.backlinks = msg.backlinks
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tagsLoadedMsg:
|
||||
m.filter.setTags(msg.tags)
|
||||
m.tagRail.setTags(msg.tags)
|
||||
m.state = stateTagFilter
|
||||
return m, nil
|
||||
|
||||
case staleEntitiesLoadedMsg:
|
||||
m.stumble = newStumbleModel()
|
||||
m.stumble.setEntries(msg.entities)
|
||||
m.stumble.setSize(m.width, m.contentHeight())
|
||||
m.state = stateStumble
|
||||
return m, nil
|
||||
|
||||
case stumbleActionMsg:
|
||||
return m, m.setStatus(msg.action)
|
||||
|
||||
case editorFinishedMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
m.status = "updated"
|
||||
}
|
||||
return m, m.reloadAfterEdit()
|
||||
}
|
||||
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
|
||||
|
||||
case confirmTimeoutMsg:
|
||||
if m.state == stateConfirm {
|
||||
@@ -261,6 +323,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case statusClearMsg:
|
||||
if msg.seq == m.statusSeq {
|
||||
m.status = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case errMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
@@ -268,8 +336,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyMsg:
|
||||
m.err = nil
|
||||
switch m.state {
|
||||
case stateInput:
|
||||
return m.updateInput(msg)
|
||||
case stateTagFilter:
|
||||
return m.updateTagFilter(msg)
|
||||
case stateConfirm:
|
||||
@@ -278,9 +344,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updatePromote(msg)
|
||||
case stateAbsorb:
|
||||
return m.updateAbsorb(msg)
|
||||
case stateStumble:
|
||||
return m.updateStumble(msg)
|
||||
default:
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
|
||||
default:
|
||||
if m.focus == focusCapture {
|
||||
var cmd tea.Cmd
|
||||
m.input.ti, cmd = m.input.ti.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -294,8 +369,168 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if m.focus == focusCapture {
|
||||
return m.updateCapture(msg)
|
||||
}
|
||||
return m.updateBrowse(msg)
|
||||
}
|
||||
|
||||
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if m.autocomplete.active {
|
||||
m.acceptAutocomplete()
|
||||
return m, nil
|
||||
}
|
||||
val := m.input.ti.Value()
|
||||
if val == "" {
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, cmd
|
||||
}
|
||||
result := m.input.submit()
|
||||
if result == nil {
|
||||
return m, nil
|
||||
}
|
||||
if result.query {
|
||||
m.searchQuery = result.body
|
||||
m.searchTags = result.tags
|
||||
m.queryDateFrom = result.dateFrom
|
||||
m.queryDateTo = result.dateTo
|
||||
m.queryCardType = result.cardType
|
||||
m.input.clearText()
|
||||
m.autocomplete.active = false
|
||||
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
|
||||
}
|
||||
m.applySearch()
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, cmd
|
||||
}
|
||||
if result.entity != nil {
|
||||
m.autocomplete.active = false
|
||||
return m, createEntity(m.store, result.entity)
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
if m.autocomplete.active {
|
||||
m.acceptAutocomplete()
|
||||
return m, nil
|
||||
}
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, cmd
|
||||
case "esc":
|
||||
if m.autocomplete.active {
|
||||
m.autocomplete.active = false
|
||||
return m, nil
|
||||
}
|
||||
cmd := m.setFocus(focusList)
|
||||
return m, cmd
|
||||
case "up":
|
||||
if m.autocomplete.active {
|
||||
m.autocomplete.moveUp()
|
||||
return m, nil
|
||||
}
|
||||
case "down":
|
||||
if m.autocomplete.active {
|
||||
m.autocomplete.moveDown()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.input = m.input.updateKey(msg)
|
||||
m.updateAutocompleteSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) updateAutocompleteSuggestions() {
|
||||
val := m.input.ti.Value()
|
||||
pos := m.input.ti.Position()
|
||||
start, end, prefix, ok := tagTokenAtCursor(val, pos)
|
||||
if !ok || prefix == "" {
|
||||
m.autocomplete.active = false
|
||||
return
|
||||
}
|
||||
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
|
||||
if len(suggestions) == 0 {
|
||||
m.autocomplete.active = false
|
||||
return
|
||||
}
|
||||
m.autocomplete.suggestions = suggestions
|
||||
m.autocomplete.prefix = prefix
|
||||
m.autocomplete.tokenStart = start
|
||||
m.autocomplete.tokenEnd = end
|
||||
m.autocomplete.active = true
|
||||
if m.autocomplete.cursor >= len(suggestions) {
|
||||
m.autocomplete.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) acceptAutocomplete() {
|
||||
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
|
||||
return
|
||||
}
|
||||
selected := m.autocomplete.selected()
|
||||
if selected == "" {
|
||||
return
|
||||
}
|
||||
val := m.input.ti.Value()
|
||||
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
|
||||
m.input.ti.SetValue(newVal)
|
||||
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
|
||||
m.autocomplete.active = false
|
||||
}
|
||||
|
||||
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Tag rail focus handling
|
||||
if m.focus == focusTagRail && m.state == stateList {
|
||||
switch msg.String() {
|
||||
case "j", "k", "up", "down":
|
||||
m.tagRail = m.tagRail.update(msg.String())
|
||||
return m, nil
|
||||
case "enter":
|
||||
tag := m.tagRail.selectedTag()
|
||||
if tag != "" {
|
||||
if tag == m.filterTag {
|
||||
m.filterTag = ""
|
||||
m.tagRail.activeTag = ""
|
||||
} else {
|
||||
m.filterTag = tag
|
||||
m.tagRail.activeTag = tag
|
||||
}
|
||||
m.focus = focusList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
return m, nil
|
||||
case "l", "tab", "esc":
|
||||
m.focus = focusList
|
||||
return m, nil
|
||||
case "ctrl+b":
|
||||
m.showTagRail = false
|
||||
m.focus = focusList
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ctrl+b toggle from any browse focus
|
||||
if msg.String() == "ctrl+b" && m.state == stateList {
|
||||
m.showTagRail = !m.showTagRail
|
||||
if !m.railVisible() && m.focus == focusTagRail {
|
||||
m.focus = focusList
|
||||
}
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.splitDetail && m.state == stateList {
|
||||
switch msg.String() {
|
||||
case "tab":
|
||||
cmd := m.setFocus(focusCapture)
|
||||
return m, cmd
|
||||
case "l":
|
||||
if m.focus == focusList {
|
||||
m.focus = focusDetail
|
||||
@@ -306,6 +541,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.focus = focusList
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == focusList && m.railVisible() {
|
||||
m.focus = focusTagRail
|
||||
return m, nil
|
||||
}
|
||||
case "esc":
|
||||
if m.focus == focusDetail {
|
||||
m.focus = focusList
|
||||
@@ -380,9 +619,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
case "q":
|
||||
if m.state == stateList {
|
||||
return m, tea.Quit
|
||||
@@ -414,12 +650,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "s":
|
||||
if m.mode == modeCards && m.state == stateList {
|
||||
m.cardsSort = m.cardsSort.next()
|
||||
m.status = "sort: " + m.cardsSort.String()
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "tab":
|
||||
case "S":
|
||||
if m.state == stateList {
|
||||
return m, loadStaleEntities(m.store)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "T":
|
||||
t := cycleTheme()
|
||||
return m, m.setStatus("theme: " + t.Name)
|
||||
|
||||
case "i":
|
||||
if m.mode == modeCards && m.state == stateList {
|
||||
m.cards.setIntent(m.cards.intent.next())
|
||||
if m.hasSearch() {
|
||||
@@ -429,12 +674,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "tab":
|
||||
cmd := m.setFocus(focusCapture)
|
||||
return m, cmd
|
||||
|
||||
case "h":
|
||||
if m.state == stateList && m.railVisible() && m.focus == focusList {
|
||||
m.focus = focusTagRail
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "a":
|
||||
if m.state == stateList {
|
||||
m.state = stateInput
|
||||
m.input.focus()
|
||||
m.recalcSizes()
|
||||
return m, m.input.ti.Focus()
|
||||
cmd := m.setFocus(focusCapture)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
case "esc":
|
||||
@@ -474,9 +728,16 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if m.state == stateList && m.hasSearch() {
|
||||
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
|
||||
m.searchQuery = ""
|
||||
m.searchTags = nil
|
||||
m.queryDateFrom = nil
|
||||
m.queryDateTo = nil
|
||||
m.queryCardType = nil
|
||||
m.status = ""
|
||||
if hadDBFilters {
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
if m.mode == modeCards {
|
||||
m.cards.applyFilter()
|
||||
} else {
|
||||
@@ -489,6 +750,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.status = ""
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
if m.state == stateList {
|
||||
cmd := m.setFocus(focusCapture)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "d":
|
||||
@@ -531,8 +796,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
m.status = "target must be fluid"
|
||||
return m, nil
|
||||
return m, m.setStatus("target must be fluid")
|
||||
}
|
||||
return m, loadAbsorbSources(m.store, e.ID)
|
||||
}
|
||||
@@ -542,8 +806,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
m.status = "already a card"
|
||||
return m, nil
|
||||
return m, m.setStatus("already a card")
|
||||
}
|
||||
m.promote = newPromoteModel(e.ID, e.Body)
|
||||
m.state = statePromote
|
||||
@@ -554,8 +817,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "D":
|
||||
if m.state == stateDetail && m.detail.entity != nil {
|
||||
if m.detail.entity.CardType == nil {
|
||||
m.status = "already fluid"
|
||||
return m, nil
|
||||
return m, m.setStatus("already fluid")
|
||||
}
|
||||
return m, demoteEntity(m.store, m.detail.entity.ID)
|
||||
}
|
||||
@@ -609,6 +871,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.state = stateDetail
|
||||
}
|
||||
return m, loadBacklinks(m.store, e.ID)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -634,37 +897,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
case "enter":
|
||||
result := m.input.submit()
|
||||
if result == nil {
|
||||
return m, nil
|
||||
}
|
||||
if result.query {
|
||||
m.searchQuery = result.body
|
||||
m.searchTags = result.tags
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
m.applySearch()
|
||||
return m, nil
|
||||
}
|
||||
if result.entity != nil {
|
||||
return m, createEntity(m.store, result.entity)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.input = m.input.updateKey(msg)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
@@ -726,6 +958,57 @@ func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.stumble.done {
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
case "n", "right":
|
||||
m.stumble.advance()
|
||||
if m.stumble.done {
|
||||
return m, m.setStatus("all caught up")
|
||||
}
|
||||
return m, nil
|
||||
case "d":
|
||||
e := m.stumble.current()
|
||||
if e != nil {
|
||||
m.stumble.removeCurrent()
|
||||
return m, stumbleDismiss(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
case "!":
|
||||
e := m.stumble.current()
|
||||
if e != nil {
|
||||
m.stumble.advance()
|
||||
return m, stumblePin(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
case "p":
|
||||
e := m.stumble.current()
|
||||
if e != nil {
|
||||
m.promote = newPromoteModel(e.ID, e.Body)
|
||||
m.state = statePromote
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case "m":
|
||||
e := m.stumble.current()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
return m, m.setStatus("target must be fluid")
|
||||
}
|
||||
return m, loadAbsorbSources(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.showHelp {
|
||||
return renderHelp(m.width, m.height)
|
||||
@@ -733,7 +1016,7 @@ func (m model) View() string {
|
||||
|
||||
var content string
|
||||
switch m.state {
|
||||
case stateList, stateInput, stateConfirm:
|
||||
case stateList, stateConfirm:
|
||||
listContent := m.listContent()
|
||||
if m.splitDetail {
|
||||
lw, rw := m.splitWidths()
|
||||
@@ -745,6 +1028,13 @@ func (m model) View() string {
|
||||
} else {
|
||||
content = listContent
|
||||
}
|
||||
if m.railVisible() {
|
||||
rw := m.railWidth()
|
||||
ch := m.contentHeight()
|
||||
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
|
||||
sep := m.renderSeparator()
|
||||
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
|
||||
}
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
case stateTagFilter:
|
||||
@@ -753,37 +1043,48 @@ func (m model) View() string {
|
||||
content = m.promote.view(m.width)
|
||||
case stateAbsorb:
|
||||
content = m.absorb.view(m.width)
|
||||
case stateStumble:
|
||||
content = m.stumble.view()
|
||||
}
|
||||
|
||||
header := m.headerView()
|
||||
footer := m.footerView()
|
||||
captureBar := m.input.viewBar(m.width, m.focus == focusCapture)
|
||||
statusLine := m.statusLine()
|
||||
|
||||
return header + "\n" + content + "\n" + footer
|
||||
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
|
||||
|
||||
acView := m.autocomplete.view(m.width)
|
||||
if acView != "" {
|
||||
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
|
||||
}
|
||||
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
|
||||
}
|
||||
|
||||
func (m model) listWidth() int {
|
||||
if m.splitDetail {
|
||||
lw, _ := m.splitWidths()
|
||||
return lw
|
||||
}
|
||||
w := m.width - m.railWidth()
|
||||
if m.railVisible() {
|
||||
w--
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m model) listContent() string {
|
||||
lw := m.listWidth()
|
||||
if m.mode == modeCards {
|
||||
lw := m.width
|
||||
if m.splitDetail {
|
||||
lw, _ = m.splitWidths()
|
||||
}
|
||||
return m.cards.view(lw)
|
||||
}
|
||||
lw := m.width
|
||||
if m.splitDetail {
|
||||
lw, _ = m.splitWidths()
|
||||
}
|
||||
return m.list.view(lw)
|
||||
}
|
||||
|
||||
func (m model) headerView() string {
|
||||
header := titleStyle.Render("nib")
|
||||
|
||||
modeName := "stream"
|
||||
if m.mode == modeCards {
|
||||
modeName = "cards"
|
||||
}
|
||||
header += " " + modeStyle.Render(modeName)
|
||||
header := titleStyle.Render("nib") + " "
|
||||
header += renderTab("stream", "1", m.mode == modeStream)
|
||||
header += " " + separatorStyle.Render("│") + " "
|
||||
header += renderTab("cards", "2", m.mode == modeCards)
|
||||
|
||||
if m.filterTag != "" {
|
||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||
@@ -797,6 +1098,15 @@ func (m model) headerView() string {
|
||||
for _, t := range m.searchTags {
|
||||
pill += " #" + t
|
||||
}
|
||||
if m.queryDateFrom != nil {
|
||||
pill += " from:" + *m.queryDateFrom
|
||||
}
|
||||
if m.queryDateTo != nil {
|
||||
pill += " to:" + *m.queryDateTo
|
||||
}
|
||||
if m.queryCardType != nil {
|
||||
pill += " ^" + string(*m.queryCardType)
|
||||
}
|
||||
header += " " + searchPillStyle.Render(pill)
|
||||
}
|
||||
|
||||
@@ -811,11 +1121,7 @@ func (m model) headerView() string {
|
||||
return header
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
if m.state == stateInput {
|
||||
return m.input.view(m.width)
|
||||
}
|
||||
|
||||
func (m model) statusLine() string {
|
||||
if m.state == stateConfirm {
|
||||
return renderConfirm(m.confirmID)
|
||||
}
|
||||
@@ -824,46 +1130,69 @@ func (m model) footerView() string {
|
||||
return errorStyle.Render("error: " + m.err.Error())
|
||||
}
|
||||
|
||||
if m.status != "" {
|
||||
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
|
||||
}
|
||||
|
||||
return renderStatusBar(m, m.width)
|
||||
}
|
||||
|
||||
func (m model) contentHeight() int {
|
||||
return m.height - 3 - m.drawerHeight()
|
||||
}
|
||||
|
||||
func (m model) drawerHeight() int {
|
||||
if m.state == stateInput {
|
||||
return drawerLines()
|
||||
h := m.height - 4
|
||||
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
|
||||
n := m.autocomplete.visibleCount()
|
||||
if len(m.autocomplete.suggestions) > maxSuggestions {
|
||||
n++
|
||||
}
|
||||
return 0
|
||||
h -= n + 1
|
||||
}
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *model) recalcSizes() {
|
||||
ch := m.contentHeight()
|
||||
lw := m.listWidth()
|
||||
if m.isSplit() && m.splitDetail {
|
||||
lw, rw := m.splitWidths()
|
||||
_, rw := m.splitWidths()
|
||||
m.list.setSize(lw, ch)
|
||||
m.cards.setSize(lw, ch)
|
||||
m.detail.setSize(rw, ch)
|
||||
} else {
|
||||
m.list.setSize(m.width, ch)
|
||||
m.cards.setSize(m.width, ch)
|
||||
m.detail.setSize(m.width, ch)
|
||||
m.list.setSize(lw, ch)
|
||||
m.cards.setSize(lw, ch)
|
||||
m.detail.setSize(lw, ch)
|
||||
}
|
||||
m.filter.setHeight(ch)
|
||||
if m.railVisible() {
|
||||
m.tagRail.setSize(m.railWidth(), ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) isSplit() bool {
|
||||
return m.width >= 100
|
||||
}
|
||||
|
||||
func (m model) railVisible() bool {
|
||||
return m.showTagRail && m.width >= 100
|
||||
}
|
||||
|
||||
func (m model) railWidth() int {
|
||||
if !m.railVisible() {
|
||||
return 0
|
||||
}
|
||||
w := m.width * 18 / 100
|
||||
if w < 16 {
|
||||
w = 16
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m model) splitWidths() (int, int) {
|
||||
left := m.width * 40 / 100
|
||||
right := m.width - left - 1
|
||||
avail := m.width - m.railWidth()
|
||||
if m.railVisible() {
|
||||
avail--
|
||||
}
|
||||
left := avail * 40 / 100
|
||||
right := avail - left - 1
|
||||
return left, right
|
||||
}
|
||||
|
||||
@@ -890,8 +1219,9 @@ func (m model) selectedEntity() *db.Entity {
|
||||
func (m model) reloadDetail(id string) tea.Cmd {
|
||||
return tea.Batch(
|
||||
loadEntities(m.store, m.listParams()),
|
||||
loadBacklinks(m.store, id),
|
||||
func() tea.Msg {
|
||||
e, err := m.store.Get(id)
|
||||
e, err := m.store.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
+59
-22
@@ -2,24 +2,52 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type hint struct {
|
||||
key string
|
||||
desc string
|
||||
}
|
||||
|
||||
func renderHints(hints []hint) string {
|
||||
parts := make([]string, len(hints))
|
||||
for i, h := range hints {
|
||||
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func renderTab(label, key string, active bool) string {
|
||||
if active {
|
||||
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
|
||||
}
|
||||
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
|
||||
}
|
||||
|
||||
func renderStatusBar(m model, width int) string {
|
||||
left := countText(m)
|
||||
right := contextHints(m)
|
||||
var leftParts []string
|
||||
|
||||
leftRendered := statusStyle.Render(left)
|
||||
rightRendered := helpStyle.Render(right)
|
||||
if m.status != "" {
|
||||
leftParts = append(leftParts, statusStyle.Render(m.status))
|
||||
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
|
||||
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
|
||||
} else {
|
||||
leftParts = append(leftParts, statusStyle.Render(countText(m)))
|
||||
}
|
||||
|
||||
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered)
|
||||
leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
|
||||
right := renderHints(contextHints(m))
|
||||
|
||||
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
|
||||
pad := lipgloss.NewStyle().Width(gap).Render("")
|
||||
return leftRendered + pad + rightRendered
|
||||
return leftRendered + pad + right
|
||||
}
|
||||
|
||||
func countText(m model) string {
|
||||
@@ -35,37 +63,46 @@ func countText(m model) string {
|
||||
return fmt.Sprintf("%d entities", total)
|
||||
}
|
||||
|
||||
func contextHints(m model) string {
|
||||
func contextHints(m model) []hint {
|
||||
switch m.state {
|
||||
case stateDetail:
|
||||
switch m.detail.mode {
|
||||
case detailRun:
|
||||
return "space:toggle j/k:nav r:reset esc:save+exit"
|
||||
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
|
||||
case detailFill:
|
||||
return "tab:next shift+tab:prev enter:copy esc:cancel"
|
||||
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
|
||||
default:
|
||||
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
|
||||
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
|
||||
}
|
||||
case stateInput:
|
||||
return ""
|
||||
case stateTagFilter:
|
||||
return "j/k:nav enter:select esc:cancel"
|
||||
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||
case stateConfirm:
|
||||
return "y:confirm n:cancel"
|
||||
return []hint{{"y", "confirm"}, {"n", "cancel"}}
|
||||
case statePromote:
|
||||
return "j/k:nav enter:select esc:cancel"
|
||||
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||
case stateAbsorb:
|
||||
return "j/k:nav enter:absorb esc:cancel"
|
||||
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
|
||||
case stateStumble:
|
||||
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
|
||||
}
|
||||
|
||||
switch m.focus {
|
||||
case focusCapture:
|
||||
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
|
||||
case focusTagRail:
|
||||
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
|
||||
case focusDetail:
|
||||
if m.splitDetail {
|
||||
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
|
||||
}
|
||||
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
|
||||
default:
|
||||
if m.splitDetail {
|
||||
if m.focus == focusDetail {
|
||||
return "h:list c:copy e:edit p:promote D:demote !:pin esc:back"
|
||||
}
|
||||
return "l:detail a:add d:del #:filter esc:close ?:help q:quit"
|
||||
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||
}
|
||||
if m.mode == modeCards {
|
||||
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
|
||||
}
|
||||
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit"
|
||||
return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
const staleThresholdDays = 30
|
||||
|
||||
type stumbleModel struct {
|
||||
entries []*db.Entity
|
||||
cursor int
|
||||
width int
|
||||
height int
|
||||
done bool
|
||||
}
|
||||
|
||||
func newStumbleModel() stumbleModel {
|
||||
return stumbleModel{}
|
||||
}
|
||||
|
||||
func (s *stumbleModel) setEntries(entries []*db.Entity) {
|
||||
s.entries = entries
|
||||
s.cursor = 0
|
||||
s.done = len(entries) == 0
|
||||
}
|
||||
|
||||
func (s *stumbleModel) setSize(width, height int) {
|
||||
s.width = width
|
||||
s.height = height
|
||||
}
|
||||
|
||||
func (s stumbleModel) current() *db.Entity {
|
||||
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
|
||||
return nil
|
||||
}
|
||||
return s.entries[s.cursor]
|
||||
}
|
||||
|
||||
func (s *stumbleModel) advance() {
|
||||
s.cursor++
|
||||
if s.cursor >= len(s.entries) {
|
||||
s.done = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stumbleModel) removeCurrent() {
|
||||
if s.cursor < len(s.entries) {
|
||||
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
|
||||
if s.cursor >= len(s.entries) {
|
||||
s.done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s stumbleModel) total() int {
|
||||
return len(s.entries)
|
||||
}
|
||||
|
||||
func (s stumbleModel) view() string {
|
||||
if s.done {
|
||||
return s.doneView()
|
||||
}
|
||||
|
||||
e := s.current()
|
||||
if e == nil {
|
||||
return s.doneView()
|
||||
}
|
||||
|
||||
w := s.width
|
||||
var b strings.Builder
|
||||
|
||||
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
|
||||
b.WriteString(detailHeaderStyle.Render(progress))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||
title := e.Body
|
||||
if e.Title != nil {
|
||||
title = *e.Title
|
||||
}
|
||||
if len(title) > w-6 {
|
||||
title = title[:w-9] + "…"
|
||||
}
|
||||
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
|
||||
b.WriteString("\n")
|
||||
|
||||
var meta []string
|
||||
meta = append(meta, string(e.Glyph))
|
||||
if e.CardType != nil {
|
||||
meta = append(meta, string(*e.CardType))
|
||||
}
|
||||
for _, t := range e.Tags {
|
||||
meta = append(meta, tagStyle.Render("#"+t))
|
||||
}
|
||||
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
|
||||
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
bodyWidth := w - 6
|
||||
if bodyWidth < 20 {
|
||||
bodyWidth = 20
|
||||
}
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath(glamourStyle()),
|
||||
glamour.WithWordWrap(bodyWidth),
|
||||
)
|
||||
rendered, err := r.Render(e.Body)
|
||||
if err != nil {
|
||||
rendered = e.Body
|
||||
}
|
||||
rendered = strings.TrimRight(rendered, "\n")
|
||||
b.WriteString(" " + rendered)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
age := daysAgo(e.ModifiedAt)
|
||||
ageText := fmt.Sprintf("last touched %d days ago", age)
|
||||
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||
|
||||
lines := strings.Split(b.String(), "\n")
|
||||
if len(lines) > s.height {
|
||||
lines = lines[:s.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s stumbleModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(detailHeaderStyle.Render(" all caught up"))
|
||||
b.WriteString("\n\n")
|
||||
reviewed := s.total()
|
||||
if reviewed > 0 {
|
||||
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
|
||||
} else {
|
||||
b.WriteString(idStyle.Render(" no stale entries found"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func daysAgo(t time.Time) int {
|
||||
return int(math.Floor(time.Since(t).Hours() / 24))
|
||||
}
|
||||
|
||||
func staleParams() db.ListParams {
|
||||
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
|
||||
return db.ListParams{
|
||||
ModifiedBefore: &threshold,
|
||||
Sort: "modified_at",
|
||||
Order: "asc",
|
||||
Limit: 50,
|
||||
}
|
||||
}
|
||||
+99
-119
@@ -3,123 +3,103 @@ package tui
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
|
||||
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
|
||||
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
PaddingLeft(1)
|
||||
|
||||
statusStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(1)
|
||||
|
||||
listItemStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(2)
|
||||
|
||||
selectedItemStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
SetString("›")
|
||||
|
||||
glyphStyle = lipgloss.NewStyle().
|
||||
Width(2)
|
||||
|
||||
completedGlyphStyle = lipgloss.NewStyle().
|
||||
Width(2).
|
||||
Foreground(dim)
|
||||
|
||||
tagStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||
|
||||
idStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
inputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(highlight).
|
||||
Bold(true)
|
||||
|
||||
detailHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
MarginBottom(1)
|
||||
|
||||
detailBodyStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(2).
|
||||
PaddingTop(1)
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(1)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000")).
|
||||
PaddingLeft(1)
|
||||
|
||||
dateHeaderStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(1)
|
||||
|
||||
pinnedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"})
|
||||
|
||||
filterPillStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
|
||||
Bold(true)
|
||||
|
||||
helpKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(highlight).
|
||||
Bold(true).
|
||||
Width(18)
|
||||
|
||||
helpDescStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
affordanceStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
|
||||
Bold(true)
|
||||
|
||||
useCountStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
|
||||
|
||||
modeStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
Bold(true)
|
||||
|
||||
detailLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(highlight).
|
||||
Bold(true)
|
||||
|
||||
detailValueStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
|
||||
|
||||
checkDoneStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||
|
||||
checkPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
searchPillStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
||||
Bold(true)
|
||||
|
||||
gutterStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
drawerBorderStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
drawerHintsStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(2)
|
||||
|
||||
drawerPreviewStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
|
||||
PaddingLeft(2)
|
||||
|
||||
separatorStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
titleStyle lipgloss.Style
|
||||
statusStyle lipgloss.Style
|
||||
listItemStyle lipgloss.Style
|
||||
selectedItemStyle lipgloss.Style
|
||||
glyphStyle lipgloss.Style
|
||||
completedGlyphStyle lipgloss.Style
|
||||
tagStyle lipgloss.Style
|
||||
idStyle lipgloss.Style
|
||||
inputPromptStyle lipgloss.Style
|
||||
detailHeaderStyle lipgloss.Style
|
||||
detailBodyStyle lipgloss.Style
|
||||
helpStyle lipgloss.Style
|
||||
errorStyle lipgloss.Style
|
||||
dateHeaderStyle lipgloss.Style
|
||||
pinnedStyle lipgloss.Style
|
||||
filterPillStyle lipgloss.Style
|
||||
helpKeyStyle lipgloss.Style
|
||||
helpDescStyle lipgloss.Style
|
||||
affordanceStyle lipgloss.Style
|
||||
useCountStyle lipgloss.Style
|
||||
modeStyle lipgloss.Style
|
||||
detailLabelStyle lipgloss.Style
|
||||
detailValueStyle lipgloss.Style
|
||||
checkDoneStyle lipgloss.Style
|
||||
checkPendingStyle lipgloss.Style
|
||||
searchPillStyle lipgloss.Style
|
||||
gutterStyle lipgloss.Style
|
||||
drawerBorderStyle lipgloss.Style
|
||||
drawerHintsStyle lipgloss.Style
|
||||
drawerPreviewStyle lipgloss.Style
|
||||
separatorStyle lipgloss.Style
|
||||
hintKeyStyle lipgloss.Style
|
||||
hintDescStyle lipgloss.Style
|
||||
railHeaderStyle lipgloss.Style
|
||||
railTagStyle lipgloss.Style
|
||||
railActiveTagStyle lipgloss.Style
|
||||
railCountStyle lipgloss.Style
|
||||
stumbleAgeStyle lipgloss.Style
|
||||
acSelectedStyle lipgloss.Style
|
||||
acItemStyle lipgloss.Style
|
||||
backlinkStyle lipgloss.Style
|
||||
)
|
||||
|
||||
func init() {
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
func applyTheme() {
|
||||
t := activeTheme()
|
||||
accent := lipgloss.Color(t.Accent)
|
||||
dim := lipgloss.Color(t.Dim)
|
||||
muted := lipgloss.Color(t.Muted)
|
||||
ok := lipgloss.Color(t.Ok)
|
||||
todo := lipgloss.Color(t.Todo)
|
||||
event := lipgloss.Color(t.Event)
|
||||
remind := lipgloss.Color(t.Remind)
|
||||
danger := lipgloss.Color(t.Danger)
|
||||
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
|
||||
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
|
||||
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("›")
|
||||
glyphStyle = lipgloss.NewStyle().Width(2)
|
||||
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
|
||||
tagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||
idStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
|
||||
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
|
||||
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
|
||||
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
|
||||
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
|
||||
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
|
||||
useCountStyle = lipgloss.NewStyle().Foreground(remind)
|
||||
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
|
||||
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
|
||||
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
|
||||
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
|
||||
gutterStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
|
||||
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
|
||||
separatorStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
|
||||
railTagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||
railCountStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
||||
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
||||
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
type tagRailModel struct {
|
||||
tags []db.TagCount
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
width int
|
||||
activeTag string
|
||||
}
|
||||
|
||||
func newTagRailModel() tagRailModel {
|
||||
return tagRailModel{}
|
||||
}
|
||||
|
||||
func (r *tagRailModel) setTags(tags []db.TagCount) {
|
||||
r.tags = tags
|
||||
if r.cursor >= len(tags) {
|
||||
r.cursor = max(0, len(tags)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *tagRailModel) setSize(width, height int) {
|
||||
r.width = width
|
||||
r.height = height
|
||||
}
|
||||
|
||||
func (r tagRailModel) selectedTag() string {
|
||||
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
|
||||
return ""
|
||||
}
|
||||
return r.tags[r.cursor].Tag
|
||||
}
|
||||
|
||||
func (r tagRailModel) update(key string) tagRailModel {
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if r.cursor > 0 {
|
||||
r.cursor--
|
||||
if r.cursor < r.offset {
|
||||
r.offset = r.cursor
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if r.cursor < len(r.tags)-1 {
|
||||
r.cursor++
|
||||
visible := r.visibleCount()
|
||||
if r.cursor >= r.offset+visible {
|
||||
r.offset = r.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r tagRailModel) visibleCount() int {
|
||||
v := r.height - 2
|
||||
if v <= 0 {
|
||||
return 10
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r tagRailModel) view(focused bool) string {
|
||||
w := r.width
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
headerStyle := railHeaderStyle
|
||||
if focused {
|
||||
headerStyle = headerStyle.Foreground(lipgloss.Color(activeTheme().Accent))
|
||||
}
|
||||
b.WriteString(headerStyle.Render("tags"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(r.tags) == 0 {
|
||||
b.WriteString(hintDescStyle.Render(" no tags"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
visible := r.visibleCount()
|
||||
end := min(r.offset+visible, len(r.tags))
|
||||
|
||||
countW := 0
|
||||
for _, tc := range r.tags {
|
||||
cw := len(fmt.Sprintf("%d", tc.Count))
|
||||
if cw > countW {
|
||||
countW = cw
|
||||
}
|
||||
}
|
||||
|
||||
nameW := w - countW - 3
|
||||
if nameW < 4 {
|
||||
nameW = 4
|
||||
}
|
||||
|
||||
for i := r.offset; i < end; i++ {
|
||||
tc := r.tags[i]
|
||||
name := "#" + tc.Tag
|
||||
if len(name) > nameW {
|
||||
name = name[:nameW-1] + "…"
|
||||
}
|
||||
|
||||
count := fmt.Sprintf("%*d", countW, tc.Count)
|
||||
gap := w - len(name) - len(count) - 1
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
var line string
|
||||
if i == r.cursor && focused {
|
||||
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
|
||||
} else if tc.Tag == r.activeTag {
|
||||
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||
} else {
|
||||
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||
}
|
||||
|
||||
b.WriteString(line)
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Theme struct {
|
||||
Name string
|
||||
Dark bool
|
||||
Accent string
|
||||
Dim string
|
||||
Muted string
|
||||
Ok string
|
||||
Todo string
|
||||
Event string
|
||||
Remind string
|
||||
Danger string
|
||||
}
|
||||
|
||||
var themes = []Theme{
|
||||
{Name: "dark", Dark: true, Accent: "#c8942a", Dim: "#504840", Muted: "#8c8070", Ok: "#7aab72", Todo: "#d4a84b", Event: "#6898c8", Remind: "#c8784a", Danger: "#b85858"},
|
||||
{Name: "tinycard", Dark: true, Accent: "#ad8ee6", Dim: "#555a6a", Muted: "#8b90a0", Ok: "#4ade80", Todo: "#fbbf24", Event: "#22d3ee", Remind: "#e8845a", Danger: "#ef4444"},
|
||||
{Name: "catppuccin", Dark: true, Accent: "#cba6f7", Dim: "#6c7086", Muted: "#a6adc8", Ok: "#a6e3a1", Todo: "#f9e2af", Event: "#89b4fa", Remind: "#fab387", Danger: "#f38ba8"},
|
||||
{Name: "nord", Dark: true, Accent: "#88c0d0", Dim: "#4c566a", Muted: "#d8dee9", Ok: "#a3be8c", Todo: "#ebcb8b", Event: "#81a1c1", Remind: "#d08770", Danger: "#bf616a"},
|
||||
{Name: "dracula", Dark: true, Accent: "#bd93f9", Dim: "#6272a4", Muted: "#bfbfbf", Ok: "#50fa7b", Todo: "#f1fa8c", Event: "#8be9fd", Remind: "#ffb86c", Danger: "#ff5555"},
|
||||
{Name: "gruvbox", Dark: true, Accent: "#fabd2f", Dim: "#665c54", Muted: "#a89984", Ok: "#b8bb26", Todo: "#fabd2f", Event: "#83a598", Remind: "#fe8019", Danger: "#fb4934"},
|
||||
{Name: "rosepine", Dark: true, Accent: "#c4a7e7", Dim: "#6e6a86", Muted: "#908caa", Ok: "#a6da95", Todo: "#f6c177", Event: "#31748f", Remind: "#ea9a97", Danger: "#eb6f92"},
|
||||
{Name: "tokyonight", Dark: true, Accent: "#7aa2f7", Dim: "#565f89", Muted: "#a9b1d6", Ok: "#9ece6a", Todo: "#e0af68", Event: "#7aa2f7", Remind: "#ff9e64", Danger: "#f7768e"},
|
||||
{Name: "solarized", Dark: true, Accent: "#268bd2", Dim: "#586e75", Muted: "#657b83", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||
{Name: "paper", Dark: false, Accent: "#8a6018", Dim: "#a09080", Muted: "#6a5e50", Ok: "#2a6828", Todo: "#7a5c00", Event: "#245890", Remind: "#984020", Danger: "#882030"},
|
||||
{Name: "catppuccin-latte", Dark: false, Accent: "#8839ef", Dim: "#9ca0b0", Muted: "#6c6f85", Ok: "#40a02b", Todo: "#df8e1d", Event: "#1e66f5", Remind: "#fe640b", Danger: "#d20f39"},
|
||||
{Name: "rosepine-dawn", Dark: false, Accent: "#907aa9", Dim: "#9893a5", Muted: "#797593", Ok: "#56949f", Todo: "#ea9d34", Event: "#286983", Remind: "#d7827e", Danger: "#b4637a"},
|
||||
{Name: "solarized-light", Dark: false, Accent: "#268bd2", Dim: "#93a1a1", Muted: "#586e75", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||
}
|
||||
|
||||
var activeThemeIndex int
|
||||
|
||||
func activeTheme() Theme {
|
||||
return themes[activeThemeIndex]
|
||||
}
|
||||
|
||||
func cycleTheme() Theme {
|
||||
activeThemeIndex = (activeThemeIndex + 1) % len(themes)
|
||||
applyTheme()
|
||||
saveTheme()
|
||||
return themes[activeThemeIndex]
|
||||
}
|
||||
|
||||
func glamourStyle() string {
|
||||
if themes[activeThemeIndex].Dark {
|
||||
return "dark"
|
||||
}
|
||||
return "light"
|
||||
}
|
||||
|
||||
func themePath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".nib", "theme")
|
||||
}
|
||||
|
||||
func loadTheme() {
|
||||
p := themePath()
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(string(data))
|
||||
for i, t := range themes {
|
||||
if t.Name == name {
|
||||
activeThemeIndex = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveTheme() {
|
||||
p := themePath()
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(themes[activeThemeIndex].Name+"\n"), 0o644)
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
entropy *ulid.MonotonicEntropy
|
||||
entropy *ulid.LockedMonotonicReader
|
||||
entropyOnce sync.Once
|
||||
)
|
||||
|
||||
func New() string {
|
||||
entropyOnce.Do(func() {
|
||||
entropy = ulid.Monotonic(rand.Reader, 0)
|
||||
entropy = &ulid.LockedMonotonicReader{
|
||||
MonotonicReader: ulid.Monotonic(rand.Reader, 0),
|
||||
}
|
||||
})
|
||||
return ulid.MustNew(ulid.Now(), entropy).String()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ulid
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -26,3 +27,25 @@ func TestNew_Sortable(t *testing.T) {
|
||||
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ConcurrentUnique(t *testing.T) {
|
||||
const n = 100
|
||||
ids := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ids[idx] = New()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
seen := make(map[string]struct{}, n)
|
||||
for _, id := range ids {
|
||||
if _, dup := seen[id]; dup {
|
||||
t.Fatalf("duplicate ULID under concurrency: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
+150
-115
@@ -44,6 +44,15 @@
|
||||
|
||||
// ========== API ==========
|
||||
|
||||
async function checkedJSON(resp) {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
const msg = body.message || body.error || `HTTP ${resp.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
const api = {
|
||||
async listEntities(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
@@ -57,7 +66,7 @@
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.offset) q.set('offset', String(params.offset));
|
||||
const resp = await fetch('/api/entities?' + q);
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async createEntity(data) {
|
||||
const resp = await fetch('/api/entities', {
|
||||
@@ -65,11 +74,11 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async getEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id);
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async updateEntity(id, data) {
|
||||
const resp = await fetch('/api/entities/' + id, {
|
||||
@@ -77,10 +86,11 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async deleteEntity(id) {
|
||||
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||
const resp = await fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async promoteEntity(id, cardType, cardData) {
|
||||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||
@@ -88,7 +98,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async demoteEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||
@@ -96,7 +106,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async useEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||
@@ -104,7 +114,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async absorbEntity(targetId, sourceId) {
|
||||
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
||||
@@ -112,13 +122,13 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_id: sourceId }),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async listTags(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.cards_only) q.set('cards_only', 'true');
|
||||
const resp = await fetch('/api/tags?' + q);
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -658,6 +668,71 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderInlineEditMode(e) {
|
||||
return `<div class="exp-inner exp-inner--edit">
|
||||
<div class="peek-edit-fields">
|
||||
<div class="peek-edit-field"><label class="peek-edit-lbl">title</label>
|
||||
<input class="peek-edit-in" id="edit-title" value="${escAttr(e.title || '')}"></div>
|
||||
<div class="peek-edit-field"><label class="peek-edit-lbl">description</label>
|
||||
<input class="peek-edit-in" id="edit-desc" value="${escAttr(e.description || '')}"></div>
|
||||
<div class="peek-edit-field"><label class="peek-edit-lbl">content</label>
|
||||
<textarea class="peek-edit-ta" id="edit-body" rows="7">${escHtml(e.body || '')}</textarea></div>
|
||||
<div class="peek-edit-field"><label class="peek-edit-lbl">tags</label>
|
||||
<input class="peek-edit-in" id="edit-tags" value="${escAttr((e.tags || []).join(' '))}" placeholder="space-separated"></div>
|
||||
</div>
|
||||
<div class="exp-acts">
|
||||
<button class="action-btn primary" onclick="event.stopPropagation();nibApp.saveEdit('${e.id}')">save</button>
|
||||
<button class="action-btn" onclick="event.stopPropagation();nibApp.exitMode()">cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCardSections(e, bodyClass) {
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const hasDecision = data.chose != null;
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasLink = !!data.url;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
|
||||
let sections = '';
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasLink && !hasDecision) {
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="${bodyClass} md">${renderMd(e.body)}</div>`;
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
return { sections, data, hasDecision, hasSteps, hasLink, hasFill };
|
||||
}
|
||||
|
||||
function renderInlineDetail(e) {
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
let actions = '';
|
||||
@@ -675,51 +750,10 @@
|
||||
|
||||
let content = '';
|
||||
if (e.card_type) {
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const hasDecision = data.chose != null;
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasLink = !!data.url;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasLink && !hasDecision) {
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||
}
|
||||
if (hasFill) {
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||
}
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
const cs = renderCardSections(e, 'exp-body');
|
||||
content = cs.sections;
|
||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||
} else {
|
||||
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
||||
}
|
||||
@@ -842,15 +876,15 @@
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
|
||||
let actions = '';
|
||||
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
if (!e.card_type) {
|
||||
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||
}
|
||||
if (e.card_type) {
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
} else {
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
}
|
||||
|
||||
return `<div class="peek-scroll">
|
||||
@@ -880,61 +914,17 @@
|
||||
const glyph = GLYPHS[e.card_type] || '◆';
|
||||
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
||||
const affs = detectAffordances(e);
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasDecision = data.chose != null;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
const hasLink = !!data.url;
|
||||
|
||||
let sections = '';
|
||||
const cs = renderCardSections(e, 'peek-body');
|
||||
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (hasLink && !hasDecision) {
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="peek-body md">${renderMd(e.body)}</div>`;
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
|
||||
return `<div class="peek-scroll">
|
||||
<div class="peek-card">
|
||||
@@ -950,7 +940,7 @@
|
||||
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
|
||||
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
|
||||
</div>
|
||||
${sections}
|
||||
${cs.sections}
|
||||
</div>
|
||||
<div class="peek-acts">${actions}</div>
|
||||
</div>`;
|
||||
@@ -1457,11 +1447,31 @@
|
||||
state.peekMode = mode;
|
||||
if (mode === 'run') state.runChecked = new Set();
|
||||
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
|
||||
if (mode === 'edit' && isMobileBreakpoint()) {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
const sel = $(`.entity-item.selected, .card-row.selected`);
|
||||
if (!e || !sel) return;
|
||||
const clip = sel.querySelector('.entity-exp-clip');
|
||||
if (clip) clip.innerHTML = renderInlineEditMode(e);
|
||||
sel.classList.add('exp-full');
|
||||
const titleInput = sel.querySelector('#edit-title');
|
||||
if (titleInput) titleInput.focus();
|
||||
return;
|
||||
}
|
||||
renderDetailPane();
|
||||
},
|
||||
|
||||
exitMode() {
|
||||
state.peekMode = 'preview';
|
||||
if (isMobileBreakpoint()) {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
const sel = $(`.entity-item.selected, .card-row.selected`);
|
||||
if (sel && e) {
|
||||
const clip = sel.querySelector('.entity-exp-clip');
|
||||
if (clip) clip.innerHTML = renderInlineDetail(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderDetailPane();
|
||||
},
|
||||
|
||||
@@ -1500,7 +1510,21 @@
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
const idx = state.entities.findIndex(x => x.id === id);
|
||||
if (idx >= 0) selectEntity(idx);
|
||||
if (idx >= 0) {
|
||||
if (isMobileBreakpoint()) {
|
||||
state.selectedIndex = idx;
|
||||
renderEntityList();
|
||||
const sel = $(`.entity-item[data-id="${id}"], .card-row[data-id="${id}"]`);
|
||||
if (sel) {
|
||||
sel.classList.add('selected');
|
||||
const clip = sel.querySelector('.entity-exp-clip');
|
||||
const e = state.entities[idx];
|
||||
if (clip && e) clip.innerHTML = renderInlineDetail(e);
|
||||
}
|
||||
} else {
|
||||
selectEntity(idx);
|
||||
}
|
||||
}
|
||||
showToast('saved');
|
||||
},
|
||||
|
||||
@@ -1580,7 +1604,17 @@
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
const tag = (ev.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea') {
|
||||
if (ev.key === 'Escape') ev.target.blur();
|
||||
if (ev.key === 'Escape' && state.peekMode === 'edit') {
|
||||
ev.target.blur();
|
||||
nibApp.exitMode();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') { ev.target.blur(); return; }
|
||||
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter' && state.peekMode === 'edit') {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
if (e) nibApp.saveEdit(e.id);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1883,7 +1917,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) {
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1859,5 +1859,9 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
||||
.card-row.exp-full .entity-exp { grid-template-rows: 1fr; }
|
||||
.entity-item.exp-full .exp-inner,
|
||||
.card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; }
|
||||
.exp-inner--edit { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.exp-inner--edit .peek-edit-fields { flex: 1; padding: 16px; }
|
||||
.exp-inner--edit .peek-edit-ta { flex: 1; min-height: 150px; }
|
||||
.exp-inner--edit .exp-acts { padding: 12px 16px; border-top: 1px solid var(--border); position: sticky; bottom: 0; background: var(--bg); }
|
||||
main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user