Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4517b2e37c | |||
| 2684eb1d24 | |||
| 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 | |||
| 618335513b | |||
| 476abbed00 | |||
| 39975a6787 | |||
| 778fab3edd | |||
| a141b2fd4f | |||
| f89ca8acb9 | |||
| e09919b679 | |||
| babf1d6620 | |||
| 77222ff1b8 | |||
| 1066c0bc7d | |||
| ce335cabd6 | |||
| c2ea63dd16 | |||
| 36999cd825 | |||
| d995d1e708 | |||
| dd8878ebcf | |||
| 805467486b | |||
| 4980714583 | |||
| 6d8170d219 | |||
| 73c6a315c1 | |||
| d5fa6cc56b | |||
| 8555d0da19 | |||
| ec907d0e0d | |||
| a854f02854 | |||
| 824192f581 | |||
| c2506ef7fd | |||
| 2b177eeae9 | |||
| 840084fbb0 | |||
| 4ec876b2d2 |
@@ -0,0 +1,18 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./tmp/nib"
|
||||||
|
cmd = "go build -o ./tmp/nib ."
|
||||||
|
args_bin = ["serve"]
|
||||||
|
delay = 500
|
||||||
|
exclude_dir = ["tmp", "testdata", "docs"]
|
||||||
|
exclude_regex = ["_test\\.go$"]
|
||||||
|
include_ext = ["go", "html", "css", "js", "svg"]
|
||||||
|
kill_delay = 500
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
@@ -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 .
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Binary
|
# Binary
|
||||||
nib
|
nib
|
||||||
|
tmp/
|
||||||
|
certs/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
BINARY := nib
|
||||||
|
MODULE := github.com/lerko/nib
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
|
.PHONY: build dev tui watch test lint fmt vet clean run cert help
|
||||||
|
|
||||||
|
## —— Build ——————————————————————————————————
|
||||||
|
|
||||||
|
build: ## Build production binary
|
||||||
|
go build $(GOFLAGS) -o $(BINARY) .
|
||||||
|
|
||||||
|
dev: ## Build and run with default serve
|
||||||
|
go run . serve
|
||||||
|
|
||||||
|
tui: ## Launch the terminal UI
|
||||||
|
go run . tui
|
||||||
|
|
||||||
|
watch: ## Live-reload dev server (requires air)
|
||||||
|
air
|
||||||
|
|
||||||
|
## —— Quality ————————————————————————————————
|
||||||
|
|
||||||
|
test: ## Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
test-v: ## Run all tests (verbose)
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
test-cover: ## Run tests with coverage report
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
@rm -f coverage.out
|
||||||
|
|
||||||
|
lint: vet fmt-check ## Run all linters
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt: ## Format all Go files
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
fmt-check: ## Check formatting (fails if unformatted)
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "unformatted files:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
## —— Utility ————————————————————————————————
|
||||||
|
|
||||||
|
cert: ## Generate self-signed dev TLS cert (certs/)
|
||||||
|
@mkdir -p certs
|
||||||
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||||
|
-nodes -keyout certs/dev.key -out certs/dev.crt -days 365 \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
@echo " certs/dev.crt and certs/dev.key ready"
|
||||||
|
@echo " usage: make run ARGS=\"serve --tls-cert certs/dev.crt --tls-key certs/dev.key\""
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -f $(BINARY) coverage.out
|
||||||
|
|
||||||
|
run: build ## Build then run with args (usage: make run ARGS="serve")
|
||||||
|
./$(BINARY) $(ARGS)
|
||||||
|
|
||||||
|
tidy: ## Tidy and verify module dependencies
|
||||||
|
go mod tidy
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
## —— Help ———————————————————————————————————
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
@@ -40,6 +40,9 @@ nib ls --tag ops
|
|||||||
# list a specific month
|
# list a specific month
|
||||||
nib ls --month 2026-05
|
nib ls --month 2026-05
|
||||||
|
|
||||||
|
# terminal UI
|
||||||
|
nib tui
|
||||||
|
|
||||||
# start the web UI
|
# start the web UI
|
||||||
nib serve
|
nib serve
|
||||||
```
|
```
|
||||||
@@ -122,9 +125,22 @@ Or use `^type` inline: `nib "proxy trick #nginx ^card"`
|
|||||||
| `nib absorb <target> <source>` | Merge source into target |
|
| `nib absorb <target> <source>` | Merge source into target |
|
||||||
| `nib delete <id>` | Soft delete (repeat to hard delete) |
|
| `nib delete <id>` | Soft delete (repeat to hard delete) |
|
||||||
| `nib serve` | Start web UI on `:4444` (or `--port`) |
|
| `nib serve` | Start web UI on `:4444` (or `--port`) |
|
||||||
|
| `nib tui` | Launch the terminal UI |
|
||||||
|
|
||||||
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
|
IDs are prefix-matchable. If `01KRQ4` is unique, that's enough.
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
`nib tui` launches a keyboard-driven interface in your terminal.
|
||||||
|
|
||||||
|
- **Stream view** — entries grouped by date with compact gutter headers
|
||||||
|
- **Cards view** — promoted cards filtered by intent (grab/read/fill), sorted by usage
|
||||||
|
- **Split-pane detail** — wide terminals (100+ cols) show list and detail side-by-side
|
||||||
|
- **Capture drawer** — inline add with live preview of parsed entity
|
||||||
|
- **Search** — type `?query #tag` in the capture bar to filter
|
||||||
|
|
||||||
|
Navigation: `j`/`k` move, `enter` opens detail, `h`/`l` switch panes, `a` to add, `?` for full keybindings.
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
`nib serve` starts a local web interface with:
|
`nib serve` starts a local web interface with:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Code Hardening — Senior Dev Audit Fixes
|
||||||
|
|
||||||
|
## Phase 1: Quick Wins (safety + correctness)
|
||||||
|
- [x] Cap API list limit at 200
|
||||||
|
- [x] Fix markdown XSS — add DOMPurify to sanitize marked output
|
||||||
|
- [x] Add missing DB indexes (deleted_at, modified_at) via v4 migration
|
||||||
|
- [x] Fix v2 migration error handling (swallowed ALTER TABLE errors)
|
||||||
|
- [x] Fix ~/.nib directory permissions (0o755 → 0o700)
|
||||||
|
|
||||||
|
## Phase 2: CI Pipeline
|
||||||
|
- [x] Gitea Actions workflow: test + lint on PR
|
||||||
|
|
||||||
|
## Phase 3: context.Context in Store
|
||||||
|
- [x] Thread context.Context through all Store methods
|
||||||
|
- [x] Use context in API handlers (from r.Context())
|
||||||
|
- [x] Use context in CLI commands (cobra context)
|
||||||
|
|
||||||
|
## Phase 4: cmd/ Tests
|
||||||
|
- [x] Test add command
|
||||||
|
- [x] Test ls command
|
||||||
|
- [x] Test promote/demote commands
|
||||||
|
- [x] Test delete command
|
||||||
|
- [x] Test absorb command
|
||||||
|
|
||||||
|
## Phase 5: Backup/Export
|
||||||
|
- [x] nib export — dump entities to JSON
|
||||||
|
- [x] nib backup — safe SQLite backup (handles WAL)
|
||||||
+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"))
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-79
@@ -1,11 +1,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
"github.com/lerko/nib/internal/display"
|
"github.com/lerko/nib/internal/display"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -22,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])
|
||||||
}
|
}
|
||||||
@@ -42,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
|
||||||
}
|
}
|
||||||
|
|
||||||
cardData := generateCardData(cardType, e.Body)
|
cd := carddata.GenerateCardData(cardType, e.Body)
|
||||||
|
|
||||||
if err := store.Promote(id, cardType, cardData); 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)
|
||||||
@@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
|
||||||
|
|
||||||
func generateCardData(ct db.CardType, body string) *string {
|
|
||||||
var data string
|
|
||||||
switch ct {
|
|
||||||
case db.CardTemplate:
|
|
||||||
matches := templateSlotRe.FindAllStringSubmatch(body, -1)
|
|
||||||
type slot struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Default string `json:"default"`
|
|
||||||
}
|
|
||||||
var slots []slot
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range matches {
|
|
||||||
name := m[1]
|
|
||||||
if !seen[name] {
|
|
||||||
slots = append(slots, slot{Name: name, Default: ""})
|
|
||||||
seen[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slots == nil {
|
|
||||||
slots = []slot{}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"slots": slots})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardChecklist:
|
|
||||||
type step struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
}
|
|
||||||
var steps []step
|
|
||||||
for _, line := range strings.Split(body, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
|
||||||
text := strings.TrimSpace(line[3:])
|
|
||||||
done := strings.HasPrefix(line, "[x]")
|
|
||||||
steps = append(steps, step{Text: text, Done: done})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if steps == nil {
|
|
||||||
steps = []step{{Text: body, Done: false}}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"steps": steps})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardDecision:
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"chose": "",
|
|
||||||
"why": "",
|
|
||||||
"rejected": []string{},
|
|
||||||
})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardLink:
|
|
||||||
url := ""
|
|
||||||
for _, word := range strings.Fields(body) {
|
|
||||||
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
|
||||||
url = word
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"url": url})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
default:
|
|
||||||
data = "{}"
|
|
||||||
}
|
|
||||||
return &data
|
|
||||||
}
|
|
||||||
|
|||||||
+55
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ func Execute() error {
|
|||||||
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
||||||
if first != "help" && first != "completion" &&
|
if first != "help" && first != "completion" &&
|
||||||
!isFlag && !isSubcommand(first) {
|
!isFlag && !isSubcommand(first) {
|
||||||
|
if near := nearSubcommand(first); near != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
||||||
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nearSubcommand(name string) string {
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
|
||||||
|
return c.Name()
|
||||||
|
}
|
||||||
|
for _, alias := range c.Aliases {
|
||||||
|
if d := editDist(name, alias); d > 0 && d <= 2 {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func editDist(a, b string) int {
|
||||||
|
la, lb := len(a), len(b)
|
||||||
|
if la == 0 {
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
if lb == 0 {
|
||||||
|
return la
|
||||||
|
}
|
||||||
|
prev := make([]int, lb+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= la; i++ {
|
||||||
|
curr := make([]int, lb+1)
|
||||||
|
curr[0] = i
|
||||||
|
for j := 1; j <= lb; j++ {
|
||||||
|
cost := 1
|
||||||
|
if a[i-1] == b[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
ins := curr[j-1] + 1
|
||||||
|
del := prev[j] + 1
|
||||||
|
sub := prev[j-1] + cost
|
||||||
|
curr[j] = ins
|
||||||
|
if del < curr[j] {
|
||||||
|
curr[j] = del
|
||||||
|
}
|
||||||
|
if sub < curr[j] {
|
||||||
|
curr[j] = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return prev[lb]
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
rootCmd.AddCommand(lsCmd)
|
rootCmd.AddCommand(lsCmd)
|
||||||
|
|||||||
+29
-5
@@ -19,7 +19,10 @@ var WebFS fs.FS
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
servePort int
|
servePort int
|
||||||
|
serveHost string
|
||||||
serveDev bool
|
serveDev bool
|
||||||
|
tlsCert string
|
||||||
|
tlsKey string
|
||||||
)
|
)
|
||||||
|
|
||||||
var serveCmd = &cobra.Command{
|
var serveCmd = &cobra.Command{
|
||||||
@@ -29,12 +32,20 @@ var serveCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444)")
|
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
|
||||||
|
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
|
||||||
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
||||||
|
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
|
||||||
|
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
|
||||||
rootCmd.AddCommand(serveCmd)
|
rootCmd.AddCommand(serveCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServe(_ *cobra.Command, _ []string) error {
|
func runServe(_ *cobra.Command, _ []string) error {
|
||||||
|
useTLS := tlsCert != "" && tlsKey != ""
|
||||||
|
if (tlsCert != "") != (tlsKey != "") {
|
||||||
|
return fmt.Errorf("both --tls-cert and --tls-key are required for TLS")
|
||||||
|
}
|
||||||
|
|
||||||
port := servePort
|
port := servePort
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
|
if envPort := os.Getenv("NIB_PORT"); envPort != "" {
|
||||||
@@ -43,6 +54,8 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
return fmt.Errorf("invalid NIB_PORT: %w", err)
|
return fmt.Errorf("invalid NIB_PORT: %w", err)
|
||||||
}
|
}
|
||||||
port = p
|
port = p
|
||||||
|
} else if useTLS {
|
||||||
|
port = 4443
|
||||||
} else {
|
} else {
|
||||||
port = 4444
|
port = 4444
|
||||||
}
|
}
|
||||||
@@ -59,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
router = api.NewRouter(store, serveDev, WebFS)
|
router = api.NewRouter(store, serveDev, WebFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf("%s:%d", serveHost, port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -69,12 +82,23 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Printf("nib serving on %s\n", addr)
|
if useTLS {
|
||||||
|
fmt.Printf("nib serving on https://%s\n", addr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("nib serving on http://%s\n", addr)
|
||||||
|
}
|
||||||
if serveDev {
|
if serveDev {
|
||||||
fmt.Println(" CORS enabled (dev mode)")
|
fmt.Println(" CORS enabled (dev mode)")
|
||||||
}
|
}
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
var listenErr error
|
||||||
|
if useTLS {
|
||||||
|
listenErr = srv.ListenAndServeTLS(tlsCert, tlsKey)
|
||||||
|
} else {
|
||||||
|
listenErr = srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
if listenErr != nil && listenErr != http.ErrServerClosed {
|
||||||
|
fmt.Fprintf(os.Stderr, "server error: %v\n", listenErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lerko/nib/internal/tui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tuiCmd = &cobra.Command{
|
||||||
|
Use: "tui",
|
||||||
|
Short: "launch the terminal UI",
|
||||||
|
RunE: runTUI,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(tuiCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTUI(_ *cobra.Command, _ []string) error {
|
||||||
|
store, err := openStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
return tui.Run(store)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- [air](https://github.com/air-verse/air) (live-reload, install: `go install github.com/air-verse/air@latest`)
|
||||||
|
- OpenSSL (for dev TLS certs)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make cert # one-time: generate self-signed TLS cert
|
||||||
|
make watch # start dev server with live-reload (HTTP, port 4444)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make Targets
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make build` | Compile production binary (`./nib`) |
|
||||||
|
| `make dev` | Run dev server from source (HTTP, port 4444) |
|
||||||
|
| `make watch` | Live-reload dev server via air — auto-rebuilds on save |
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make test` | Run all tests |
|
||||||
|
| `make test-v` | Run all tests with verbose output |
|
||||||
|
| `make test-cover` | Run tests with per-function coverage report |
|
||||||
|
| `make lint` | Run vet + format check |
|
||||||
|
| `make vet` | Static analysis via `go vet` |
|
||||||
|
| `make fmt` | Auto-format all Go files |
|
||||||
|
| `make fmt-check` | Check formatting without modifying (CI-friendly) |
|
||||||
|
|
||||||
|
### Utility
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make cert` | Generate self-signed dev TLS cert in `certs/` (valid 365 days) |
|
||||||
|
| `make clean` | Remove build artifacts |
|
||||||
|
| `make tidy` | Tidy and verify Go module dependencies |
|
||||||
|
| `make run ARGS="..."` | Build then run with custom args |
|
||||||
|
| `make help` | List all targets |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
nib supports TLS via `--tls-cert` and `--tls-key` flags on the `serve` command.
|
||||||
|
|
||||||
|
### Dev (self-signed)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make cert
|
||||||
|
make run ARGS="serve --tls-cert certs/dev.crt --tls-key certs/dev.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Serves HTTPS on port 4443. Browser will warn about the self-signed cert — accept once.
|
||||||
|
|
||||||
|
This is needed for features that require a secure context (e.g. clipboard API).
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
For production, use a reverse proxy (Caddy, nginx) with real certificates in front of nib's HTTP server. Alternatively, pass real cert/key paths directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./nib serve --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Defaults
|
||||||
|
|
||||||
|
| Mode | Default Port |
|
||||||
|
|------|-------------|
|
||||||
|
| HTTP | 4444 |
|
||||||
|
| HTTPS | 4443 |
|
||||||
|
|
||||||
|
Override with `--port` or the `NIB_PORT` environment variable.
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
Run the TUI directly from source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . tui
|
||||||
|
```
|
||||||
|
|
||||||
|
TUI code lives in `internal/tui/`. No live-reload — restart manually after changes.
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. `make cert` — once, generates dev TLS cert
|
||||||
|
2. `make watch` — start coding, air rebuilds on save
|
||||||
|
3. Edit code, save, changes appear automatically
|
||||||
|
4. `make test` — verify nothing broke
|
||||||
|
5. `make lint` — check formatting and vet
|
||||||
|
6. Commit and push
|
||||||
@@ -4,6 +4,9 @@ go 1.24.4
|
|||||||
|
|
||||||
require (
|
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/bubbletea v1.3.10
|
||||||
|
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
|
||||||
@@ -11,15 +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/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/glamour v1.0.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/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/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-runewidth v0.0.19 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/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/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/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/sys v0.33.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // 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,18 +1,73 @@
|
|||||||
|
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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
@@ -20,23 +75,45 @@ 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/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=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
golang.org/x/tools v0.33.0 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=
|
||||||
|
|||||||
+33
-17
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
|||||||
return srv, store
|
return srv, store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listEnvelope struct {
|
||||||
|
Data []EntityResponse `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
|
||||||
|
t.Helper()
|
||||||
|
var env listEnvelope
|
||||||
|
json.NewDecoder(resp.Body).Decode(&env)
|
||||||
|
return env.Data
|
||||||
|
}
|
||||||
|
|
||||||
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 2 {
|
if len(entities) != 2 {
|
||||||
t.Fatalf("expected 2, got %d", len(entities))
|
t.Fatalf("expected 2, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var page1 []EntityResponse
|
page1 := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&page1)
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var page2 []EntityResponse
|
page2 := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&page2)
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(page1) != 2 || len(page2) != 2 {
|
if len(page1) != 2 || len(page2) != 2 {
|
||||||
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, listResp)
|
||||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
|
||||||
listResp.Body.Close()
|
listResp.Body.Close()
|
||||||
for _, ent := range entities {
|
for _, ent := range entities {
|
||||||
if ent.ID == source.ID {
|
if ent.ID == source.ID {
|
||||||
@@ -630,7 +638,10 @@ func TestUpdateEntity_Title(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||||
mustJSON(map[string]any{"title": "new title"})))
|
mustJSON(map[string]any{"title": "new title"})))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -651,7 +662,10 @@ func TestUpdateEntity_Description(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
|
||||||
mustJSON(map[string]any{"description": "new desc"})))
|
mustJSON(map[string]any{"description": "new desc"})))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -674,11 +688,13 @@ func TestListEntities_TitleInResponse(t *testing.T) {
|
|||||||
"title": title,
|
"title": title,
|
||||||
}).Body.Close()
|
}).Body.Close()
|
||||||
|
|
||||||
resp, _ := http.Get(srv.URL + "/api/entities")
|
resp, err := http.Get(srv.URL + "/api/entities")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-18
@@ -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 != "" {
|
||||||
@@ -102,18 +105,32 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
p.Offset = offset
|
p.Offset = offset
|
||||||
}
|
}
|
||||||
|
if p.Limit <= 0 {
|
||||||
|
p.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
total, err := store.Count(r.Context(), p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := make([]EntityResponse, len(entities))
|
entities, err := store.List(r.Context(), p)
|
||||||
for i, e := range entities {
|
if err != nil {
|
||||||
resp[i] = entityToResponse(e)
|
writeInternalError(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
|
items := make([]EntityResponse, len(entities))
|
||||||
|
for i, e := range entities {
|
||||||
|
items[i] = entityToResponse(e)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": p.Limit,
|
||||||
|
"offset": p.Offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +177,11 @@ 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 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -172,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)
|
||||||
@@ -222,16 +243,20 @@ 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
|
||||||
}
|
}
|
||||||
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
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
|
||||||
@@ -247,7 +272,7 @@ 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)
|
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)
|
||||||
@@ -282,7 +307,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
|
||||||
@@ -291,11 +316,15 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
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
|
||||||
@@ -308,7 +337,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
|
||||||
@@ -321,7 +350,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
|
||||||
@@ -352,7 +381,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
|
||||||
@@ -365,7 +394,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
|
||||||
@@ -378,7 +407,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
|
||||||
@@ -387,7 +416,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package carddata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
||||||
|
|
||||||
|
func GenerateCardData(ct db.CardType, body string) *string {
|
||||||
|
var data string
|
||||||
|
switch ct {
|
||||||
|
case db.CardTemplate:
|
||||||
|
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
||||||
|
type slot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
var slots []slot
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if !seen[name] {
|
||||||
|
slots = append(slots, slot{Name: name, Default: ""})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slots == nil {
|
||||||
|
slots = []slot{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"slots": slots})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardChecklist:
|
||||||
|
type step struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
var steps []step
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
||||||
|
text := strings.TrimSpace(line[3:])
|
||||||
|
done := strings.HasPrefix(line, "[x]")
|
||||||
|
steps = append(steps, step{Text: text, Done: done})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if steps == nil {
|
||||||
|
steps = []step{{Text: body, Done: false}}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": steps})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"chose": "",
|
||||||
|
"why": "",
|
||||||
|
"rejected": []string{},
|
||||||
|
})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
url := ""
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
url = word
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"url": url})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
default:
|
||||||
|
data = "{}"
|
||||||
|
}
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectCardType(body string) *db.CardType {
|
||||||
|
if TemplateSlotRe.MatchString(body) {
|
||||||
|
ct := db.CardTemplate
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
|
||||||
|
ct := db.CardDecision
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
|
||||||
|
ct := db.CardChecklist
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
ct := db.CardLink
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package cmd
|
package carddata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateCardData_Snippet(t *testing.T) {
|
func TestGenerateCardData_Snippet(t *testing.T) {
|
||||||
data := generateCardData(db.CardSnippet, "some snippet")
|
data := GenerateCardData(db.CardSnippet, "some snippet")
|
||||||
if data == nil || *data != "{}" {
|
if data == nil || *data != "{}" {
|
||||||
t.Errorf("snippet should produce {}, got %v", data)
|
t.Errorf("snippet should produce {}, got %v", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Template(t *testing.T) {
|
func TestGenerateCardData_Template(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "${x} and ${x}")
|
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Slots []struct {
|
Slots []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "no placeholders here")
|
data := GenerateCardData(db.CardTemplate, "no placeholders here")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Slots []struct {
|
Slots []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerateCardData_Checklist(t *testing.T) {
|
func TestGenerateCardData_Checklist(t *testing.T) {
|
||||||
body := "[ ] step one\n[x] step two\n[ ] step three"
|
body := "[ ] step one\n[x] step two\n[ ] step three"
|
||||||
data := generateCardData(db.CardChecklist, body)
|
data := GenerateCardData(db.CardChecklist, body)
|
||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
||||||
data := generateCardData(db.CardChecklist, "no checkbox syntax")
|
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Steps []struct {
|
Steps []struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Decision(t *testing.T) {
|
func TestGenerateCardData_Decision(t *testing.T) {
|
||||||
data := generateCardData(db.CardDecision, "which db?")
|
data := GenerateCardData(db.CardDecision, "which db?")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Chose string `json:"chose"`
|
Chose string `json:"chose"`
|
||||||
Why string `json:"why"`
|
Why string `json:"why"`
|
||||||
@@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Link(t *testing.T) {
|
func TestGenerateCardData_Link(t *testing.T) {
|
||||||
data := generateCardData(db.CardLink, "check https://example.com/path for details")
|
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
||||||
data := generateCardData(db.CardLink, "no url here")
|
data := GenerateCardData(db.CardLink, "no url here")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
+143
-10
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ var (
|
|||||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||||
ErrTargetCrystallized = errors.New("invalid_absorb")
|
ErrTargetCrystallized = errors.New("invalid_absorb")
|
||||||
|
ErrInvalidCardData = errors.New("invalid_card_data")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -49,22 +51,28 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) Backup(dst string) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec("VACUUM INTO ?", dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSchema = 5
|
||||||
|
|
||||||
|
var migrations = []func(db *sql.DB) error{
|
||||||
|
// v1: initial schema
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
modified_at TEXT NOT NULL,
|
modified_at TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
glyph TEXT NOT NULL
|
glyph TEXT NOT NULL,
|
||||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
|
||||||
time_anchor TEXT,
|
time_anchor TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
card_type TEXT
|
card_type TEXT,
|
||||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link')
|
|
||||||
OR card_type IS NULL),
|
|
||||||
card_data TEXT,
|
card_data TEXT,
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used_at TEXT
|
last_used_at TEXT
|
||||||
@@ -84,14 +92,139 @@ func (s *Store) migrate() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
||||||
ON entity_tags(tag);
|
ON entity_tags(tag);
|
||||||
`)
|
`)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
|
||||||
|
// v2: add title and description columns
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
|
||||||
|
return fmt.Errorf("add title column: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil {
|
||||||
|
return fmt.Errorf("add description column: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
|
||||||
|
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
// Disable FK checks during rebuild to avoid dangling references
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk off: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate rename: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`CREATE TABLE entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
modified_at TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
glyph TEXT NOT NULL
|
||||||
|
CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
|
||||||
|
time_anchor TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT,
|
||||||
|
card_type TEXT
|
||||||
|
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
|
||||||
|
OR card_type IS NULL),
|
||||||
|
card_data TEXT,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("migrate create: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate copy: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate drop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild entity_tags to point FK at new entities table
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags rename: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`CREATE TABLE entity_tags (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entity_id, tag)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags create: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags copy: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags drop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
|
||||||
|
|
||||||
|
var version int
|
||||||
|
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
version = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := version; i < len(migrations); i++ {
|
||||||
|
if err := migrations[i](s.db); err != nil {
|
||||||
|
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
|
||||||
|
} else if len(migrations) > version {
|
||||||
|
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
@@ -103,7 +236,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
|
||||||
|
|||||||
+114
-54
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -27,6 +28,7 @@ const (
|
|||||||
CardChecklist CardType = "checklist"
|
CardChecklist CardType = "checklist"
|
||||||
CardDecision CardType = "decision"
|
CardDecision CardType = "decision"
|
||||||
CardLink CardType = "link"
|
CardLink CardType = "link"
|
||||||
|
CardNote CardType = "note"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidGlyph(s string) bool {
|
func ValidGlyph(s string) bool {
|
||||||
@@ -39,7 +41,7 @@ func ValidGlyph(s string) bool {
|
|||||||
|
|
||||||
func ValidCardType(s string) bool {
|
func ValidCardType(s string) bool {
|
||||||
switch CardType(s) {
|
switch CardType(s) {
|
||||||
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink:
|
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -70,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
|
||||||
@@ -94,13 +97,18 @@ type EntityUpdate struct {
|
|||||||
Glyph *Glyph
|
Glyph *Glyph
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
ClearTime bool
|
ClearTime bool
|
||||||
|
CompletedAt *time.Time
|
||||||
|
ClearCompleted bool
|
||||||
Pinned *bool
|
Pinned *bool
|
||||||
CardType *CardType
|
CardType *CardType
|
||||||
CardData *string
|
CardData *string
|
||||||
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)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
e.ID = nibulid.New()
|
e.ID = nibulid.New()
|
||||||
e.CreatedAt = now
|
e.CreatedAt = now
|
||||||
@@ -109,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)
|
||||||
@@ -140,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
|
||||||
@@ -167,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
|
||||||
}
|
}
|
||||||
@@ -176,7 +188,7 @@ func (s *Store) Get(id string) (*Entity, error) {
|
|||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
func listWhere(params ListParams) (string, []any) {
|
||||||
var where []string
|
var where []string
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
@@ -210,19 +222,46 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
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 {
|
||||||
whereClause := ""
|
where = append(where, "e.modified_at < ?")
|
||||||
if len(where) > 0 {
|
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
|
||||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clause := ""
|
||||||
|
if len(where) > 0 {
|
||||||
|
clause = "WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
return clause, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Count(ctx context.Context, params ListParams) (int, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
|
||||||
orderCol := "e.created_at"
|
orderCol := "e.created_at"
|
||||||
if params.Sort == "use_count" {
|
switch params.Sort {
|
||||||
|
case "use_count":
|
||||||
orderCol = "e.use_count"
|
orderCol = "e.use_count"
|
||||||
|
case "modified_at":
|
||||||
|
orderCol = "e.modified_at"
|
||||||
|
case "created_at", "":
|
||||||
|
orderCol = "e.created_at"
|
||||||
|
default:
|
||||||
|
orderCol = "e.created_at"
|
||||||
}
|
}
|
||||||
orderDir := "DESC"
|
orderDir := "DESC"
|
||||||
if strings.EqualFold(params.Order, "asc") {
|
switch strings.ToLower(params.Order) {
|
||||||
|
case "asc":
|
||||||
orderDir = "ASC"
|
orderDir = "ASC"
|
||||||
|
default:
|
||||||
|
orderDir = "DESC"
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := params.Limit
|
limit := params.Limit
|
||||||
@@ -241,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
|
||||||
}
|
}
|
||||||
@@ -263,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
|
||||||
}
|
}
|
||||||
@@ -310,6 +349,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
sets = append(sets, "time_anchor = ?")
|
sets = append(sets, "time_anchor = ?")
|
||||||
args = append(args, *u.TimeAnchor)
|
args = append(args, *u.TimeAnchor)
|
||||||
}
|
}
|
||||||
|
if u.ClearCompleted {
|
||||||
|
sets = append(sets, "completed_at = NULL")
|
||||||
|
} else if u.CompletedAt != nil {
|
||||||
|
sets = append(sets, "completed_at = ?")
|
||||||
|
args = append(args, u.CompletedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
if u.Pinned != nil {
|
if u.Pinned != nil {
|
||||||
sets = append(sets, "pinned = ?")
|
sets = append(sets, "pinned = ?")
|
||||||
args = append(args, boolToInt(*u.Pinned))
|
args = append(args, boolToInt(*u.Pinned))
|
||||||
@@ -319,6 +364,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
args = append(args, string(*u.CardType))
|
args = append(args, string(*u.CardType))
|
||||||
}
|
}
|
||||||
if u.CardData != nil {
|
if u.CardData != nil {
|
||||||
|
if !json.Valid([]byte(*u.CardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
sets = append(sets, "card_data = ?")
|
sets = append(sets, "card_data = ?")
|
||||||
args = append(args, *u.CardData)
|
args = append(args, *u.CardData)
|
||||||
}
|
}
|
||||||
@@ -326,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,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
|
||||||
}
|
}
|
||||||
@@ -353,18 +407,21 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
|||||||
|
|
||||||
dataVal := "{}"
|
dataVal := "{}"
|
||||||
if cardData != nil {
|
if cardData != nil {
|
||||||
|
if !json.Valid([]byte(*cardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -372,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 = ?`,
|
||||||
@@ -387,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
|
||||||
}
|
}
|
||||||
@@ -398,21 +455,21 @@ 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) Absorb(ctx context.Context, targetID, sourceID string) error {
|
||||||
target, err := s.Get(targetID)
|
target, err := s.Get(ctx, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source, err := s.Get(sourceID)
|
source, err := s.Get(ctx, sourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -421,7 +478,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
|
||||||
}
|
}
|
||||||
@@ -430,7 +487,7 @@ 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 = ?",
|
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
||||||
merged, now, targetID); err != nil {
|
merged, now, targetID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -441,31 +498,36 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
||||||
now, sourceID); err != nil {
|
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
|
||||||
|
absorbNote, now, now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -479,8 +541,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
|
||||||
}
|
}
|
||||||
@@ -546,9 +608,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
|
||||||
}
|
}
|
||||||
@@ -568,7 +628,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
|
||||||
}
|
}
|
||||||
@@ -586,8 +646,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
|
||||||
}
|
}
|
||||||
@@ -610,9 +670,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,103 @@
|
|||||||
|
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 (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 ? AND id != ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, "%"+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) ResolveLink(ctx context.Context, linkText string) (*Entity, error) {
|
||||||
|
lower := strings.ToLower(linkText)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(title) = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, lower).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM entities
|
||||||
|
WHERE LOWER(body) LIKE ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%").Scan(&id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,272 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_TitleMatch(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
title := "nginx config"
|
||||||
|
target := &Entity{Body: "proxy_pass details", Title: &title, Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := s.ResolveLink(ctx, "nginx config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.ID != target.ID {
|
||||||
|
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_BodyFallback(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &Entity{Body: "deploy staging checklist", Glyph: "note", Tags: []string{}}
|
||||||
|
if err := s.Create(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := s.ResolveLink(ctx, "deploy staging")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.ID != target.ID {
|
||||||
|
t.Errorf("resolved ID = %s, want %s", resolved.ID, target.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLink_NotFound(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := s.ResolveLink(ctx, "nonexistent entry")
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -1,16 +1,18 @@
|
|||||||
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
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
|
|||||||
db.CardChecklist: "☐",
|
db.CardChecklist: "☐",
|
||||||
db.CardDecision: "⚖",
|
db.CardDecision: "⚖",
|
||||||
db.CardLink: "↗",
|
db.CardLink: "↗",
|
||||||
|
db.CardNote: "¶",
|
||||||
}
|
}
|
||||||
|
|
||||||
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template.html
|
||||||
|
var templateHTML string
|
||||||
|
|
||||||
|
type TemplateSlot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckStep struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecisionData struct {
|
||||||
|
Chose string `json:"chose"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Rejected []string `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardView struct {
|
||||||
|
ID string
|
||||||
|
Glyph string
|
||||||
|
CardType string
|
||||||
|
Title string
|
||||||
|
Body template.HTML
|
||||||
|
Description template.HTML
|
||||||
|
SearchText string
|
||||||
|
Pinned bool
|
||||||
|
Tags []string
|
||||||
|
UseCount int
|
||||||
|
LinkURL string
|
||||||
|
Slots []TemplateSlot
|
||||||
|
TemplateBody string
|
||||||
|
Steps []CheckStep
|
||||||
|
Progress int
|
||||||
|
Decision DecisionData
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeckView struct {
|
||||||
|
Title string
|
||||||
|
Count int
|
||||||
|
ExportedAt string
|
||||||
|
Types []string
|
||||||
|
Cards []CardView
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
|
||||||
|
tmpl, err := template.New("deck").Parse(templateHTML)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var types []string
|
||||||
|
var cards []CardView
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.CardType == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ct := string(*e.CardType)
|
||||||
|
if !seen[ct] {
|
||||||
|
seen[ct] = true
|
||||||
|
types = append(types, ct)
|
||||||
|
}
|
||||||
|
cards = append(cards, buildCardView(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := DeckView{
|
||||||
|
Title: title,
|
||||||
|
Count: len(cards),
|
||||||
|
ExportedAt: time.Now().Format("Jan 2, 2006"),
|
||||||
|
Types: types,
|
||||||
|
Cards: cards,
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl.Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardView(e *db.Entity) CardView {
|
||||||
|
ct := ""
|
||||||
|
if e.CardType != nil {
|
||||||
|
ct = string(*e.CardType)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
if e.Title != nil {
|
||||||
|
title = *e.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
desc := ""
|
||||||
|
if e.Description != nil {
|
||||||
|
desc = *e.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
|
||||||
|
|
||||||
|
cv := CardView{
|
||||||
|
ID: e.ID,
|
||||||
|
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
|
||||||
|
CardType: ct,
|
||||||
|
Title: title,
|
||||||
|
Body: template.HTML(template.HTMLEscapeString(body)),
|
||||||
|
SearchText: search,
|
||||||
|
Pinned: e.Pinned,
|
||||||
|
Tags: e.Tags,
|
||||||
|
UseCount: e.UseCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc != "" {
|
||||||
|
cv.Description = template.HTML(template.HTMLEscapeString(desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.CardData != nil {
|
||||||
|
parseCardData(&cv, ct, *e.CardData, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cv
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCardData(cv *CardView, ct string, raw string, body string) {
|
||||||
|
switch ct {
|
||||||
|
case "snippet":
|
||||||
|
// body is the snippet content, already set
|
||||||
|
|
||||||
|
case "template":
|
||||||
|
var data struct {
|
||||||
|
Slots []TemplateSlot `json:"slots"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.Slots = data.Slots
|
||||||
|
}
|
||||||
|
cv.TemplateBody = body
|
||||||
|
|
||||||
|
case "checklist":
|
||||||
|
var data struct {
|
||||||
|
Steps []CheckStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.Steps = data.Steps
|
||||||
|
done := 0
|
||||||
|
for _, s := range cv.Steps {
|
||||||
|
if s.Done {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cv.Steps) > 0 {
|
||||||
|
cv.Progress = (done * 100) / len(cv.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "decision":
|
||||||
|
var dec DecisionData
|
||||||
|
if json.Unmarshal([]byte(raw), &dec) == nil {
|
||||||
|
cv.Decision = dec
|
||||||
|
}
|
||||||
|
|
||||||
|
case "link":
|
||||||
|
var data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||||
|
cv.LinkURL = data.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
|
||||||
|
--border:#2e2e2c;--border-focus:#4a4a47;
|
||||||
|
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
|
||||||
|
--accent:#c4a46c;--accent2:#a68a52;
|
||||||
|
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
|
||||||
|
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
|
||||||
|
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
|
||||||
|
--card-radius:12px;
|
||||||
|
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
font-family:var(--sans);color:var(--text);background:var(--bg);
|
||||||
|
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-header{
|
||||||
|
position:sticky;top:0;z-index:10;
|
||||||
|
background:var(--bg);border-bottom:1px solid var(--border);
|
||||||
|
padding:16px 20px 12px;
|
||||||
|
}
|
||||||
|
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
|
||||||
|
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
|
||||||
|
|
||||||
|
.filter-bar{
|
||||||
|
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
|
||||||
|
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||||
|
}
|
||||||
|
.filter-bar::-webkit-scrollbar{display:none}
|
||||||
|
.filter-chip{
|
||||||
|
flex-shrink:0;padding:6px 14px;border-radius:20px;
|
||||||
|
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text2);cursor:pointer;
|
||||||
|
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||||
|
|
||||||
|
.search-wrap{padding:8px 20px 4px}
|
||||||
|
.search-input{
|
||||||
|
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text);font-size:0.9rem;
|
||||||
|
font-family:var(--sans);outline:none;transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-input:focus{border-color:var(--accent)}
|
||||||
|
.search-input::placeholder{color:var(--text3)}
|
||||||
|
|
||||||
|
.card-list{padding:8px 16px}
|
||||||
|
|
||||||
|
.card{
|
||||||
|
background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
|
||||||
|
transition:border-color 0.15s;position:relative;
|
||||||
|
}
|
||||||
|
.card:active{border-color:var(--border-focus)}
|
||||||
|
.card.pinned{border-left:3px solid var(--accent)}
|
||||||
|
|
||||||
|
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||||
|
.card-glyph{
|
||||||
|
font-size:1.1rem;width:28px;height:28px;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:var(--surface2);border-radius:6px;flex-shrink:0;
|
||||||
|
}
|
||||||
|
.card-type{
|
||||||
|
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
|
||||||
|
color:var(--text3);
|
||||||
|
}
|
||||||
|
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
|
||||||
|
|
||||||
|
.card-title{
|
||||||
|
font-size:0.95rem;font-weight:600;color:var(--text);
|
||||||
|
line-height:1.4;margin-bottom:6px;
|
||||||
|
}
|
||||||
|
.card-body{
|
||||||
|
font-size:0.85rem;color:var(--text2);line-height:1.55;
|
||||||
|
white-space:pre-wrap;word-break:break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body code{
|
||||||
|
font-family:var(--mono);font-size:0.8rem;
|
||||||
|
background:var(--surface2);padding:2px 5px;border-radius:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||||
|
.tag{
|
||||||
|
font-size:0.7rem;color:var(--accent);background:var(--surface2);
|
||||||
|
padding:3px 8px;border-radius:6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* snippet */
|
||||||
|
.snippet-block{
|
||||||
|
background:var(--bg);border:1px solid var(--border);border-radius:8px;
|
||||||
|
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
|
||||||
|
}
|
||||||
|
.snippet-block pre{
|
||||||
|
font-family:var(--mono);font-size:0.8rem;color:var(--text);
|
||||||
|
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
|
||||||
|
}
|
||||||
|
.copy-btn{
|
||||||
|
position:absolute;top:8px;right:8px;
|
||||||
|
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
|
||||||
|
background:var(--surface);color:var(--text2);font-size:0.7rem;
|
||||||
|
cursor:pointer;transition:all 0.15s;
|
||||||
|
}
|
||||||
|
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||||
|
|
||||||
|
/* checklist */
|
||||||
|
.checklist{margin-top:8px;list-style:none}
|
||||||
|
.checklist li{
|
||||||
|
display:flex;align-items:flex-start;gap:10px;
|
||||||
|
padding:8px 0;border-bottom:1px solid var(--border);
|
||||||
|
font-size:0.85rem;color:var(--text2);
|
||||||
|
}
|
||||||
|
.checklist li:last-child{border-bottom:none}
|
||||||
|
.check-box{
|
||||||
|
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
|
||||||
|
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||||
|
transition:all 0.15s;margin-top:1px;
|
||||||
|
}
|
||||||
|
.check-box.checked{background:var(--green);border-color:var(--green)}
|
||||||
|
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
|
||||||
|
.check-text.done{text-decoration:line-through;color:var(--text3)}
|
||||||
|
.progress-bar{
|
||||||
|
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
|
||||||
|
}
|
||||||
|
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
|
||||||
|
|
||||||
|
/* template */
|
||||||
|
.template-field{margin-top:8px}
|
||||||
|
.template-field label{
|
||||||
|
display:block;font-size:0.7rem;font-weight:600;
|
||||||
|
text-transform:uppercase;letter-spacing:0.05em;
|
||||||
|
color:var(--text3);margin-bottom:4px;
|
||||||
|
}
|
||||||
|
.template-field input{
|
||||||
|
width:100%;padding:8px 12px;border-radius:8px;
|
||||||
|
border:1px solid var(--border);background:var(--surface2);
|
||||||
|
color:var(--text);font-size:0.85rem;font-family:var(--sans);
|
||||||
|
outline:none;transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.template-field input:focus{border-color:var(--accent)}
|
||||||
|
.template-output{margin-top:10px}
|
||||||
|
.template-copy-btn{
|
||||||
|
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
|
||||||
|
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
|
||||||
|
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
|
||||||
|
}
|
||||||
|
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
|
||||||
|
|
||||||
|
/* decision */
|
||||||
|
.decision-grid{margin-top:8px}
|
||||||
|
.decision-row{
|
||||||
|
padding:8px 0;border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.decision-row:last-child{border-bottom:none}
|
||||||
|
.decision-label{
|
||||||
|
font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||||
|
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
|
||||||
|
}
|
||||||
|
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
|
||||||
|
.decision-rejected{color:var(--red);font-style:italic}
|
||||||
|
|
||||||
|
/* link */
|
||||||
|
.link-target{
|
||||||
|
display:flex;align-items:center;gap:10px;margin-top:8px;
|
||||||
|
padding:12px;background:var(--bg);border:1px solid var(--border);
|
||||||
|
border-radius:8px;text-decoration:none;color:var(--text);
|
||||||
|
transition:border-color 0.15s;
|
||||||
|
}
|
||||||
|
.link-target:active{border-color:var(--accent)}
|
||||||
|
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
|
||||||
|
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
|
||||||
|
|
||||||
|
.empty-state{
|
||||||
|
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width:640px){
|
||||||
|
.card-list{
|
||||||
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
|
||||||
|
gap:10px;padding:8px 20px;
|
||||||
|
}
|
||||||
|
.card{margin-bottom:0}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="deck-header">
|
||||||
|
<div class="deck-title">{{.Title}}</div>
|
||||||
|
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input type="text" class="search-input" placeholder="search cards…" id="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar" id="filters">
|
||||||
|
<button class="filter-chip active" data-type="all">All</button>
|
||||||
|
{{range .Types}}
|
||||||
|
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-list" id="cards">
|
||||||
|
{{range .Cards}}
|
||||||
|
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-glyph">{{.Glyph}}</span>
|
||||||
|
<span class="card-type">{{.CardType}}</span>
|
||||||
|
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if eq .CardType "snippet"}}
|
||||||
|
<div class="snippet-block">
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">copy</button>
|
||||||
|
<pre>{{.Body}}</pre>
|
||||||
|
</div>
|
||||||
|
{{else if eq .CardType "checklist"}}
|
||||||
|
<div class="card-body">{{.Description}}</div>
|
||||||
|
<ul class="checklist" data-card-id="{{.ID}}">
|
||||||
|
{{range $i, $step := .Steps}}
|
||||||
|
<li>
|
||||||
|
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
|
||||||
|
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
|
||||||
|
{{else if eq .CardType "template"}}
|
||||||
|
<div class="card-body">{{.Description}}</div>
|
||||||
|
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
|
||||||
|
{{range .Slots}}
|
||||||
|
<div class="template-field">
|
||||||
|
<label>{{.Name}}</label>
|
||||||
|
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="template-output">
|
||||||
|
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else if eq .CardType "decision"}}
|
||||||
|
<div class="decision-grid">
|
||||||
|
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
|
||||||
|
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
|
||||||
|
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
|
||||||
|
{{else if eq .CardType "link"}}
|
||||||
|
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
|
||||||
|
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
|
||||||
|
<span class="link-url">{{.LinkURL}}</span>
|
||||||
|
<span class="link-arrow">↗</span>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<div class="card-body">{{.Body}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Tags}}
|
||||||
|
<div class="card-tags">
|
||||||
|
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const cards=document.querySelectorAll('.card');
|
||||||
|
const chips=document.querySelectorAll('.filter-chip');
|
||||||
|
const search=document.getElementById('search');
|
||||||
|
const empty=document.getElementById('empty');
|
||||||
|
let activeType='all';
|
||||||
|
|
||||||
|
function applyFilters(){
|
||||||
|
const q=search.value.toLowerCase().trim();
|
||||||
|
let visible=0;
|
||||||
|
cards.forEach(c=>{
|
||||||
|
const typeMatch=activeType==='all'||c.dataset.type===activeType;
|
||||||
|
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
|
||||||
|
c.style.display=(typeMatch&&searchMatch)?'':'none';
|
||||||
|
if(typeMatch&&searchMatch)visible++;
|
||||||
|
});
|
||||||
|
empty.style.display=visible?'none':'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.forEach(chip=>{
|
||||||
|
chip.addEventListener('click',()=>{
|
||||||
|
chips.forEach(c=>c.classList.remove('active'));
|
||||||
|
chip.classList.add('active');
|
||||||
|
activeType=chip.dataset.type;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
search.addEventListener('input',applyFilters);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function copyText(btn){
|
||||||
|
const pre=btn.parentElement.querySelector('pre');
|
||||||
|
navigator.clipboard.writeText(pre.textContent).then(()=>{
|
||||||
|
btn.textContent='copied!';
|
||||||
|
setTimeout(()=>btn.textContent='copy',1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheck(box){
|
||||||
|
box.classList.toggle('checked');
|
||||||
|
const span=box.nextElementSibling;
|
||||||
|
span.classList.toggle('done');
|
||||||
|
const list=box.closest('.checklist');
|
||||||
|
const boxes=list.querySelectorAll('.check-box');
|
||||||
|
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
|
||||||
|
const bar=list.nextElementSibling.querySelector('.progress-fill');
|
||||||
|
bar.style.width=Math.round((done/boxes.length)*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplate(input){
|
||||||
|
const form=input.closest('.template-form');
|
||||||
|
const tmpl=form.dataset.template;
|
||||||
|
let filled=tmpl;
|
||||||
|
form.querySelectorAll('input[data-slot]').forEach(inp=>{
|
||||||
|
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
|
||||||
|
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
|
||||||
|
});
|
||||||
|
form._filled=filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTemplate(btn){
|
||||||
|
const form=btn.closest('.template-form');
|
||||||
|
const text=form._filled||form.dataset.template;
|
||||||
|
navigator.clipboard.writeText(text).then(()=>{
|
||||||
|
btn.textContent='copied!';
|
||||||
|
setTimeout(()=>btn.textContent='copy filled template',1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var linkRe = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||||
|
|
||||||
|
func ExtractLinks(body string) []string {
|
||||||
|
matches := linkRe.FindAllStringSubmatch(body, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var result []string
|
||||||
|
for _, m := range matches {
|
||||||
|
text := strings.TrimSpace(m[1])
|
||||||
|
if text == "" || seen[text] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[text] = true
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"no links", "plain text with no links", nil},
|
||||||
|
{"single link", "see [[nginx config]] for details", []string{"nginx config"}},
|
||||||
|
{"multiple links", "see [[nginx config]] and [[deploy steps]]", []string{"nginx config", "deploy steps"}},
|
||||||
|
{"duplicate deduped", "[[foo]] then [[foo]] again", []string{"foo"}},
|
||||||
|
{"empty brackets", "empty [[ ]] ignored", nil},
|
||||||
|
{"just brackets no content", "[[]] empty", nil},
|
||||||
|
{"link with special chars", "see [[deploy: staging (v2)]]", []string{"deploy: staging (v2)"}},
|
||||||
|
{"link in markdown", "# heading\n\nsee [[my note]] for info", []string{"my note"}},
|
||||||
|
{"adjacent links", "[[one]][[two]]", []string{"one", "two"}},
|
||||||
|
{"partial brackets ignored", "not a [link] or [[incomplete", nil},
|
||||||
|
{"link with hash", "see [[#ops channel]]", []string{"#ops channel"}},
|
||||||
|
{"multiline body", "line one [[link one]]\nline two [[link two]]", []string{"link one", "link two"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ExtractLinks(tt.body)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -17,6 +18,9 @@ type Result struct {
|
|||||||
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{
|
||||||
@@ -27,6 +31,8 @@ var validCardTypes = map[string]string{
|
|||||||
"checklist": "checklist",
|
"checklist": "checklist",
|
||||||
"decision": "decision",
|
"decision": "decision",
|
||||||
"link": "link",
|
"link": "link",
|
||||||
|
"note": "note",
|
||||||
|
"n": "note",
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(input string) (*Result, error) {
|
func Parse(input string) (*Result, error) {
|
||||||
@@ -64,13 +70,50 @@ 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)
|
||||||
|
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 {
|
} else {
|
||||||
bodyParts = append(bodyParts, tok)
|
bodyParts = append(bodyParts, tok)
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
|
||||||
|
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||||
|
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||||
|
suffix := tok[1:]
|
||||||
|
if ct, ok := validCardTypes[suffix]; ok {
|
||||||
|
r.QueryCardType = &ct
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
r.Body = strings.Join(bodyParts, " ")
|
r.Body = strings.Join(bodyParts, " ")
|
||||||
return r, nil
|
return r, nil
|
||||||
|
|||||||
@@ -158,6 +158,67 @@ func TestParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseQueryComposition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantBody string
|
||||||
|
wantTags []string
|
||||||
|
wantDateFrom bool
|
||||||
|
wantDateTo bool
|
||||||
|
wantCardType *string
|
||||||
|
}{
|
||||||
|
{"today", "?@today", "", nil, true, true, nil},
|
||||||
|
{"yesterday", "?@yesterday", "", nil, true, true, nil},
|
||||||
|
{"week", "?@week", "", nil, true, false, nil},
|
||||||
|
{"month", "?@month", "", nil, true, false, nil},
|
||||||
|
{"newer than", "?<7d", "", nil, true, false, nil},
|
||||||
|
{"older than", "?>30d", "", nil, false, true, nil},
|
||||||
|
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
|
||||||
|
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
|
||||||
|
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
|
||||||
|
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
|
||||||
|
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
|
||||||
|
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
|
||||||
|
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Query {
|
||||||
|
t.Fatal("expected Query=true")
|
||||||
|
}
|
||||||
|
if got.Body != tt.wantBody {
|
||||||
|
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
|
||||||
|
}
|
||||||
|
if !tagsEq(got.FilterTags, tt.wantTags) {
|
||||||
|
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
|
||||||
|
}
|
||||||
|
if tt.wantDateFrom && got.QueryDateFrom == nil {
|
||||||
|
t.Error("expected QueryDateFrom to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateFrom && got.QueryDateFrom != nil {
|
||||||
|
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
|
||||||
|
}
|
||||||
|
if tt.wantDateTo && got.QueryDateTo == nil {
|
||||||
|
t.Error("expected QueryDateTo to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateTo && got.QueryDateTo != nil {
|
||||||
|
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
|
||||||
|
}
|
||||||
|
if !ptrEq(got.QueryCardType, tt.wantCardType) {
|
||||||
|
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ptrEq(a, b *string) bool {
|
func ptrEq(a, b *string) bool {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type absorbModel struct {
|
||||||
|
targetID string
|
||||||
|
sources []*db.Entity
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAbsorbModel(targetID string) absorbModel {
|
||||||
|
return absorbModel{targetID: targetID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *absorbModel) setSources(entities []*db.Entity) {
|
||||||
|
a.sources = nil
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.ID != a.targetID {
|
||||||
|
a.sources = append(a.sources, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cursor = 0
|
||||||
|
a.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *absorbModel) setHeight(h int) {
|
||||||
|
a.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) selectedSource() *db.Entity {
|
||||||
|
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.sources[a.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if a.cursor > 0 {
|
||||||
|
a.cursor--
|
||||||
|
if a.cursor < a.offset {
|
||||||
|
a.offset = a.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if a.cursor < len(a.sources)-1 {
|
||||||
|
a.cursor++
|
||||||
|
visible := a.visibleCount()
|
||||||
|
if a.cursor >= a.offset+visible {
|
||||||
|
a.offset = a.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) view(width int) string {
|
||||||
|
if len(a.sources) == 0 {
|
||||||
|
return statusStyle.Render("no other entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("select source to merge"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
visible := a.visibleCount() - 4
|
||||||
|
if visible <= 0 {
|
||||||
|
visible = 10
|
||||||
|
}
|
||||||
|
end := min(a.offset+visible, len(a.sources))
|
||||||
|
|
||||||
|
for i := a.offset; i < end; i++ {
|
||||||
|
e := a.sources[i]
|
||||||
|
line := renderAbsorbSource(e, width-4)
|
||||||
|
|
||||||
|
if i == a.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a absorbModel) visibleCount() int {
|
||||||
|
if a.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return a.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||||
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags string
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
tagParts := make([]string, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
|
||||||
|
}
|
||||||
|
tags = " " + strings.Join(tagParts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxSuggestions = 5
|
||||||
|
|
||||||
|
type autocompleteModel struct {
|
||||||
|
suggestions []string
|
||||||
|
cursor int
|
||||||
|
active bool
|
||||||
|
prefix string
|
||||||
|
tokenStart int
|
||||||
|
tokenEnd int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveUp() {
|
||||||
|
if a.cursor > 0 {
|
||||||
|
a.cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveDown() {
|
||||||
|
if a.cursor < len(a.suggestions)-1 {
|
||||||
|
a.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) selected() string {
|
||||||
|
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.suggestions[a.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) visibleCount() int {
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
return maxSuggestions
|
||||||
|
}
|
||||||
|
return len(a.suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) view(width int) string {
|
||||||
|
if !a.active || len(a.suggestions) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
n := a.visibleCount()
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
tag := "#" + a.suggestions[i]
|
||||||
|
if i == a.cursor {
|
||||||
|
b.WriteString(acSelectedStyle.Render(" › " + tag))
|
||||||
|
} else {
|
||||||
|
b.WriteString(acItemStyle.Render(" " + tag))
|
||||||
|
}
|
||||||
|
if i < n-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(acItemStyle.Render(" …"))
|
||||||
|
}
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Width(min(30, width)).
|
||||||
|
Render(b.String())
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
|
||||||
|
if cursorPos > len(val) {
|
||||||
|
cursorPos = len(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := cursorPos
|
||||||
|
for start > 0 && val[start-1] != ' ' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
|
||||||
|
if start >= len(val) || val[start] != '#' {
|
||||||
|
return 0, 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
end := cursorPos
|
||||||
|
for end < len(val) && val[end] != ' ' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = strings.ToLower(val[start+1 : cursorPos])
|
||||||
|
return start, end, prefix, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
|
||||||
|
if prefix == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix = strings.ToLower(prefix)
|
||||||
|
var result []string
|
||||||
|
for _, tc := range tags {
|
||||||
|
lower := strings.ToLower(tc.Tag)
|
||||||
|
if strings.HasPrefix(lower, prefix) && lower != prefix {
|
||||||
|
result = append(result, tc.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagTokenAtCursor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
cursor int
|
||||||
|
wantStart int
|
||||||
|
wantEnd int
|
||||||
|
wantPfx string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
|
||||||
|
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
|
||||||
|
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
|
||||||
|
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
|
||||||
|
{"not in tag", "hello world", 5, 0, 0, "", false},
|
||||||
|
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
|
||||||
|
{"cursor at hash", "#ops", 1, 0, 4, "", true},
|
||||||
|
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
|
||||||
|
{"empty string", "", 0, 0, 0, "", false},
|
||||||
|
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
|
||||||
|
if ok != tt.wantOk {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if start != tt.wantStart || end != tt.wantEnd {
|
||||||
|
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if pfx != tt.wantPfx {
|
||||||
|
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterTagSuggestions(t *testing.T) {
|
||||||
|
tags := []db.TagCount{
|
||||||
|
{Tag: "ops", Count: 5},
|
||||||
|
{Tag: "ops-deploy", Count: 3},
|
||||||
|
{Tag: "infra", Count: 2},
|
||||||
|
{Tag: "ops-team", Count: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"empty prefix", "", nil},
|
||||||
|
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
|
||||||
|
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"no match", "zzz", nil},
|
||||||
|
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"single match", "inf", []string{"infra"}},
|
||||||
|
{"full match excluded", "infra", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterTagSuggestions(tags, tt.prefix)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type intent int
|
||||||
|
|
||||||
|
const (
|
||||||
|
intentAll intent = iota
|
||||||
|
intentGrab
|
||||||
|
intentRead
|
||||||
|
intentFill
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i intent) String() string {
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return "grab"
|
||||||
|
case intentRead:
|
||||||
|
return "read"
|
||||||
|
case intentFill:
|
||||||
|
return "fill"
|
||||||
|
default:
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i intent) next() intent {
|
||||||
|
switch i {
|
||||||
|
case intentAll:
|
||||||
|
return intentGrab
|
||||||
|
case intentGrab:
|
||||||
|
return intentRead
|
||||||
|
case intentRead:
|
||||||
|
return intentFill
|
||||||
|
default:
|
||||||
|
return intentAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesIntent(e *db.Entity, i intent) bool {
|
||||||
|
if i == intentAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ct := e.CardType
|
||||||
|
if ct == nil {
|
||||||
|
return i == intentGrab
|
||||||
|
}
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return *ct == db.CardSnippet
|
||||||
|
case intentRead:
|
||||||
|
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
|
||||||
|
case intentFill:
|
||||||
|
return *ct == db.CardTemplate || *ct == db.CardChecklist
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardGroup struct {
|
||||||
|
label string
|
||||||
|
start int
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardsModel struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
|
groups []cardGroup
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
intent intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCardsModel() cardsModel {
|
||||||
|
return cardsModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setEntities(entities []*db.Entity) {
|
||||||
|
c.entities = entities
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setIntent(i intent) {
|
||||||
|
c.intent = i
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) applyFilter() {
|
||||||
|
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
|
||||||
|
if c.cursor >= len(c.filtered) {
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
|
||||||
|
if intentFilter != intentAll {
|
||||||
|
var pinned, rest []*db.Entity
|
||||||
|
for _, e := range 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) {
|
||||||
|
c.width = width
|
||||||
|
c.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) selected() *db.Entity {
|
||||||
|
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.filtered[c.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if c.cursor > 0 {
|
||||||
|
c.cursor--
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if c.cursor < len(c.filtered)-1 {
|
||||||
|
c.cursor++
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
case "end", "G":
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
c.cursor = max(0, c.cursor-c.visibleCount())
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) view(width int) string {
|
||||||
|
if len(c.filtered) == 0 {
|
||||||
|
return statusStyle.Render("no cards")
|
||||||
|
}
|
||||||
|
if len(c.groups) > 0 {
|
||||||
|
return c.groupedView(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
visible := c.visibleCount()
|
||||||
|
end := min(c.offset+visible, len(c.filtered))
|
||||||
|
|
||||||
|
for i := c.offset; i < end; i++ {
|
||||||
|
e := c.filtered[i]
|
||||||
|
line := renderCard(e, width-4)
|
||||||
|
|
||||||
|
if i == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) groupedView(width int) string {
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
entityIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []displayLine
|
||||||
|
for _, g := range c.groups {
|
||||||
|
for i := 0; i < g.count; i++ {
|
||||||
|
eIdx := g.start + i
|
||||||
|
var gutter string
|
||||||
|
if i == 0 {
|
||||||
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
|
} else {
|
||||||
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
|
||||||
|
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := c.visibleCount()
|
||||||
|
offset := c.offset
|
||||||
|
if c.cursor < offset {
|
||||||
|
offset = c.cursor
|
||||||
|
}
|
||||||
|
if c.cursor >= offset+visible {
|
||||||
|
offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
end := min(offset+visible, len(lines))
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
dl := lines[i]
|
||||||
|
if dl.entityIdx == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(dl.text))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) visibleCount() int {
|
||||||
|
if c.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return c.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCard(e *db.Entity, maxWidth int) string {
|
||||||
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
affordance := detectAffordance(e)
|
||||||
|
affordStr := ""
|
||||||
|
if affordance != "" {
|
||||||
|
affordStr = " " + affordanceStyle.Render(affordance)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if e.Pinned {
|
||||||
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
|
}
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
for _, t := range e.Tags[:limit] {
|
||||||
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraStr := ""
|
||||||
|
if len(extras) > 0 {
|
||||||
|
extraStr = " " + strings.Join(extras, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
useStr := ""
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectAffordance(e *db.Entity) string {
|
||||||
|
if e.CardType == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardSnippet:
|
||||||
|
return "code"
|
||||||
|
case db.CardTemplate:
|
||||||
|
return "fill"
|
||||||
|
case db.CardChecklist:
|
||||||
|
return "steps"
|
||||||
|
case db.CardDecision:
|
||||||
|
return "decide"
|
||||||
|
case db.CardLink:
|
||||||
|
return "link"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchesIntent(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
checklist := db.CardChecklist
|
||||||
|
note := db.CardNote
|
||||||
|
link := db.CardLink
|
||||||
|
decision := db.CardDecision
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardType *db.CardType
|
||||||
|
intent intent
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"all matches nil", nil, intentAll, true},
|
||||||
|
{"all matches snippet", &snippet, intentAll, true},
|
||||||
|
{"all matches template", &template, intentAll, true},
|
||||||
|
|
||||||
|
{"grab matches nil", nil, intentGrab, true},
|
||||||
|
{"grab matches snippet", &snippet, intentGrab, true},
|
||||||
|
{"grab rejects template", &template, intentGrab, false},
|
||||||
|
{"grab rejects note", ¬e, intentGrab, false},
|
||||||
|
|
||||||
|
{"read matches note", ¬e, intentRead, true},
|
||||||
|
{"read matches link", &link, intentRead, true},
|
||||||
|
{"read matches decision", &decision, intentRead, true},
|
||||||
|
{"read rejects snippet", &snippet, intentRead, false},
|
||||||
|
{"read rejects nil", nil, intentRead, false},
|
||||||
|
|
||||||
|
{"fill matches template", &template, intentFill, true},
|
||||||
|
{"fill matches checklist", &checklist, intentFill, true},
|
||||||
|
{"fill rejects snippet", &snippet, intentFill, false},
|
||||||
|
{"fill rejects nil", nil, intentFill, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &db.Entity{CardType: tt.cardType}
|
||||||
|
if got := matchesIntent(e, tt.intent); got != tt.want {
|
||||||
|
t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAffordance(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
checklist := db.CardChecklist
|
||||||
|
decision := db.CardDecision
|
||||||
|
link := db.CardLink
|
||||||
|
note := db.CardNote
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardType *db.CardType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil", nil, ""},
|
||||||
|
{"snippet", &snippet, "code"},
|
||||||
|
{"template", &template, "fill"},
|
||||||
|
{"checklist", &checklist, "steps"},
|
||||||
|
{"decision", &decision, "decide"},
|
||||||
|
{"link", &link, "link"},
|
||||||
|
{"note", ¬e, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &db.Entity{CardType: tt.cardType}
|
||||||
|
if got := detectAffordance(e); got != tt.want {
|
||||||
|
t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentCycle(t *testing.T) {
|
||||||
|
order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll}
|
||||||
|
for i := 0; i < len(order)-1; i++ {
|
||||||
|
got := order[i].next()
|
||||||
|
if got != order[i+1] {
|
||||||
|
t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFilter_PinnedFirst(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
c := newCardsModel()
|
||||||
|
c.setEntities([]*db.Entity{
|
||||||
|
{ID: "1", Body: "a", CardType: &snippet},
|
||||||
|
{ID: "2", Body: "b", Pinned: true, CardType: &snippet},
|
||||||
|
{ID: "3", Body: "c", CardType: &snippet},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(c.filtered) != 3 {
|
||||||
|
t.Fatalf("expected 3 filtered, got %d", len(c.filtered))
|
||||||
|
}
|
||||||
|
if c.filtered[0].ID != "2" {
|
||||||
|
t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFilter_CursorClamps(t *testing.T) {
|
||||||
|
snippet := db.CardSnippet
|
||||||
|
template := db.CardTemplate
|
||||||
|
c := newCardsModel()
|
||||||
|
c.setEntities([]*db.Entity{
|
||||||
|
{ID: "1", Body: "a", CardType: &snippet},
|
||||||
|
{ID: "2", Body: "b", CardType: &snippet},
|
||||||
|
{ID: "3", Body: "c", CardType: &template},
|
||||||
|
})
|
||||||
|
c.cursor = 2
|
||||||
|
|
||||||
|
c.setIntent(intentFill)
|
||||||
|
if len(c.filtered) != 1 {
|
||||||
|
t.Fatalf("expected 1 fill entity, got %d", len(c.filtered))
|
||||||
|
}
|
||||||
|
if c.cursor != 0 {
|
||||||
|
t.Fatalf("cursor should clamp to 0, got %d", c.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entitiesLoadedMsg struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityCreatedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityDeletedMsg struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityUpdatedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityPromotedMsg struct {
|
||||||
|
id string
|
||||||
|
cardType db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityDemotedMsg struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityCopiedMsg struct{}
|
||||||
|
|
||||||
|
type entityAbsorbedMsg struct {
|
||||||
|
targetID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type absorbSourcesLoadedMsg struct {
|
||||||
|
targetID string
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepsPersistedMsg struct{}
|
||||||
|
|
||||||
|
type templateCopiedMsg struct{}
|
||||||
|
|
||||||
|
type backlinksLoadedMsg struct {
|
||||||
|
backlinks []db.Backlink
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkFollowedMsg struct {
|
||||||
|
entity *db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagsLoadedMsg struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type railTagsLoadedMsg struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type staleEntitiesLoadedMsg struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
type stumbleActionMsg struct {
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusClearMsg struct{ seq int }
|
||||||
|
|
||||||
|
type editorFinishedMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type errMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), params)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entitiesLoadedMsg{entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Create(context.Background(), e); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityCreatedMsg{e}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityDeletedMsg{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
var update db.EntityUpdate
|
||||||
|
if e.CompletedAt == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
update = db.EntityUpdate{CompletedAt: &now}
|
||||||
|
} else {
|
||||||
|
update = db.EntityUpdate{ClearCompleted: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
action := "completed"
|
||||||
|
if e.CompletedAt != nil {
|
||||||
|
action = "reopened"
|
||||||
|
}
|
||||||
|
return entityUpdatedMsg{updated, action}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
newPinned := !e.Pinned
|
||||||
|
update := db.EntityUpdate{Pinned: &newPinned}
|
||||||
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
updated, err := store.Get(context.Background(), e.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
action := "pinned"
|
||||||
|
if !newPinned {
|
||||||
|
action = "unpinned"
|
||||||
|
}
|
||||||
|
return entityUpdatedMsg{updated, action}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
cd := carddata.GenerateCardData(ct, body)
|
||||||
|
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityPromotedMsg{id, ct}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func demoteEntity(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Demote(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityDemotedMsg{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := clipboard.WriteAll(e.Body); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityCopiedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTags(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return tagsLoadedMsg{tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 followLink(store *db.Store, linkText string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entity, err := store.ResolveLink(context.Background(), linkText)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return linkFollowedMsg{entity}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func followLinkByID(store *db.Store, entityID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entity, err := store.Get(context.Background(), entityID)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return linkFollowedMsg{entity}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRailTags(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return railTagsLoadedMsg{tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
||||||
|
editorEnv := os.Getenv("EDITOR")
|
||||||
|
if editorEnv == "" {
|
||||||
|
editorEnv = os.Getenv("VISUAL")
|
||||||
|
}
|
||||||
|
if editorEnv == "" {
|
||||||
|
editorEnv = "vi"
|
||||||
|
}
|
||||||
|
parts := strings.Fields(editorEnv)
|
||||||
|
editor, editorArgs := parts[0], parts[1:]
|
||||||
|
|
||||||
|
f, err := os.CreateTemp("", "nib-edit-*.md")
|
||||||
|
if err != nil {
|
||||||
|
return func() tea.Msg { return errMsg{err} }
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString(e.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
return func() tea.Msg { return errMsg{err} }
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
c := exec.Command(editor, append(editorArgs, f.Name())...)
|
||||||
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
return editorFinishedMsg{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, readErr := os.ReadFile(f.Name())
|
||||||
|
if readErr != nil {
|
||||||
|
return editorFinishedMsg{readErr}
|
||||||
|
}
|
||||||
|
|
||||||
|
newBody := string(content)
|
||||||
|
if newBody == e.Body {
|
||||||
|
return editorFinishedMsg{nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
update := db.EntityUpdate{Body: &newBody}
|
||||||
|
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
|
||||||
|
return editorFinishedMsg{updateErr}
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorFinishedMsg{nil}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), db.DefaultListParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return absorbSourcesLoadedMsg{targetID, entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return entityAbsorbedMsg{targetID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
update := db.EntityUpdate{CardData: &stepsJSON}
|
||||||
|
if err := store.Update(context.Background(), entityID, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stepsPersistedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := clipboard.WriteAll(resolved); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := store.IncrementUse(context.Background(), entityID); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return templateCopiedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
||||||
|
return tea.Tick(d, func(time.Time) tea.Msg {
|
||||||
|
return statusClearMsg{seq: seq}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStaleEntities(store *db.Store) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
entities, err := store.List(context.Background(), staleParams())
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return staleEntitiesLoadedMsg{entities}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"dismissed"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stumblePin(store *db.Store, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
pinned := true
|
||||||
|
update := db.EntityUpdate{Pinned: &pinned}
|
||||||
|
if err := store.Update(context.Background(), id, &update); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return stumbleActionMsg{"pinned"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type confirmTimeoutMsg struct{}
|
||||||
|
|
||||||
|
func confirmTimeout() tea.Cmd {
|
||||||
|
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||||
|
return confirmTimeoutMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConfirm(entityID string) string {
|
||||||
|
short := display.FormatID(entityID)
|
||||||
|
return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short))
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailPreview detailMode = iota
|
||||||
|
detailRun
|
||||||
|
detailFill
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailModel struct {
|
||||||
|
entity *db.Entity
|
||||||
|
backlinks []db.Backlink
|
||||||
|
scroll int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
mode detailMode
|
||||||
|
run runModel
|
||||||
|
fill fillModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDetailModel() detailModel {
|
||||||
|
return detailModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailModel) setEntity(e *db.Entity) {
|
||||||
|
d.entity = e
|
||||||
|
d.backlinks = nil
|
||||||
|
d.scroll = 0
|
||||||
|
d.mode = detailPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailModel) setSize(width, height int) {
|
||||||
|
d.width = width
|
||||||
|
d.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
d.run = d.run.update(msg.String())
|
||||||
|
return d, nil
|
||||||
|
case detailFill:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
d.fill, cmd = d.fill.update(msg)
|
||||||
|
return d, cmd
|
||||||
|
default:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if d.scroll > 0 {
|
||||||
|
d.scroll--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
d.scroll++
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
d.scroll += d.height
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
d.scroll -= d.height
|
||||||
|
if d.scroll < 0 {
|
||||||
|
d.scroll = 0
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
d.scroll = 0
|
||||||
|
case "end", "G":
|
||||||
|
d.scroll = 1<<31 - 1
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) view(width int) string {
|
||||||
|
switch d.mode {
|
||||||
|
case detailRun:
|
||||||
|
return d.run.view(width)
|
||||||
|
case detailFill:
|
||||||
|
return d.fill.view(width)
|
||||||
|
default:
|
||||||
|
return d.previewView(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d detailModel) previewView(width int) string {
|
||||||
|
if d.entity == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
e := d.entity
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
||||||
|
if e.CardType != nil {
|
||||||
|
header += " " + affordanceStyle.Render(string(*e.CardType))
|
||||||
|
}
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if e.Title != nil {
|
||||||
|
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyWidth := width - 4
|
||||||
|
if bodyWidth < 20 {
|
||||||
|
bodyWidth = 20
|
||||||
|
}
|
||||||
|
r, _ := glamour.NewTermRenderer(
|
||||||
|
glamour.WithStylePath(glamourStyle()),
|
||||||
|
glamour.WithWordWrap(bodyWidth),
|
||||||
|
)
|
||||||
|
rendered, err := r.Render(e.Body)
|
||||||
|
if err != nil {
|
||||||
|
rendered = e.Body
|
||||||
|
}
|
||||||
|
rendered = strings.TrimRight(rendered, "\n")
|
||||||
|
b.WriteString(detailBodyStyle.Render(rendered))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if e.CardType != nil {
|
||||||
|
cardSection := renderCardData(e)
|
||||||
|
if cardSection != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(cardSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
tagParts := make([]string, len(e.Tags))
|
||||||
|
for i, t := range e.Tags {
|
||||||
|
tagParts[i] = tagStyle.Render("#" + t)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(d.backlinks) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, bl := range d.backlinks {
|
||||||
|
label := bl.Body
|
||||||
|
if bl.Title != nil {
|
||||||
|
label = *bl.Title
|
||||||
|
} else if len(label) > 40 {
|
||||||
|
label = label[:40] + "…"
|
||||||
|
}
|
||||||
|
line := " " + backlinkStyle.Render(label)
|
||||||
|
if bl.LinkText != "" {
|
||||||
|
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||||
|
if e.ModifiedAt != e.CreatedAt {
|
||||||
|
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
if e.TimeAnchor != nil {
|
||||||
|
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
meta += "\n" + pinnedStyle.Render("pinned")
|
||||||
|
}
|
||||||
|
if e.CardType != nil {
|
||||||
|
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||||||
|
}
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
meta += fmt.Sprintf("\nused %d×", e.UseCount)
|
||||||
|
}
|
||||||
|
if e.CompletedAt != nil {
|
||||||
|
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
||||||
|
}
|
||||||
|
b.WriteString(idStyle.Render(meta))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
|
||||||
|
maxScroll := totalLines - d.height
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
scroll := d.scroll
|
||||||
|
if scroll > maxScroll {
|
||||||
|
scroll = maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
|
||||||
|
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
|
||||||
|
lines[0] = lines[0] + indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
if scroll > 0 && scroll < totalLines {
|
||||||
|
lines = lines[scroll:]
|
||||||
|
}
|
||||||
|
if d.height > 0 && len(lines) > d.height {
|
||||||
|
lines = lines[:d.height]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCardData(e *db.Entity) string {
|
||||||
|
if e.CardData == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := e.CardDataJSON()
|
||||||
|
if err != nil || data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardChecklist:
|
||||||
|
steps, ok := data["steps"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
done := 0
|
||||||
|
for _, s := range steps {
|
||||||
|
step, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, _ := step["text"].(string)
|
||||||
|
isDone, _ := step["done"].(bool)
|
||||||
|
if isDone {
|
||||||
|
done++
|
||||||
|
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
||||||
|
b.WriteString(detailLabelStyle.Render(progress))
|
||||||
|
b.WriteString(" " + helpStyle.Render("r:run"))
|
||||||
|
|
||||||
|
case db.CardTemplate:
|
||||||
|
slots, ok := data["slots"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
|
||||||
|
for _, s := range slots {
|
||||||
|
slot, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := slot["name"].(string)
|
||||||
|
def, _ := slot["default"].(string)
|
||||||
|
line := " ${" + name + "}"
|
||||||
|
if def != "" {
|
||||||
|
line += " " + detailValueStyle.Render("default: "+def)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" " + helpStyle.Render("f:fill"))
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
if chose, ok := data["chose"].(string); ok && chose != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
|
||||||
|
}
|
||||||
|
if why, ok := data["why"].(string); ok && why != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
|
||||||
|
}
|
||||||
|
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
|
||||||
|
items := make([]string, 0, len(rejected))
|
||||||
|
for _, r := range rejected {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
if url, ok := data["url"].(string); ok && url != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fillSlot struct {
|
||||||
|
Name string
|
||||||
|
Default string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fillModel struct {
|
||||||
|
slots []fillSlot
|
||||||
|
active int
|
||||||
|
body string
|
||||||
|
entityID string
|
||||||
|
ti textinput.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFillModel(entityID, body string) fillModel {
|
||||||
|
slots := discoverSlots(body)
|
||||||
|
m := fillModel{
|
||||||
|
slots: slots,
|
||||||
|
body: body,
|
||||||
|
entityID: entityID,
|
||||||
|
}
|
||||||
|
m.ti = textinput.New()
|
||||||
|
m.ti.CharLimit = 200
|
||||||
|
if len(slots) > 0 {
|
||||||
|
m.ti.Placeholder = slots[0].Name
|
||||||
|
m.ti.Focus()
|
||||||
|
if slots[0].Default != "" {
|
||||||
|
m.ti.SetValue(slots[0].Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverSlots(body string) []fillSlot {
|
||||||
|
matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var slots []fillSlot
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
slots = append(slots, fillSlot{Name: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) resolve() string {
|
||||||
|
result := f.body
|
||||||
|
for _, s := range f.slots {
|
||||||
|
val := s.Value
|
||||||
|
if val == "" {
|
||||||
|
val = "${" + s.Name + "}"
|
||||||
|
}
|
||||||
|
result = strings.ReplaceAll(result, "${"+s.Name+"}", val)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab":
|
||||||
|
f.commitActive()
|
||||||
|
if f.active < len(f.slots)-1 {
|
||||||
|
f.active++
|
||||||
|
} else {
|
||||||
|
f.active = 0
|
||||||
|
}
|
||||||
|
f.loadActive()
|
||||||
|
return f, nil
|
||||||
|
case "shift+tab":
|
||||||
|
f.commitActive()
|
||||||
|
if f.active > 0 {
|
||||||
|
f.active--
|
||||||
|
} else {
|
||||||
|
f.active = len(f.slots) - 1
|
||||||
|
}
|
||||||
|
f.loadActive()
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f.ti, _ = f.ti.Update(msg)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fillModel) commitActive() {
|
||||||
|
if f.active < len(f.slots) {
|
||||||
|
f.slots[f.active].Value = f.ti.Value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fillModel) loadActive() {
|
||||||
|
if f.active < len(f.slots) {
|
||||||
|
s := f.slots[f.active]
|
||||||
|
f.ti.SetValue(s.Value)
|
||||||
|
f.ti.Placeholder = s.Name
|
||||||
|
f.ti.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fillModel) view(width int) string {
|
||||||
|
if len(f.slots) == 0 {
|
||||||
|
return statusStyle.Render("no slots")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots))
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, slot := range f.slots {
|
||||||
|
name := detailLabelStyle.Render(slot.Name)
|
||||||
|
var val string
|
||||||
|
if i == f.active {
|
||||||
|
val = f.ti.View()
|
||||||
|
} else if slot.Value != "" {
|
||||||
|
val = detailValueStyle.Render(slot.Value)
|
||||||
|
} else {
|
||||||
|
val = idStyle.Render("(empty)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == f.active {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + name + " " + val))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(name + " " + val))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
preview := f.resolve()
|
||||||
|
if len(preview) > width-4 {
|
||||||
|
preview = preview[:width-7] + "…"
|
||||||
|
}
|
||||||
|
b.WriteString(detailBodyStyle.Render(preview))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverSlots(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantNames []string
|
||||||
|
}{
|
||||||
|
{"no slots", "plain text", nil},
|
||||||
|
{"single slot", "Hello ${name}", []string{"name"}},
|
||||||
|
{"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}},
|
||||||
|
{"duplicate slot deduped", "${x} and ${x} again", []string{"x"}},
|
||||||
|
{"adjacent slots", "${a}${b}", []string{"a", "b"}},
|
||||||
|
{"nested braces ignored", "${{bad}}", nil},
|
||||||
|
{"empty body", "", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := discoverSlots(tt.body)
|
||||||
|
if len(tt.wantNames) == 0 && len(got) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.wantNames) {
|
||||||
|
t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames))
|
||||||
|
}
|
||||||
|
for i, s := range got {
|
||||||
|
if s.Name != tt.wantNames[i] {
|
||||||
|
t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
slots []fillSlot
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"all filled",
|
||||||
|
"Hello ${name}, welcome to ${place}",
|
||||||
|
[]fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}},
|
||||||
|
"Hello Alice, welcome to Nib",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unfilled stays as placeholder",
|
||||||
|
"${greeting} ${name}",
|
||||||
|
[]fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}},
|
||||||
|
"Hi ${name}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no slots",
|
||||||
|
"plain text",
|
||||||
|
nil,
|
||||||
|
"plain text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"repeated slot filled everywhere",
|
||||||
|
"${x} and ${x}",
|
||||||
|
[]fillSlot{{Name: "x", Value: "Y"}},
|
||||||
|
"Y and Y",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
f := fillModel{body: tt.body, slots: tt.slots}
|
||||||
|
if got := f.resolve(); got != tt.want {
|
||||||
|
t.Fatalf("resolve() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type filterModel struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
cursor int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilterModel() filterModel {
|
||||||
|
return filterModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterModel) setTags(tags []db.TagCount) {
|
||||||
|
f.tags = tags
|
||||||
|
f.cursor = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterModel) setHeight(h int) {
|
||||||
|
f.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) selectedTag() string {
|
||||||
|
if len(f.tags) == 0 || f.cursor >= len(f.tags) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return f.tags[f.cursor].Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) update(key string) filterModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if f.cursor > 0 {
|
||||||
|
f.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if f.cursor < len(f.tags)-1 {
|
||||||
|
f.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f filterModel) view(width int) string {
|
||||||
|
if len(f.tags) == 0 {
|
||||||
|
return statusStyle.Render("no tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("filter by tag"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
visible := f.height - 4
|
||||||
|
if visible <= 0 {
|
||||||
|
visible = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if f.cursor >= visible {
|
||||||
|
offset = f.cursor - visible + 1
|
||||||
|
}
|
||||||
|
end := min(offset+visible, len(f.tags))
|
||||||
|
|
||||||
|
for i := offset; i < end; i++ {
|
||||||
|
tc := f.tags[i]
|
||||||
|
tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count)
|
||||||
|
if i == f.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(tagStyle.Render(tag)))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func renderHelp(width, height int) string {
|
||||||
|
sections := []struct {
|
||||||
|
title string
|
||||||
|
binds [][2]string
|
||||||
|
}{
|
||||||
|
{"Focus", [][2]string{
|
||||||
|
{"tab", "toggle capture ↔ list"},
|
||||||
|
{"esc", "back / clear filter / to capture"},
|
||||||
|
{"a", "focus capture bar"},
|
||||||
|
{"h", "focus tag rail (from list)"},
|
||||||
|
{"l", "focus detail (split view)"},
|
||||||
|
{"ctrl+b", "toggle tag rail"},
|
||||||
|
}},
|
||||||
|
{"Capture Bar", [][2]string{
|
||||||
|
{"enter", "submit (or browse if empty)"},
|
||||||
|
{"?…", "search (type ?query)"},
|
||||||
|
{"#…", "tag (autocomplete with tab)"},
|
||||||
|
{"-", "todo prefix"},
|
||||||
|
{"@", "event prefix"},
|
||||||
|
{"!", "reminder prefix"},
|
||||||
|
}},
|
||||||
|
{"Query Operators", [][2]string{
|
||||||
|
{"?text", "substring search"},
|
||||||
|
{"?#tag1 #tag2", "filter by tags (AND)"},
|
||||||
|
{"?@today @week", "date filter (@yesterday @month)"},
|
||||||
|
{"?<7d >30d", "newer/older than N days"},
|
||||||
|
{"?^snippet", "card type filter"},
|
||||||
|
}},
|
||||||
|
{"Navigation", [][2]string{
|
||||||
|
{"j/k ↑/↓", "move cursor"},
|
||||||
|
{"g/G home/end", "top / bottom"},
|
||||||
|
{"pgup/pgdn", "page up / down"},
|
||||||
|
{"enter", "view detail"},
|
||||||
|
}},
|
||||||
|
{"Views", [][2]string{
|
||||||
|
{"1", "stream view"},
|
||||||
|
{"2", "cards view"},
|
||||||
|
{"s", "cycle sort (cards)"},
|
||||||
|
{"i", "cycle intent (cards)"},
|
||||||
|
{"T", "cycle theme"},
|
||||||
|
}},
|
||||||
|
{"Actions", [][2]string{
|
||||||
|
{"d", "delete (with confirm)"},
|
||||||
|
{"x", "toggle todo completion"},
|
||||||
|
{"!", "toggle pin"},
|
||||||
|
{"#", "filter by tag"},
|
||||||
|
{"m", "absorb (merge into target)"},
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"S", "stumble (resurface stale entries)"},
|
||||||
|
}},
|
||||||
|
{"Detail View", [][2]string{
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"D", "demote to fluid"},
|
||||||
|
{"c", "copy to clipboard"},
|
||||||
|
{"e", "edit in $EDITOR"},
|
||||||
|
{"!", "toggle pin"},
|
||||||
|
{"r", "run checklist"},
|
||||||
|
{"f", "fill template"},
|
||||||
|
{"[", "follow [[link]]"},
|
||||||
|
{"esc", "back (pops link history)"},
|
||||||
|
}},
|
||||||
|
{"Stumble", [][2]string{
|
||||||
|
{"n / →", "skip to next"},
|
||||||
|
{"d", "dismiss (soft delete)"},
|
||||||
|
{"!", "pin"},
|
||||||
|
{"p", "promote to card"},
|
||||||
|
{"m", "absorb"},
|
||||||
|
{"esc", "exit"},
|
||||||
|
}},
|
||||||
|
{"Run Mode", [][2]string{
|
||||||
|
{"j/k", "move between steps"},
|
||||||
|
{"space", "toggle step"},
|
||||||
|
{"r", "reset all steps"},
|
||||||
|
{"esc", "save + exit"},
|
||||||
|
}},
|
||||||
|
{"Fill Mode", [][2]string{
|
||||||
|
{"tab", "next slot"},
|
||||||
|
{"shift+tab", "prev slot"},
|
||||||
|
{"enter", "copy resolved"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}},
|
||||||
|
{"Split View", [][2]string{
|
||||||
|
{"l", "focus detail pane"},
|
||||||
|
{"h", "focus list pane"},
|
||||||
|
{"esc", "close detail / back"},
|
||||||
|
}},
|
||||||
|
{"Global", [][2]string{
|
||||||
|
{"?", "toggle help"},
|
||||||
|
{"q / ctrl+c", "quit"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(detailHeaderStyle.Render("keybindings"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for _, s := range sections {
|
||||||
|
b.WriteString(titleStyle.Render(s.title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, bind := range s.binds {
|
||||||
|
key := helpKeyStyle.Render(bind[0])
|
||||||
|
desc := helpDescStyle.Render(bind[1])
|
||||||
|
b.WriteString(" " + key + " " + desc + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("press ? or esc to close"))
|
||||||
|
|
||||||
|
lines := strings.Split(b.String(), "\n")
|
||||||
|
if len(lines) > height {
|
||||||
|
lines = lines[:height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/parse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inputResult struct {
|
||||||
|
entity *db.Entity
|
||||||
|
query bool
|
||||||
|
body string
|
||||||
|
tags []string
|
||||||
|
dateFrom *string
|
||||||
|
dateTo *string
|
||||||
|
cardType *db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputModel struct {
|
||||||
|
ti textinput.Model
|
||||||
|
preview *parse.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInputModel() inputModel {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "capture a thought…"
|
||||||
|
ti.Prompt = inputPromptStyle.Render("› ")
|
||||||
|
ti.CharLimit = 500
|
||||||
|
return inputModel{ti: ti}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *inputModel) clearText() {
|
||||||
|
i.ti.SetValue("")
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) submit() *inputResult {
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parse.Parse(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Query {
|
||||||
|
r := &inputResult{
|
||||||
|
query: true,
|
||||||
|
body: parsed.Body,
|
||||||
|
tags: parsed.FilterTags,
|
||||||
|
dateFrom: parsed.QueryDateFrom,
|
||||||
|
dateTo: parsed.QueryDateTo,
|
||||||
|
}
|
||||||
|
if parsed.QueryCardType != nil {
|
||||||
|
ct := db.CardType(*parsed.QueryCardType)
|
||||||
|
r.cardType = &ct
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &db.Entity{
|
||||||
|
Body: parsed.Body,
|
||||||
|
Title: parsed.Title,
|
||||||
|
Glyph: db.Glyph(parsed.Glyph),
|
||||||
|
Tags: parsed.Tags,
|
||||||
|
}
|
||||||
|
if parsed.TimeAnchor != nil {
|
||||||
|
e.TimeAnchor = parsed.TimeAnchor
|
||||||
|
}
|
||||||
|
if parsed.CardSuffix != nil {
|
||||||
|
ct := db.CardType(*parsed.CardSuffix)
|
||||||
|
e.CardType = &ct
|
||||||
|
}
|
||||||
|
if parsed.Pin {
|
||||||
|
e.Pinned = true
|
||||||
|
}
|
||||||
|
if parsed.Description != nil {
|
||||||
|
e.Description = parsed.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return &inputResult{entity: e}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||||
|
i.ti, _ = i.ti.Update(msg)
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val != "" {
|
||||||
|
parsed, err := parse.Parse(val)
|
||||||
|
if err == nil {
|
||||||
|
i.preview = parsed
|
||||||
|
} else {
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i.preview = nil
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) viewBar(width int, focused bool) string {
|
||||||
|
tiView := i.ti.View()
|
||||||
|
if focused {
|
||||||
|
return tiView
|
||||||
|
}
|
||||||
|
val := i.ti.Value()
|
||||||
|
if val != "" {
|
||||||
|
return hintDescStyle.Render("› " + val)
|
||||||
|
}
|
||||||
|
return hintDescStyle.Render("› capture a thought…")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i inputModel) previewText() string {
|
||||||
|
if i.preview == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
p := i.preview
|
||||||
|
|
||||||
|
if p.Query {
|
||||||
|
q := "?"
|
||||||
|
if p.Body != "" {
|
||||||
|
q += p.Body
|
||||||
|
}
|
||||||
|
for _, t := range p.FilterTags {
|
||||||
|
q += " #" + t
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
body := p.Body
|
||||||
|
if p.Title != nil {
|
||||||
|
body = *p.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, glyph, body)
|
||||||
|
for _, t := range p.Tags {
|
||||||
|
parts = append(parts, "#"+t)
|
||||||
|
}
|
||||||
|
if p.Pin {
|
||||||
|
parts = append(parts, "•")
|
||||||
|
}
|
||||||
|
if p.CardSuffix != nil {
|
||||||
|
parts = append(parts, *p.CardSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func glyphForParsed(glyph string) string {
|
||||||
|
switch glyph {
|
||||||
|
case "todo":
|
||||||
|
return "○"
|
||||||
|
case "event":
|
||||||
|
return "◇"
|
||||||
|
case "reminder":
|
||||||
|
return "△"
|
||||||
|
default:
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
Capture key.Binding
|
||||||
|
Delete key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDn key.Binding
|
||||||
|
Top key.Binding
|
||||||
|
Bottom key.Binding
|
||||||
|
Todo key.Binding
|
||||||
|
Pin key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
Promote key.Binding
|
||||||
|
Demote key.Binding
|
||||||
|
Copy key.Binding
|
||||||
|
Edit key.Binding
|
||||||
|
Stream key.Binding
|
||||||
|
Cards key.Binding
|
||||||
|
Sort key.Binding
|
||||||
|
Intent key.Binding
|
||||||
|
Absorb key.Binding
|
||||||
|
Run key.Binding
|
||||||
|
Fill key.Binding
|
||||||
|
FocusLeft key.Binding
|
||||||
|
FocusRight key.Binding
|
||||||
|
Tab key.Binding
|
||||||
|
ToggleRail key.Binding
|
||||||
|
Stumble key.Binding
|
||||||
|
Theme key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = keyMap{
|
||||||
|
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||||
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
|
||||||
|
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||||
|
Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
|
||||||
|
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
|
PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")),
|
||||||
|
PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")),
|
||||||
|
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")),
|
||||||
|
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")),
|
||||||
|
Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")),
|
||||||
|
Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")),
|
||||||
|
Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")),
|
||||||
|
Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")),
|
||||||
|
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
|
||||||
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
|
||||||
|
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
||||||
|
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
|
||||||
|
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||||
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
|
Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
|
||||||
|
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||||
|
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||||
|
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||||
|
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
||||||
|
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||||
|
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
|
||||||
|
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
|
||||||
|
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
|
||||||
|
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
type linkKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
linkOutgoing linkKind = iota
|
||||||
|
linkBacklink
|
||||||
|
)
|
||||||
|
|
||||||
|
type linkItem struct {
|
||||||
|
text string
|
||||||
|
entityID string
|
||||||
|
kind linkKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkPickerModel struct {
|
||||||
|
items []linkItem
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLinkPicker(body string, backlinks []db.Backlink) linkPickerModel {
|
||||||
|
var items []linkItem
|
||||||
|
|
||||||
|
for _, lt := range link.ExtractLinks(body) {
|
||||||
|
items = append(items, linkItem{text: lt, kind: linkOutgoing})
|
||||||
|
}
|
||||||
|
for _, bl := range backlinks {
|
||||||
|
label := bl.Body
|
||||||
|
if bl.Title != nil {
|
||||||
|
label = *bl.Title
|
||||||
|
} else if len(label) > 50 {
|
||||||
|
label = label[:50] + "…"
|
||||||
|
}
|
||||||
|
items = append(items, linkItem{text: label, entityID: bl.EntityID, kind: linkBacklink})
|
||||||
|
}
|
||||||
|
|
||||||
|
return linkPickerModel{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) selected() linkItem {
|
||||||
|
if len(lp.items) == 0 || lp.cursor >= len(lp.items) {
|
||||||
|
return linkItem{}
|
||||||
|
}
|
||||||
|
return lp.items[lp.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) update(key string) linkPickerModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if lp.cursor > 0 {
|
||||||
|
lp.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if lp.cursor < len(lp.items)-1 {
|
||||||
|
lp.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lp linkPickerModel) view(width int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("follow link") + "\n\n")
|
||||||
|
|
||||||
|
if len(lp.items) == 0 {
|
||||||
|
b.WriteString(hintDescStyle.Render(" no links or backlinks"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("esc:back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKind := linkKind(-1)
|
||||||
|
for i, item := range lp.items {
|
||||||
|
if item.kind != prevKind {
|
||||||
|
if item.kind == linkOutgoing {
|
||||||
|
b.WriteString(dateHeaderStyle.Render("── outgoing ──") + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(dateHeaderStyle.Render("── backlinks ──") + "\n")
|
||||||
|
}
|
||||||
|
prevKind = item.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
var label string
|
||||||
|
if item.kind == linkOutgoing {
|
||||||
|
label = "[[" + item.text + "]]"
|
||||||
|
} else {
|
||||||
|
label = "← " + item.text
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == lp.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + label))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(label))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("enter:follow esc:back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listModel struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListModel() listModel {
|
||||||
|
return listModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listModel) setEntities(entities []*db.Entity) {
|
||||||
|
l.entities = entities
|
||||||
|
l.filtered = nil
|
||||||
|
if l.cursor >= len(entities) {
|
||||||
|
l.cursor = max(0, len(entities)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listModel) setSize(width, height int) {
|
||||||
|
l.width = width
|
||||||
|
l.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) displayEntities() []*db.Entity {
|
||||||
|
if l.filtered != nil {
|
||||||
|
return l.filtered
|
||||||
|
}
|
||||||
|
return l.entities
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) selected() *db.Entity {
|
||||||
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 || l.cursor >= len(ents) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ents[l.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if l.cursor > 0 {
|
||||||
|
l.cursor--
|
||||||
|
if l.cursor < l.offset {
|
||||||
|
l.offset = l.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if l.cursor < len(l.displayEntities())-1 {
|
||||||
|
l.cursor++
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= l.offset+visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
l.cursor = 0
|
||||||
|
l.offset = 0
|
||||||
|
case "end", "G":
|
||||||
|
l.cursor = max(0, len(l.displayEntities())-1)
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
l.cursor = max(0, l.cursor-l.visibleCount())
|
||||||
|
if l.cursor < l.offset {
|
||||||
|
l.offset = l.cursor
|
||||||
|
}
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
|
||||||
|
visible := l.visibleCount()
|
||||||
|
if l.cursor >= l.offset+visible {
|
||||||
|
l.offset = l.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateGutterWidth = 9
|
||||||
|
|
||||||
|
func (l listModel) view(width int) string {
|
||||||
|
ents := l.displayEntities()
|
||||||
|
if len(ents) == 0 {
|
||||||
|
return statusStyle.Render("no entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := groupByDate(ents)
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
entityIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []displayLine
|
||||||
|
entityIdx := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
for i, e := range g.entities {
|
||||||
|
var gutter string
|
||||||
|
if i == 0 {
|
||||||
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
|
} else {
|
||||||
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderEntity(e, entityWidth)
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: line,
|
||||||
|
entityIdx: entityIdx,
|
||||||
|
})
|
||||||
|
entityIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := l.visibleCount()
|
||||||
|
offset := 0
|
||||||
|
if l.cursor >= visible {
|
||||||
|
offset = l.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 == l.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 (l listModel) visibleCount() int {
|
||||||
|
if l.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return l.height
|
||||||
|
}
|
||||||
|
|
||||||
|
type dateGroup struct {
|
||||||
|
label string
|
||||||
|
entities []*db.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupByDate(entities []*db.Entity) []dateGroup {
|
||||||
|
var groups []dateGroup
|
||||||
|
var current *dateGroup
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
label := formatDateLabel(e.CreatedAt)
|
||||||
|
if current == nil || current.label != label {
|
||||||
|
if current != nil {
|
||||||
|
groups = append(groups, *current)
|
||||||
|
}
|
||||||
|
current = &dateGroup{label: label}
|
||||||
|
}
|
||||||
|
current.entities = append(current.entities, e)
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
groups = append(groups, *current)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDateLabel(t time.Time) string {
|
||||||
|
return strings.ToLower(t.Format("Jan 2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) >= n {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", n-len(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||||
|
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
|
style := glyphStyle
|
||||||
|
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
|
||||||
|
glyphStr = "●"
|
||||||
|
style = completedGlyphStyle
|
||||||
|
}
|
||||||
|
glyph := style.Render(glyphStr)
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||||
|
body = body[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if e.Pinned {
|
||||||
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
|
}
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
for _, t := range e.Tags[:limit] {
|
||||||
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraStr := ""
|
||||||
|
if len(extras) > 0 {
|
||||||
|
extraStr = " " + strings.Join(extras, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
overhead := len(stripAnsi(line)) - len([]rune(body))
|
||||||
|
body = truncate(body, maxWidth-overhead)
|
||||||
|
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if maxLen <= 3 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripAnsi(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inEsc := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEsc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEsc {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
inEsc = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupByDate(t *testing.T) {
|
||||||
|
may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC)
|
||||||
|
may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
entities := []*db.Entity{
|
||||||
|
{ID: "1", CreatedAt: may19, Body: "a"},
|
||||||
|
{ID: "2", CreatedAt: may19b, Body: "b"},
|
||||||
|
{ID: "3", CreatedAt: may18, Body: "c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := groupByDate(entities)
|
||||||
|
if len(groups) != 2 {
|
||||||
|
t.Fatalf("expected 2 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
if len(groups[0].entities) != 2 {
|
||||||
|
t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities))
|
||||||
|
}
|
||||||
|
if len(groups[1].entities) != 1 {
|
||||||
|
t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities))
|
||||||
|
}
|
||||||
|
if groups[0].label != "may 19" {
|
||||||
|
t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupByDate_Empty(t *testing.T) {
|
||||||
|
groups := groupByDate(nil)
|
||||||
|
if len(groups) != 0 {
|
||||||
|
t.Fatalf("expected 0 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupByDate_SingleEntity(t *testing.T) {
|
||||||
|
e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}}
|
||||||
|
groups := groupByDate(e)
|
||||||
|
if len(groups) != 1 || len(groups[0].entities) != 1 {
|
||||||
|
t.Fatal("single entity should produce 1 group with 1 entity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"short enough", "hello", 10, "hello"},
|
||||||
|
{"exact length", "hello", 5, "hello"},
|
||||||
|
{"truncated", "hello world", 6, "hello…"},
|
||||||
|
{"very short max", "hello", 3, "…"},
|
||||||
|
{"unicode", "héllo wörld", 7, "héllo …"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := truncate(tt.input, tt.maxLen)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnsi(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no ansi", "hello", "hello"},
|
||||||
|
{"with color", "\x1b[31mred\x1b[0m", "red"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := stripAnsi(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promoteOption struct {
|
||||||
|
cardType db.CardType
|
||||||
|
label string
|
||||||
|
group string
|
||||||
|
}
|
||||||
|
|
||||||
|
var promoteOptions = []promoteOption{
|
||||||
|
{db.CardSnippet, "snippet", "grab"},
|
||||||
|
{db.CardNote, "note", "read"},
|
||||||
|
{db.CardLink, "link", "read"},
|
||||||
|
{db.CardDecision, "decision", "read"},
|
||||||
|
{db.CardTemplate, "template", "fill"},
|
||||||
|
{db.CardChecklist, "checklist", "fill"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteModel struct {
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
body string
|
||||||
|
suggested *db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromoteModel(entityID, body string) promoteModel {
|
||||||
|
return promoteModel{
|
||||||
|
entityID: entityID,
|
||||||
|
body: body,
|
||||||
|
suggested: carddata.DetectCardType(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) selectedType() db.CardType {
|
||||||
|
return promoteOptions[p.cursor].cardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) update(key string) promoteModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if p.cursor > 0 {
|
||||||
|
p.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if p.cursor < len(promoteOptions)-1 {
|
||||||
|
p.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) view(width int) string {
|
||||||
|
var b string
|
||||||
|
b += titleStyle.Render("promote to card") + "\n\n"
|
||||||
|
|
||||||
|
currentGroup := ""
|
||||||
|
for i, opt := range promoteOptions {
|
||||||
|
if opt.group != currentGroup {
|
||||||
|
currentGroup = opt.group
|
||||||
|
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
|
||||||
|
label := glyph + " " + opt.label
|
||||||
|
|
||||||
|
if p.suggested != nil && *p.suggested == opt.cardType {
|
||||||
|
label += " " + affordanceStyle.Render("*")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == p.cursor {
|
||||||
|
b += selectedItemStyle.Render(" "+label) + "\n"
|
||||||
|
} else {
|
||||||
|
b += listItemStyle.Render(label) + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b += "\n" + helpStyle.Render("enter:select esc:cancel")
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runStep struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runModel struct {
|
||||||
|
steps []runStep
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunModel(entityID string, cardData *string) runModel {
|
||||||
|
m := runModel{entityID: entityID}
|
||||||
|
m.steps = parseChecklist(cardData)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChecklist(cardData *string) []runStep {
|
||||||
|
if cardData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Steps []runStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*cardData), &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data.Steps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) stepsJSON() string {
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": r.steps})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) doneCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, s := range r.steps {
|
||||||
|
if s.Done {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) update(key string) runModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if r.cursor > 0 {
|
||||||
|
r.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if r.cursor < len(r.steps)-1 {
|
||||||
|
r.cursor++
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
if r.cursor < len(r.steps) {
|
||||||
|
r.steps[r.cursor].Done = !r.steps[r.cursor].Done
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
for i := range r.steps {
|
||||||
|
r.steps[i].Done = false
|
||||||
|
}
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r runModel) view(width int) string {
|
||||||
|
if len(r.steps) == 0 {
|
||||||
|
return statusStyle.Render("no steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
done := r.doneCount()
|
||||||
|
total := len(r.steps)
|
||||||
|
pct := 0
|
||||||
|
if total > 0 {
|
||||||
|
pct = done * 100 / total
|
||||||
|
}
|
||||||
|
|
||||||
|
header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct)
|
||||||
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, step := range r.steps {
|
||||||
|
var line string
|
||||||
|
if step.Done {
|
||||||
|
line = checkDoneStyle.Render("[✓] " + step.Text)
|
||||||
|
} else {
|
||||||
|
line = checkPendingStyle.Render("[ ] " + step.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == r.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseChecklist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardData *string
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{"nil data", nil, 0},
|
||||||
|
{"empty JSON", ptr("{}"), 0},
|
||||||
|
{"malformed JSON", ptr("{bad"), 0},
|
||||||
|
{"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2},
|
||||||
|
{"empty steps array", ptr(`{"steps":[]}`), 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseChecklist(tt.cardData)
|
||||||
|
if len(got) != tt.wantLen {
|
||||||
|
t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseChecklist_PreservesDoneState(t *testing.T) {
|
||||||
|
data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}`
|
||||||
|
steps := parseChecklist(&data)
|
||||||
|
if steps[0].Done {
|
||||||
|
t.Fatal("step 0 should not be done")
|
||||||
|
}
|
||||||
|
if !steps[1].Done {
|
||||||
|
t.Fatal("step 1 should be done")
|
||||||
|
}
|
||||||
|
if steps[0].Text != "first" || steps[1].Text != "second" {
|
||||||
|
t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneCount(t *testing.T) {
|
||||||
|
r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`))
|
||||||
|
if got := r.doneCount(); got != 2 {
|
||||||
|
t.Fatalf("doneCount() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepsJSON_Roundtrip(t *testing.T) {
|
||||||
|
r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`))
|
||||||
|
out := r.stepsJSON()
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Steps []runStep `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||||
|
t.Fatalf("stepsJSON() produced invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" {
|
||||||
|
t.Fatalf("roundtrip failed: %+v", parsed.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
|
||||||
|
if query == "" && len(tags) == 0 {
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
lowerTags := make([]string, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
lowerTags[i] = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*db.Entity
|
||||||
|
for _, e := range entities {
|
||||||
|
if !matchesSearch(e, query, lowerTags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesSearch(e *db.Entity, query string, tags []string) bool {
|
||||||
|
if len(tags) > 0 {
|
||||||
|
eTags := make(map[string]bool, len(e.Tags))
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
eTags[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if !eTags[t] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
haystack := strings.ToLower(e.Body)
|
||||||
|
if e.Title != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Title)
|
||||||
|
}
|
||||||
|
if e.Description != nil {
|
||||||
|
haystack += " " + strings.ToLower(*e.Description)
|
||||||
|
}
|
||||||
|
return strings.Contains(haystack, query)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestFilterEntities(t *testing.T) {
|
||||||
|
entities := []*db.Entity{
|
||||||
|
{ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}},
|
||||||
|
{ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}},
|
||||||
|
{ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}},
|
||||||
|
{ID: "4", Body: "empty tags"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
tags []string
|
||||||
|
wantIDs []string
|
||||||
|
}{
|
||||||
|
{"no filter returns all", "", nil, []string{"1", "2", "3", "4"}},
|
||||||
|
{"query matches body", "groceries", nil, []string{"1"}},
|
||||||
|
{"query case insensitive", "GROCERIES", nil, []string{"1"}},
|
||||||
|
{"query matches title", "go book", nil, []string{"2"}},
|
||||||
|
{"query matches description", "middleware", nil, []string{"3"}},
|
||||||
|
{"query no match", "nonexistent", nil, nil},
|
||||||
|
{"single tag filter", "", []string{"study"}, []string{"2"}},
|
||||||
|
{"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}},
|
||||||
|
{"multi tag filter partial miss", "", []string{"work", "food"}, nil},
|
||||||
|
{"tag filter no match", "", []string{"missing"}, nil},
|
||||||
|
{"query plus tag", "fix", []string{"work"}, []string{"3"}},
|
||||||
|
{"query plus tag mismatch", "groceries", []string{"work"}, nil},
|
||||||
|
{"entity with nil title and description", "empty", nil, []string{"4"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterEntities(entities, tt.query, tt.tags)
|
||||||
|
gotIDs := make([]string, len(got))
|
||||||
|
for i, e := range got {
|
||||||
|
gotIDs[i] = e.ID
|
||||||
|
}
|
||||||
|
if len(tt.wantIDs) == 0 && len(gotIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(gotIDs) != len(tt.wantIDs) {
|
||||||
|
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
|
||||||
|
}
|
||||||
|
for i := range gotIDs {
|
||||||
|
if gotIDs[i] != tt.wantIDs[i] {
|
||||||
|
t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesSearch_NilFields(t *testing.T) {
|
||||||
|
e := &db.Entity{Body: "hello world"}
|
||||||
|
if !matchesSearch(e, "hello", nil) {
|
||||||
|
t.Fatal("should match body")
|
||||||
|
}
|
||||||
|
if matchesSearch(e, "title", nil) {
|
||||||
|
t.Fatal("should not match nil title")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hint struct {
|
||||||
|
key string
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHints(hints []hint) string {
|
||||||
|
parts := make([]string, len(hints))
|
||||||
|
for i, h := range hints {
|
||||||
|
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTab(label, key string, active bool) string {
|
||||||
|
if active {
|
||||||
|
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
|
||||||
|
}
|
||||||
|
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStatusBar(m model, width int) string {
|
||||||
|
var leftParts []string
|
||||||
|
|
||||||
|
if m.status != "" {
|
||||||
|
leftParts = append(leftParts, statusStyle.Render(m.status))
|
||||||
|
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
|
||||||
|
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
|
||||||
|
} else {
|
||||||
|
leftParts = append(leftParts, statusStyle.Render(countText(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
|
||||||
|
right := renderHints(contextHints(m))
|
||||||
|
|
||||||
|
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
|
||||||
|
if gap < 0 {
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pad := lipgloss.NewStyle().Width(gap).Render("")
|
||||||
|
return leftRendered + pad + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func countText(m model) string {
|
||||||
|
var total int
|
||||||
|
if m.mode == modeCards {
|
||||||
|
total = len(m.cards.filtered)
|
||||||
|
} else {
|
||||||
|
total = len(m.list.displayEntities())
|
||||||
|
}
|
||||||
|
if m.filterTag != "" {
|
||||||
|
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d entities", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextHints(m model) []hint {
|
||||||
|
switch m.state {
|
||||||
|
case stateDetail:
|
||||||
|
switch m.detail.mode {
|
||||||
|
case detailRun:
|
||||||
|
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
|
||||||
|
case detailFill:
|
||||||
|
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
|
||||||
|
default:
|
||||||
|
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
|
||||||
|
}
|
||||||
|
case stateTagFilter:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
|
case stateConfirm:
|
||||||
|
return []hint{{"y", "confirm"}, {"n", "cancel"}}
|
||||||
|
case statePromote:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
|
||||||
|
case stateAbsorb:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
|
||||||
|
case stateStumble:
|
||||||
|
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.focus {
|
||||||
|
case focusCapture:
|
||||||
|
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
|
||||||
|
case focusTagRail:
|
||||||
|
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
|
||||||
|
case focusDetail:
|
||||||
|
if m.splitDetail {
|
||||||
|
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
|
||||||
|
}
|
||||||
|
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
|
||||||
|
default:
|
||||||
|
if m.splitDetail {
|
||||||
|
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
|
}
|
||||||
|
if m.mode == modeCards {
|
||||||
|
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle lipgloss.Style
|
||||||
|
statusStyle lipgloss.Style
|
||||||
|
listItemStyle lipgloss.Style
|
||||||
|
selectedItemStyle lipgloss.Style
|
||||||
|
glyphStyle lipgloss.Style
|
||||||
|
completedGlyphStyle lipgloss.Style
|
||||||
|
tagStyle lipgloss.Style
|
||||||
|
idStyle lipgloss.Style
|
||||||
|
inputPromptStyle lipgloss.Style
|
||||||
|
detailHeaderStyle lipgloss.Style
|
||||||
|
detailBodyStyle lipgloss.Style
|
||||||
|
helpStyle lipgloss.Style
|
||||||
|
errorStyle lipgloss.Style
|
||||||
|
dateHeaderStyle lipgloss.Style
|
||||||
|
pinnedStyle lipgloss.Style
|
||||||
|
filterPillStyle lipgloss.Style
|
||||||
|
helpKeyStyle lipgloss.Style
|
||||||
|
helpDescStyle lipgloss.Style
|
||||||
|
affordanceStyle lipgloss.Style
|
||||||
|
useCountStyle lipgloss.Style
|
||||||
|
modeStyle lipgloss.Style
|
||||||
|
detailLabelStyle lipgloss.Style
|
||||||
|
detailValueStyle lipgloss.Style
|
||||||
|
checkDoneStyle lipgloss.Style
|
||||||
|
checkPendingStyle lipgloss.Style
|
||||||
|
searchPillStyle lipgloss.Style
|
||||||
|
gutterStyle lipgloss.Style
|
||||||
|
drawerBorderStyle lipgloss.Style
|
||||||
|
drawerHintsStyle lipgloss.Style
|
||||||
|
drawerPreviewStyle lipgloss.Style
|
||||||
|
separatorStyle lipgloss.Style
|
||||||
|
hintKeyStyle lipgloss.Style
|
||||||
|
hintDescStyle lipgloss.Style
|
||||||
|
railHeaderStyle lipgloss.Style
|
||||||
|
railTagStyle lipgloss.Style
|
||||||
|
railActiveTagStyle lipgloss.Style
|
||||||
|
railCountStyle lipgloss.Style
|
||||||
|
stumbleAgeStyle lipgloss.Style
|
||||||
|
acSelectedStyle lipgloss.Style
|
||||||
|
acItemStyle lipgloss.Style
|
||||||
|
backlinkStyle lipgloss.Style
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTheme() {
|
||||||
|
t := activeTheme()
|
||||||
|
accent := lipgloss.Color(t.Accent)
|
||||||
|
dim := lipgloss.Color(t.Dim)
|
||||||
|
muted := lipgloss.Color(t.Muted)
|
||||||
|
ok := lipgloss.Color(t.Ok)
|
||||||
|
todo := lipgloss.Color(t.Todo)
|
||||||
|
event := lipgloss.Color(t.Event)
|
||||||
|
remind := lipgloss.Color(t.Remind)
|
||||||
|
danger := lipgloss.Color(t.Danger)
|
||||||
|
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
|
||||||
|
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("›")
|
||||||
|
glyphStyle = lipgloss.NewStyle().Width(2)
|
||||||
|
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
|
||||||
|
tagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
idStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
|
||||||
|
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
|
||||||
|
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
|
||||||
|
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
|
||||||
|
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
|
||||||
|
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||||
|
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
|
||||||
|
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
|
||||||
|
useCountStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
|
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
|
||||||
|
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
|
||||||
|
gutterStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
|
||||||
|
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
|
||||||
|
separatorStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
|
||||||
|
railTagStyle = lipgloss.NewStyle().Foreground(ok)
|
||||||
|
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||||
|
railCountStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
|
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
|
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tagRailModel struct {
|
||||||
|
tags []db.TagCount
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
activeTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTagRailModel() tagRailModel {
|
||||||
|
return tagRailModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tagRailModel) setTags(tags []db.TagCount) {
|
||||||
|
r.tags = tags
|
||||||
|
if r.cursor >= len(tags) {
|
||||||
|
r.cursor = max(0, len(tags)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tagRailModel) setSize(width, height int) {
|
||||||
|
r.width = width
|
||||||
|
r.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) selectedTag() string {
|
||||||
|
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.tags[r.cursor].Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) update(key string) tagRailModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if r.cursor > 0 {
|
||||||
|
r.cursor--
|
||||||
|
if r.cursor < r.offset {
|
||||||
|
r.offset = r.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if r.cursor < len(r.tags)-1 {
|
||||||
|
r.cursor++
|
||||||
|
visible := r.visibleCount()
|
||||||
|
if r.cursor >= r.offset+visible {
|
||||||
|
r.offset = r.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) visibleCount() int {
|
||||||
|
v := r.height - 2
|
||||||
|
if v <= 0 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r tagRailModel) view(focused bool) string {
|
||||||
|
w := r.width
|
||||||
|
if w <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
headerStyle := railHeaderStyle
|
||||||
|
if focused {
|
||||||
|
headerStyle = headerStyle.Foreground(lipgloss.Color(activeTheme().Accent))
|
||||||
|
}
|
||||||
|
b.WriteString(headerStyle.Render("tags"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if len(r.tags) == 0 {
|
||||||
|
b.WriteString(hintDescStyle.Render(" no tags"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := r.visibleCount()
|
||||||
|
end := min(r.offset+visible, len(r.tags))
|
||||||
|
|
||||||
|
countW := 0
|
||||||
|
for _, tc := range r.tags {
|
||||||
|
cw := len(fmt.Sprintf("%d", tc.Count))
|
||||||
|
if cw > countW {
|
||||||
|
countW = cw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nameW := w - countW - 3
|
||||||
|
if nameW < 4 {
|
||||||
|
nameW = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := r.offset; i < end; i++ {
|
||||||
|
tc := r.tags[i]
|
||||||
|
name := "#" + tc.Tag
|
||||||
|
if len(name) > nameW {
|
||||||
|
name = name[:nameW-1] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
count := fmt.Sprintf("%*d", countW, tc.Count)
|
||||||
|
gap := w - len(name) - len(count) - 1
|
||||||
|
if gap < 1 {
|
||||||
|
gap = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var line string
|
||||||
|
if i == r.cursor && focused {
|
||||||
|
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
|
||||||
|
} else if tc.Tag == r.activeTag {
|
||||||
|
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||||
|
} else {
|
||||||
|
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(line)
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Theme struct {
|
||||||
|
Name string
|
||||||
|
Dark bool
|
||||||
|
Accent string
|
||||||
|
Dim string
|
||||||
|
Muted string
|
||||||
|
Ok string
|
||||||
|
Todo string
|
||||||
|
Event string
|
||||||
|
Remind string
|
||||||
|
Danger string
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes = []Theme{
|
||||||
|
{Name: "dark", Dark: true, Accent: "#c8942a", Dim: "#504840", Muted: "#8c8070", Ok: "#7aab72", Todo: "#d4a84b", Event: "#6898c8", Remind: "#c8784a", Danger: "#b85858"},
|
||||||
|
{Name: "tinycard", Dark: true, Accent: "#ad8ee6", Dim: "#555a6a", Muted: "#8b90a0", Ok: "#4ade80", Todo: "#fbbf24", Event: "#22d3ee", Remind: "#e8845a", Danger: "#ef4444"},
|
||||||
|
{Name: "catppuccin", Dark: true, Accent: "#cba6f7", Dim: "#6c7086", Muted: "#a6adc8", Ok: "#a6e3a1", Todo: "#f9e2af", Event: "#89b4fa", Remind: "#fab387", Danger: "#f38ba8"},
|
||||||
|
{Name: "nord", Dark: true, Accent: "#88c0d0", Dim: "#4c566a", Muted: "#d8dee9", Ok: "#a3be8c", Todo: "#ebcb8b", Event: "#81a1c1", Remind: "#d08770", Danger: "#bf616a"},
|
||||||
|
{Name: "dracula", Dark: true, Accent: "#bd93f9", Dim: "#6272a4", Muted: "#bfbfbf", Ok: "#50fa7b", Todo: "#f1fa8c", Event: "#8be9fd", Remind: "#ffb86c", Danger: "#ff5555"},
|
||||||
|
{Name: "gruvbox", Dark: true, Accent: "#fabd2f", Dim: "#665c54", Muted: "#a89984", Ok: "#b8bb26", Todo: "#fabd2f", Event: "#83a598", Remind: "#fe8019", Danger: "#fb4934"},
|
||||||
|
{Name: "rosepine", Dark: true, Accent: "#c4a7e7", Dim: "#6e6a86", Muted: "#908caa", Ok: "#a6da95", Todo: "#f6c177", Event: "#31748f", Remind: "#ea9a97", Danger: "#eb6f92"},
|
||||||
|
{Name: "tokyonight", Dark: true, Accent: "#7aa2f7", Dim: "#565f89", Muted: "#a9b1d6", Ok: "#9ece6a", Todo: "#e0af68", Event: "#7aa2f7", Remind: "#ff9e64", Danger: "#f7768e"},
|
||||||
|
{Name: "solarized", Dark: true, Accent: "#268bd2", Dim: "#586e75", Muted: "#657b83", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||||
|
{Name: "paper", Dark: false, Accent: "#8a6018", Dim: "#a09080", Muted: "#6a5e50", Ok: "#2a6828", Todo: "#7a5c00", Event: "#245890", Remind: "#984020", Danger: "#882030"},
|
||||||
|
{Name: "catppuccin-latte", Dark: false, Accent: "#8839ef", Dim: "#9ca0b0", Muted: "#6c6f85", Ok: "#40a02b", Todo: "#df8e1d", Event: "#1e66f5", Remind: "#fe640b", Danger: "#d20f39"},
|
||||||
|
{Name: "rosepine-dawn", Dark: false, Accent: "#907aa9", Dim: "#9893a5", Muted: "#797593", Ok: "#56949f", Todo: "#ea9d34", Event: "#286983", Remind: "#d7827e", Danger: "#b4637a"},
|
||||||
|
{Name: "solarized-light", Dark: false, Accent: "#268bd2", Dim: "#93a1a1", Muted: "#586e75", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeThemeIndex int
|
||||||
|
|
||||||
|
func activeTheme() Theme {
|
||||||
|
return themes[activeThemeIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleTheme() Theme {
|
||||||
|
activeThemeIndex = (activeThemeIndex + 1) % len(themes)
|
||||||
|
applyTheme()
|
||||||
|
saveTheme()
|
||||||
|
return themes[activeThemeIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func glamourStyle() string {
|
||||||
|
if themes[activeThemeIndex].Dark {
|
||||||
|
return "dark"
|
||||||
|
}
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
func themePath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".nib", "theme")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTheme() {
|
||||||
|
p := themePath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(string(data))
|
||||||
|
for i, t := range themes {
|
||||||
|
if t.Name == name {
|
||||||
|
activeThemeIndex = i
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTheme() {
|
||||||
|
p := themePath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(p, []byte(themes[activeThemeIndex].Name+"\n"), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(store *db.Store) error {
|
||||||
|
m := newModel(store)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+217
-24
@@ -4,19 +4,22 @@
|
|||||||
const GLYPHS = {
|
const GLYPHS = {
|
||||||
note: '—', todo: '○', event: '◇', reminder: '△',
|
note: '—', todo: '○', event: '◇', reminder: '△',
|
||||||
snippet: '◆', template: '◈', checklist: '☐',
|
snippet: '◆', template: '◈', checklist: '☐',
|
||||||
decision: '⚖', link: '↗',
|
decision: '⚖', link: '↗', note: '¶',
|
||||||
};
|
};
|
||||||
|
|
||||||
const GLYPH_CLASSES = {
|
const GLYPH_CLASSES = {
|
||||||
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
|
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
|
||||||
snippet: 'glyph-snippet', template: 'glyph-template',
|
snippet: 'glyph-snippet', template: 'glyph-template',
|
||||||
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
||||||
link: 'glyph-link',
|
link: 'glyph-link', note: 'glyph-note',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
|
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
|
||||||
|
const READ_TYPES = ['note', 'link', 'decision'];
|
||||||
|
const FILL_TYPES = ['template', 'checklist'];
|
||||||
|
const GRAB_TYPES = ['snippet'];
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
view: 'stream',
|
view: 'stream',
|
||||||
@@ -121,7 +124,7 @@
|
|||||||
|
|
||||||
// ========== Grammar parser (mirrors Go parser) ==========
|
// ========== Grammar parser (mirrors Go parser) ==========
|
||||||
|
|
||||||
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
|
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link', note: 'note', n: 'note' };
|
||||||
|
|
||||||
function validateTime(s) {
|
function validateTime(s) {
|
||||||
const parts = s.split(':');
|
const parts = s.split(':');
|
||||||
@@ -363,7 +366,7 @@
|
|||||||
html += '<div class="rail-lbl">intent</div>';
|
html += '<div class="rail-lbl">intent</div>';
|
||||||
for (const k of ['grab', 'read', 'fill']) {
|
for (const k of ['grab', 'read', 'fill']) {
|
||||||
const on = state.intent === k ? ' on' : '';
|
const on = state.intent === k ? ' on' : '';
|
||||||
const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length;
|
const count = k === 'grab' ? state.entities.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type)).length : k === 'read' ? state.entities.filter(e => READ_TYPES.includes(e.card_type)).length : state.entities.filter(e => FILL_TYPES.includes(e.card_type)).length;
|
||||||
html += `<button class="rail-item${on}" data-intent="${k}">`;
|
html += `<button class="rail-item${on}" data-intent="${k}">`;
|
||||||
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
|
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
|
||||||
html += '<span class="rail-dot"></span>';
|
html += '<span class="rail-dot"></span>';
|
||||||
@@ -655,6 +658,25 @@
|
|||||||
</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 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 = '';
|
||||||
@@ -664,12 +686,65 @@
|
|||||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();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 primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy</button>`;
|
||||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();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="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
if (e.card_type) {
|
||||||
|
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||||
|
const hasDecision = data.chose != null;
|
||||||
|
const hasSteps = data.steps && data.steps.length;
|
||||||
|
const hasLink = !!data.url;
|
||||||
|
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||||
|
|
||||||
|
if (hasDecision) {
|
||||||
|
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||||
|
<div class="peek-sec-inner peek-decision">
|
||||||
|
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||||
|
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||||
|
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (hasLink && !hasDecision) {
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">link</div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (hasSteps) {
|
||||||
|
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||||
|
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||||
|
</div>`;
|
||||||
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||||
|
}
|
||||||
|
if (hasFill) {
|
||||||
|
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||||
|
}
|
||||||
|
if (!hasDecision && e.body) {
|
||||||
|
const lang = data.lang || '';
|
||||||
|
const isCode = lang || e.card_type === 'snippet';
|
||||||
|
const bodyHtml = isCode
|
||||||
|
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||||
|
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
|
||||||
|
content += `<div class="peek-sec">
|
||||||
|
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
|
||||||
|
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="exp-inner">
|
return `<div class="exp-inner">
|
||||||
<div class="exp-body md">${renderMd(e.body || '')}</div>
|
${content}
|
||||||
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
|
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
|
||||||
<div class="exp-acts">${actions}</div>
|
<div class="exp-acts">${actions}</div>
|
||||||
<div class="exp-toolbar">
|
<div class="exp-toolbar">
|
||||||
@@ -1191,9 +1266,9 @@
|
|||||||
|
|
||||||
async function loadEntities() {
|
async function loadEntities() {
|
||||||
const params = buildListParams(0);
|
const params = buildListParams(0);
|
||||||
const results = await api.listEntities(params);
|
const resp = await api.listEntities(params);
|
||||||
state.entities = results;
|
state.entities = resp.data;
|
||||||
state.hasMore = results.length === PAGE_SIZE;
|
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||||
state.selectedIndex = -1;
|
state.selectedIndex = -1;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
renderDetailPane();
|
renderDetailPane();
|
||||||
@@ -1202,9 +1277,9 @@
|
|||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
const params = buildListParams(state.entities.length);
|
const params = buildListParams(state.entities.length);
|
||||||
const results = await api.listEntities(params);
|
const resp = await api.listEntities(params);
|
||||||
state.entities = state.entities.concat(results);
|
state.entities = state.entities.concat(resp.data);
|
||||||
state.hasMore = results.length === PAGE_SIZE;
|
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,9 +1392,13 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteEntity(id) {
|
async deleteEntity(id) {
|
||||||
|
const prevIdx = state.selectedIndex;
|
||||||
await api.deleteEntity(id);
|
await api.deleteEntity(id);
|
||||||
await loadEntities();
|
await loadEntities();
|
||||||
await loadTags();
|
await loadTags();
|
||||||
|
if (state.entities.length > 0) {
|
||||||
|
selectEntity(Math.min(prevIdx, state.entities.length - 1));
|
||||||
|
}
|
||||||
showToast('deleted');
|
showToast('deleted');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1397,11 +1476,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();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1440,7 +1539,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');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1520,7 +1633,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1785,9 +1908,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function filterByIntent(entities) {
|
function filterByIntent(entities) {
|
||||||
if (state.view !== 'cards' || state.intent === 'grab') return entities;
|
if (state.view !== 'cards') return entities;
|
||||||
if (state.intent === 'read') return entities.filter(e => e.card_data);
|
if (state.intent === 'grab') return entities.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type));
|
||||||
if (state.intent === 'fill') return entities.filter(e => e.body && /\$\{.+\}/.test(e.body));
|
if (state.intent === 'read') return entities.filter(e => READ_TYPES.includes(e.card_type));
|
||||||
|
if (state.intent === 'fill') return entities.filter(e => FILL_TYPES.includes(e.card_type));
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1822,7 +1946,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) {
|
||||||
@@ -1831,20 +1956,88 @@
|
|||||||
|
|
||||||
// ========== Theme ==========
|
// ========== Theme ==========
|
||||||
|
|
||||||
const THEMES = ['dark', 'paper', 'tinycard'];
|
const THEMES_DARK = [
|
||||||
const THEME_ICONS = { dark: '◑', paper: '◐', tinycard: '◈' };
|
{ id: 'dark', label: 'Noir', swatch: '#c8942a' },
|
||||||
|
{ id: 'tinycard', label: 'Tinycard', swatch: '#ad8ee6' },
|
||||||
|
{ id: 'catppuccin', label: 'Catppuccin', swatch: '#cba6f7' },
|
||||||
|
{ id: 'nord', label: 'Nord', swatch: '#88c0d0' },
|
||||||
|
{ id: 'dracula', label: 'Dracula', swatch: '#bd93f9' },
|
||||||
|
{ id: 'gruvbox', label: 'Gruvbox', swatch: '#fabd2f' },
|
||||||
|
{ id: 'rosepine', label: 'Rosé Pine', swatch: '#c4a7e7' },
|
||||||
|
{ id: 'tokyonight', label: 'Tokyo Night', swatch: '#7aa2f7' },
|
||||||
|
{ id: 'solarized', label: 'Solarized', swatch: '#268bd2' },
|
||||||
|
];
|
||||||
|
const THEMES_LIGHT = [
|
||||||
|
{ id: 'paper', label: 'Paper', swatch: '#8a6018' },
|
||||||
|
{ id: 'catppuccin-latte', label: 'Catppuccin Latte', swatch: '#8839ef' },
|
||||||
|
{ id: 'rosepine-dawn', label: 'Rosé Pine Dawn', swatch: '#907aa9' },
|
||||||
|
{ id: 'solarized-light', label: 'Solarized Light', swatch: '#268bd2' },
|
||||||
|
];
|
||||||
|
const ALL_THEME_IDS = [...THEMES_DARK, ...THEMES_LIGHT].map(t => t.id);
|
||||||
|
|
||||||
const themeToggle = $('#theme-toggle');
|
const themeToggle = $('#theme-toggle');
|
||||||
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
|
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
|
||||||
if (!THEMES.includes(nibTheme)) nibTheme = 'dark';
|
if (!ALL_THEME_IDS.includes(nibTheme)) nibTheme = 'dark';
|
||||||
document.documentElement.setAttribute('data-theme', nibTheme);
|
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||||
themeToggle.textContent = THEME_ICONS[nibTheme];
|
themeToggle.textContent = '◑';
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
const popover = document.createElement('div');
|
||||||
nibTheme = THEMES[(THEMES.indexOf(nibTheme) + 1) % THEMES.length];
|
popover.className = 'theme-popover';
|
||||||
|
function buildPopover() {
|
||||||
|
popover.innerHTML = '';
|
||||||
|
const addSection = (label, themes) => {
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'theme-popover-label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
popover.appendChild(lbl);
|
||||||
|
themes.forEach(t => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'theme-popover-item' + (t.id === nibTheme ? ' active' : '');
|
||||||
|
item.dataset.theme = t.id;
|
||||||
|
item.innerHTML = `<span class="theme-popover-swatch" style="background:${t.swatch}"></span>${t.label}`;
|
||||||
|
popover.appendChild(item);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addSection('Dark', THEMES_DARK);
|
||||||
|
addSection('Light', THEMES_LIGHT);
|
||||||
|
}
|
||||||
|
buildPopover();
|
||||||
|
themeToggle.appendChild(popover);
|
||||||
|
|
||||||
|
let previewTheme = null;
|
||||||
|
themeToggle.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.theme-popover-item')) return;
|
||||||
|
popover.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('mouseover', (e) => {
|
||||||
|
const item = e.target.closest('.theme-popover-item');
|
||||||
|
if (!item) return;
|
||||||
|
previewTheme = item.dataset.theme;
|
||||||
|
document.documentElement.setAttribute('data-theme', previewTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('mouseleave', () => {
|
||||||
|
previewTheme = null;
|
||||||
|
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.theme-popover-item');
|
||||||
|
if (!item) return;
|
||||||
|
nibTheme = item.dataset.theme;
|
||||||
|
previewTheme = null;
|
||||||
document.documentElement.setAttribute('data-theme', nibTheme);
|
document.documentElement.setAttribute('data-theme', nibTheme);
|
||||||
localStorage.setItem('nib:theme', nibTheme);
|
localStorage.setItem('nib:theme', nibTheme);
|
||||||
themeToggle.textContent = THEME_ICONS[nibTheme];
|
popover.classList.remove('open');
|
||||||
|
buildPopover();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!themeToggle.contains(e.target)) popover.classList.remove('open');
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') popover.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Init ==========
|
// ========== Init ==========
|
||||||
|
|||||||
+34
-19
@@ -44,31 +44,45 @@
|
|||||||
<h3>promote to card</h3>
|
<h3>promote to card</h3>
|
||||||
<div class="modal-sub" id="promote-sub"></div>
|
<div class="modal-sub" id="promote-sub"></div>
|
||||||
<div class="type-picker">
|
<div class="type-picker">
|
||||||
<button data-type="snippet" class="type-btn">
|
<div class="type-col">
|
||||||
<span class="type-glyph glyph-snippet">◆</span>
|
<div class="type-col-lbl">read</div>
|
||||||
<span class="type-name">snippet</span>
|
<button data-type="note" class="type-btn">
|
||||||
<span class="type-hint">quick reference, command, code</span>
|
<span class="type-glyph glyph-note">¶</span>
|
||||||
</button>
|
<span class="type-name">note</span>
|
||||||
<button data-type="template" class="type-btn">
|
<span class="type-hint">markdown content</span>
|
||||||
<span class="type-glyph glyph-template">◈</span>
|
|
||||||
<span class="type-name">template</span>
|
|
||||||
<span class="type-hint">fillable with ${slot}s</span>
|
|
||||||
</button>
|
|
||||||
<button data-type="checklist" class="type-btn">
|
|
||||||
<span class="type-glyph glyph-checklist">☐</span>
|
|
||||||
<span class="type-name">checklist</span>
|
|
||||||
<span class="type-hint">step-by-step process</span>
|
|
||||||
</button>
|
|
||||||
<button data-type="decision" class="type-btn">
|
|
||||||
<span class="type-glyph glyph-decision">⚖</span>
|
|
||||||
<span class="type-name">decision</span>
|
|
||||||
<span class="type-hint">record a choice + rationale</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button data-type="link" class="type-btn">
|
<button data-type="link" class="type-btn">
|
||||||
<span class="type-glyph glyph-link">↗</span>
|
<span class="type-glyph glyph-link">↗</span>
|
||||||
<span class="type-name">link</span>
|
<span class="type-name">link</span>
|
||||||
<span class="type-hint">reference URL</span>
|
<span class="type-hint">reference URL</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button data-type="decision" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-decision">⚖</span>
|
||||||
|
<span class="type-name">decision</span>
|
||||||
|
<span class="type-hint">choice + rationale</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="type-col">
|
||||||
|
<div class="type-col-lbl">grab</div>
|
||||||
|
<button data-type="snippet" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-snippet">◆</span>
|
||||||
|
<span class="type-name">snippet</span>
|
||||||
|
<span class="type-hint">code, command, text</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="type-col">
|
||||||
|
<div class="type-col-lbl">fill</div>
|
||||||
|
<button data-type="template" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-template">◈</span>
|
||||||
|
<span class="type-name">template</span>
|
||||||
|
<span class="type-hint">fillable ${slot}s</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="checklist" class="type-btn">
|
||||||
|
<span class="type-glyph glyph-checklist">☐</span>
|
||||||
|
<span class="type-name">checklist</span>
|
||||||
|
<span class="type-hint">step-by-step</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close">esc to cancel</button>
|
<button class="modal-close">esc to cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,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>
|
||||||
|
|||||||
+334
-25
@@ -74,6 +74,226 @@
|
|||||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #1e1e2e;
|
||||||
|
--surf: #181825;
|
||||||
|
--raised: #313244;
|
||||||
|
--border: #45475a;
|
||||||
|
--soft: #252536;
|
||||||
|
--text: #cdd6f4;
|
||||||
|
--muted: #a6adc8;
|
||||||
|
--dim: #6c7086;
|
||||||
|
--accent: #cba6f7;
|
||||||
|
--a-bg: rgba(203,166,247,.09);
|
||||||
|
--a-str: rgba(203,166,247,.22);
|
||||||
|
--todo: #f9e2af;
|
||||||
|
--note: #94e2d5;
|
||||||
|
--event: #89b4fa;
|
||||||
|
--remind: #fab387;
|
||||||
|
--ok: #a6e3a1;
|
||||||
|
--danger: #f38ba8;
|
||||||
|
--lineage: #f5c2e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="nord"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #2e3440;
|
||||||
|
--surf: #3b4252;
|
||||||
|
--raised: #434c5e;
|
||||||
|
--border: #4c566a;
|
||||||
|
--soft: #363d4a;
|
||||||
|
--text: #eceff4;
|
||||||
|
--muted: #d8dee9;
|
||||||
|
--dim: #4c566a;
|
||||||
|
--accent: #88c0d0;
|
||||||
|
--a-bg: rgba(136,192,208,.09);
|
||||||
|
--a-str: rgba(136,192,208,.22);
|
||||||
|
--todo: #ebcb8b;
|
||||||
|
--note: #8fbcbb;
|
||||||
|
--event: #81a1c1;
|
||||||
|
--remind: #d08770;
|
||||||
|
--ok: #a3be8c;
|
||||||
|
--danger: #bf616a;
|
||||||
|
--lineage: #b48ead;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dracula"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #282a36;
|
||||||
|
--surf: #21222c;
|
||||||
|
--raised: #44475a;
|
||||||
|
--border: #6272a4;
|
||||||
|
--soft: #343746;
|
||||||
|
--text: #f8f8f2;
|
||||||
|
--muted: #bfbfbf;
|
||||||
|
--dim: #6272a4;
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--a-bg: rgba(189,147,249,.09);
|
||||||
|
--a-str: rgba(189,147,249,.22);
|
||||||
|
--todo: #f1fa8c;
|
||||||
|
--note: #8be9fd;
|
||||||
|
--event: #8be9fd;
|
||||||
|
--remind: #ffb86c;
|
||||||
|
--ok: #50fa7b;
|
||||||
|
--danger: #ff5555;
|
||||||
|
--lineage: #ff79c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="gruvbox"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #282828;
|
||||||
|
--surf: #1d2021;
|
||||||
|
--raised: #3c3836;
|
||||||
|
--border: #504945;
|
||||||
|
--soft: #32302f;
|
||||||
|
--text: #ebdbb2;
|
||||||
|
--muted: #a89984;
|
||||||
|
--dim: #665c54;
|
||||||
|
--accent: #fabd2f;
|
||||||
|
--a-bg: rgba(250,189,47,.09);
|
||||||
|
--a-str: rgba(250,189,47,.22);
|
||||||
|
--todo: #fabd2f;
|
||||||
|
--note: #8ec07c;
|
||||||
|
--event: #83a598;
|
||||||
|
--remind: #fe8019;
|
||||||
|
--ok: #b8bb26;
|
||||||
|
--danger: #fb4934;
|
||||||
|
--lineage: #d3869b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="rosepine"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #191724;
|
||||||
|
--surf: #1f1d2e;
|
||||||
|
--raised: #26233a;
|
||||||
|
--border: #403d52;
|
||||||
|
--soft: #211f30;
|
||||||
|
--text: #e0def4;
|
||||||
|
--muted: #908caa;
|
||||||
|
--dim: #6e6a86;
|
||||||
|
--accent: #c4a7e7;
|
||||||
|
--a-bg: rgba(196,167,231,.09);
|
||||||
|
--a-str: rgba(196,167,231,.22);
|
||||||
|
--todo: #f6c177;
|
||||||
|
--note: #9ccfd8;
|
||||||
|
--event: #31748f;
|
||||||
|
--remind: #ea9a97;
|
||||||
|
--ok: #a6da95;
|
||||||
|
--danger: #eb6f92;
|
||||||
|
--lineage: #c4a7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="tokyonight"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #1a1b26;
|
||||||
|
--surf: #16161e;
|
||||||
|
--raised: #292e42;
|
||||||
|
--border: #3b4261;
|
||||||
|
--soft: #1f2335;
|
||||||
|
--text: #c0caf5;
|
||||||
|
--muted: #a9b1d6;
|
||||||
|
--dim: #565f89;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
--a-bg: rgba(122,162,247,.09);
|
||||||
|
--a-str: rgba(122,162,247,.22);
|
||||||
|
--todo: #e0af68;
|
||||||
|
--note: #7dcfff;
|
||||||
|
--event: #7aa2f7;
|
||||||
|
--remind: #ff9e64;
|
||||||
|
--ok: #9ece6a;
|
||||||
|
--danger: #f7768e;
|
||||||
|
--lineage: #bb9af7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #002b36;
|
||||||
|
--surf: #073642;
|
||||||
|
--raised: #094552;
|
||||||
|
--border: #586e75;
|
||||||
|
--soft: #05303b;
|
||||||
|
--text: #839496;
|
||||||
|
--muted: #657b83;
|
||||||
|
--dim: #586e75;
|
||||||
|
--accent: #268bd2;
|
||||||
|
--a-bg: rgba(38,139,210,.09);
|
||||||
|
--a-str: rgba(38,139,210,.22);
|
||||||
|
--todo: #b58900;
|
||||||
|
--note: #2aa198;
|
||||||
|
--event: #268bd2;
|
||||||
|
--remind: #cb4b16;
|
||||||
|
--ok: #859900;
|
||||||
|
--danger: #dc322f;
|
||||||
|
--lineage: #6c71c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin-latte"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #eff1f5;
|
||||||
|
--surf: #e6e9ef;
|
||||||
|
--raised: #dce0e8;
|
||||||
|
--border: #ccd0da;
|
||||||
|
--soft: #e4e7ed;
|
||||||
|
--text: #4c4f69;
|
||||||
|
--muted: #6c6f85;
|
||||||
|
--dim: #9ca0b0;
|
||||||
|
--accent: #8839ef;
|
||||||
|
--a-bg: rgba(136,57,239,.08);
|
||||||
|
--a-str: rgba(136,57,239,.18);
|
||||||
|
--todo: #df8e1d;
|
||||||
|
--note: #179299;
|
||||||
|
--event: #1e66f5;
|
||||||
|
--remind: #fe640b;
|
||||||
|
--ok: #40a02b;
|
||||||
|
--danger: #d20f39;
|
||||||
|
--lineage: #ea76cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="rosepine-dawn"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #faf4ed;
|
||||||
|
--surf: #fffaf3;
|
||||||
|
--raised: #f2e9e1;
|
||||||
|
--border: #dfdad9;
|
||||||
|
--soft: #f4ede8;
|
||||||
|
--text: #575279;
|
||||||
|
--muted: #797593;
|
||||||
|
--dim: #9893a5;
|
||||||
|
--accent: #907aa9;
|
||||||
|
--a-bg: rgba(144,122,169,.08);
|
||||||
|
--a-str: rgba(144,122,169,.18);
|
||||||
|
--todo: #ea9d34;
|
||||||
|
--note: #56949f;
|
||||||
|
--event: #286983;
|
||||||
|
--remind: #d7827e;
|
||||||
|
--ok: #56949f;
|
||||||
|
--danger: #b4637a;
|
||||||
|
--lineage: #907aa9;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #fdf6e3;
|
||||||
|
--surf: #eee8d5;
|
||||||
|
--raised: #e4ddc8;
|
||||||
|
--border: #d3cbb7;
|
||||||
|
--soft: #f5eedb;
|
||||||
|
--text: #657b83;
|
||||||
|
--muted: #586e75;
|
||||||
|
--dim: #93a1a1;
|
||||||
|
--accent: #268bd2;
|
||||||
|
--a-bg: rgba(38,139,210,.08);
|
||||||
|
--a-str: rgba(38,139,210,.18);
|
||||||
|
--todo: #b58900;
|
||||||
|
--note: #2aa198;
|
||||||
|
--event: #268bd2;
|
||||||
|
--remind: #cb4b16;
|
||||||
|
--ok: #859900;
|
||||||
|
--danger: #dc322f;
|
||||||
|
--lineage: #6c71c4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── RESET ──────────────────────────────────────────── */
|
/* ── RESET ──────────────────────────────────────────── */
|
||||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { height: 100%; overflow: hidden; }
|
html, body { height: 100%; overflow: hidden; }
|
||||||
@@ -162,6 +382,7 @@ nav { display: flex; gap: 2px; }
|
|||||||
#search-input::placeholder { color: var(--dim); }
|
#search-input::placeholder { color: var(--dim); }
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
|
position: relative;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r1);
|
border-radius: var(--r1);
|
||||||
@@ -174,6 +395,60 @@ nav { display: flex; gap: 2px; }
|
|||||||
|
|
||||||
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.theme-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 900;
|
||||||
|
background: var(--surf);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r3);
|
||||||
|
padding: 8px 0;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.3);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover.open { display: block; }
|
||||||
|
|
||||||
|
.theme-popover-label {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-item:hover {
|
||||||
|
background: var(--a-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── MAIN LAYOUT ────────────────────────────────────── */
|
/* ── MAIN LAYOUT ────────────────────────────────────── */
|
||||||
main {
|
main {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -414,7 +689,8 @@ main.focus-peek .resize-handle { visibility: hidden; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-item.exp-full .exp-body {
|
.entity-item.exp-full .exp-body,
|
||||||
|
.card-row.exp-full .exp-body {
|
||||||
-webkit-line-clamp: unset;
|
-webkit-line-clamp: unset;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -461,6 +737,7 @@ main.focus-peek .resize-handle { visibility: hidden; }
|
|||||||
.glyph-checklist { color: var(--remind); }
|
.glyph-checklist { color: var(--remind); }
|
||||||
.glyph-decision { color: var(--note); }
|
.glyph-decision { color: var(--note); }
|
||||||
.glyph-link { color: var(--event); }
|
.glyph-link { color: var(--event); }
|
||||||
|
.glyph-note { color: var(--note); }
|
||||||
|
|
||||||
.entity-content {
|
.entity-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -961,7 +1238,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
|
|
||||||
.peek-body:hover { background: var(--raised); }
|
.peek-body:hover { background: var(--raised); }
|
||||||
|
|
||||||
.peek-body.md {
|
.peek-body.md,
|
||||||
|
.exp-body.md {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
@@ -969,35 +1247,40 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md h1, .peek-body.md h2, .peek-body.md h3,
|
.peek-body.md h1, .peek-body.md h2, .peek-body.md h3,
|
||||||
.peek-body.md h4, .peek-body.md h5, .peek-body.md h6 {
|
.peek-body.md h4, .peek-body.md h5, .peek-body.md h6,
|
||||||
|
.exp-body.md h1, .exp-body.md h2, .exp-body.md h3,
|
||||||
|
.exp-body.md h4, .exp-body.md h5, .exp-body.md h6 {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 14px 0 6px;
|
margin: 14px 0 6px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md h1 { font-size: 17px; }
|
.peek-body.md h1, .exp-body.md h1 { font-size: 17px; }
|
||||||
.peek-body.md h2 { font-size: 15px; }
|
.peek-body.md h2, .exp-body.md h2 { font-size: 15px; }
|
||||||
.peek-body.md h3 { font-size: 14px; }
|
.peek-body.md h3, .exp-body.md h3 { font-size: 14px; }
|
||||||
|
|
||||||
.peek-body.md p { margin: 0 0 10px; }
|
.peek-body.md p, .exp-body.md p { margin: 0 0 10px; }
|
||||||
.peek-body.md p:last-child { margin-bottom: 0; }
|
.peek-body.md p:last-child, .exp-body.md p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
.peek-body.md ul, .peek-body.md ol {
|
.peek-body.md ul, .peek-body.md ol,
|
||||||
|
.exp-body.md ul, .exp-body.md ol {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md li { margin-bottom: 3px; }
|
.peek-body.md li, .exp-body.md li { margin-bottom: 3px; }
|
||||||
|
|
||||||
.peek-body.md blockquote {
|
.peek-body.md blockquote,
|
||||||
|
.exp-body.md blockquote {
|
||||||
border-left: 2px solid var(--accent);
|
border-left: 2px solid var(--accent);
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md code {
|
.peek-body.md code,
|
||||||
|
.exp-body.md code {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -1006,7 +1289,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md pre {
|
.peek-body.md pre,
|
||||||
|
.exp-body.md pre {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r2);
|
border-radius: var(--r2);
|
||||||
@@ -1015,7 +1299,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md pre code {
|
.peek-body.md pre code,
|
||||||
|
.exp-body.md pre code {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1023,18 +1308,20 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md a {
|
.peek-body.md a,
|
||||||
|
.exp-body.md a {
|
||||||
color: var(--event);
|
color: var(--event);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px solid rgba(104,152,200,.3);
|
border-bottom: 1px solid rgba(104,152,200,.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-body.md a:hover { border-bottom-color: var(--event); }
|
.peek-body.md a:hover, .exp-body.md a:hover { border-bottom-color: var(--event); }
|
||||||
|
|
||||||
.peek-body.md strong { font-weight: 600; }
|
.peek-body.md strong, .exp-body.md strong { font-weight: 600; }
|
||||||
.peek-body.md em { font-style: italic; color: var(--muted); }
|
.peek-body.md em, .exp-body.md em { font-style: italic; color: var(--muted); }
|
||||||
|
|
||||||
.peek-body.md hr {
|
.peek-body.md hr,
|
||||||
|
.exp-body.md hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
@@ -1368,7 +1655,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
border-radius: var(--r3);
|
border-radius: var(--r3);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
z-index: 101;
|
z-index: 101;
|
||||||
min-width: 300px;
|
min-width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,16 +1679,32 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
}
|
}
|
||||||
|
|
||||||
.type-picker {
|
.type-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-col-lbl {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.type-btn {
|
.type-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 10px;
|
align-items: flex-start;
|
||||||
padding: 8px 12px;
|
gap: 3px;
|
||||||
|
padding: 8px 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r2);
|
border-radius: var(--r2);
|
||||||
@@ -1414,8 +1718,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
.type-btn:hover { border-color: var(--accent); background: var(--raised); }
|
.type-btn:hover { border-color: var(--accent); background: var(--raised); }
|
||||||
.type-btn.suggested { border-color: var(--accent); background: var(--a-bg); }
|
.type-btn.suggested { border-color: var(--accent); background: var(--a-bg); }
|
||||||
|
|
||||||
.type-glyph { font-size: 13px; width: 16px; flex-shrink: 0; }
|
.type-glyph { font-size: 13px; flex-shrink: 0; }
|
||||||
.type-name { font-family: var(--mono); font-size: 12px; color: var(--text); min-width: 72px; }
|
.type-name { font-family: var(--mono); font-size: 11px; color: var(--text); }
|
||||||
|
|
||||||
.type-hint {
|
.type-hint {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
@@ -1516,6 +1820,7 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
|
|||||||
#tag-rail { display: none !important; }
|
#tag-rail { display: none !important; }
|
||||||
.resize-handle { display: none !important; }
|
.resize-handle { display: none !important; }
|
||||||
#entity-panel { overflow: auto; }
|
#entity-panel { overflow: auto; }
|
||||||
|
#capture-bar { position: sticky; bottom: 0; z-index: 10; }
|
||||||
#detail-pane {
|
#detail-pane {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1554,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