38 Commits

Author SHA1 Message Date
lerko 8426c2fbc1 Merge pull request 'feat(db): add wiki-link extraction, resolution, and backlinks' (#44) from feat/entry-linking into main
CI / test (push) Successful in 2m25s
Reviewed-on: #44
2026-05-21 17:49:04 +00:00
lerko 1e58433936 feat(db): add wiki-link extraction, resolution, and backlinks
CI / test (pull_request) Successful in 2m27s
[[wiki-links]] in entry bodies are extracted at save time, resolved
to entity IDs (title match first, body substring fallback), and
stored in entity_links junction table. Backlinks surface in TUI
detail view showing entries that link to the current entry.

Schema migration v5 adds entity_links with CASCADE/SET NULL
semantics. Links sync on Create, Update, and Absorb.
2026-05-21 13:34:56 -04:00
lerko d24df8432f Merge pull request 'feat(tui): add tag autocomplete and query composition' (#43) from feat/tag-autocomplete-query-compose into main
CI / test (push) Successful in 2m17s
Reviewed-on: #43
2026-05-21 16:22:00 +00:00
lerko e22e040688 feat(tui): add tag autocomplete and query composition
CI / test (pull_request) Successful in 2m31s
Tag autocomplete shows suggestions when typing #partial in capture bar.
Tab/enter accepts, up/down navigates, esc dismisses.

Query composition extends ? search with date filters (@today, @week,
@month, <7d, >30d), card type filters (^snippet), all composable
with existing text and tag filters.
2026-05-21 12:12:07 -04:00
lerko 29bd7d3dc6 Merge pull request 'fix(ci): act runner compatibility' (#42) from fix/ci-act-runner-compat into main
CI / test (push) Successful in 2m41s
Reviewed-on: #42
2026-05-21 15:11:04 +00:00
lerko a9da5c1765 fix(ci): act runner compatibility
CI / test (pull_request) Successful in 2m28s
- Default shell to sh (act runner image lacks bash)
- Drop -race flag (requires cgo, unavailable in act runner)
2026-05-21 11:07:37 -04:00
lerko b9b3f99be9 Revert "fix(ci): use sh instead of bash for act runner compatibility"
CI / test (push) Failing after 13s
This reverts commit cae651302a.
2026-05-21 11:07:10 -04:00
lerko cae651302a fix(ci): use sh instead of bash for act runner compatibility
CI / test (push) Failing after 1m25s
Gitea act runner image lacks bash, causing all run steps to fail with
exit 127. Default shell to sh which is available in all images.
2026-05-21 10:54:34 -04:00
lerko 8fc686ec6d Merge pull request 'feat(export): add HTML card deck export' (#41) from feat/html-card-export into main
CI / test (push) Failing after 17s
2026-05-21 02:00:38 +00:00
lerko 564039112a feat(export): add HTML card deck export
CI / test (pull_request) Failing after 10s
Self-contained single-file HTML export for cards. Mobile-first,
dark theme, zero dependencies. Each card type gets its own
interactive treatment: snippet tap-to-copy, template slot filling,
checklist with progress bar, decision structured layout, link
tap targets. Filter chips by type, search across all cards.

Usage: nib export -f html -o deck.html
       nib export -f html -t triage -o triage.html
2026-05-20 21:49:19 -04:00
lerko eea59b3f3c Merge pull request 'fix: code hardening from senior dev audit' (#40) from fix/audit-phase1-hardening into main
CI / test (push) Failing after 16s
2026-05-21 01:04:30 +00:00
lerko ceb29fdd7b chore: mark all audit phases complete in TODO
CI / test (pull_request) Failing after 56s
2026-05-20 20:54:57 -04:00
lerko 2152baeb4f feat: add export and backup commands
- nib export: dump all entities to JSON (stdout or --output file)
- nib backup: atomic SQLite backup via VACUUM INTO (WAL-safe)
- Store.Backup() method on db layer
- Tests for both commands
2026-05-20 20:54:44 -04:00
lerko 33f6d99ba7 test(cmd): add tests for add, delete, promote, demote, absorb, ls
Covers happy paths, error cases (not found, already promoted,
already fluid, crystallized target, same-entity absorb), and
empty result sets. Uses NIB_DB env var for test isolation.
2026-05-20 20:52:57 -04:00
lerko d715b053e7 refactor(db): thread context.Context through all Store methods
Enables request-scoped cancellation, timeouts, and graceful shutdown
for all database operations across API handlers, CLI commands, and TUI.
2026-05-20 20:51:51 -04:00
lerko 50b80f4407 ci: add Gitea Actions workflow for test and lint on PR 2026-05-20 20:42:16 -04:00
lerko 8663beeb96 fix: harden API, DB, and web layer from audit findings
- Cap list API limit at 200 to prevent unbounded queries
- Sanitize markdown output with DOMPurify to prevent XSS
- Add v4 migration with indexes on deleted_at and modified_at
- Fix v2 migration swallowed ALTER TABLE errors
- Tighten ~/.nib directory permissions to 0o700
2026-05-20 20:41:53 -04:00
lerko 1ac4196547 Merge pull request 'feat(tui): add 13 preloaded themes matching web design system' (#39) from feat/tui-theme into main
Reviewed-on: #39
2026-05-21 00:27:49 +00:00
lerko a96c1a52f4 feat(tui): add 13 preloaded themes matching web design system
Port all web CSS token themes to TUI via shared vocabulary (accent, dim,
muted, ok, todo, event, remind, danger). Styles rebuild from active
theme on switch. Press T to cycle, persists to ~/.nib/theme. Glamour
markdown renderer respects light/dark per theme.
2026-05-20 20:13:21 -04:00
lerko db1dc135d2 Merge pull request 'fix(web): mobile edit via inline fullscreen' (#38) from fix/mobile-edit into main
Reviewed-on: #38
2026-05-20 23:17:45 +00:00
lerko 7d1e0f895c fix(web): mobile edit via inline fullscreen instead of hidden detail pane
Detail pane is display:none on mobile, so edit mode was unreachable.
Render edit fields directly in the inline expansion with exp-full
takeover. ESC and Cmd+Enter work from within inputs.

Closes #32
2026-05-20 19:14:02 -04:00
lerko 82bc6e7ba1 Merge pull request 'fix(tui): stream layout density and alignment' (#37) from fix/stream-layout-density into main
Reviewed-on: #37
2026-05-20 22:57:58 +00:00
lerko 533e086ffb fix(tui): esc closes detail split when list is focused
Previously esc in split view with list focus fell through to capture
focus. Now closes the detail pane and stays in stream browse mode.
2026-05-20 18:57:15 -04:00
lerko 989aa86679 fix(tui): compute truncation budget from actual overhead, not magic numbers
Tags wrapped past pane edge when detail split narrowed the list.
Truncation used fixed constants that didn't account for real tag width.
Now measures everything-except-body and gives body exactly what remains.
2026-05-20 18:49:38 -04:00
lerko 3eb778f31b fix(tui): clean up stream row density — drop ID, fix newline leak, align margins
ID cluttered rows and caused wrapping on long entries. Body newlines
leaked into stream rendering extra unindented lines. Cursor glyph
shifted selected rows 1 col right of unselected.

Remove ID from all row renderers (detail pane already shows it),
collapse multiline body to first line, cap tags to 2 in stream,
and reserve cursor column on unselected rows for consistent alignment.
2026-05-20 18:12:18 -04:00
lerko 98fdae1e3a Merge pull request 'feat(tui): stumble mode — resurface stale entries card by card' (#36) from feat/stumble into main
Reviewed-on: #36
2026-05-20 20:57:25 +00:00
lerko a567b2ce73 feat(tui): stumble mode — resurface stale entries card by card
Card-by-card walkthrough of entries untouched for 30+ days.
Prevents write-mostly decay by bringing old entries back to attention.

- S from list triggers stumble, loads entries where modified_at < 30d
- Single-card view with markdown body, glyph, tags, age indicator
- Actions: n skip, d dismiss, ! pin, p promote, m absorb, esc exit
- Progress indicator: stumble [3/12]
- After promote/absorb from stumble, returns to deck (not list)
- "All caught up" screen when deck exhausted
- DB: add ModifiedBefore to ListParams, modified_at sort column
2026-05-20 16:40:40 -04:00
lerko 388ae88d4a Merge pull request 'feat(tui): collapsible tag rail with ambient tag awareness' (#35) from feat/tag-rail into main
Reviewed-on: #35
2026-05-20 19:24:42 +00:00
lerko 60705463c1 fix(tui): simplify focus model — tab toggles capture ↔ list only
Tag rail removed from tab cycle to reduce focus confusion.
Rail is now ambient-by-default, focusable via h from list (spatial).

- Tab: capture ↔ list (no rail, no detail in cycle)
- h from list: focus tag rail (when visible)
- l from rail: back to list
- Split detail reachable via l/enter, not tab
- Remove nextFocusFromCapture helper
2026-05-20 15:08:11 -04:00
lerko b5b7f6b6ee feat(tui): collapsible tag rail with ambient tag awareness
Persistent left panel showing tags with counts. Provides ambient
awareness of tag landscape without requiring a modal.

- New tagRailModel in tagrail.go: tag list with cursor, scroll, counts
- Rail visible at >=100 cols width, 18% width (min 16 chars)
- ctrl+b toggles rail visibility
- focusTagRail added to focus cycle: capture → tags → list → detail
- j/k navigates, enter filters/unfilters by tag
- Active filter tag highlighted bold in rail
- Tags refresh after entity create/delete/absorb
- Rail auto-hides on narrow terminals, # modal still works as fallback
- Width allocation accounts for rail in split and non-split layouts
2026-05-20 14:32:32 -04:00
lerko 3f57531995 Merge pull request 'feat(tui): always-visible capture bar with focus cycling' (#34) from feat/capture-first into main
Reviewed-on: #34
2026-05-20 18:25:55 +00:00
lerko a2dac64d1f feat(tui): always-visible capture bar with focus cycling
Replace drawer-based input with permanent capture bar at bottom.
Focus defaults to capture on startup — open nib, start typing.

- Remove stateInput; route via focusCapture/focusList/focusDetail
- Tab cycles: capture → list → detail (split) → capture
- Esc cascades: clear search → clear filter → focus capture
- Capture bar shows blinking cursor when focused, dims when not
- Intent cycling moved from tab to i (tab now cycles focus)
- Parse preview shown inline in status bar while typing
- Content area constant height (no layout thrash from drawer)
2026-05-20 14:11:46 -04:00
lerko 3daa5a2e11 Merge pull request 'feat(tui): layout and interaction polish' (#33) from fix/tui-polish into main
Reviewed-on: #33
2026-05-20 16:33:58 +00:00
lerko c26e2d2022 feat(tui): status debounce, scroll indicator, drawer label, card grouping
Status messages now use a sequence counter so rapid actions don't
cause premature clearing. Detail pane shows scroll position and
supports pgup/pgdown/g/G. Capture drawer border includes inline
label. Cards view groups by intent (pinned/grab/read/fill) with
gutter labels matching the stream view's date grouping pattern.
2026-05-20 11:49:11 -04:00
lerko cb10d1e93d feat(tui): render entity body as markdown via glamour
Detail pane now pipes entity body through charmbracelet/glamour for
styled markdown output — headers, bold, code blocks, lists. Uses
hardcoded dark style to avoid terminal query freeze in alt screen.
2026-05-20 11:27:40 -04:00
lerko e20fae3543 feat(tui): add broot-style tab affordances to header and footer
Header now renders stream/cards as tabs with keybindings inline —
active tab highlighted, inactive shows the key to switch. Footer
shows a capture tab affordance when in list state. Redundant mode
and capture hints removed from the context hint bar.
2026-05-20 11:16:58 -04:00
lerko 4e0ac8402f fix(tui): pin footer to bottom, style hint bar, auto-clear status
Content area now enforces full height so the context help bar stays
pinned to the terminal bottom. Hint keys rendered with bold highlight
color for scannability. Status messages (created, deleted, etc.)
auto-clear after 2 seconds, reverting to the entity count.
2026-05-20 11:01:13 -04:00
lerko e2d0f3e997 fix(tui): add $VISUAL fallback for editor resolution
Check $EDITOR, then $VISUAL, then fall back to vi.
2026-05-20 10:34:09 -04:00
51 changed files with 3645 additions and 631 deletions
+37
View File
@@ -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 .
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -36,7 +36,7 @@ func init() {
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities") lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
} }
func runLs(_ *cobra.Command, _ []string) error { func runLs(cmd *cobra.Command, _ []string) error {
store, err := openStore() store, err := openStore()
if err != nil { if err != nil {
return err return err
@@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error {
p.Since = &since p.Since = &since
} }
entities, err := store.List(p) entities, err := store.List(cmd.Context(), p)
if err != nil { if err != nil {
return err return err
} }
+4 -4
View File
@@ -20,14 +20,14 @@ func init() {
rootCmd.AddCommand(promoteCmd) rootCmd.AddCommand(promoteCmd)
} }
func runPromote(_ *cobra.Command, args []string) error { func runPromote(cmd *cobra.Command, args []string) error {
store, err := openStore() store, err := openStore()
if err != nil { if err != nil {
return err return err
} }
defer store.Close() defer store.Close()
id, err := store.Resolve(args[0]) id, err := store.Resolve(cmd.Context(), args[0])
if err != nil { if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0]) return fmt.Errorf("not_found — no entity with id %s", args[0])
} }
@@ -40,14 +40,14 @@ func runPromote(_ *cobra.Command, args []string) error {
cardType = db.CardType(args[1]) cardType = db.CardType(args[1])
} }
e, err := store.Get(id) e, err := store.Get(cmd.Context(), id)
if err != nil { if err != nil {
return err return err
} }
cd := carddata.GenerateCardData(cardType, e.Body) cd := carddata.GenerateCardData(cardType, e.Body)
if err := store.Promote(id, cardType, cd); err != nil { if err := store.Promote(cmd.Context(), id, cardType, cd); err != nil {
if err == db.ErrAlreadyPromoted { if err == db.ErrAlreadyPromoted {
return fmt.Errorf("invalid_promote — entity %s is already a %s", return fmt.Errorf("invalid_promote — entity %s is already a %s",
display.FormatID(id), *e.CardType) display.FormatID(id), *e.CardType)
+14 -2
View File
@@ -6,7 +6,7 @@ require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -14,33 +14,45 @@ require (
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/glamour v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+34
View File
@@ -1,19 +1,29 @@
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
@@ -23,6 +33,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -33,6 +45,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -41,12 +55,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -56,6 +75,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -65,21 +86,34 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+18 -15
View File
@@ -92,6 +92,9 @@ func listEntities(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer") writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer")
return return
} }
if limit > 200 {
limit = 200
}
p.Limit = limit p.Limit = limit
} }
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -106,13 +109,13 @@ func listEntities(store *db.Store) http.HandlerFunc {
p.Limit = 50 p.Limit = 50
} }
total, err := store.Count(p) total, err := store.Count(r.Context(), p)
if err != nil { if err != nil {
writeInternalError(w, err) writeInternalError(w, err)
return return
} }
entities, err := store.List(p) entities, err := store.List(r.Context(), p)
if err != nil { if err != nil {
writeInternalError(w, err) writeInternalError(w, err)
return return
@@ -174,7 +177,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
e.CardData = req.CardData e.CardData = req.CardData
} }
if err := store.Create(e); err != nil { if err := store.Create(r.Context(), e); err != nil {
if err == db.ErrInvalidCardData { if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON") writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return return
@@ -190,7 +193,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
func getEntity(store *db.Store) http.HandlerFunc { func getEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
e, err := store.Get(id) e, err := store.Get(r.Context(), id)
if err != nil { if err != nil {
if err == db.ErrNotFound { if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
@@ -240,7 +243,7 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u.CardType = &ct u.CardType = &ct
} }
if err := store.Update(id, u); err != nil { if err := store.Update(r.Context(), id, u); err != nil {
if err == db.ErrNotFound { if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
@@ -253,7 +256,7 @@ func updateEntity(store *db.Store) http.HandlerFunc {
return return
} }
e, err := store.Get(id) e, err := store.Get(r.Context(), id)
if err != nil { if err != nil {
writeInternalError(w, err) writeInternalError(w, err)
return return
@@ -269,7 +272,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)
@@ -304,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
@@ -321,7 +324,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
return return
} }
e, err := store.Get(id) e, err := store.Get(r.Context(), id)
if err != nil { if err != nil {
writeInternalError(w, err) writeInternalError(w, err)
return return
@@ -334,7 +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
@@ -347,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
@@ -378,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
@@ -391,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
@@ -404,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
@@ -413,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
+1 -1
View File
@@ -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
+40 -4
View File
@@ -51,7 +51,12 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
const currentSchema = 3 func (s *Store) Backup(dst string) error {
_, err := s.db.Exec("VACUUM INTO ?", dst)
return err
}
const currentSchema = 5
var migrations = []func(db *sql.DB) error{ var migrations = []func(db *sql.DB) error{
// v1: initial schema // v1: initial schema
@@ -92,8 +97,12 @@ var migrations = []func(db *sql.DB) error{
// v2: add title and description columns // v2: add title and description columns
func(db *sql.DB) error { func(db *sql.DB) error {
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) return fmt.Errorf("add title column: %w", err)
}
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil {
return fmt.Errorf("add description column: %w", err)
}
return nil return nil
}, },
@@ -166,6 +175,33 @@ var migrations = []func(db *sql.DB) error{
return tx.Commit() return tx.Commit()
}, },
// v4: add indexes for common query filters
func(db *sql.DB) error {
for _, idx := range []string{
`CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`,
`CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`,
} {
if _, err := db.Exec(idx); err != nil {
return fmt.Errorf("create index: %w", err)
}
}
return nil
},
// v5: add entity_links table for wiki-links
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE entity_links (
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
link_text TEXT NOT NULL,
PRIMARY KEY (from_id, link_text)
);
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
`)
return err
},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {
@@ -200,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
+68 -48
View File
@@ -1,6 +1,7 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -71,6 +72,7 @@ type ListParams struct {
From *string From *string
To *string To *string
Since *time.Time Since *time.Time
ModifiedBefore *time.Time
CardsOnly bool CardsOnly bool
IncludeDeleted bool IncludeDeleted bool
CardTypeFilter *CardType CardTypeFilter *CardType
@@ -103,7 +105,7 @@ type EntityUpdate struct {
Tags *[]string Tags *[]string
} }
func (s *Store) Create(e *Entity) error { func (s *Store) Create(ctx context.Context, e *Entity) error {
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) { if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
return ErrInvalidCardData return ErrInvalidCardData
} }
@@ -115,13 +117,13 @@ func (s *Store) Create(e *Entity) error {
e.Tags = []string{} e.Tags = []string{}
} }
tx, err := s.db.Begin() tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec(` _, err = tx.ExecContext(ctx, `
INSERT INTO entities (id, created_at, modified_at, body, title, description, INSERT INTO entities (id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at, glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at) card_type, card_data, use_count, last_used_at)
@@ -146,18 +148,22 @@ func (s *Store) Create(e *Entity) error {
return err return err
} }
if err := insertTags(tx, e.ID, e.Tags); err != nil { if err := insertTags(ctx, tx, e.ID, e.Tags); err != nil {
return err
}
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
} }
func (s *Store) Get(id string) (*Entity, error) { func (s *Store) Get(ctx context.Context, id string) (*Entity, error) {
e := &Entity{} e := &Entity{}
row := newEntityRow() row := newEntityRow()
err := s.db.QueryRow(` err := s.db.QueryRowContext(ctx, `
SELECT id, created_at, modified_at, body, title, description, SELECT id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at, glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at card_type, card_data, use_count, last_used_at
@@ -173,7 +179,7 @@ func (s *Store) Get(id string) (*Entity, error) {
return nil, fmt.Errorf("scan entity %s: %w", id, err) return nil, fmt.Errorf("scan entity %s: %w", id, err)
} }
tags, err := s.loadTags(id) tags, err := s.loadTags(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -216,6 +222,10 @@ func listWhere(params ListParams) (string, []any) {
where = append(where, "e.card_type = ?") where = append(where, "e.card_type = ?")
args = append(args, string(*params.CardTypeFilter)) args = append(args, string(*params.CardTypeFilter))
} }
if params.ModifiedBefore != nil {
where = append(where, "e.modified_at < ?")
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
}
clause := "" clause := ""
if len(where) > 0 { if len(where) > 0 {
@@ -224,21 +234,23 @@ func listWhere(params ListParams) (string, []any) {
return clause, args return clause, args
} }
func (s *Store) Count(params ListParams) (int, error) { func (s *Store) Count(ctx context.Context, params ListParams) (int, error) {
whereClause, args := listWhere(params) whereClause, args := listWhere(params)
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause) query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
var count int var count int
err := s.db.QueryRow(query, args...).Scan(&count) err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err return count, err
} }
func (s *Store) List(params ListParams) ([]*Entity, error) { func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) {
whereClause, args := listWhere(params) whereClause, args := listWhere(params)
orderCol := "e.created_at" orderCol := "e.created_at"
switch params.Sort { switch params.Sort {
case "use_count": case "use_count":
orderCol = "e.use_count" orderCol = "e.use_count"
case "modified_at":
orderCol = "e.modified_at"
case "created_at", "": case "created_at", "":
orderCol = "e.created_at" orderCol = "e.created_at"
default: default:
@@ -268,7 +280,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
args = append(args, limit, params.Offset) args = append(args, limit, params.Offset)
rows, err := s.db.Query(query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -290,20 +302,20 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
return nil, err return nil, err
} }
if err := s.batchLoadTags(entities); err != nil { if err := s.batchLoadTags(ctx, entities); err != nil {
return nil, err return nil, err
} }
return entities, nil return entities, nil
} }
func (s *Store) Update(id string, u *EntityUpdate) error { func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
existing, err := s.Get(id) existing, err := s.Get(ctx, id)
if err != nil { if err != nil {
return err return err
} }
tx, err := s.db.Begin() tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -362,15 +374,21 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, existing.ID) args = append(args, existing.ID)
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", ")) query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
if _, err := tx.Exec(query, args...); err != nil { if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err return err
} }
if u.Tags != nil { if u.Tags != nil {
if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil { if _, err := tx.ExecContext(ctx, "DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
return err return err
} }
if err := insertTags(tx, existing.ID, *u.Tags); err != nil { if err := insertTags(ctx, tx, existing.ID, *u.Tags); err != nil {
return err
}
}
if u.Body != nil {
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
return err return err
} }
} }
@@ -378,8 +396,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
return tx.Commit() return tx.Commit()
} }
func (s *Store) Promote(id string, cardType CardType, cardData *string) error { func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error {
e, err := s.Get(id) e, err := s.Get(ctx, id)
if err != nil { if err != nil {
return err return err
} }
@@ -395,15 +413,15 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
dataVal = *cardData dataVal = *cardData
} }
_, err = s.db.Exec(` _, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ? UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
WHERE id = ?`, WHERE id = ?`,
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id) string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
return err return err
} }
func (s *Store) Demote(id string) error { func (s *Store) Demote(ctx context.Context, id string) error {
e, err := s.Get(id) e, err := s.Get(ctx, id)
if err != nil { if err != nil {
return err return err
} }
@@ -411,7 +429,7 @@ func (s *Store) Demote(id string) error {
return ErrAlreadyFluid return ErrAlreadyFluid
} }
_, err = s.db.Exec(` _, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = NULL, card_data = NULL, UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ? use_count = 0, last_used_at = NULL, modified_at = ?
WHERE id = ?`, WHERE id = ?`,
@@ -426,9 +444,9 @@ const (
DeletedHard DeletedHard
) )
func (s *Store) SoftDelete(id string) (DeleteResult, error) { func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) {
var deletedAt sql.NullString var deletedAt sql.NullString
err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt) err := s.db.QueryRowContext(ctx, "SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return 0, ErrNotFound return 0, ErrNotFound
} }
@@ -437,21 +455,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
} }
@@ -460,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
} }
@@ -469,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
} }
@@ -480,15 +498,19 @@ func (s *Store) Absorb(targetID, sourceID string) error {
} }
for _, t := range source.Tags { for _, t := range source.Tags {
if !seen[t] { if !seen[t] {
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
targetID, t); err != nil { targetID, t); err != nil {
return err return err
} }
} }
} }
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
return err
}
if source.CardType != nil { if source.CardType != nil {
if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL, if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`, use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
now, sourceID); err != nil { now, sourceID); err != nil {
return err return err
@@ -496,7 +518,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
} }
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]" absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?", if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
absorbNote, now, now, sourceID); err != nil { absorbNote, now, now, sourceID); err != nil {
return err return err
} }
@@ -504,8 +526,8 @@ func (s *Store) Absorb(targetID, sourceID string) error {
return tx.Commit() return tx.Commit()
} }
func (s *Store) IncrementUse(id string) error { func (s *Store) IncrementUse(ctx context.Context, id string) error {
res, err := s.db.Exec(` res, err := s.db.ExecContext(ctx, `
UPDATE entities SET use_count = use_count + 1, last_used_at = ? UPDATE entities SET use_count = use_count + 1, last_used_at = ?
WHERE id = ?`, WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id) time.Now().UTC().Format(time.RFC3339), id)
@@ -519,8 +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
} }
@@ -586,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
} }
@@ -608,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
} }
@@ -626,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
} }
@@ -650,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
View File
@@ -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)
} }
+83
View File
@@ -0,0 +1,83 @@
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) 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()
}
+225
View File
@@ -0,0 +1,225 @@
package db
import (
"context"
"testing"
)
func TestSyncLinks_OnCreate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "nginx proxy config", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx proxy config]] for setup", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
if backlinks[0].EntityID != source.ID {
t.Errorf("backlink entity = %s, want %s", backlinks[0].EntityID, source.ID)
}
if backlinks[0].LinkText != "nginx proxy config" {
t.Errorf("link text = %q, want %q", backlinks[0].LinkText, "nginx proxy config")
}
}
func TestSyncLinks_TitleMatch(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "deploy checklist"
target := &Entity{Body: "steps to deploy", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "follow [[deploy checklist]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
}
func TestSyncLinks_TitlePriority(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "nginx config"
titled := &Entity{Body: "some body", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, titled); err != nil {
t.Fatal(err)
}
bodyMatch := &Entity{Body: "nginx config details", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, bodyMatch); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx config]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, titled.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("title match should win, got %d backlinks on titled entity", len(backlinks))
}
bodyBacklinks, err := s.LoadBacklinks(ctx, bodyMatch.ID)
if err != nil {
t.Fatal(err)
}
if len(bodyBacklinks) != 0 {
t.Fatalf("body match entity should have 0 backlinks, got %d", len(bodyBacklinks))
}
}
func TestSyncLinks_Unresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
source := &Entity{Body: "see [[nonexistent entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", source.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 link row (unresolved), got %d", count)
}
var toID *string
err = s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", source.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Errorf("expected NULL to_id for unresolved link, got %v", *toID)
}
}
func TestSyncLinks_OnUpdate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "original target", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "no links yet", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
newBody := "now has [[original target]]"
if err := s.Update(ctx, source.ID, &EntityUpdate{Body: &newBody}); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink after update, got %d", len(backlinks))
}
}
func TestSyncLinks_SelfLinkUnresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "I reference [[I reference]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var toID *string
err := s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", e.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Fatalf("self-matching link should be unresolved (NULL to_id), got %v", *toID)
}
}
func TestSyncLinks_NoLinks(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "plain text no links", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", e.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("expected 0 links, got %d", count)
}
}
func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target entry", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[target entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink before delete, got %d", len(backlinks))
}
if _, err := s.SoftDelete(ctx, source.ID); err != nil {
t.Fatal(err)
}
backlinks, err = s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 0 {
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
}
}
+5 -3
View File
@@ -1,20 +1,22 @@
package db package db
import "context"
type TagCount struct { type TagCount struct {
Tag string Tag string
Count int Count int
} }
func (s *Store) ListTags(cardsOnly bool) ([]TagCount, error) { func (s *Store) ListTags(ctx context.Context, cardsOnly bool) ([]TagCount, error) {
where := "WHERE e.deleted_at IS NULL" where := "WHERE e.deleted_at IS NULL"
if cardsOnly { if cardsOnly {
where += " AND e.card_type IS NOT NULL" where += " AND e.card_type IS NOT NULL"
} }
rows, err := s.db.Query(` rows, err := s.db.QueryContext(ctx, `
SELECT t.tag, COUNT(*) as cnt SELECT t.tag, COUNT(*) as cnt
FROM entity_tags t FROM entity_tags t
JOIN entities e ON t.entity_id = e.id JOIN entities e ON t.entity_id = e.id
` + where + ` `+where+`
GROUP BY t.tag GROUP BY t.tag
ORDER BY t.tag`) ORDER BY t.tag`)
if err != nil { if err != nil {
+20 -14
View File
@@ -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)
} }
+181
View File
@@ -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
}
}
}
+355
View File
@@ -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>
+27
View File
@@ -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
}
+38
View File
@@ -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)
}
}
})
}
}
+42 -1
View File
@@ -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{
@@ -66,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
+61
View File
@@ -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
+7 -4
View File
@@ -108,12 +108,14 @@ func (a absorbModel) visibleCount() int {
func renderAbsorbSource(e *db.Entity, maxWidth int) string { func renderAbsorbSource(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body body := e.Body
if e.Title != nil { if e.Title != nil {
body = *e.Title body = *e.Title
} }
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var tags string var tags string
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
@@ -125,11 +127,12 @@ func renderAbsorbSource(e *db.Entity, maxWidth int) string {
tags = " " + strings.Join(tagParts, " ") tags = " " + strings.Join(tagParts, " ")
} }
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) line := fmt.Sprintf("%s %s%s", glyph, body, tags)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20) overhead := len(stripAnsi(line)) - len([]rune(body))
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
} }
return line return line
+112
View File
@@ -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
}
+85
View File
@@ -0,0 +1,85 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestTagTokenAtCursor(t *testing.T) {
tests := []struct {
name string
val string
cursor int
wantStart int
wantEnd int
wantPfx string
wantOk bool
}{
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
{"not in tag", "hello world", 5, 0, 0, "", false},
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
{"cursor at hash", "#ops", 1, 0, 4, "", true},
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
{"empty string", "", 0, 0, 0, "", false},
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
if ok != tt.wantOk {
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
}
if !ok {
return
}
if start != tt.wantStart || end != tt.wantEnd {
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
}
if pfx != tt.wantPfx {
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
}
})
}
}
func TestFilterTagSuggestions(t *testing.T) {
tags := []db.TagCount{
{Tag: "ops", Count: 5},
{Tag: "ops-deploy", Count: 3},
{Tag: "infra", Count: 2},
{Tag: "ops-team", Count: 1},
}
tests := []struct {
name string
prefix string
want []string
}{
{"empty prefix", "", nil},
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
{"no match", "zzz", nil},
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
{"single match", "inf", []string{"infra"}},
{"full match excluded", "infra", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterTagSuggestions(tags, tt.prefix)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
+117 -10
View File
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
return false return false
} }
type cardGroup struct {
label string
start int
count int
}
type cardsModel struct { type cardsModel struct {
entities []*db.Entity entities []*db.Entity
filtered []*db.Entity filtered []*db.Entity
groups []cardGroup
cursor int cursor int
offset int offset int
height int height int
@@ -91,10 +98,17 @@ func (c *cardsModel) setIntent(i intent) {
} }
func (c *cardsModel) applyFilter() { func (c *cardsModel) applyFilter() {
c.filtered = nil c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
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 var pinned, rest []*db.Entity
for _, e := range c.entities { for _, e := range entities {
if !matchesIntent(e, c.intent) { if !matchesIntent(e, intentFilter) {
continue continue
} }
if e.Pinned { if e.Pinned {
@@ -103,10 +117,48 @@ func (c *cardsModel) applyFilter() {
rest = append(rest, e) rest = append(rest, e)
} }
} }
c.filtered = append(pinned, rest...) return append(pinned, rest...), nil
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
} }
var pinned, grab, read, fill []*db.Entity
for _, e := range entities {
if e.Pinned {
pinned = append(pinned, e)
} else {
switch {
case matchesIntent(e, intentGrab):
grab = append(grab, e)
case matchesIntent(e, intentRead):
read = append(read, e)
case matchesIntent(e, intentFill):
fill = append(fill, e)
}
}
}
var filtered []*db.Entity
var groups []cardGroup
for _, bucket := range []struct {
label string
entities []*db.Entity
}{
{"pinned", pinned},
{"grab", grab},
{"read", read},
{"fill", fill},
} {
if len(bucket.entities) == 0 {
continue
}
groups = append(groups, cardGroup{
label: bucket.label,
start: len(filtered),
count: len(bucket.entities),
})
filtered = append(filtered, bucket.entities...)
}
return filtered, groups
} }
func (c *cardsModel) setSize(width, height int) { func (c *cardsModel) setSize(width, height int) {
@@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 { if len(c.filtered) == 0 {
return statusStyle.Render("no cards") return statusStyle.Render("no cards")
} }
if len(c.groups) > 0 {
return c.groupedView(width)
}
var b strings.Builder var b strings.Builder
visible := c.visibleCount() visible := c.visibleCount()
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
return b.String() return b.String()
} }
func (c cardsModel) groupedView(width int) string {
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
for _, g := range c.groups {
for i := 0; i < g.count; i++ {
eIdx := g.start + i
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
}
}
visible := c.visibleCount()
offset := c.offset
if c.cursor < offset {
offset = c.cursor
}
if c.cursor >= offset+visible {
offset = c.cursor - visible + 1
}
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.entityIdx == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) visibleCount() int { func (c cardsModel) visibleCount() int {
if c.height <= 0 { if c.height <= 0 {
return 20 return 20
@@ -197,12 +301,14 @@ func (c cardsModel) visibleCount() int {
func renderCard(e *db.Entity, maxWidth int) string { func renderCard(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body body := e.Body
if e.Title != nil { if e.Title != nil {
body = *e.Title body = *e.Title
} }
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
affordance := detectAffordance(e) affordance := detectAffordance(e)
affordStr := "" affordStr := ""
@@ -231,11 +337,12 @@ func renderCard(e *db.Entity, maxWidth int) string {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount)) useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
} }
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-30) overhead := len(stripAnsi(line)) - len([]rune(body))
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
} }
return line return line
+94 -16
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"context"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -54,10 +55,28 @@ type stepsPersistedMsg struct{}
type templateCopiedMsg struct{} type templateCopiedMsg struct{}
type backlinksLoadedMsg struct {
backlinks []db.Backlink
}
type tagsLoadedMsg struct { type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type staleEntitiesLoadedMsg struct {
entities []*db.Entity
}
type stumbleActionMsg struct {
action string
}
type statusClearMsg struct{ seq int }
type editorFinishedMsg struct { type editorFinishedMsg struct {
err error err error
} }
@@ -68,7 +87,7 @@ type errMsg struct {
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd { func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
entities, err := store.List(params) entities, err := store.List(context.Background(), params)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -78,7 +97,7 @@ func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
func createEntity(store *db.Store, e *db.Entity) tea.Cmd { func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := store.Create(e); err != nil { if err := store.Create(context.Background(), e); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityCreatedMsg{e} return entityCreatedMsg{e}
@@ -87,7 +106,7 @@ func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
func deleteEntity(store *db.Store, id string) tea.Cmd { func deleteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := store.SoftDelete(id); err != nil { if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityDeletedMsg{id} return entityDeletedMsg{id}
@@ -104,10 +123,10 @@ func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
update = db.EntityUpdate{ClearCompleted: true} update = db.EntityUpdate{ClearCompleted: true}
} }
if err := store.Update(e.ID, &update); err != nil { if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err} return errMsg{err}
} }
updated, err := store.Get(e.ID) updated, err := store.Get(context.Background(), e.ID)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -123,10 +142,10 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
newPinned := !e.Pinned newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned} update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(e.ID, &update); err != nil { if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err} return errMsg{err}
} }
updated, err := store.Get(e.ID) updated, err := store.Get(context.Background(), e.ID)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -141,7 +160,7 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd { func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
cd := carddata.GenerateCardData(ct, body) cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(id, ct, cd); err != nil { if err := store.Promote(context.Background(), id, ct, cd); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityPromotedMsg{id, ct} return entityPromotedMsg{id, ct}
@@ -150,7 +169,7 @@ func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.
func demoteEntity(store *db.Store, id string) tea.Cmd { func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := store.Demote(id); err != nil { if err := store.Demote(context.Background(), id); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityDemotedMsg{id} return entityDemotedMsg{id}
@@ -162,7 +181,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
if err := clipboard.WriteAll(e.Body); err != nil { if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err} return errMsg{err}
} }
if err := store.IncrementUse(e.ID); err != nil { if err := store.IncrementUse(context.Background(), e.ID); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityCopiedMsg{} return entityCopiedMsg{}
@@ -171,7 +190,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
func loadTags(store *db.Store) tea.Cmd { func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
tags, err := store.ListTags(false) tags, err := store.ListTags(context.Background(), false)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -179,8 +198,31 @@ func loadTags(store *db.Store) tea.Cmd {
} }
} }
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
return func() tea.Msg {
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
if err != nil {
return errMsg{err}
}
return backlinksLoadedMsg{backlinks}
}
}
func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR") editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
editorEnv = os.Getenv("VISUAL")
}
if editorEnv == "" { if editorEnv == "" {
editorEnv = "vi" editorEnv = "vi"
} }
@@ -216,7 +258,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
} }
update := db.EntityUpdate{Body: &newBody} update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(e.ID, &update); updateErr != nil { if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr} return editorFinishedMsg{updateErr}
} }
@@ -226,7 +268,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd { func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
entities, err := store.List(db.DefaultListParams()) entities, err := store.List(context.Background(), db.DefaultListParams())
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
@@ -236,7 +278,7 @@ func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := store.Absorb(targetID, sourceID); err != nil { if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
return errMsg{err} return errMsg{err}
} }
return entityAbsorbedMsg{targetID} return entityAbsorbedMsg{targetID}
@@ -246,7 +288,7 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd { func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
update := db.EntityUpdate{CardData: &stepsJSON} update := db.EntityUpdate{CardData: &stepsJSON}
if err := store.Update(entityID, &update); err != nil { if err := store.Update(context.Background(), entityID, &update); err != nil {
return errMsg{err} return errMsg{err}
} }
return stepsPersistedMsg{} return stepsPersistedMsg{}
@@ -258,9 +300,45 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
if err := clipboard.WriteAll(resolved); err != nil { if err := clipboard.WriteAll(resolved); err != nil {
return errMsg{err} return errMsg{err}
} }
if err := store.IncrementUse(entityID); err != nil { if err := store.IncrementUse(context.Background(), entityID); err != nil {
return errMsg{err} return errMsg{err}
} }
return templateCopiedMsg{} return templateCopiedMsg{}
} }
} }
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return statusClearMsg{seq: seq}
})
}
func loadStaleEntities(store *db.Store) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), staleParams())
if err != nil {
return errMsg{err}
}
return staleEntitiesLoadedMsg{entities}
}
}
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"dismissed"}
}
}
func stumblePin(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
pinned := true
update := db.EntityUpdate{Pinned: &pinned}
if err := store.Update(context.Background(), id, &update); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"pinned"}
}
}
+65 -3
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display" "github.com/lerko/nib/internal/display"
@@ -21,6 +22,7 @@ const (
type detailModel struct { type detailModel struct {
entity *db.Entity entity *db.Entity
backlinks []db.Backlink
scroll int scroll int
height int height int
width int width int
@@ -35,6 +37,7 @@ func newDetailModel() detailModel {
func (d *detailModel) setEntity(e *db.Entity) { func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e d.entity = e
d.backlinks = nil
d.scroll = 0 d.scroll = 0
d.mode = detailPreview d.mode = detailPreview
} }
@@ -61,6 +64,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
} }
case "down", "j": case "down", "j":
d.scroll++ d.scroll++
case "pgdown", "ctrl+d":
d.scroll += d.height
case "pgup", "ctrl+u":
d.scroll -= d.height
if d.scroll < 0 {
d.scroll = 0
}
case "home", "g":
d.scroll = 0
case "end", "G":
d.scroll = 1<<31 - 1
} }
return d, nil return d, nil
} }
@@ -98,7 +112,20 @@ func (d detailModel) previewView(width int) string {
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString(detailBodyStyle.Render(e.Body)) bodyWidth := width - 4
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(detailBodyStyle.Render(rendered))
b.WriteString("\n") b.WriteString("\n")
if e.CardType != nil { if e.CardType != nil {
@@ -119,6 +146,25 @@ func (d detailModel) previewView(width int) string {
b.WriteString("\n") b.WriteString("\n")
} }
if len(d.backlinks) > 0 {
b.WriteString("\n")
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
b.WriteString("\n")
for _, bl := range d.backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 40 {
label = label[:40] + "…"
}
line := " " + backlinkStyle.Render(label)
if bl.LinkText != "" {
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
}
b.WriteString(line + "\n")
}
}
b.WriteString("\n") b.WriteString("\n")
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime)) meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
if e.ModifiedAt != e.CreatedAt { if e.ModifiedAt != e.CreatedAt {
@@ -142,8 +188,24 @@ func (d detailModel) previewView(width int) string {
b.WriteString(idStyle.Render(meta)) b.WriteString(idStyle.Render(meta))
lines := strings.Split(b.String(), "\n") lines := strings.Split(b.String(), "\n")
if d.scroll > 0 && d.scroll < len(lines) { totalLines := len(lines)
lines = lines[d.scroll:]
maxScroll := totalLines - d.height
if maxScroll < 0 {
maxScroll = 0
}
scroll := d.scroll
if scroll > maxScroll {
scroll = maxScroll
}
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
lines[0] = lines[0] + indicator
}
if scroll > 0 && scroll < totalLines {
lines = lines[scroll:]
} }
if d.height > 0 && len(lines) > d.height { if d.height > 0 && len(lines) > d.height {
lines = lines[:d.height] lines = lines[:d.height]
+34 -3
View File
@@ -7,27 +7,50 @@ func renderHelp(width, height int) string {
title string title string
binds [][2]string binds [][2]string
}{ }{
{"Focus", [][2]string{
{"tab", "toggle capture ↔ list"},
{"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"},
{"h", "focus tag rail (from list)"},
{"l", "focus detail (split view)"},
{"ctrl+b", "toggle tag rail"},
}},
{"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"},
{"?…", "search (type ?query)"},
{"#…", "tag (autocomplete with tab)"},
{"-", "todo prefix"},
{"@", "event prefix"},
{"!", "reminder prefix"},
}},
{"Query Operators", [][2]string{
{"?text", "substring search"},
{"?#tag1 #tag2", "filter by tags (AND)"},
{"?@today @week", "date filter (@yesterday @month)"},
{"?<7d >30d", "newer/older than N days"},
{"?^snippet", "card type filter"},
}},
{"Navigation", [][2]string{ {"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"}, {"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"}, {"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"}, {"pgup/pgdn", "page up / down"},
{"enter", "view detail"}, {"enter", "view detail"},
{"esc", "back / clear filter"},
}}, }},
{"Views", [][2]string{ {"Views", [][2]string{
{"1", "stream view"}, {"1", "stream view"},
{"2", "cards view"}, {"2", "cards view"},
{"s", "cycle sort (cards)"}, {"s", "cycle sort (cards)"},
{"tab", "cycle intent (cards)"}, {"i", "cycle intent (cards)"},
{"T", "cycle theme"},
}}, }},
{"Actions", [][2]string{ {"Actions", [][2]string{
{"a", "add entity (or ?query to search)"},
{"d", "delete (with confirm)"}, {"d", "delete (with confirm)"},
{"x", "toggle todo completion"}, {"x", "toggle todo completion"},
{"!", "toggle pin"}, {"!", "toggle pin"},
{"#", "filter by tag"}, {"#", "filter by tag"},
{"m", "absorb (merge into target)"}, {"m", "absorb (merge into target)"},
{"p", "promote to card"}, {"p", "promote to card"},
{"S", "stumble (resurface stale entries)"},
}}, }},
{"Detail View", [][2]string{ {"Detail View", [][2]string{
{"p", "promote to card"}, {"p", "promote to card"},
@@ -38,6 +61,14 @@ func renderHelp(width, height int) string {
{"r", "run checklist"}, {"r", "run checklist"},
{"f", "fill template"}, {"f", "fill template"},
}}, }},
{"Stumble", [][2]string{
{"n / →", "skip to next"},
{"d", "dismiss (soft delete)"},
{"!", "pin"},
{"p", "promote to card"},
{"m", "absorb"},
{"esc", "exit"},
}},
{"Run Mode", [][2]string{ {"Run Mode", [][2]string{
{"j/k", "move between steps"}, {"j/k", "move between steps"},
{"space", "toggle step"}, {"space", "toggle step"},
+38 -37
View File
@@ -15,11 +15,13 @@ type inputResult struct {
query bool query bool
body string body string
tags []string tags []string
dateFrom *string
dateTo *string
cardType *db.CardType
} }
type inputModel struct { type inputModel struct {
ti textinput.Model ti textinput.Model
active bool
preview *parse.Result preview *parse.Result
} }
@@ -31,15 +33,8 @@ func newInputModel() inputModel {
return inputModel{ti: ti} return inputModel{ti: ti}
} }
func (i *inputModel) focus() { func (i *inputModel) clearText() {
i.active = true
i.ti.Focus()
}
func (i *inputModel) reset() {
i.active = false
i.ti.SetValue("") i.ti.SetValue("")
i.ti.Blur()
i.preview = nil i.preview = nil
} }
@@ -55,11 +50,18 @@ func (i inputModel) submit() *inputResult {
} }
if parsed.Query { if parsed.Query {
return &inputResult{ r := &inputResult{
query: true, query: true,
body: parsed.Body, body: parsed.Body,
tags: parsed.FilterTags, tags: parsed.FilterTags,
dateFrom: parsed.QueryDateFrom,
dateTo: parsed.QueryDateTo,
} }
if parsed.QueryCardType != nil {
ct := db.CardType(*parsed.QueryCardType)
r.cardType = &ct
}
return r
} }
e := &db.Entity{ e := &db.Entity{
@@ -101,21 +103,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
return i return i
} }
func (i inputModel) view(width int) string { func (i inputModel) viewBar(width int, focused bool) string {
var b strings.Builder tiView := i.ti.View()
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width))) if focused {
b.WriteString("\n") return tiView
b.WriteString(i.ti.View()) }
b.WriteString("\n") val := i.ti.Value()
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder")) if val != "" {
b.WriteString("\n") return hintDescStyle.Render(" " + val)
b.WriteString(i.renderPreview(width)) }
return b.String() return hintDescStyle.Render(" capture a thought…")
} }
func (i inputModel) renderPreview(width int) string { func (i inputModel) previewText() string {
if i.preview == nil { if i.preview == nil {
return drawerPreviewStyle.Render("") return ""
} }
p := i.preview p := i.preview
@@ -128,7 +130,16 @@ func (i inputModel) renderPreview(width int) string {
for _, t := range p.FilterTags { for _, t := range p.FilterTags {
q += " #" + t q += " #" + t
} }
return drawerPreviewStyle.Render("search: " + q) if p.QueryDateFrom != nil {
q += " from:" + *p.QueryDateFrom
}
if p.QueryDateTo != nil {
q += " to:" + *p.QueryDateTo
}
if p.QueryCardType != nil {
q += " ^" + *p.QueryCardType
}
return "search: " + q
} }
glyph := glyphForParsed(p.Glyph) glyph := glyphForParsed(p.Glyph)
@@ -140,22 +151,16 @@ func (i inputModel) renderPreview(width int) string {
var parts []string var parts []string
parts = append(parts, glyph, body) parts = append(parts, glyph, body)
for _, t := range p.Tags { for _, t := range p.Tags {
parts = append(parts, tagStyle.Render("#"+t)) parts = append(parts, "#"+t)
} }
if p.Pin { if p.Pin {
parts = append(parts, pinnedStyle.Render("•")) parts = append(parts, "•")
} }
if p.CardSuffix != nil { if p.CardSuffix != nil {
parts = append(parts, affordanceStyle.Render(*p.CardSuffix)) parts = append(parts, *p.CardSuffix)
} }
line := strings.Join(parts, " ") return strings.Join(parts, " ")
maxW := width - 4
if maxW > 0 && len(stripAnsi(line)) > maxW {
line = truncate(line, maxW)
}
return drawerPreviewStyle.Render(line)
} }
func glyphForParsed(glyph string) string { func glyphForParsed(glyph string) string {
@@ -170,7 +175,3 @@ func glyphForParsed(glyph string) string {
return "—" return "—"
} }
} }
func drawerLines() int {
return 3
}
+11 -3
View File
@@ -7,7 +7,7 @@ type keyMap struct {
Down key.Binding Down key.Binding
Enter key.Binding Enter key.Binding
Back key.Binding Back key.Binding
Add key.Binding Capture key.Binding
Delete key.Binding Delete key.Binding
Quit key.Binding Quit key.Binding
Help key.Binding Help key.Binding
@@ -31,6 +31,10 @@ type keyMap struct {
Fill key.Binding Fill key.Binding
FocusLeft key.Binding FocusLeft key.Binding
FocusRight key.Binding FocusRight key.Binding
Tab key.Binding
ToggleRail key.Binding
Stumble key.Binding
Theme key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -38,7 +42,7 @@ var keys = keyMap{
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
@@ -56,10 +60,14 @@ var keys = keyMap{
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
} }
+10 -9
View File
@@ -204,23 +204,23 @@ func renderEntity(e *db.Entity, maxWidth int) string {
} }
glyph := style.Render(glyphStr) glyph := style.Render(glyphStr)
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body body := e.Body
if e.Title != nil { if e.Title != nil {
body = *e.Title body = *e.Title
} }
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var extras []string var extras []string
if e.Pinned { if e.Pinned {
extras = append(extras, pinnedStyle.Render("•")) extras = append(extras, pinnedStyle.Render("•"))
} }
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags)) limit := min(2, len(e.Tags))
for i, t := range e.Tags { for _, t := range e.Tags[:limit] {
tagParts[i] = tagStyle.Render("#" + t) extras = append(extras, tagStyle.Render("#"+t))
} }
extras = append(extras, strings.Join(tagParts, " "))
} }
extraStr := "" extraStr := ""
@@ -228,11 +228,12 @@ func renderEntity(e *db.Entity, maxWidth int) string {
extraStr = " " + strings.Join(extras, " ") extraStr = " " + strings.Join(extras, " ")
} }
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id) line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20) overhead := len(stripAnsi(line)) - len([]rune(body))
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id) body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
} }
return line return line
+461 -131
View File
@@ -1,8 +1,10 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -10,16 +12,18 @@ import (
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
) )
const statusTimeout = 2 * time.Second
type viewState int type viewState int
const ( const (
stateList viewState = iota stateList viewState = iota
stateDetail stateDetail
stateInput
stateTagFilter stateTagFilter
stateConfirm stateConfirm
statePromote statePromote
stateAbsorb stateAbsorb
stateStumble
) )
type viewMode int type viewMode int
@@ -62,7 +66,9 @@ func (s cardsSort) next() cardsSort {
type focusPane int type focusPane int
const ( const (
focusList focusPane = iota focusCapture focusPane = iota
focusTagRail
focusList
focusDetail focusDetail
) )
@@ -80,36 +86,66 @@ type model struct {
filter filterModel filter filterModel
promote promoteModel promote promoteModel
absorb absorbModel absorb absorbModel
tagRail tagRailModel
stumble stumbleModel
showHelp bool showHelp bool
autocomplete autocompleteModel
focus focusPane focus focusPane
splitDetail bool splitDetail bool
showTagRail bool
filterTag string filterTag string
confirmID string confirmID string
cardsSort cardsSort cardsSort cardsSort
searchQuery string searchQuery string
searchTags []string searchTags []string
queryDateFrom *string
queryDateTo *string
queryCardType *db.CardType
status string status string
statusSeq int
err error err error
} }
func newModel(store *db.Store) model { func newModel(store *db.Store) model {
loadTheme()
applyTheme()
inp := newInputModel()
inp.ti.Focus()
return model{ return model{
store: store, store: store,
state: stateList, state: stateList,
mode: modeStream, mode: modeStream,
focus: focusCapture,
showTagRail: true,
list: newListModel(), list: newListModel(),
cards: newCardsModel(), cards: newCardsModel(),
detail: newDetailModel(), detail: newDetailModel(),
input: newInputModel(), input: inp,
filter: newFilterModel(), filter: newFilterModel(),
tagRail: newTagRailModel(),
} }
} }
func (m *model) setFocus(f focusPane) tea.Cmd {
m.focus = f
if f == focusCapture {
return m.input.ti.Focus()
}
m.input.ti.Blur()
return nil
}
func (m *model) setStatus(msg string) tea.Cmd {
m.statusSeq++
m.status = msg
return clearStatusAfter(statusTimeout, m.statusSeq)
}
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return loadEntities(m.store, m.listParams()) return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
} }
func (m model) listParams() db.ListParams { func (m model) listParams() db.ListParams {
@@ -117,6 +153,15 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" { if m.filterTag != "" {
p.Tag = &m.filterTag p.Tag = &m.filterTag
} }
if m.queryDateFrom != nil {
p.From = m.queryDateFrom
}
if m.queryDateTo != nil {
p.To = m.queryDateTo
}
if m.queryCardType != nil {
p.CardTypeFilter = m.queryCardType
}
if m.mode == modeCards { if m.mode == modeCards {
p.CardsOnly = true p.CardsOnly = true
switch m.cardsSort { switch m.cardsSort {
@@ -135,25 +180,19 @@ func (m model) listParams() db.ListParams {
} }
func (m model) hasSearch() bool { func (m model) hasSearch() bool {
return m.searchQuery != "" || len(m.searchTags) > 0 return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
} }
func (m *model) applySearch() { func (m *model) applySearch() {
if m.mode == modeCards { if m.mode == modeCards {
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
m.cards.filtered = nil var intentFiltered []*db.Entity
var pinned, rest []*db.Entity for _, e := range searchFiltered {
for _, e := range filtered { if matchesIntent(e, m.cards.intent) {
if !matchesIntent(e, m.cards.intent) { intentFiltered = append(intentFiltered, e)
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
} }
} }
m.cards.filtered = append(pinned, rest...) m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent)
if m.cards.cursor >= len(m.cards.filtered) { if m.cards.cursor >= len(m.cards.filtered) {
m.cards.cursor = max(0, len(m.cards.filtered)-1) m.cards.cursor = max(0, len(m.cards.filtered)-1)
} }
@@ -172,6 +211,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splitDetail = false m.splitDetail = false
m.focus = focusList m.focus = focusList
} }
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes() m.recalcSizes()
return m, nil return m, nil
@@ -187,42 +229,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
return m, nil return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg: case entityCreatedMsg:
m.state = stateList m.input.clearText()
m.input.reset() return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
m.recalcSizes()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg: case entityDeletedMsg:
m.status = "deleted"
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
case entityUpdatedMsg: case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail { if m.state == stateDetail {
m.detail.setEntity(msg.entity) m.detail.setEntity(msg.entity)
} }
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
case entityPromotedMsg: case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType) if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) }
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
case entityDemotedMsg: case entityDemotedMsg:
m.status = "demoted → fluid" return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
return m, m.reloadDetail(msg.id)
case entityCopiedMsg: case entityCopiedMsg:
m.status = "copied" return m, m.setStatus("copied")
return m, nil
case entityAbsorbedMsg: case entityAbsorbedMsg:
m.status = "absorbed" if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList m.state = stateList
return m, loadEntities(m.store, m.listParams()) }
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
case absorbSourcesLoadedMsg: case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID) m.absorb = newAbsorbModel(msg.targetID)
@@ -232,27 +280,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case stepsPersistedMsg: case stepsPersistedMsg:
m.status = "steps saved"
m.detail.mode = detailPreview m.detail.mode = detailPreview
return m, m.reloadDetail(m.detail.entity.ID) return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved"))
case templateCopiedMsg: case templateCopiedMsg:
m.status = "copied resolved"
m.detail.mode = detailPreview m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams()) return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
case backlinksLoadedMsg:
if m.detail.entity != nil {
m.detail.backlinks = msg.backlinks
}
return m, nil
case tagsLoadedMsg: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter m.state = stateTagFilter
return m, nil return m, nil
case staleEntitiesLoadedMsg:
m.stumble = newStumbleModel()
m.stumble.setEntries(msg.entities)
m.stumble.setSize(m.width, m.contentHeight())
m.state = stateStumble
return m, nil
case stumbleActionMsg:
return m, m.setStatus(msg.action)
case editorFinishedMsg: case editorFinishedMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
} else {
m.status = "updated"
}
return m, m.reloadAfterEdit() return m, m.reloadAfterEdit()
}
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
case confirmTimeoutMsg: case confirmTimeoutMsg:
if m.state == stateConfirm { if m.state == stateConfirm {
@@ -261,6 +323,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case statusClearMsg:
if msg.seq == m.statusSeq {
m.status = ""
}
return m, nil
case errMsg: case errMsg:
m.err = msg.err m.err = msg.err
return m, nil return m, nil
@@ -268,8 +336,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
m.err = nil m.err = nil
switch m.state { switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter: case stateTagFilter:
return m.updateTagFilter(msg) return m.updateTagFilter(msg)
case stateConfirm: case stateConfirm:
@@ -278,9 +344,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updatePromote(msg) return m.updatePromote(msg)
case stateAbsorb: case stateAbsorb:
return m.updateAbsorb(msg) return m.updateAbsorb(msg)
case stateStumble:
return m.updateStumble(msg)
default: default:
return m.updateKeys(msg) return m.updateKeys(msg)
} }
default:
if m.focus == focusCapture {
var cmd tea.Cmd
m.input.ti, cmd = m.input.ti.Update(msg)
return m, cmd
}
} }
return m, nil return m, nil
@@ -294,8 +369,168 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.focus == focusCapture {
return m.updateCapture(msg)
}
return m.updateBrowse(msg)
}
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
val := m.input.ti.Value()
if val == "" {
cmd := m.setFocus(focusList)
return m, cmd
}
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.queryDateFrom = result.dateFrom
m.queryDateTo = result.dateTo
m.queryCardType = result.cardType
m.input.clearText()
m.autocomplete.active = false
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
cmd := m.setFocus(focusList)
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
}
m.applySearch()
cmd := m.setFocus(focusList)
return m, cmd
}
if result.entity != nil {
m.autocomplete.active = false
return m, createEntity(m.store, result.entity)
}
return m, nil
case "tab":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "esc":
if m.autocomplete.active {
m.autocomplete.active = false
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "up":
if m.autocomplete.active {
m.autocomplete.moveUp()
return m, nil
}
case "down":
if m.autocomplete.active {
m.autocomplete.moveDown()
return m, nil
}
}
m.input = m.input.updateKey(msg)
m.updateAutocompleteSuggestions()
return m, nil
}
func (m *model) updateAutocompleteSuggestions() {
val := m.input.ti.Value()
pos := m.input.ti.Position()
start, end, prefix, ok := tagTokenAtCursor(val, pos)
if !ok || prefix == "" {
m.autocomplete.active = false
return
}
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
if len(suggestions) == 0 {
m.autocomplete.active = false
return
}
m.autocomplete.suggestions = suggestions
m.autocomplete.prefix = prefix
m.autocomplete.tokenStart = start
m.autocomplete.tokenEnd = end
m.autocomplete.active = true
if m.autocomplete.cursor >= len(suggestions) {
m.autocomplete.cursor = 0
}
}
func (m *model) acceptAutocomplete() {
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
return
}
selected := m.autocomplete.selected()
if selected == "" {
return
}
val := m.input.ti.Value()
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
m.input.ti.SetValue(newVal)
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
m.autocomplete.active = false
}
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList {
switch msg.String() {
case "j", "k", "up", "down":
m.tagRail = m.tagRail.update(msg.String())
return m, nil
case "enter":
tag := m.tagRail.selectedTag()
if tag != "" {
if tag == m.filterTag {
m.filterTag = ""
m.tagRail.activeTag = ""
} else {
m.filterTag = tag
m.tagRail.activeTag = tag
}
m.focus = focusList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "l", "tab", "esc":
m.focus = focusList
return m, nil
case "ctrl+b":
m.showTagRail = false
m.focus = focusList
m.recalcSizes()
return m, nil
}
}
// ctrl+b toggle from any browse focus
if msg.String() == "ctrl+b" && m.state == stateList {
m.showTagRail = !m.showTagRail
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
}
if m.splitDetail && m.state == stateList { if m.splitDetail && m.state == stateList {
switch msg.String() { switch msg.String() {
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "l": case "l":
if m.focus == focusList { if m.focus == focusList {
m.focus = focusDetail m.focus = focusDetail
@@ -306,6 +541,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = focusList m.focus = focusList
return m, nil return m, nil
} }
if m.focus == focusList && m.railVisible() {
m.focus = focusTagRail
return m, nil
}
case "esc": case "esc":
if m.focus == focusDetail { if m.focus == focusDetail {
m.focus = focusList m.focus = focusList
@@ -380,9 +619,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
switch msg.String() { switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "q": case "q":
if m.state == stateList { if m.state == stateList {
return m, tea.Quit return m, tea.Quit
@@ -414,12 +650,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "s": case "s":
if m.mode == modeCards && m.state == stateList { if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next() m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String() return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
return m, loadEntities(m.store, m.listParams())
} }
return m, nil return m, nil
case "tab": case "S":
if m.state == stateList {
return m, loadStaleEntities(m.store)
}
return m, nil
case "T":
t := cycleTheme()
return m, m.setStatus("theme: " + t.Name)
case "i":
if m.mode == modeCards && m.state == stateList { if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next()) m.cards.setIntent(m.cards.intent.next())
if m.hasSearch() { if m.hasSearch() {
@@ -429,12 +674,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "h":
if m.state == stateList && m.railVisible() && m.focus == focusList {
m.focus = focusTagRail
return m, nil
}
return m, nil
case "a": case "a":
if m.state == stateList { if m.state == stateList {
m.state = stateInput cmd := m.setFocus(focusCapture)
m.input.focus() return m, cmd
m.recalcSizes()
return m, m.input.ti.Focus()
} }
case "esc": case "esc":
@@ -474,9 +728,16 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.state == stateList && m.hasSearch() { if m.state == stateList && m.hasSearch() {
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
m.searchQuery = "" m.searchQuery = ""
m.searchTags = nil m.searchTags = nil
m.queryDateFrom = nil
m.queryDateTo = nil
m.queryCardType = nil
m.status = "" m.status = ""
if hadDBFilters {
return m, loadEntities(m.store, m.listParams())
}
if m.mode == modeCards { if m.mode == modeCards {
m.cards.applyFilter() m.cards.applyFilter()
} else { } else {
@@ -489,6 +750,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "" m.status = ""
return m, loadEntities(m.store, m.listParams()) return m, loadEntities(m.store, m.listParams())
} }
if m.state == stateList {
cmd := m.setFocus(focusCapture)
return m, cmd
}
return m, nil return m, nil
case "d": case "d":
@@ -531,8 +796,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
e := m.selectedEntity() e := m.selectedEntity()
if e != nil { if e != nil {
if e.CardType != nil { if e.CardType != nil {
m.status = "target must be fluid" return m, m.setStatus("target must be fluid")
return m, nil
} }
return m, loadAbsorbSources(m.store, e.ID) return m, loadAbsorbSources(m.store, e.ID)
} }
@@ -542,8 +806,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
e := m.selectedEntity() e := m.selectedEntity()
if e != nil { if e != nil {
if e.CardType != nil { if e.CardType != nil {
m.status = "already a card" return m, m.setStatus("already a card")
return m, nil
} }
m.promote = newPromoteModel(e.ID, e.Body) m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote m.state = statePromote
@@ -554,8 +817,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "D": case "D":
if m.state == stateDetail && m.detail.entity != nil { if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil { if m.detail.entity.CardType == nil {
m.status = "already fluid" return m, m.setStatus("already fluid")
return m, nil
} }
return m, demoteEntity(m.store, m.detail.entity.ID) return m, demoteEntity(m.store, m.detail.entity.ID)
} }
@@ -609,6 +871,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else { } else {
m.state = stateDetail m.state = stateDetail
} }
return m, loadBacklinks(m.store, e.ID)
} }
} }
return m, nil return m, nil
@@ -634,37 +897,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateList
m.input.reset()
m.recalcSizes()
return m, nil
case "enter":
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.state = stateList
m.input.reset()
m.recalcSizes()
m.applySearch()
return m, nil
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc", "q": case "esc", "q":
@@ -726,6 +958,57 @@ func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
} }
func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.stumble.done {
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case "n", "right":
m.stumble.advance()
if m.stumble.done {
return m, m.setStatus("all caught up")
}
return m, nil
case "d":
e := m.stumble.current()
if e != nil {
m.stumble.removeCurrent()
return m, stumbleDismiss(m.store, e.ID)
}
return m, nil
case "!":
e := m.stumble.current()
if e != nil {
m.stumble.advance()
return m, stumblePin(m.store, e.ID)
}
return m, nil
case "p":
e := m.stumble.current()
if e != nil {
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
case "m":
e := m.stumble.current()
if e != nil {
if e.CardType != nil {
return m, m.setStatus("target must be fluid")
}
return m, loadAbsorbSources(m.store, e.ID)
}
return m, nil
}
return m, nil
}
func (m model) View() string { func (m model) View() string {
if m.showHelp { if m.showHelp {
return renderHelp(m.width, m.height) return renderHelp(m.width, m.height)
@@ -733,7 +1016,7 @@ func (m model) View() string {
var content string var content string
switch m.state { switch m.state {
case stateList, stateInput, stateConfirm: case stateList, stateConfirm:
listContent := m.listContent() listContent := m.listContent()
if m.splitDetail { if m.splitDetail {
lw, rw := m.splitWidths() lw, rw := m.splitWidths()
@@ -745,6 +1028,13 @@ func (m model) View() string {
} else { } else {
content = listContent content = listContent
} }
if m.railVisible() {
rw := m.railWidth()
ch := m.contentHeight()
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
sep := m.renderSeparator()
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
}
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
case stateTagFilter: case stateTagFilter:
@@ -753,37 +1043,48 @@ func (m model) View() string {
content = m.promote.view(m.width) content = m.promote.view(m.width)
case stateAbsorb: case stateAbsorb:
content = m.absorb.view(m.width) content = m.absorb.view(m.width)
case stateStumble:
content = m.stumble.view()
} }
header := m.headerView() header := m.headerView()
footer := m.footerView() captureBar := m.input.viewBar(m.width, m.focus == focusCapture)
statusLine := m.statusLine()
return header + "\n" + content + "\n" + footer content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
acView := m.autocomplete.view(m.width)
if acView != "" {
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
}
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
}
func (m model) listWidth() int {
if m.splitDetail {
lw, _ := m.splitWidths()
return lw
}
w := m.width - m.railWidth()
if m.railVisible() {
w--
}
return w
} }
func (m model) listContent() string { func (m model) listContent() string {
lw := m.listWidth()
if m.mode == modeCards { if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw) return m.cards.view(lw)
} }
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.list.view(lw) return m.list.view(lw)
} }
func (m model) headerView() string { func (m model) headerView() string {
header := titleStyle.Render("nib") header := titleStyle.Render("nib") + " "
header += renderTab("stream", "1", m.mode == modeStream)
modeName := "stream" header += " " + separatorStyle.Render("│") + " "
if m.mode == modeCards { header += renderTab("cards", "2", m.mode == modeCards)
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
if m.filterTag != "" { if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag) header += " " + filterPillStyle.Render("#"+m.filterTag)
@@ -797,6 +1098,15 @@ func (m model) headerView() string {
for _, t := range m.searchTags { for _, t := range m.searchTags {
pill += " #" + t pill += " #" + t
} }
if m.queryDateFrom != nil {
pill += " from:" + *m.queryDateFrom
}
if m.queryDateTo != nil {
pill += " to:" + *m.queryDateTo
}
if m.queryCardType != nil {
pill += " ^" + string(*m.queryCardType)
}
header += " " + searchPillStyle.Render(pill) header += " " + searchPillStyle.Render(pill)
} }
@@ -811,11 +1121,7 @@ func (m model) headerView() string {
return header return header
} }
func (m model) footerView() string { func (m model) statusLine() string {
if m.state == stateInput {
return m.input.view(m.width)
}
if m.state == stateConfirm { if m.state == stateConfirm {
return renderConfirm(m.confirmID) return renderConfirm(m.confirmID)
} }
@@ -824,46 +1130,69 @@ func (m model) footerView() string {
return errorStyle.Render("error: " + m.err.Error()) return errorStyle.Render("error: " + m.err.Error())
} }
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width) return renderStatusBar(m, m.width)
} }
func (m model) contentHeight() int { func (m model) contentHeight() int {
return m.height - 3 - m.drawerHeight() h := m.height - 4
} if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
n := m.autocomplete.visibleCount()
func (m model) drawerHeight() int { if len(m.autocomplete.suggestions) > maxSuggestions {
if m.state == stateInput { n++
return drawerLines()
} }
return 0 h -= n + 1
}
if h < 1 {
h = 1
}
return h
} }
func (m *model) recalcSizes() { func (m *model) recalcSizes() {
ch := m.contentHeight() ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail { if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths() _, rw := m.splitWidths()
m.list.setSize(lw, ch) m.list.setSize(lw, ch)
m.cards.setSize(lw, ch) m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch) m.detail.setSize(rw, ch)
} else { } else {
m.list.setSize(m.width, ch) m.list.setSize(lw, ch)
m.cards.setSize(m.width, ch) m.cards.setSize(lw, ch)
m.detail.setSize(m.width, ch) m.detail.setSize(lw, ch)
} }
m.filter.setHeight(ch) m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
} }
func (m model) isSplit() bool { func (m model) isSplit() bool {
return m.width >= 100 return m.width >= 100
} }
func (m model) railVisible() bool {
return m.showTagRail && m.width >= 100
}
func (m model) railWidth() int {
if !m.railVisible() {
return 0
}
w := m.width * 18 / 100
if w < 16 {
w = 16
}
return w
}
func (m model) splitWidths() (int, int) { func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100 avail := m.width - m.railWidth()
right := m.width - left - 1 if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right return left, right
} }
@@ -890,8 +1219,9 @@ func (m model) selectedEntity() *db.Entity {
func (m model) reloadDetail(id string) tea.Cmd { func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch( return tea.Batch(
loadEntities(m.store, m.listParams()), loadEntities(m.store, m.listParams()),
loadBacklinks(m.store, id),
func() tea.Msg { func() tea.Msg {
e, err := m.store.Get(id) e, err := m.store.Get(context.Background(), id)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
+59 -22
View File
@@ -2,24 +2,52 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type hint struct {
key string
desc string
}
func renderHints(hints []hint) string {
parts := make([]string, len(hints))
for i, h := range hints {
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
}
return strings.Join(parts, " ")
}
func renderTab(label, key string, active bool) string {
if active {
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
}
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
}
func renderStatusBar(m model, width int) string { func renderStatusBar(m model, width int) string {
left := countText(m) var leftParts []string
right := contextHints(m)
leftRendered := statusStyle.Render(left) if m.status != "" {
rightRendered := helpStyle.Render(right) leftParts = append(leftParts, statusStyle.Render(m.status))
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
} else {
leftParts = append(leftParts, statusStyle.Render(countText(m)))
}
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
right := renderHints(contextHints(m))
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
if gap < 0 { if gap < 0 {
gap = 0 gap = 0
} }
pad := lipgloss.NewStyle().Width(gap).Render("") pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + rightRendered return leftRendered + pad + right
} }
func countText(m model) string { func countText(m model) string {
@@ -35,37 +63,46 @@ func countText(m model) string {
return fmt.Sprintf("%d entities", total) return fmt.Sprintf("%d entities", total)
} }
func contextHints(m model) string { func contextHints(m model) []hint {
switch m.state { switch m.state {
case stateDetail: case stateDetail:
switch m.detail.mode { switch m.detail.mode {
case detailRun: case detailRun:
return "space:toggle j/k:nav r:reset esc:save+exit" return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
case detailFill: case detailFill:
return "tab:next shift+tab:prev enter:copy esc:cancel" return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
default: default:
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
} }
case stateInput:
return ""
case stateTagFilter: case stateTagFilter:
return "j/k:nav enter:select esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm: case stateConfirm:
return "y:confirm n:cancel" return []hint{{"y", "confirm"}, {"n", "cancel"}}
case statePromote: case statePromote:
return "j/k:nav enter:select esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb: case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel" return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
case stateStumble:
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
}
switch m.focus {
case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusTagRail:
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
case focusDetail:
if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
}
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
default: default:
if m.splitDetail { if m.splitDetail {
if m.focus == focusDetail { return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
return "h:list c:copy e:edit p:promote D:demote !:pin esc:back"
}
return "l:detail a:add d:del #:filter esc:close ?:help q:quit"
} }
if m.mode == modeCards { if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
} }
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit" return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
} }
} }
+164
View File
@@ -0,0 +1,164 @@
package tui
import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
const staleThresholdDays = 30
type stumbleModel struct {
entries []*db.Entity
cursor int
width int
height int
done bool
}
func newStumbleModel() stumbleModel {
return stumbleModel{}
}
func (s *stumbleModel) setEntries(entries []*db.Entity) {
s.entries = entries
s.cursor = 0
s.done = len(entries) == 0
}
func (s *stumbleModel) setSize(width, height int) {
s.width = width
s.height = height
}
func (s stumbleModel) current() *db.Entity {
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
return nil
}
return s.entries[s.cursor]
}
func (s *stumbleModel) advance() {
s.cursor++
if s.cursor >= len(s.entries) {
s.done = true
}
}
func (s *stumbleModel) removeCurrent() {
if s.cursor < len(s.entries) {
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
if s.cursor >= len(s.entries) {
s.done = true
}
}
}
func (s stumbleModel) total() int {
return len(s.entries)
}
func (s stumbleModel) view() string {
if s.done {
return s.doneView()
}
e := s.current()
if e == nil {
return s.doneView()
}
w := s.width
var b strings.Builder
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
b.WriteString(detailHeaderStyle.Render(progress))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
b.WriteString("\n\n")
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
title := e.Body
if e.Title != nil {
title = *e.Title
}
if len(title) > w-6 {
title = title[:w-9] + "…"
}
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
b.WriteString("\n")
var meta []string
meta = append(meta, string(e.Glyph))
if e.CardType != nil {
meta = append(meta, string(*e.CardType))
}
for _, t := range e.Tags {
meta = append(meta, tagStyle.Render("#"+t))
}
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
b.WriteString("\n\n")
bodyWidth := w - 6
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(" " + rendered)
b.WriteString("\n\n")
age := daysAgo(e.ModifiedAt)
ageText := fmt.Sprintf("last touched %d days ago", age)
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
lines := strings.Split(b.String(), "\n")
if len(lines) > s.height {
lines = lines[:s.height]
}
return strings.Join(lines, "\n")
}
func (s stumbleModel) doneView() string {
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(detailHeaderStyle.Render(" all caught up"))
b.WriteString("\n\n")
reviewed := s.total()
if reviewed > 0 {
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
} else {
b.WriteString(idStyle.Render(" no stale entries found"))
}
return b.String()
}
func daysAgo(t time.Time) int {
return int(math.Floor(time.Since(t).Hours() / 24))
}
func staleParams() db.ListParams {
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
return db.ListParams{
ModifiedBefore: &threshold,
Sort: "modified_at",
Order: "asc",
Limit: 50,
}
}
+99 -119
View File
@@ -3,123 +3,103 @@ package tui
import "github.com/charmbracelet/lipgloss" import "github.com/charmbracelet/lipgloss"
var ( var (
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} titleStyle lipgloss.Style
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} statusStyle lipgloss.Style
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} listItemStyle lipgloss.Style
selectedItemStyle lipgloss.Style
titleStyle = lipgloss.NewStyle(). glyphStyle lipgloss.Style
Bold(true). completedGlyphStyle lipgloss.Style
Foreground(highlight). tagStyle lipgloss.Style
PaddingLeft(1) idStyle lipgloss.Style
inputPromptStyle lipgloss.Style
statusStyle = lipgloss.NewStyle(). detailHeaderStyle lipgloss.Style
Foreground(dim). detailBodyStyle lipgloss.Style
PaddingLeft(1) helpStyle lipgloss.Style
errorStyle lipgloss.Style
listItemStyle = lipgloss.NewStyle(). dateHeaderStyle lipgloss.Style
PaddingLeft(2) pinnedStyle lipgloss.Style
filterPillStyle lipgloss.Style
selectedItemStyle = lipgloss.NewStyle(). helpKeyStyle lipgloss.Style
PaddingLeft(1). helpDescStyle lipgloss.Style
Bold(true). affordanceStyle lipgloss.Style
Foreground(highlight). useCountStyle lipgloss.Style
SetString("") modeStyle lipgloss.Style
detailLabelStyle lipgloss.Style
glyphStyle = lipgloss.NewStyle(). detailValueStyle lipgloss.Style
Width(2) checkDoneStyle lipgloss.Style
checkPendingStyle lipgloss.Style
completedGlyphStyle = lipgloss.NewStyle(). searchPillStyle lipgloss.Style
Width(2). gutterStyle lipgloss.Style
Foreground(dim) drawerBorderStyle lipgloss.Style
drawerHintsStyle lipgloss.Style
tagStyle = lipgloss.NewStyle(). drawerPreviewStyle lipgloss.Style
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) separatorStyle lipgloss.Style
hintKeyStyle lipgloss.Style
idStyle = lipgloss.NewStyle(). hintDescStyle lipgloss.Style
Foreground(dim) railHeaderStyle lipgloss.Style
railTagStyle lipgloss.Style
inputPromptStyle = lipgloss.NewStyle(). railActiveTagStyle lipgloss.Style
Foreground(highlight). railCountStyle lipgloss.Style
Bold(true) stumbleAgeStyle lipgloss.Style
acSelectedStyle lipgloss.Style
detailHeaderStyle = lipgloss.NewStyle(). acItemStyle lipgloss.Style
Bold(true). backlinkStyle lipgloss.Style
Foreground(highlight).
MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().
PaddingLeft(2).
PaddingTop(1)
helpStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"})
filterPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
helpKeyStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true).
Width(18)
helpDescStyle = lipgloss.NewStyle().
Foreground(dim)
affordanceStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
Bold(true)
useCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
modeStyle = lipgloss.NewStyle().
Foreground(dim).
Bold(true)
detailLabelStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
checkDoneStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
checkPendingStyle = lipgloss.NewStyle().
Foreground(dim)
searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true)
gutterStyle = lipgloss.NewStyle().
Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().
Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().
Foreground(dim)
) )
func init() {
applyTheme()
}
func applyTheme() {
t := activeTheme()
accent := lipgloss.Color(t.Accent)
dim := lipgloss.Color(t.Dim)
muted := lipgloss.Color(t.Muted)
ok := lipgloss.Color(t.Ok)
todo := lipgloss.Color(t.Todo)
event := lipgloss.Color(t.Event)
remind := lipgloss.Color(t.Remind)
danger := lipgloss.Color(t.Danger)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("")
glyphStyle = lipgloss.NewStyle().Width(2)
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
tagStyle = lipgloss.NewStyle().Foreground(ok)
idStyle = lipgloss.NewStyle().Foreground(dim)
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
useCountStyle = lipgloss.NewStyle().Foreground(remind)
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
gutterStyle = lipgloss.NewStyle().Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().Foreground(dim)
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
railTagStyle = lipgloss.NewStyle().Foreground(ok)
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
railCountStyle = lipgloss.NewStyle().Foreground(dim)
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
acItemStyle = lipgloss.NewStyle().Foreground(muted)
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
}
+139
View File
@@ -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()
}
+90
View File
@@ -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)
}
+67 -3
View File
@@ -658,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 = '';
@@ -1457,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();
}, },
@@ -1500,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');
}, },
@@ -1580,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;
} }
@@ -1883,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) {
+1
View File
@@ -97,6 +97,7 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
+4
View File
@@ -1859,5 +1859,9 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
.card-row.exp-full .entity-exp { grid-template-rows: 1fr; } .card-row.exp-full .entity-exp { grid-template-rows: 1fr; }
.entity-item.exp-full .exp-inner, .entity-item.exp-full .exp-inner,
.card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; } .card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; }
.exp-inner--edit { display: flex; flex-direction: column; min-height: 100%; }
.exp-inner--edit .peek-edit-fields { flex: 1; padding: 16px; }
.exp-inner--edit .peek-edit-ta { flex: 1; min-height: 150px; }
.exp-inner--edit .exp-acts { padding: 12px 16px; border-top: 1px solid var(--border); position: sticky; bottom: 0; background: var(--bg); }
main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; } main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; }
} }