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)
|
rootCmd.AddCommand(absorbCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAbsorb(_ *cobra.Command, args []string) error {
|
func runAbsorb(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
targetID, err := store.Resolve(args[0])
|
targetID, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[1])
|
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")
|
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 {
|
if err == db.ErrTargetCrystallized {
|
||||||
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
|
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
|
||||||
display.FormatID(targetID))
|
display.FormatID(targetID))
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ var addCmd = &cobra.Command{
|
|||||||
RunE: runAdd,
|
RunE: runAdd,
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAdd(_ *cobra.Command, args []string) error {
|
func runAdd(cmd *cobra.Command, args []string) error {
|
||||||
input := strings.Join(args, " ")
|
input := strings.Join(args, " ")
|
||||||
|
|
||||||
parsed, err := parse.Parse(input)
|
parsed, err := parse.Parse(input)
|
||||||
@@ -47,7 +47,7 @@ func runAdd(_ *cobra.Command, args []string) error {
|
|||||||
e.CardType = &ct
|
e.CardType = &ct
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(cmd.Context(), e); err != nil {
|
||||||
return err
|
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)
|
rootCmd.AddCommand(cardsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCards(_ *cobra.Command, _ []string) error {
|
func runCards(cmd *cobra.Command, _ []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -49,7 +49,7 @@ func runCards(_ *cobra.Command, _ []string) error {
|
|||||||
p.CardTypeFilter = &ct
|
p.CardTypeFilter = &ct
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(cmd.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
rootCmd.AddCommand(copyCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCopy(_ *cobra.Command, args []string) error {
|
func runCopy(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func runCopy(_ *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("clipboard: %w", err)
|
return fmt.Errorf("clipboard: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.IncrementUse(id); err != nil {
|
if err := store.IncrementUse(cmd.Context(), id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(deleteCmd)
|
rootCmd.AddCommand(deleteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDelete(_ *cobra.Command, args []string) error {
|
func runDelete(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -35,7 +36,7 @@ type demoEntity struct {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDemo(_ *cobra.Command, _ []string) error {
|
func runDemo(cmd *cobra.Command, _ []string) error {
|
||||||
tmpDir, err := os.MkdirTemp("", "nib-demo-*")
|
tmpDir, err := os.MkdirTemp("", "nib-demo-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -48,7 +49,7 @@ func runDemo(_ *cobra.Command, _ []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := seedDemo(store); err != nil {
|
if err := seedDemo(cmd.Context(), store); err != nil {
|
||||||
store.Close()
|
store.Close()
|
||||||
return fmt.Errorf("seed demo data: %w", err)
|
return fmt.Errorf("seed demo data: %w", err)
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ func runDemo(_ *cobra.Command, _ []string) error {
|
|||||||
return runServe(nil, nil)
|
return runServe(nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDemo(store *db.Store) error {
|
func seedDemo(ctx context.Context, store *db.Store) error {
|
||||||
data, err := findDemoFile()
|
data, err := findDemoFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -94,19 +95,19 @@ func seedDemo(store *db.Store) error {
|
|||||||
e.CompletedAt = &t
|
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)
|
return fmt.Errorf("entity %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.CardType != nil {
|
if entry.CardType != nil {
|
||||||
ct := db.CardType(*entry.CardType)
|
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)
|
return fmt.Errorf("promote entity %d: %w", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Deleted {
|
if entry.Deleted {
|
||||||
store.SoftDelete(e.ID)
|
store.SoftDelete(ctx, e.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -19,19 +19,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(demoteCmd)
|
rootCmd.AddCommand(demoteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDemote(_ *cobra.Command, args []string) error {
|
func runDemote(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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 {
|
if err == db.ErrAlreadyFluid {
|
||||||
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
|
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -21,19 +21,19 @@ func init() {
|
|||||||
rootCmd.AddCommand(editCmd)
|
rootCmd.AddCommand(editCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runEdit(_ *cobra.Command, args []string) error {
|
func runEdit(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,11 @@ func runEdit(_ *cobra.Command, args []string) error {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(editor, tmpfile.Name())
|
editorCmd := exec.Command(editor, tmpfile.Name())
|
||||||
cmd.Stdin = os.Stdin
|
editorCmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
editorCmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
editorCmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := editorCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("editor: %w", err)
|
return fmt.Errorf("editor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func runEdit(_ *cobra.Command, args []string) error {
|
|||||||
return nil
|
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
|
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")
|
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()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error {
|
|||||||
p.Since = &since
|
p.Since = &since
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(cmd.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -20,14 +20,14 @@ func init() {
|
|||||||
rootCmd.AddCommand(promoteCmd)
|
rootCmd.AddCommand(promoteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPromote(_ *cobra.Command, args []string) error {
|
func runPromote(cmd *cobra.Command, args []string) error {
|
||||||
store, err := openStore()
|
store, err := openStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
id, err := store.Resolve(args[0])
|
id, err := store.Resolve(cmd.Context(), args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not_found — no entity with id %s", args[0])
|
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])
|
cardType = db.CardType(args[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(cmd.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cd := carddata.GenerateCardData(cardType, e.Body)
|
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 {
|
if err == db.ErrAlreadyPromoted {
|
||||||
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
||||||
display.FormatID(id), *e.CardType)
|
display.FormatID(id), *e.CardType)
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
if serveDev {
|
if serveDev {
|
||||||
fmt.Println(" CORS enabled (dev mode)")
|
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
|
var listenErr error
|
||||||
if useTLS {
|
if useTLS {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
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/go-chi/chi/v5 v5.2.5
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
@@ -14,33 +14,45 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // 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/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // 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/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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/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/libc v1.65.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
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 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
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 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
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-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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
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 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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=
|
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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=
|
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 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
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 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
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=
|
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 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
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")
|
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
p.Limit = limit
|
p.Limit = limit
|
||||||
}
|
}
|
||||||
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
||||||
@@ -106,13 +109,13 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
p.Limit = 50
|
p.Limit = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := store.Count(p)
|
total, err := store.Count(r.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(r.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
@@ -174,7 +177,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
e.CardData = req.CardData
|
e.CardData = req.CardData
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(r.Context(), e); err != nil {
|
||||||
if err == db.ErrInvalidCardData {
|
if err == db.ErrInvalidCardData {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
return
|
return
|
||||||
@@ -190,7 +193,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
func getEntity(store *db.Store) http.HandlerFunc {
|
func getEntity(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
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
|
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 {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -253,7 +256,7 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
@@ -269,7 +272,21 @@ type DeleteResponse struct {
|
|||||||
func deleteEntity(store *db.Store) http.HandlerFunc {
|
func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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 != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
@@ -304,7 +321,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
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 {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -321,7 +338,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
@@ -334,7 +351,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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 {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -347,7 +364,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
@@ -378,7 +395,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
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 {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
|
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
|
||||||
return
|
return
|
||||||
@@ -391,7 +408,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
@@ -404,7 +421,7 @@ func useEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
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 {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
@@ -413,7 +430,7 @@ func useEntity(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e, err := store.Get(id)
|
e, err := store.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ type EntityResponse struct {
|
|||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
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) {
|
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")
|
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := r.URL.Path
|
p := path.Clean(r.URL.Path)
|
||||||
if p == "/" || path.Ext(p) == "" {
|
if p == "/" || path.Ext(p) == "" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(indexHTML)
|
w.Write(indexHTML)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type TagResponse struct {
|
|||||||
func listTags(store *db.Store) http.HandlerFunc {
|
func listTags(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cardsOnly := r.URL.Query().Get("cards_only") == "true"
|
cardsOnly := r.URL.Query().Get("cards_only") == "true"
|
||||||
tags, err := store.ListTags(cardsOnly)
|
tags, err := store.ListTags(r.Context(), cardsOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
+49
-11
@@ -51,7 +51,10 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
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{
|
var migrations = []func(db *sql.DB) error{
|
||||||
// v1: initial schema
|
// v1: initial schema
|
||||||
@@ -92,24 +95,29 @@ var migrations = []func(db *sql.DB) error{
|
|||||||
|
|
||||||
// v2: add title and description columns
|
// v2: add title and description columns
|
||||||
func(db *sql.DB) error {
|
func(db *sql.DB) error {
|
||||||
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
|
||||||
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
|
|
||||||
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||||
func(db *sql.DB) error {
|
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()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 {
|
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||||
return fmt.Errorf("migrate rename: %w", err)
|
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)
|
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 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
|
return "", err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(home, ".nib")
|
dir := filepath.Join(home, ".nib")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(dir, "nib.db"), nil
|
return filepath.Join(dir, "nib.db"), nil
|
||||||
|
|||||||
+91
-49
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -71,6 +72,7 @@ type ListParams struct {
|
|||||||
From *string
|
From *string
|
||||||
To *string
|
To *string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
|
ModifiedBefore *time.Time
|
||||||
CardsOnly bool
|
CardsOnly bool
|
||||||
IncludeDeleted bool
|
IncludeDeleted bool
|
||||||
CardTypeFilter *CardType
|
CardTypeFilter *CardType
|
||||||
@@ -103,7 +105,7 @@ type EntityUpdate struct {
|
|||||||
Tags *[]string
|
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)) {
|
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
|
||||||
return ErrInvalidCardData
|
return ErrInvalidCardData
|
||||||
}
|
}
|
||||||
@@ -115,13 +117,13 @@ func (s *Store) Create(e *Entity) error {
|
|||||||
e.Tags = []string{}
|
e.Tags = []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.ExecContext(ctx, `
|
||||||
INSERT INTO entities (id, created_at, modified_at, body, title, description,
|
INSERT INTO entities (id, created_at, modified_at, body, title, description,
|
||||||
glyph, time_anchor, completed_at, pinned, deleted_at,
|
glyph, time_anchor, completed_at, pinned, deleted_at,
|
||||||
card_type, card_data, use_count, last_used_at)
|
card_type, card_data, use_count, last_used_at)
|
||||||
@@ -146,18 +148,22 @@ func (s *Store) Create(e *Entity) error {
|
|||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Get(id string) (*Entity, error) {
|
func (s *Store) Get(ctx context.Context, id string) (*Entity, error) {
|
||||||
e := &Entity{}
|
e := &Entity{}
|
||||||
row := newEntityRow()
|
row := newEntityRow()
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, created_at, modified_at, body, title, description,
|
SELECT id, created_at, modified_at, body, title, description,
|
||||||
glyph, time_anchor, completed_at, pinned, deleted_at,
|
glyph, time_anchor, completed_at, pinned, deleted_at,
|
||||||
card_type, card_data, use_count, last_used_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)
|
return nil, fmt.Errorf("scan entity %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tags, err := s.loadTags(id)
|
tags, err := s.loadTags(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -216,6 +222,10 @@ func listWhere(params ListParams) (string, []any) {
|
|||||||
where = append(where, "e.card_type = ?")
|
where = append(where, "e.card_type = ?")
|
||||||
args = append(args, string(*params.CardTypeFilter))
|
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 := ""
|
clause := ""
|
||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
@@ -224,21 +234,23 @@ func listWhere(params ListParams) (string, []any) {
|
|||||||
return clause, args
|
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)
|
whereClause, args := listWhere(params)
|
||||||
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
||||||
var count int
|
var count int
|
||||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||||
return count, err
|
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)
|
whereClause, args := listWhere(params)
|
||||||
|
|
||||||
orderCol := "e.created_at"
|
orderCol := "e.created_at"
|
||||||
switch params.Sort {
|
switch params.Sort {
|
||||||
case "use_count":
|
case "use_count":
|
||||||
orderCol = "e.use_count"
|
orderCol = "e.use_count"
|
||||||
|
case "modified_at":
|
||||||
|
orderCol = "e.modified_at"
|
||||||
case "created_at", "":
|
case "created_at", "":
|
||||||
orderCol = "e.created_at"
|
orderCol = "e.created_at"
|
||||||
default:
|
default:
|
||||||
@@ -268,7 +280,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
|
|
||||||
args = append(args, limit, params.Offset)
|
args = append(args, limit, params.Offset)
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -290,20 +302,20 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.batchLoadTags(entities); err != nil {
|
if err := s.batchLoadTags(ctx, entities); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return entities, nil
|
return entities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Update(id string, u *EntityUpdate) error {
|
func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
|
||||||
existing, err := s.Get(id)
|
existing, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -362,15 +374,21 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
args = append(args, existing.ID)
|
args = append(args, existing.ID)
|
||||||
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Tags != nil {
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,8 +396,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error {
|
||||||
e, err := s.Get(id)
|
e, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -395,15 +413,15 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
|||||||
dataVal = *cardData
|
dataVal = *cardData
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
|
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
|
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Demote(id string) error {
|
func (s *Store) Demote(ctx context.Context, id string) error {
|
||||||
e, err := s.Get(id)
|
e, err := s.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -411,7 +429,7 @@ func (s *Store) Demote(id string) error {
|
|||||||
return ErrAlreadyFluid
|
return ErrAlreadyFluid
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`
|
_, err = s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET card_type = NULL, card_data = NULL,
|
UPDATE entities SET card_type = NULL, card_data = NULL,
|
||||||
use_count = 0, last_used_at = NULL, modified_at = ?
|
use_count = 0, last_used_at = NULL, modified_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
@@ -426,9 +444,9 @@ const (
|
|||||||
DeletedHard
|
DeletedHard
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) SoftDelete(id string) (DeleteResult, error) {
|
func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) {
|
||||||
var deletedAt sql.NullString
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return 0, ErrNotFound
|
return 0, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -437,21 +455,33 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if deletedAt.Valid {
|
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
|
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)
|
time.Now().UTC().Format(time.RFC3339), id)
|
||||||
return DeletedSoft, err
|
return DeletedSoft, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Absorb(targetID, sourceID string) error {
|
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||||
target, err := s.Get(targetID)
|
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -460,7 +490,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
return ErrTargetCrystallized
|
return ErrTargetCrystallized
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -469,8 +499,18 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
merged := target.Body + "\n" + source.Body
|
merged := target.Body + "\n" + source.Body
|
||||||
|
|
||||||
if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
title := target.Title
|
||||||
merged, now, targetID); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,15 +520,19 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
}
|
}
|
||||||
for _, t := range source.Tags {
|
for _, t := range source.Tags {
|
||||||
if !seen[t] {
|
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 {
|
targetID, t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if source.CardType != nil {
|
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 = ?`,
|
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
|
||||||
now, sourceID); err != nil {
|
now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -496,7 +540,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
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 {
|
absorbNote, now, now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -504,8 +548,8 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) IncrementUse(id string) error {
|
func (s *Store) IncrementUse(ctx context.Context, id string) error {
|
||||||
res, err := s.db.Exec(`
|
res, err := s.db.ExecContext(ctx, `
|
||||||
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
|
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
time.Now().UTC().Format(time.RFC3339), id)
|
time.Now().UTC().Format(time.RFC3339), id)
|
||||||
@@ -519,8 +563,8 @@ func (s *Store) IncrementUse(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Resolve(prefix string) (string, error) {
|
func (s *Store) Resolve(ctx context.Context, prefix string) (string, error) {
|
||||||
rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
|
rows, err := s.db.QueryContext(ctx, "SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -586,9 +630,7 @@ func (r *entityRow) apply(e *Entity) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// helpers
|
func (s *Store) batchLoadTags(ctx context.Context, entities []*Entity) error {
|
||||||
|
|
||||||
func (s *Store) batchLoadTags(entities []*Entity) error {
|
|
||||||
if len(entities) == 0 {
|
if len(entities) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -608,7 +650,7 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
|
|||||||
strings.Join(placeholders, ","),
|
strings.Join(placeholders, ","),
|
||||||
)
|
)
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -626,8 +668,8 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) loadTags(entityID string) ([]string, error) {
|
func (s *Store) loadTags(ctx context.Context, entityID string) ([]string, error) {
|
||||||
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
rows, err := s.db.QueryContext(ctx, "SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -650,9 +692,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
|
|||||||
return tags, nil
|
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 {
|
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 {
|
entityID, tag); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-87
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -11,15 +12,16 @@ func ptr[T any](v T) *T {
|
|||||||
|
|
||||||
func TestCreate_Note(t *testing.T) {
|
func TestCreate_Note(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "hello world", Glyph: GlyphNote}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if e.ID == "" {
|
if e.ID == "" {
|
||||||
t.Fatal("ID not set")
|
t.Fatal("ID not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -33,12 +35,13 @@ func TestCreate_Note(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -49,12 +52,13 @@ func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithTags(t *testing.T) {
|
func TestCreate_WithTags(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -65,13 +69,14 @@ func TestCreate_WithTags(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithCardType(t *testing.T) {
|
func TestCreate_WithCardType(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +87,7 @@ func TestCreate_WithCardType(t *testing.T) {
|
|||||||
|
|
||||||
func TestGet_NotFound(t *testing.T) {
|
func TestGet_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.Get("01NONEXISTENT0000000000000")
|
_, err := s.Get(context.Background(), "01NONEXISTENT0000000000000")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -90,11 +95,12 @@ func TestGet_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_DefaultParams(t *testing.T) {
|
func TestList_DefaultParams(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
for i := 0; i < 3; i++ {
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -109,15 +115,16 @@ func TestList_DefaultParams(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_FilterByTag(t *testing.T) {
|
func TestList_FilterByTag(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
ctx := context.Background()
|
||||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
|
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
|
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()
|
p := DefaultListParams()
|
||||||
tag := "ops"
|
tag := "ops"
|
||||||
p.Tag = &tag
|
p.Tag = &tag
|
||||||
|
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -128,13 +135,14 @@ func TestList_FilterByTag(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_FilterByDate(t *testing.T) {
|
func TestList_FilterByDate(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
|
ctx := context.Background()
|
||||||
|
s.Create(ctx, &Entity{Body: "today", Glyph: GlyphNote})
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
p.Date = &date
|
p.Date = &date
|
||||||
|
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -144,7 +152,7 @@ func TestList_FilterByDate(t *testing.T) {
|
|||||||
|
|
||||||
otherDate := "2020-01-01"
|
otherDate := "2020-01-01"
|
||||||
p.Date = &otherDate
|
p.Date = &otherDate
|
||||||
entities, err = s.List(p)
|
entities, err = s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -155,13 +163,14 @@ func TestList_FilterByDate(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_CardsOnly(t *testing.T) {
|
func TestList_CardsOnly(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
|
ctx := context.Background()
|
||||||
|
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote})
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.CardsOnly = true
|
p.CardsOnly = true
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -175,12 +184,13 @@ func TestList_CardsOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_IncludeDeleted(t *testing.T) {
|
func TestList_IncludeDeleted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.SoftDelete(e.ID)
|
s.SoftDelete(ctx, e.ID)
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +199,7 @@ func TestList_IncludeDeleted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.IncludeDeleted = true
|
p.IncludeDeleted = true
|
||||||
entities, err = s.List(p)
|
entities, err = s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -200,17 +210,18 @@ func TestList_IncludeDeleted(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_SortByUseCount(t *testing.T) {
|
func TestList_SortByUseCount(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
||||||
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
||||||
s.Create(e1)
|
s.Create(ctx, e1)
|
||||||
s.Create(e2)
|
s.Create(ctx, e2)
|
||||||
s.IncrementUse(e2.ID)
|
s.IncrementUse(ctx, e2.ID)
|
||||||
s.IncrementUse(e2.ID)
|
s.IncrementUse(ctx, e2.ID)
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.Sort = "use_count"
|
p.Sort = "use_count"
|
||||||
entities, err := s.List(p)
|
entities, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -221,14 +232,15 @@ func TestList_SortByUseCount(t *testing.T) {
|
|||||||
|
|
||||||
func TestList_Pagination(t *testing.T) {
|
func TestList_Pagination(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
|
||||||
}
|
}
|
||||||
|
|
||||||
p := DefaultListParams()
|
p := DefaultListParams()
|
||||||
p.Limit = 3
|
p.Limit = 3
|
||||||
p.Offset = 0
|
p.Offset = 0
|
||||||
page1, err := s.List(p)
|
page1, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +249,7 @@ func TestList_Pagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.Offset = 3
|
p.Offset = 3
|
||||||
page2, err := s.List(p)
|
page2, err := s.List(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -251,16 +263,17 @@ func TestList_Pagination(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Body(t *testing.T) {
|
func TestUpdate_Body(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "old", Glyph: GlyphNote}
|
e := &Entity{Body: "old", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
time.Sleep(1100 * time.Millisecond)
|
||||||
newBody := "new"
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Body != "new" {
|
if got.Body != "new" {
|
||||||
t.Errorf("body not updated: %q", got.Body)
|
t.Errorf("body not updated: %q", got.Body)
|
||||||
}
|
}
|
||||||
@@ -271,15 +284,16 @@ func TestUpdate_Body(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Tags(t *testing.T) {
|
func TestUpdate_Tags(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newTags := []string{"new1", "new2"}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if len(got.Tags) != 2 {
|
if len(got.Tags) != 2 {
|
||||||
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
|
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) {
|
func TestPromote_Success(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.CardType == nil || *got.CardType != CardSnippet {
|
if got.CardType == nil || *got.CardType != CardSnippet {
|
||||||
t.Errorf("expected snippet, got %v", got.CardType)
|
t.Errorf("expected snippet, got %v", got.CardType)
|
||||||
}
|
}
|
||||||
@@ -302,26 +317,28 @@ func TestPromote_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestPromote_AlreadyPromoted(t *testing.T) {
|
func TestPromote_AlreadyPromoted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
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)
|
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemote_Success(t *testing.T) {
|
func TestDemote_Success(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.Promote(e.ID, CardSnippet, nil)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.CardType != nil {
|
if got.CardType != nil {
|
||||||
t.Errorf("expected nil card_type, got %v", got.CardType)
|
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) {
|
func TestDemote_AlreadyFluid(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
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)
|
t.Errorf("expected ErrAlreadyFluid, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSoftDelete_First(t *testing.T) {
|
func TestSoftDelete_First(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -353,7 +372,7 @@ func TestSoftDelete_First(t *testing.T) {
|
|||||||
t.Errorf("expected DeletedSoft, got %d", result)
|
t.Errorf("expected DeletedSoft, got %d", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.DeletedAt == nil {
|
if got.DeletedAt == nil {
|
||||||
t.Error("expected deleted_at to be set")
|
t.Error("expected deleted_at to be set")
|
||||||
}
|
}
|
||||||
@@ -361,11 +380,12 @@ func TestSoftDelete_First(t *testing.T) {
|
|||||||
|
|
||||||
func TestSoftDelete_Second(t *testing.T) {
|
func TestSoftDelete_Second(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
s.SoftDelete(e.ID)
|
s.SoftDelete(ctx, e.ID)
|
||||||
result, err := s.SoftDelete(e.ID)
|
result, err := s.SoftDelete(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +393,7 @@ func TestSoftDelete_Second(t *testing.T) {
|
|||||||
t.Errorf("expected DeletedHard, got %d", result)
|
t.Errorf("expected DeletedHard, got %d", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.Get(e.ID)
|
_, err = s.Get(ctx, e.ID)
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
|
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) {
|
func TestSoftDelete_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
|
_, err := s.SoftDelete(context.Background(), "01NONEXISTENT0000000000000")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -389,15 +409,16 @@ func TestSoftDelete_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestIncrementUse(t *testing.T) {
|
func TestIncrementUse(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
ct := CardSnippet
|
ct := CardSnippet
|
||||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.UseCount != 1 {
|
if got.UseCount != 1 {
|
||||||
t.Errorf("expected use_count=1, got %d", got.UseCount)
|
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) {
|
func TestResolve_FullID(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -422,10 +444,11 @@ func TestResolve_FullID(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve_Prefix(t *testing.T) {
|
func TestResolve_Prefix(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -436,7 +459,7 @@ func TestResolve_Prefix(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve_NotFound(t *testing.T) {
|
func TestResolve_NotFound(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
_, err := s.Resolve("ZZZZZZZZZ")
|
_, err := s.Resolve(context.Background(), "ZZZZZZZZZ")
|
||||||
if err != ErrNotFound {
|
if err != ErrNotFound {
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -444,24 +467,25 @@ func TestResolve_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestAbsorb_SourceIsCard(t *testing.T) {
|
func TestAbsorb_SourceIsCard(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
|
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
|
||||||
s.Create(target)
|
s.Create(ctx, target)
|
||||||
|
|
||||||
source := &Entity{Body: "source", Glyph: GlyphNote}
|
source := &Entity{Body: "source", Glyph: GlyphNote}
|
||||||
s.Create(source)
|
s.Create(ctx, source)
|
||||||
s.Promote(source.ID, CardSnippet, nil)
|
s.Promote(ctx, source.ID, CardSnippet, nil)
|
||||||
s.IncrementUse(source.ID)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(target.ID)
|
got, _ := s.Get(ctx, target.ID)
|
||||||
if got.Body != "target\nsource" {
|
if got.Body != "target\nsource" {
|
||||||
t.Errorf("merged body: %q", got.Body)
|
t.Errorf("merged body: %q", got.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
src, _ := s.Get(source.ID)
|
src, _ := s.Get(ctx, source.ID)
|
||||||
if src.CardType != nil {
|
if src.CardType != nil {
|
||||||
t.Error("source card_type should be cleared after absorb")
|
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) {
|
func TestCreate_WithTitleAndDescription(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{
|
e := &Entity{
|
||||||
Body: "body text",
|
Body: "body text",
|
||||||
Title: ptr("nginx trick"),
|
Title: ptr("nginx trick"),
|
||||||
@@ -482,11 +507,11 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
|
|||||||
Glyph: GlyphNote,
|
Glyph: GlyphNote,
|
||||||
Tags: []string{"ops"},
|
Tags: []string{"ops"},
|
||||||
}
|
}
|
||||||
if err := s.Create(e); err != nil {
|
if err := s.Create(ctx, e); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := s.Get(e.ID)
|
got, err := s.Get(ctx, e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -503,12 +528,13 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_WithoutTitle(t *testing.T) {
|
func TestCreate_WithoutTitle(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "just body", Glyph: GlyphNote}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Title != nil {
|
if got.Title != nil {
|
||||||
t.Errorf("expected nil title, got %v", got.Title)
|
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) {
|
func TestUpdate_Title(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "body", Glyph: GlyphNote}
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newTitle := "new title"
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Title == nil || *got.Title != "new title" {
|
if got.Title == nil || *got.Title != "new title" {
|
||||||
t.Errorf("title: got %v", got.Title)
|
t.Errorf("title: got %v", got.Title)
|
||||||
}
|
}
|
||||||
@@ -535,15 +562,16 @@ func TestUpdate_Title(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdate_Description(t *testing.T) {
|
func TestUpdate_Description(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "body", Glyph: GlyphNote}
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
|
|
||||||
newDesc := "new desc"
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(e.ID)
|
got, _ := s.Get(ctx, e.ID)
|
||||||
if got.Description == nil || *got.Description != "new desc" {
|
if got.Description == nil || *got.Description != "new desc" {
|
||||||
t.Errorf("description: got %v", got.Description)
|
t.Errorf("description: got %v", got.Description)
|
||||||
}
|
}
|
||||||
@@ -551,16 +579,17 @@ func TestUpdate_Description(t *testing.T) {
|
|||||||
|
|
||||||
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
|
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
|
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
|
||||||
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
|
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
|
||||||
s.Create(target)
|
s.Create(ctx, target)
|
||||||
s.Create(source)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := s.Get(target.ID)
|
got, _ := s.Get(ctx, target.ID)
|
||||||
if got.Title == nil || *got.Title != "target title" {
|
if got.Title == nil || *got.Title != "target title" {
|
||||||
t.Errorf("target title should be preserved, got %v", got.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
|
package db
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
type TagCount struct {
|
type TagCount struct {
|
||||||
Tag string
|
Tag string
|
||||||
Count int
|
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"
|
where := "WHERE e.deleted_at IS NULL"
|
||||||
if cardsOnly {
|
if cardsOnly {
|
||||||
where += " AND e.card_type IS NOT NULL"
|
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
|
SELECT t.tag, COUNT(*) as cnt
|
||||||
FROM entity_tags t
|
FROM entity_tags t
|
||||||
JOIN entities e ON t.entity_id = e.id
|
JOIN entities e ON t.entity_id = e.id
|
||||||
` + where + `
|
`+where+`
|
||||||
GROUP BY t.tag
|
GROUP BY t.tag
|
||||||
ORDER BY t.tag`)
|
ORDER BY t.tag`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+20
-14
@@ -1,10 +1,13 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestListTags_Empty(t *testing.T) {
|
func TestListTags_Empty(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
tags, err := s.ListTags(false)
|
tags, err := s.ListTags(context.Background(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -15,11 +18,12 @@ func TestListTags_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTags_Counts(t *testing.T) {
|
func TestListTags_Counts(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
|
ctx := context.Background()
|
||||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
|
||||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -44,13 +48,14 @@ func TestListTags_Counts(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTags_ExcludesDeleted(t *testing.T) {
|
func TestListTags_ExcludesDeleted(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
|
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
|
||||||
s.Create(e)
|
s.Create(ctx, e)
|
||||||
s.SoftDelete(e.ID)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -64,12 +69,13 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTags_CardsOnly(t *testing.T) {
|
func TestListTags_CardsOnly(t *testing.T) {
|
||||||
s := testStore(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
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +83,7 @@ func TestListTags_CardsOnly(t *testing.T) {
|
|||||||
t.Fatalf("all tags: expected 3, got %d", len(all))
|
t.Fatalf("all tags: expected 3, got %d", len(all))
|
||||||
}
|
}
|
||||||
|
|
||||||
cards, err := s.ListTags(true)
|
cards, err := s.ListTags(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-12
@@ -4,19 +4,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Body string
|
Body string
|
||||||
Glyph string
|
Glyph string
|
||||||
Title *string
|
Title *string
|
||||||
Description *string
|
Description *string
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
Tags []string
|
Tags []string
|
||||||
FilterTags []string
|
FilterTags []string
|
||||||
CardSuffix *string
|
CardSuffix *string
|
||||||
Pin bool
|
Pin bool
|
||||||
Query bool
|
Query bool
|
||||||
|
QueryDateFrom *string
|
||||||
|
QueryDateTo *string
|
||||||
|
QueryCardType *string
|
||||||
}
|
}
|
||||||
|
|
||||||
var validCardTypes = map[string]string{
|
var validCardTypes = map[string]string{
|
||||||
@@ -66,11 +70,48 @@ func Parse(input string) (*Result, error) {
|
|||||||
r.Glyph = ""
|
r.Glyph = ""
|
||||||
tokens := strings.Fields(remaining)
|
tokens := strings.Fields(remaining)
|
||||||
var bodyParts []string
|
var bodyParts []string
|
||||||
|
now := time.Now()
|
||||||
for _, tok := range tokens {
|
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:])
|
tag := strings.ToLower(tok[1:])
|
||||||
r.FilterTags = append(r.FilterTags, tag)
|
r.FilterTags = append(r.FilterTags, tag)
|
||||||
} else {
|
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)
|
bodyParts = append(bodyParts, tok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func ptrEq(a, b *string) bool {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -108,12 +108,14 @@ func (a absorbModel) visibleCount() int {
|
|||||||
|
|
||||||
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
|
||||||
|
|
||||||
body := e.Body
|
body := e.Body
|
||||||
if e.Title != nil {
|
if e.Title != nil {
|
||||||
body = *e.Title
|
body = *e.Title
|
||||||
}
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
var tags string
|
var tags string
|
||||||
if len(e.Tags) > 0 {
|
if len(e.Tags) > 0 {
|
||||||
@@ -125,11 +127,12 @@ func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
|||||||
tags = " " + strings.Join(tagParts, " ")
|
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 {
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
body = truncate(body, maxWidth-20)
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
return line
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
-17
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cardGroup struct {
|
||||||
|
label string
|
||||||
|
start int
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
type cardsModel struct {
|
type cardsModel struct {
|
||||||
entities []*db.Entity
|
entities []*db.Entity
|
||||||
filtered []*db.Entity
|
filtered []*db.Entity
|
||||||
|
groups []cardGroup
|
||||||
cursor int
|
cursor int
|
||||||
offset int
|
offset int
|
||||||
height int
|
height int
|
||||||
@@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *cardsModel) applyFilter() {
|
func (c *cardsModel) applyFilter() {
|
||||||
c.filtered = nil
|
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
|
||||||
var pinned, rest []*db.Entity
|
|
||||||
for _, e := range c.entities {
|
|
||||||
if !matchesIntent(e, c.intent) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Pinned {
|
|
||||||
pinned = append(pinned, e)
|
|
||||||
} else {
|
|
||||||
rest = append(rest, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.filtered = append(pinned, rest...)
|
|
||||||
if c.cursor >= len(c.filtered) {
|
if c.cursor >= len(c.filtered) {
|
||||||
c.cursor = max(0, len(c.filtered)-1)
|
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 entities {
|
||||||
|
if !matchesIntent(e, intentFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
func (c *cardsModel) setSize(width, height int) {
|
||||||
c.width = width
|
c.width = width
|
||||||
c.height = height
|
c.height = height
|
||||||
@@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string {
|
|||||||
if len(c.filtered) == 0 {
|
if len(c.filtered) == 0 {
|
||||||
return statusStyle.Render("no cards")
|
return statusStyle.Render("no cards")
|
||||||
}
|
}
|
||||||
|
if len(c.groups) > 0 {
|
||||||
|
return c.groupedView(width)
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
visible := c.visibleCount()
|
visible := c.visibleCount()
|
||||||
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
|
|||||||
return b.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 {
|
func (c cardsModel) visibleCount() int {
|
||||||
if c.height <= 0 {
|
if c.height <= 0 {
|
||||||
return 20
|
return 20
|
||||||
@@ -197,12 +301,14 @@ func (c cardsModel) visibleCount() int {
|
|||||||
|
|
||||||
func renderCard(e *db.Entity, maxWidth int) string {
|
func renderCard(e *db.Entity, maxWidth int) string {
|
||||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
|
||||||
|
|
||||||
body := e.Body
|
body := e.Body
|
||||||
if e.Title != nil {
|
if e.Title != nil {
|
||||||
body = *e.Title
|
body = *e.Title
|
||||||
}
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
affordance := detectAffordance(e)
|
affordance := detectAffordance(e)
|
||||||
affordStr := ""
|
affordStr := ""
|
||||||
@@ -231,11 +337,12 @@ func renderCard(e *db.Entity, maxWidth int) string {
|
|||||||
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
|
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 {
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
body = truncate(body, maxWidth-30)
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return line
|
return line
|
||||||
|
|||||||
+94
-16
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -54,10 +55,28 @@ type stepsPersistedMsg struct{}
|
|||||||
|
|
||||||
type templateCopiedMsg struct{}
|
type templateCopiedMsg struct{}
|
||||||
|
|
||||||
|
type backlinksLoadedMsg struct {
|
||||||
|
backlinks []db.Backlink
|
||||||
|
}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []db.TagCount
|
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 {
|
type editorFinishedMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -68,7 +87,7 @@ type errMsg struct {
|
|||||||
|
|
||||||
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
entities, err := store.List(params)
|
entities, err := store.List(context.Background(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
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 {
|
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(context.Background(), e); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return entityCreatedMsg{e}
|
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 {
|
func deleteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if _, err := store.SoftDelete(id); err != nil {
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return entityDeletedMsg{id}
|
return entityDeletedMsg{id}
|
||||||
@@ -104,10 +123,10 @@ func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
update = db.EntityUpdate{ClearCompleted: true}
|
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}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
updated, err := store.Get(e.ID)
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
@@ -123,10 +142,10 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
newPinned := !e.Pinned
|
newPinned := !e.Pinned
|
||||||
update := db.EntityUpdate{Pinned: &newPinned}
|
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}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
updated, err := store.Get(e.ID)
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
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 {
|
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
cd := carddata.GenerateCardData(ct, body)
|
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 errMsg{err}
|
||||||
}
|
}
|
||||||
return entityPromotedMsg{id, ct}
|
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 {
|
func demoteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := store.Demote(id); err != nil {
|
if err := store.Demote(context.Background(), id); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return entityDemotedMsg{id}
|
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 {
|
if err := clipboard.WriteAll(e.Body); err != nil {
|
||||||
return errMsg{err}
|
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 errMsg{err}
|
||||||
}
|
}
|
||||||
return entityCopiedMsg{}
|
return entityCopiedMsg{}
|
||||||
@@ -171,7 +190,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
|
|
||||||
func loadTags(store *db.Store) tea.Cmd {
|
func loadTags(store *db.Store) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
tags, err := store.ListTags(false)
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
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 {
|
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
editorEnv := os.Getenv("EDITOR")
|
editorEnv := os.Getenv("EDITOR")
|
||||||
|
if editorEnv == "" {
|
||||||
|
editorEnv = os.Getenv("VISUAL")
|
||||||
|
}
|
||||||
if editorEnv == "" {
|
if editorEnv == "" {
|
||||||
editorEnv = "vi"
|
editorEnv = "vi"
|
||||||
}
|
}
|
||||||
@@ -216,7 +258,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update := db.EntityUpdate{Body: &newBody}
|
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}
|
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 {
|
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
entities, err := store.List(db.DefaultListParams())
|
entities, err := store.List(context.Background(), db.DefaultListParams())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
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 {
|
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
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 errMsg{err}
|
||||||
}
|
}
|
||||||
return entityAbsorbedMsg{targetID}
|
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 {
|
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
update := db.EntityUpdate{CardData: &stepsJSON}
|
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 errMsg{err}
|
||||||
}
|
}
|
||||||
return stepsPersistedMsg{}
|
return stepsPersistedMsg{}
|
||||||
@@ -258,9 +300,45 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
|||||||
if err := clipboard.WriteAll(resolved); err != nil {
|
if err := clipboard.WriteAll(resolved); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
if err := store.IncrementUse(entityID); err != nil {
|
if err := store.IncrementUse(context.Background(), entityID); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return templateCopiedMsg{}
|
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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+72
-10
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
"github.com/lerko/nib/internal/display"
|
"github.com/lerko/nib/internal/display"
|
||||||
@@ -20,13 +21,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type detailModel struct {
|
type detailModel struct {
|
||||||
entity *db.Entity
|
entity *db.Entity
|
||||||
scroll int
|
backlinks []db.Backlink
|
||||||
height int
|
scroll int
|
||||||
width int
|
height int
|
||||||
mode detailMode
|
width int
|
||||||
run runModel
|
mode detailMode
|
||||||
fill fillModel
|
run runModel
|
||||||
|
fill fillModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDetailModel() detailModel {
|
func newDetailModel() detailModel {
|
||||||
@@ -35,6 +37,7 @@ func newDetailModel() detailModel {
|
|||||||
|
|
||||||
func (d *detailModel) setEntity(e *db.Entity) {
|
func (d *detailModel) setEntity(e *db.Entity) {
|
||||||
d.entity = e
|
d.entity = e
|
||||||
|
d.backlinks = nil
|
||||||
d.scroll = 0
|
d.scroll = 0
|
||||||
d.mode = detailPreview
|
d.mode = detailPreview
|
||||||
}
|
}
|
||||||
@@ -61,6 +64,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
d.scroll++
|
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
|
return d, nil
|
||||||
}
|
}
|
||||||
@@ -98,7 +112,20 @@ func (d detailModel) previewView(width int) string {
|
|||||||
b.WriteString("\n")
|
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")
|
b.WriteString("\n")
|
||||||
|
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
@@ -119,6 +146,25 @@ func (d detailModel) previewView(width int) string {
|
|||||||
b.WriteString("\n")
|
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")
|
b.WriteString("\n")
|
||||||
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||||
if e.ModifiedAt != e.CreatedAt {
|
if e.ModifiedAt != e.CreatedAt {
|
||||||
@@ -142,8 +188,24 @@ func (d detailModel) previewView(width int) string {
|
|||||||
b.WriteString(idStyle.Render(meta))
|
b.WriteString(idStyle.Render(meta))
|
||||||
|
|
||||||
lines := strings.Split(b.String(), "\n")
|
lines := strings.Split(b.String(), "\n")
|
||||||
if d.scroll > 0 && d.scroll < len(lines) {
|
totalLines := len(lines)
|
||||||
lines = lines[d.scroll:]
|
|
||||||
|
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 {
|
if d.height > 0 && len(lines) > d.height {
|
||||||
lines = lines[:d.height]
|
lines = lines[:d.height]
|
||||||
|
|||||||
+34
-3
@@ -7,27 +7,50 @@ func renderHelp(width, height int) string {
|
|||||||
title string
|
title string
|
||||||
binds [][2]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{
|
{"Navigation", [][2]string{
|
||||||
{"j/k ↑/↓", "move cursor"},
|
{"j/k ↑/↓", "move cursor"},
|
||||||
{"g/G home/end", "top / bottom"},
|
{"g/G home/end", "top / bottom"},
|
||||||
{"pgup/pgdn", "page up / down"},
|
{"pgup/pgdn", "page up / down"},
|
||||||
{"enter", "view detail"},
|
{"enter", "view detail"},
|
||||||
{"esc", "back / clear filter"},
|
|
||||||
}},
|
}},
|
||||||
{"Views", [][2]string{
|
{"Views", [][2]string{
|
||||||
{"1", "stream view"},
|
{"1", "stream view"},
|
||||||
{"2", "cards view"},
|
{"2", "cards view"},
|
||||||
{"s", "cycle sort (cards)"},
|
{"s", "cycle sort (cards)"},
|
||||||
{"tab", "cycle intent (cards)"},
|
{"i", "cycle intent (cards)"},
|
||||||
|
{"T", "cycle theme"},
|
||||||
}},
|
}},
|
||||||
{"Actions", [][2]string{
|
{"Actions", [][2]string{
|
||||||
{"a", "add entity (or ?query to search)"},
|
|
||||||
{"d", "delete (with confirm)"},
|
{"d", "delete (with confirm)"},
|
||||||
{"x", "toggle todo completion"},
|
{"x", "toggle todo completion"},
|
||||||
{"!", "toggle pin"},
|
{"!", "toggle pin"},
|
||||||
{"#", "filter by tag"},
|
{"#", "filter by tag"},
|
||||||
{"m", "absorb (merge into target)"},
|
{"m", "absorb (merge into target)"},
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
|
{"S", "stumble (resurface stale entries)"},
|
||||||
}},
|
}},
|
||||||
{"Detail View", [][2]string{
|
{"Detail View", [][2]string{
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
@@ -38,6 +61,14 @@ func renderHelp(width, height int) string {
|
|||||||
{"r", "run checklist"},
|
{"r", "run checklist"},
|
||||||
{"f", "fill template"},
|
{"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{
|
{"Run Mode", [][2]string{
|
||||||
{"j/k", "move between steps"},
|
{"j/k", "move between steps"},
|
||||||
{"space", "toggle step"},
|
{"space", "toggle step"},
|
||||||
|
|||||||
+45
-44
@@ -11,15 +11,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type inputResult struct {
|
type inputResult struct {
|
||||||
entity *db.Entity
|
entity *db.Entity
|
||||||
query bool
|
query bool
|
||||||
body string
|
body string
|
||||||
tags []string
|
tags []string
|
||||||
|
dateFrom *string
|
||||||
|
dateTo *string
|
||||||
|
cardType *db.CardType
|
||||||
}
|
}
|
||||||
|
|
||||||
type inputModel struct {
|
type inputModel struct {
|
||||||
ti textinput.Model
|
ti textinput.Model
|
||||||
active bool
|
|
||||||
preview *parse.Result
|
preview *parse.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,15 +33,8 @@ func newInputModel() inputModel {
|
|||||||
return inputModel{ti: ti}
|
return inputModel{ti: ti}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *inputModel) focus() {
|
func (i *inputModel) clearText() {
|
||||||
i.active = true
|
|
||||||
i.ti.Focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *inputModel) reset() {
|
|
||||||
i.active = false
|
|
||||||
i.ti.SetValue("")
|
i.ti.SetValue("")
|
||||||
i.ti.Blur()
|
|
||||||
i.preview = nil
|
i.preview = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +50,18 @@ func (i inputModel) submit() *inputResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Query {
|
if parsed.Query {
|
||||||
return &inputResult{
|
r := &inputResult{
|
||||||
query: true,
|
query: true,
|
||||||
body: parsed.Body,
|
body: parsed.Body,
|
||||||
tags: parsed.FilterTags,
|
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{
|
e := &db.Entity{
|
||||||
@@ -101,21 +103,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) view(width int) string {
|
func (i inputModel) viewBar(width int, focused bool) string {
|
||||||
var b strings.Builder
|
tiView := i.ti.View()
|
||||||
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
|
if focused {
|
||||||
b.WriteString("\n")
|
return tiView
|
||||||
b.WriteString(i.ti.View())
|
}
|
||||||
b.WriteString("\n")
|
val := i.ti.Value()
|
||||||
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder"))
|
if val != "" {
|
||||||
b.WriteString("\n")
|
return hintDescStyle.Render("› " + val)
|
||||||
b.WriteString(i.renderPreview(width))
|
}
|
||||||
return b.String()
|
return hintDescStyle.Render("› capture a thought…")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) renderPreview(width int) string {
|
func (i inputModel) previewText() string {
|
||||||
if i.preview == nil {
|
if i.preview == nil {
|
||||||
return drawerPreviewStyle.Render("")
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
p := i.preview
|
p := i.preview
|
||||||
@@ -128,7 +130,16 @@ func (i inputModel) renderPreview(width int) string {
|
|||||||
for _, t := range p.FilterTags {
|
for _, t := range p.FilterTags {
|
||||||
q += " #" + t
|
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)
|
glyph := glyphForParsed(p.Glyph)
|
||||||
@@ -140,22 +151,16 @@ func (i inputModel) renderPreview(width int) string {
|
|||||||
var parts []string
|
var parts []string
|
||||||
parts = append(parts, glyph, body)
|
parts = append(parts, glyph, body)
|
||||||
for _, t := range p.Tags {
|
for _, t := range p.Tags {
|
||||||
parts = append(parts, tagStyle.Render("#"+t))
|
parts = append(parts, "#"+t)
|
||||||
}
|
}
|
||||||
if p.Pin {
|
if p.Pin {
|
||||||
parts = append(parts, pinnedStyle.Render("•"))
|
parts = append(parts, "•")
|
||||||
}
|
}
|
||||||
if p.CardSuffix != nil {
|
if p.CardSuffix != nil {
|
||||||
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
|
parts = append(parts, *p.CardSuffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
maxW := width - 4
|
|
||||||
if maxW > 0 && len(stripAnsi(line)) > maxW {
|
|
||||||
line = truncate(line, maxW)
|
|
||||||
}
|
|
||||||
|
|
||||||
return drawerPreviewStyle.Render(line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func glyphForParsed(glyph string) string {
|
func glyphForParsed(glyph string) string {
|
||||||
@@ -170,7 +175,3 @@ func glyphForParsed(glyph string) string {
|
|||||||
return "—"
|
return "—"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawerLines() int {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-3
@@ -7,7 +7,7 @@ type keyMap struct {
|
|||||||
Down key.Binding
|
Down key.Binding
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Back key.Binding
|
Back key.Binding
|
||||||
Add key.Binding
|
Capture key.Binding
|
||||||
Delete key.Binding
|
Delete key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
Help key.Binding
|
Help key.Binding
|
||||||
@@ -31,6 +31,10 @@ type keyMap struct {
|
|||||||
Fill key.Binding
|
Fill key.Binding
|
||||||
FocusLeft key.Binding
|
FocusLeft key.Binding
|
||||||
FocusRight key.Binding
|
FocusRight key.Binding
|
||||||
|
Tab key.Binding
|
||||||
|
ToggleRail key.Binding
|
||||||
|
Stumble key.Binding
|
||||||
|
Theme key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -38,7 +42,7 @@ var keys = keyMap{
|
|||||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
|
||||||
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
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")),
|
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
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")),
|
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
|
||||||
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
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")),
|
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||||
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||||
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||||
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
||||||
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
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)
|
glyph := style.Render(glyphStr)
|
||||||
|
|
||||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
|
||||||
|
|
||||||
body := e.Body
|
body := e.Body
|
||||||
if e.Title != nil {
|
if e.Title != nil {
|
||||||
body = *e.Title
|
body = *e.Title
|
||||||
}
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
var extras []string
|
var extras []string
|
||||||
if e.Pinned {
|
if e.Pinned {
|
||||||
extras = append(extras, pinnedStyle.Render("•"))
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
}
|
}
|
||||||
if len(e.Tags) > 0 {
|
if len(e.Tags) > 0 {
|
||||||
tagParts := make([]string, len(e.Tags))
|
limit := min(2, len(e.Tags))
|
||||||
for i, t := range e.Tags {
|
for _, t := range e.Tags[:limit] {
|
||||||
tagParts[i] = tagStyle.Render("#" + t)
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
}
|
}
|
||||||
extras = append(extras, strings.Join(tagParts, " "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extraStr := ""
|
extraStr := ""
|
||||||
@@ -228,11 +228,12 @@ func renderEntity(e *db.Entity, maxWidth int) string {
|
|||||||
extraStr = " " + strings.Join(extras, " ")
|
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 {
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
body = truncate(body, maxWidth-20)
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return line
|
return line
|
||||||
|
|||||||
+486
-156
@@ -1,8 +1,10 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -10,16 +12,18 @@ import (
|
|||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const statusTimeout = 2 * time.Second
|
||||||
|
|
||||||
type viewState int
|
type viewState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateList viewState = iota
|
stateList viewState = iota
|
||||||
stateDetail
|
stateDetail
|
||||||
stateInput
|
|
||||||
stateTagFilter
|
stateTagFilter
|
||||||
stateConfirm
|
stateConfirm
|
||||||
statePromote
|
statePromote
|
||||||
stateAbsorb
|
stateAbsorb
|
||||||
|
stateStumble
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
@@ -62,7 +66,9 @@ func (s cardsSort) next() cardsSort {
|
|||||||
type focusPane int
|
type focusPane int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
focusList focusPane = iota
|
focusCapture focusPane = iota
|
||||||
|
focusTagRail
|
||||||
|
focusList
|
||||||
focusDetail
|
focusDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,43 +79,73 @@ type model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
list listModel
|
list listModel
|
||||||
cards cardsModel
|
cards cardsModel
|
||||||
detail detailModel
|
detail detailModel
|
||||||
input inputModel
|
input inputModel
|
||||||
filter filterModel
|
filter filterModel
|
||||||
promote promoteModel
|
promote promoteModel
|
||||||
absorb absorbModel
|
absorb absorbModel
|
||||||
showHelp bool
|
tagRail tagRailModel
|
||||||
|
stumble stumbleModel
|
||||||
|
showHelp bool
|
||||||
|
autocomplete autocompleteModel
|
||||||
|
|
||||||
focus focusPane
|
focus focusPane
|
||||||
splitDetail bool
|
splitDetail bool
|
||||||
|
showTagRail bool
|
||||||
|
|
||||||
filterTag string
|
filterTag string
|
||||||
confirmID string
|
confirmID string
|
||||||
cardsSort cardsSort
|
cardsSort cardsSort
|
||||||
searchQuery string
|
searchQuery string
|
||||||
searchTags []string
|
searchTags []string
|
||||||
|
queryDateFrom *string
|
||||||
|
queryDateTo *string
|
||||||
|
queryCardType *db.CardType
|
||||||
|
|
||||||
status string
|
status string
|
||||||
err error
|
statusSeq int
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func newModel(store *db.Store) model {
|
func newModel(store *db.Store) model {
|
||||||
|
loadTheme()
|
||||||
|
applyTheme()
|
||||||
|
inp := newInputModel()
|
||||||
|
inp.ti.Focus()
|
||||||
return model{
|
return model{
|
||||||
store: store,
|
store: store,
|
||||||
state: stateList,
|
state: stateList,
|
||||||
mode: modeStream,
|
mode: modeStream,
|
||||||
list: newListModel(),
|
focus: focusCapture,
|
||||||
cards: newCardsModel(),
|
showTagRail: true,
|
||||||
detail: newDetailModel(),
|
list: newListModel(),
|
||||||
input: newInputModel(),
|
cards: newCardsModel(),
|
||||||
filter: newFilterModel(),
|
detail: newDetailModel(),
|
||||||
|
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 {
|
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 {
|
func (m model) listParams() db.ListParams {
|
||||||
@@ -117,6 +153,15 @@ func (m model) listParams() db.ListParams {
|
|||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
p.Tag = &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 {
|
if m.mode == modeCards {
|
||||||
p.CardsOnly = true
|
p.CardsOnly = true
|
||||||
switch m.cardsSort {
|
switch m.cardsSort {
|
||||||
@@ -135,25 +180,19 @@ func (m model) listParams() db.ListParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) hasSearch() bool {
|
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() {
|
func (m *model) applySearch() {
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||||
m.cards.filtered = nil
|
var intentFiltered []*db.Entity
|
||||||
var pinned, rest []*db.Entity
|
for _, e := range searchFiltered {
|
||||||
for _, e := range filtered {
|
if matchesIntent(e, m.cards.intent) {
|
||||||
if !matchesIntent(e, m.cards.intent) {
|
intentFiltered = append(intentFiltered, e)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Pinned {
|
|
||||||
pinned = append(pinned, e)
|
|
||||||
} else {
|
|
||||||
rest = append(rest, 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) {
|
if m.cards.cursor >= len(m.cards.filtered) {
|
||||||
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
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.splitDetail = false
|
||||||
m.focus = focusList
|
m.focus = focusList
|
||||||
}
|
}
|
||||||
|
if !m.railVisible() && m.focus == focusTagRail {
|
||||||
|
m.focus = focusList
|
||||||
|
}
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -187,42 +229,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = nil
|
m.err = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case railTagsLoadedMsg:
|
||||||
|
m.tagRail.setTags(msg.tags)
|
||||||
|
m.tagRail.activeTag = m.filterTag
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case entityCreatedMsg:
|
case entityCreatedMsg:
|
||||||
m.state = stateList
|
m.input.clearText()
|
||||||
m.input.reset()
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
|
||||||
m.recalcSizes()
|
|
||||||
m.status = "created"
|
|
||||||
return m, loadEntities(m.store, m.listParams())
|
|
||||||
|
|
||||||
case entityDeletedMsg:
|
case entityDeletedMsg:
|
||||||
m.status = "deleted"
|
|
||||||
m.state = stateList
|
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:
|
case entityUpdatedMsg:
|
||||||
m.status = msg.action
|
|
||||||
if m.state == stateDetail {
|
if m.state == stateDetail {
|
||||||
m.detail.setEntity(msg.entity)
|
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:
|
case entityPromotedMsg:
|
||||||
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||||
m.state = stateList
|
m.stumble.advance()
|
||||||
return m, loadEntities(m.store, m.listParams())
|
m.state = stateStumble
|
||||||
|
} else {
|
||||||
|
m.state = stateList
|
||||||
|
}
|
||||||
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
|
||||||
|
|
||||||
case entityDemotedMsg:
|
case entityDemotedMsg:
|
||||||
m.status = "demoted → fluid"
|
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
|
||||||
return m, m.reloadDetail(msg.id)
|
|
||||||
|
|
||||||
case entityCopiedMsg:
|
case entityCopiedMsg:
|
||||||
m.status = "copied"
|
return m, m.setStatus("copied")
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case entityAbsorbedMsg:
|
case entityAbsorbedMsg:
|
||||||
m.status = "absorbed"
|
if !m.stumble.done && len(m.stumble.entries) > 0 {
|
||||||
m.state = stateList
|
m.stumble.advance()
|
||||||
return m, loadEntities(m.store, m.listParams())
|
m.state = stateStumble
|
||||||
|
} else {
|
||||||
|
m.state = stateList
|
||||||
|
}
|
||||||
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
|
||||||
|
|
||||||
case absorbSourcesLoadedMsg:
|
case absorbSourcesLoadedMsg:
|
||||||
m.absorb = newAbsorbModel(msg.targetID)
|
m.absorb = newAbsorbModel(msg.targetID)
|
||||||
@@ -232,27 +280,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case stepsPersistedMsg:
|
case stepsPersistedMsg:
|
||||||
m.status = "steps saved"
|
|
||||||
m.detail.mode = detailPreview
|
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:
|
case templateCopiedMsg:
|
||||||
m.status = "copied resolved"
|
|
||||||
m.detail.mode = detailPreview
|
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:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
|
m.tagRail.setTags(msg.tags)
|
||||||
m.state = stateTagFilter
|
m.state = stateTagFilter
|
||||||
return m, nil
|
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:
|
case editorFinishedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
} else {
|
return m, m.reloadAfterEdit()
|
||||||
m.status = "updated"
|
|
||||||
}
|
}
|
||||||
return m, m.reloadAfterEdit()
|
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
|
||||||
|
|
||||||
case confirmTimeoutMsg:
|
case confirmTimeoutMsg:
|
||||||
if m.state == stateConfirm {
|
if m.state == stateConfirm {
|
||||||
@@ -261,6 +323,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case statusClearMsg:
|
||||||
|
if msg.seq == m.statusSeq {
|
||||||
|
m.status = ""
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case errMsg:
|
case errMsg:
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -268,8 +336,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
m.err = nil
|
m.err = nil
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateInput:
|
|
||||||
return m.updateInput(msg)
|
|
||||||
case stateTagFilter:
|
case stateTagFilter:
|
||||||
return m.updateTagFilter(msg)
|
return m.updateTagFilter(msg)
|
||||||
case stateConfirm:
|
case stateConfirm:
|
||||||
@@ -278,9 +344,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updatePromote(msg)
|
return m.updatePromote(msg)
|
||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
return m.updateAbsorb(msg)
|
return m.updateAbsorb(msg)
|
||||||
|
case stateStumble:
|
||||||
|
return m.updateStumble(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateKeys(msg)
|
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
|
return m, nil
|
||||||
@@ -294,8 +369,168 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 {
|
if m.splitDetail && m.state == stateList {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
|
case "tab":
|
||||||
|
cmd := m.setFocus(focusCapture)
|
||||||
|
return m, cmd
|
||||||
case "l":
|
case "l":
|
||||||
if m.focus == focusList {
|
if m.focus == focusList {
|
||||||
m.focus = focusDetail
|
m.focus = focusDetail
|
||||||
@@ -306,6 +541,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.focus = focusList
|
m.focus = focusList
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if m.focus == focusList && m.railVisible() {
|
||||||
|
m.focus = focusTagRail
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.focus == focusDetail {
|
if m.focus == focusDetail {
|
||||||
m.focus = focusList
|
m.focus = focusList
|
||||||
@@ -380,9 +619,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
|
|
||||||
case "q":
|
case "q":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -414,12 +650,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "s":
|
case "s":
|
||||||
if m.mode == modeCards && m.state == stateList {
|
if m.mode == modeCards && m.state == stateList {
|
||||||
m.cardsSort = m.cardsSort.next()
|
m.cardsSort = m.cardsSort.next()
|
||||||
m.status = "sort: " + m.cardsSort.String()
|
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
|
||||||
return m, loadEntities(m.store, m.listParams())
|
|
||||||
}
|
}
|
||||||
return m, nil
|
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 {
|
if m.mode == modeCards && m.state == stateList {
|
||||||
m.cards.setIntent(m.cards.intent.next())
|
m.cards.setIntent(m.cards.intent.next())
|
||||||
if m.hasSearch() {
|
if m.hasSearch() {
|
||||||
@@ -429,12 +674,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
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":
|
case "a":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
m.state = stateInput
|
cmd := m.setFocus(focusCapture)
|
||||||
m.input.focus()
|
return m, cmd
|
||||||
m.recalcSizes()
|
|
||||||
return m, m.input.ti.Focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "esc":
|
case "esc":
|
||||||
@@ -474,9 +728,16 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.state == stateList && m.hasSearch() {
|
if m.state == stateList && m.hasSearch() {
|
||||||
|
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
|
||||||
m.searchQuery = ""
|
m.searchQuery = ""
|
||||||
m.searchTags = nil
|
m.searchTags = nil
|
||||||
|
m.queryDateFrom = nil
|
||||||
|
m.queryDateTo = nil
|
||||||
|
m.queryCardType = nil
|
||||||
m.status = ""
|
m.status = ""
|
||||||
|
if hadDBFilters {
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
m.cards.applyFilter()
|
m.cards.applyFilter()
|
||||||
} else {
|
} else {
|
||||||
@@ -489,6 +750,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = ""
|
m.status = ""
|
||||||
return m, loadEntities(m.store, m.listParams())
|
return m, loadEntities(m.store, m.listParams())
|
||||||
}
|
}
|
||||||
|
if m.state == stateList {
|
||||||
|
cmd := m.setFocus(focusCapture)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "d":
|
case "d":
|
||||||
@@ -531,8 +796,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
e := m.selectedEntity()
|
e := m.selectedEntity()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
m.status = "target must be fluid"
|
return m, m.setStatus("target must be fluid")
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
return m, loadAbsorbSources(m.store, e.ID)
|
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()
|
e := m.selectedEntity()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
m.status = "already a card"
|
return m, m.setStatus("already a card")
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
m.promote = newPromoteModel(e.ID, e.Body)
|
m.promote = newPromoteModel(e.ID, e.Body)
|
||||||
m.state = statePromote
|
m.state = statePromote
|
||||||
@@ -554,8 +817,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "D":
|
case "D":
|
||||||
if m.state == stateDetail && m.detail.entity != nil {
|
if m.state == stateDetail && m.detail.entity != nil {
|
||||||
if m.detail.entity.CardType == nil {
|
if m.detail.entity.CardType == nil {
|
||||||
m.status = "already fluid"
|
return m, m.setStatus("already fluid")
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
return m, demoteEntity(m.store, m.detail.entity.ID)
|
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 {
|
} else {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
|
return m, loadBacklinks(m.store, e.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -634,37 +897,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "q":
|
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 {
|
func (m model) View() string {
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
return renderHelp(m.width, m.height)
|
return renderHelp(m.width, m.height)
|
||||||
@@ -733,7 +1016,7 @@ func (m model) View() string {
|
|||||||
|
|
||||||
var content string
|
var content string
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateList, stateInput, stateConfirm:
|
case stateList, stateConfirm:
|
||||||
listContent := m.listContent()
|
listContent := m.listContent()
|
||||||
if m.splitDetail {
|
if m.splitDetail {
|
||||||
lw, rw := m.splitWidths()
|
lw, rw := m.splitWidths()
|
||||||
@@ -745,6 +1028,13 @@ func (m model) View() string {
|
|||||||
} else {
|
} else {
|
||||||
content = listContent
|
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:
|
case stateDetail:
|
||||||
content = m.detail.view(m.width)
|
content = m.detail.view(m.width)
|
||||||
case stateTagFilter:
|
case stateTagFilter:
|
||||||
@@ -753,40 +1043,51 @@ func (m model) View() string {
|
|||||||
content = m.promote.view(m.width)
|
content = m.promote.view(m.width)
|
||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
content = m.absorb.view(m.width)
|
content = m.absorb.view(m.width)
|
||||||
|
case stateStumble:
|
||||||
|
content = m.stumble.view()
|
||||||
}
|
}
|
||||||
|
|
||||||
header := m.headerView()
|
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 {
|
func (m model) listContent() string {
|
||||||
|
lw := m.listWidth()
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
lw := m.width
|
|
||||||
if m.splitDetail {
|
|
||||||
lw, _ = m.splitWidths()
|
|
||||||
}
|
|
||||||
return m.cards.view(lw)
|
return m.cards.view(lw)
|
||||||
}
|
}
|
||||||
lw := m.width
|
|
||||||
if m.splitDetail {
|
|
||||||
lw, _ = m.splitWidths()
|
|
||||||
}
|
|
||||||
return m.list.view(lw)
|
return m.list.view(lw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) headerView() string {
|
func (m model) headerView() string {
|
||||||
header := titleStyle.Render("nib")
|
header := titleStyle.Render("nib") + " "
|
||||||
|
header += renderTab("stream", "1", m.mode == modeStream)
|
||||||
modeName := "stream"
|
header += " " + separatorStyle.Render("│") + " "
|
||||||
if m.mode == modeCards {
|
header += renderTab("cards", "2", m.mode == modeCards)
|
||||||
modeName = "cards"
|
|
||||||
}
|
|
||||||
header += " " + modeStyle.Render(modeName)
|
|
||||||
|
|
||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.hasSearch() {
|
if m.hasSearch() {
|
||||||
@@ -797,6 +1098,15 @@ func (m model) headerView() string {
|
|||||||
for _, t := range m.searchTags {
|
for _, t := range m.searchTags {
|
||||||
pill += " #" + t
|
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)
|
header += " " + searchPillStyle.Render(pill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,11 +1121,7 @@ func (m model) headerView() string {
|
|||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) footerView() string {
|
func (m model) statusLine() string {
|
||||||
if m.state == stateInput {
|
|
||||||
return m.input.view(m.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.state == stateConfirm {
|
if m.state == stateConfirm {
|
||||||
return renderConfirm(m.confirmID)
|
return renderConfirm(m.confirmID)
|
||||||
}
|
}
|
||||||
@@ -824,46 +1130,69 @@ func (m model) footerView() string {
|
|||||||
return errorStyle.Render("error: " + m.err.Error())
|
return errorStyle.Render("error: " + m.err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.status != "" {
|
|
||||||
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderStatusBar(m, m.width)
|
return renderStatusBar(m, m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) contentHeight() int {
|
func (m model) contentHeight() int {
|
||||||
return m.height - 3 - m.drawerHeight()
|
h := m.height - 4
|
||||||
}
|
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
|
||||||
|
n := m.autocomplete.visibleCount()
|
||||||
func (m model) drawerHeight() int {
|
if len(m.autocomplete.suggestions) > maxSuggestions {
|
||||||
if m.state == stateInput {
|
n++
|
||||||
return drawerLines()
|
}
|
||||||
|
h -= n + 1
|
||||||
}
|
}
|
||||||
return 0
|
if h < 1 {
|
||||||
|
h = 1
|
||||||
|
}
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) recalcSizes() {
|
func (m *model) recalcSizes() {
|
||||||
ch := m.contentHeight()
|
ch := m.contentHeight()
|
||||||
|
lw := m.listWidth()
|
||||||
if m.isSplit() && m.splitDetail {
|
if m.isSplit() && m.splitDetail {
|
||||||
lw, rw := m.splitWidths()
|
_, rw := m.splitWidths()
|
||||||
m.list.setSize(lw, ch)
|
m.list.setSize(lw, ch)
|
||||||
m.cards.setSize(lw, ch)
|
m.cards.setSize(lw, ch)
|
||||||
m.detail.setSize(rw, ch)
|
m.detail.setSize(rw, ch)
|
||||||
} else {
|
} else {
|
||||||
m.list.setSize(m.width, ch)
|
m.list.setSize(lw, ch)
|
||||||
m.cards.setSize(m.width, ch)
|
m.cards.setSize(lw, ch)
|
||||||
m.detail.setSize(m.width, ch)
|
m.detail.setSize(lw, ch)
|
||||||
}
|
}
|
||||||
m.filter.setHeight(ch)
|
m.filter.setHeight(ch)
|
||||||
|
if m.railVisible() {
|
||||||
|
m.tagRail.setSize(m.railWidth(), ch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) isSplit() bool {
|
func (m model) isSplit() bool {
|
||||||
return m.width >= 100
|
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) {
|
func (m model) splitWidths() (int, int) {
|
||||||
left := m.width * 40 / 100
|
avail := m.width - m.railWidth()
|
||||||
right := m.width - left - 1
|
if m.railVisible() {
|
||||||
|
avail--
|
||||||
|
}
|
||||||
|
left := avail * 40 / 100
|
||||||
|
right := avail - left - 1
|
||||||
return left, right
|
return left, right
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,8 +1219,9 @@ func (m model) selectedEntity() *db.Entity {
|
|||||||
func (m model) reloadDetail(id string) tea.Cmd {
|
func (m model) reloadDetail(id string) tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
loadEntities(m.store, m.listParams()),
|
loadEntities(m.store, m.listParams()),
|
||||||
|
loadBacklinks(m.store, id),
|
||||||
func() tea.Msg {
|
func() tea.Msg {
|
||||||
e, err := m.store.Get(id)
|
e, err := m.store.Get(context.Background(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-22
@@ -2,24 +2,52 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func renderStatusBar(m model, width int) string {
|
||||||
left := countText(m)
|
var leftParts []string
|
||||||
right := contextHints(m)
|
|
||||||
|
|
||||||
leftRendered := statusStyle.Render(left)
|
if m.status != "" {
|
||||||
rightRendered := helpStyle.Render(right)
|
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 {
|
if gap < 0 {
|
||||||
gap = 0
|
gap = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
pad := lipgloss.NewStyle().Width(gap).Render("")
|
pad := lipgloss.NewStyle().Width(gap).Render("")
|
||||||
return leftRendered + pad + rightRendered
|
return leftRendered + pad + right
|
||||||
}
|
}
|
||||||
|
|
||||||
func countText(m model) string {
|
func countText(m model) string {
|
||||||
@@ -35,37 +63,46 @@ func countText(m model) string {
|
|||||||
return fmt.Sprintf("%d entities", total)
|
return fmt.Sprintf("%d entities", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextHints(m model) string {
|
func contextHints(m model) []hint {
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
switch m.detail.mode {
|
switch m.detail.mode {
|
||||||
case detailRun:
|
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:
|
case detailFill:
|
||||||
return "tab:next shift+tab:prev enter:copy esc:cancel"
|
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
|
||||||
default:
|
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:
|
case stateTagFilter:
|
||||||
return "j/k:nav enter:select esc:cancel"
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
case stateConfirm:
|
case stateConfirm:
|
||||||
return "y:confirm n:cancel"
|
return []hint{{"y", "confirm"}, {"n", "cancel"}}
|
||||||
case statePromote:
|
case statePromote:
|
||||||
return "j/k:nav enter:select esc:cancel"
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
case stateAbsorb:
|
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:
|
default:
|
||||||
if m.splitDetail {
|
if m.splitDetail {
|
||||||
if m.focus == focusDetail {
|
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
if m.mode == modeCards {
|
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"
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
|
titleStyle lipgloss.Style
|
||||||
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
|
statusStyle lipgloss.Style
|
||||||
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
listItemStyle lipgloss.Style
|
||||||
|
selectedItemStyle lipgloss.Style
|
||||||
titleStyle = lipgloss.NewStyle().
|
glyphStyle lipgloss.Style
|
||||||
Bold(true).
|
completedGlyphStyle lipgloss.Style
|
||||||
Foreground(highlight).
|
tagStyle lipgloss.Style
|
||||||
PaddingLeft(1)
|
idStyle lipgloss.Style
|
||||||
|
inputPromptStyle lipgloss.Style
|
||||||
statusStyle = lipgloss.NewStyle().
|
detailHeaderStyle lipgloss.Style
|
||||||
Foreground(dim).
|
detailBodyStyle lipgloss.Style
|
||||||
PaddingLeft(1)
|
helpStyle lipgloss.Style
|
||||||
|
errorStyle lipgloss.Style
|
||||||
listItemStyle = lipgloss.NewStyle().
|
dateHeaderStyle lipgloss.Style
|
||||||
PaddingLeft(2)
|
pinnedStyle lipgloss.Style
|
||||||
|
filterPillStyle lipgloss.Style
|
||||||
selectedItemStyle = lipgloss.NewStyle().
|
helpKeyStyle lipgloss.Style
|
||||||
PaddingLeft(1).
|
helpDescStyle lipgloss.Style
|
||||||
Bold(true).
|
affordanceStyle lipgloss.Style
|
||||||
Foreground(highlight).
|
useCountStyle lipgloss.Style
|
||||||
SetString("›")
|
modeStyle lipgloss.Style
|
||||||
|
detailLabelStyle lipgloss.Style
|
||||||
glyphStyle = lipgloss.NewStyle().
|
detailValueStyle lipgloss.Style
|
||||||
Width(2)
|
checkDoneStyle lipgloss.Style
|
||||||
|
checkPendingStyle lipgloss.Style
|
||||||
completedGlyphStyle = lipgloss.NewStyle().
|
searchPillStyle lipgloss.Style
|
||||||
Width(2).
|
gutterStyle lipgloss.Style
|
||||||
Foreground(dim)
|
drawerBorderStyle lipgloss.Style
|
||||||
|
drawerHintsStyle lipgloss.Style
|
||||||
tagStyle = lipgloss.NewStyle().
|
drawerPreviewStyle lipgloss.Style
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
separatorStyle lipgloss.Style
|
||||||
|
hintKeyStyle lipgloss.Style
|
||||||
idStyle = lipgloss.NewStyle().
|
hintDescStyle lipgloss.Style
|
||||||
Foreground(dim)
|
railHeaderStyle lipgloss.Style
|
||||||
|
railTagStyle lipgloss.Style
|
||||||
inputPromptStyle = lipgloss.NewStyle().
|
railActiveTagStyle lipgloss.Style
|
||||||
Foreground(highlight).
|
railCountStyle lipgloss.Style
|
||||||
Bold(true)
|
stumbleAgeStyle lipgloss.Style
|
||||||
|
acSelectedStyle lipgloss.Style
|
||||||
detailHeaderStyle = lipgloss.NewStyle().
|
acItemStyle lipgloss.Style
|
||||||
Bold(true).
|
backlinkStyle lipgloss.Style
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 (
|
var (
|
||||||
entropy *ulid.MonotonicEntropy
|
entropy *ulid.LockedMonotonicReader
|
||||||
entropyOnce sync.Once
|
entropyOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func New() string {
|
func New() string {
|
||||||
entropyOnce.Do(func() {
|
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()
|
return ulid.MustNew(ulid.Now(), entropy).String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ulid
|
package ulid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"testing"
|
"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)
|
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 ==========
|
// ========== 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 = {
|
const api = {
|
||||||
async listEntities(params = {}) {
|
async listEntities(params = {}) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
if (params.limit) q.set('limit', String(params.limit));
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
if (params.offset) q.set('offset', String(params.offset));
|
if (params.offset) q.set('offset', String(params.offset));
|
||||||
const resp = await fetch('/api/entities?' + q);
|
const resp = await fetch('/api/entities?' + q);
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async createEntity(data) {
|
async createEntity(data) {
|
||||||
const resp = await fetch('/api/entities', {
|
const resp = await fetch('/api/entities', {
|
||||||
@@ -65,11 +74,11 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async getEntity(id) {
|
async getEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id);
|
const resp = await fetch('/api/entities/' + id);
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async updateEntity(id, data) {
|
async updateEntity(id, data) {
|
||||||
const resp = await fetch('/api/entities/' + id, {
|
const resp = await fetch('/api/entities/' + id, {
|
||||||
@@ -77,10 +86,11 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async deleteEntity(id) {
|
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) {
|
async promoteEntity(id, cardType, cardData) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||||
@@ -88,7 +98,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async demoteEntity(id) {
|
async demoteEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||||
@@ -96,7 +106,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async useEntity(id) {
|
async useEntity(id) {
|
||||||
const resp = await fetch('/api/entities/' + id + '/use', {
|
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||||
@@ -104,7 +114,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async absorbEntity(targetId, sourceId) {
|
async absorbEntity(targetId, sourceId) {
|
||||||
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
||||||
@@ -112,13 +122,13 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source_id: sourceId }),
|
body: JSON.stringify({ source_id: sourceId }),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
async listTags(params = {}) {
|
async listTags(params = {}) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.cards_only) q.set('cards_only', 'true');
|
if (params.cards_only) q.set('cards_only', 'true');
|
||||||
const resp = await fetch('/api/tags?' + q);
|
const resp = await fetch('/api/tags?' + q);
|
||||||
return resp.json();
|
return checkedJSON(resp);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -658,6 +668,71 @@
|
|||||||
</div>`;
|
</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) {
|
function renderInlineDetail(e) {
|
||||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
let actions = '';
|
let actions = '';
|
||||||
@@ -675,51 +750,10 @@
|
|||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
if (e.card_type) {
|
if (e.card_type) {
|
||||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
const cs = renderCardSections(e, 'exp-body');
|
||||||
const hasDecision = data.chose != null;
|
content = cs.sections;
|
||||||
const hasSteps = data.steps && data.steps.length;
|
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||||
const hasLink = !!data.url;
|
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
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('');
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
|
|
||||||
let actions = '';
|
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) {
|
if (!e.card_type) {
|
||||||
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></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="nibApp.showPromote('${e.id}')">promote →</button>`;
|
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||||
}
|
}
|
||||||
if (e.card_type) {
|
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 {
|
} 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">
|
return `<div class="peek-scroll">
|
||||||
@@ -880,61 +914,17 @@
|
|||||||
const glyph = GLYPHS[e.card_type] || '◆';
|
const glyph = GLYPHS[e.card_type] || '◆';
|
||||||
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
||||||
const affs = detectAffordances(e);
|
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 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 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) {
|
let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||||
sections += `<div class="peek-sec">
|
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||||
<div class="peek-sec-inner peek-decision">
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||||
<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>`;
|
|
||||||
|
|
||||||
return `<div class="peek-scroll">
|
return `<div class="peek-scroll">
|
||||||
<div class="peek-card">
|
<div class="peek-card">
|
||||||
@@ -950,7 +940,7 @@
|
|||||||
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
|
${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 class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
${sections}
|
${cs.sections}
|
||||||
</div>
|
</div>
|
||||||
<div class="peek-acts">${actions}</div>
|
<div class="peek-acts">${actions}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1457,11 +1447,31 @@
|
|||||||
state.peekMode = mode;
|
state.peekMode = mode;
|
||||||
if (mode === 'run') state.runChecked = new Set();
|
if (mode === 'run') state.runChecked = new Set();
|
||||||
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
|
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();
|
renderDetailPane();
|
||||||
},
|
},
|
||||||
|
|
||||||
exitMode() {
|
exitMode() {
|
||||||
state.peekMode = 'preview';
|
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();
|
renderDetailPane();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1500,7 +1510,21 @@
|
|||||||
await loadEntities();
|
await loadEntities();
|
||||||
await loadTags();
|
await loadTags();
|
||||||
const idx = state.entities.findIndex(x => x.id === id);
|
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');
|
showToast('saved');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1580,7 +1604,17 @@
|
|||||||
document.addEventListener('keydown', (ev) => {
|
document.addEventListener('keydown', (ev) => {
|
||||||
const tag = (ev.target.tagName || '').toLowerCase();
|
const tag = (ev.target.tagName || '').toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1883,7 +1917,8 @@
|
|||||||
function renderMd(s) {
|
function renderMd(s) {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
if (typeof marked === 'undefined') return escHtml(s);
|
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) {
|
function isSafeUrl(url) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</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; }
|
.card-row.exp-full .entity-exp { grid-template-rows: 1fr; }
|
||||||
.entity-item.exp-full .exp-inner,
|
.entity-item.exp-full .exp-inner,
|
||||||
.card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; }
|
.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; }
|
main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user