39 Commits

Author SHA1 Message Date
lerko e9ecc4c1f7 fix: address code review findings across backend and frontend
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader.
Move PRAGMA foreign_keys outside transaction in v3 migration where
SQLite was silently ignoring it. Escape LIKE wildcards in link
resolution to prevent false matches. Add non-localhost binding warning,
log writeJSON encoder errors, add ?permanent=true for explicit hard
delete, preserve title/description during absorb, use millisecond
backup timestamps, add path.Clean to spaHandler. Frontend gains
checkedJSON() for resp.ok validation, consistent stopPropagation, and
shared renderCardSections() to eliminate duplicate rendering.
2026-05-21 16:02:57 -04:00
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
56 changed files with 3856 additions and 755 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 .
+70
View File
@@ -0,0 +1,70 @@
# Code Review Fixes
## Phase 1: Critical — Concurrency & Data Safety
### 1. ULID entropy not goroutine-safe
- [x] Wrap MonotonicEntropy in LockedMonotonicReader (`internal/ulid/ulid.go`)
- [x] Add concurrent uniqueness test (100 goroutines)
- [x] Verify `go test -race ./internal/ulid/...`
### 2. Migration PRAGMA bug
- [x] Move `PRAGMA foreign_keys = OFF` before transaction in v3 migration (`internal/db/db.go`)
- [x] Re-enable after commit
- [x] Remove dead `currentSchema` constant while here
### 3. LIKE wildcard injection in link resolution
- [x] Escape `%` and `_` in link text before LIKE query (`internal/db/links.go`)
- [x] Add `ESCAPE '\'` clause
- [ ] Test with `[[%]]` and `[[_]]` link text
## Phase 2: High — Security & API Hygiene
### 4. No auth warning for non-localhost binding
- [x] Print loud warning when `--host != 127.0.0.1` and no auth configured (`cmd/serve.go`)
- [ ] Consider `--no-auth` flag requirement for non-localhost
### 5. writeJSON ignores encoder errors
- [x] Log error from `json.Encoder.Encode()` in `writeJSON` (`internal/api/helpers.go`)
### 6. DELETE endpoint semantics
- [x] Add `?permanent=true` query param for hard delete (`internal/api/entities.go`)
- [x] Add `HardDelete` store method (`internal/db/entities.go`)
- [ ] Update frontend and CLI to match
- [x] Keep backward compat: double-delete still works without param
## Phase 3: Medium — Frontend Robustness
### 7. No resp.ok checks on fetch calls
- [x] Add `checkedJSON()` wrapper with error extraction (`web/app.js`)
- [x] All API methods use `checkedJSON(resp)` instead of `resp.json()`
- [ ] Surface API errors to user via notification/toast
### 8. Inconsistent event.stopPropagation
- [x] Add stopPropagation to all renderStreamPeek action buttons
- [x] Add stopPropagation to all renderCardPeek action buttons and inline section buttons
### 9. Duplicate section rendering
- [x] Extract `renderCardSections()` shared function (`web/app.js`)
- [x] Refactor `renderInlineDetail` to use shared function
- [x] Refactor `renderCardPeek` to use shared function
## Phase 4: Medium — Data Integrity
### 10. Absorb discards source title/description
- [x] If target has no title, inherit from source (`internal/db/entities.go`)
- [x] If target has no description, inherit from source
- [ ] Test title/description preservation
## Phase 5: Low — Housekeeping & UX
### 11. Backup path collision
- [x] Use millisecond-precision timestamps (`cmd/backup.go`)
### 12. spaHandler path safety
- [x] Add explicit `path.Clean` in spaHandler (`internal/api/router.go`)
### 13. Focus-peek escape hatch
- [ ] Add visible close/back button in focus-peek mode (`web/app.js`)
### 14. Hard delete confirmation
- [ ] Add confirmation step or undo toast before permanent delete (`web/app.js`)
+4 -4
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(absorbCmd)
}
func runAbsorb(_ *cobra.Command, args []string) error {
func runAbsorb(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
targetID, err := store.Resolve(args[0])
targetID, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
sourceID, err := store.Resolve(args[1])
sourceID, err := store.Resolve(cmd.Context(), args[1])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[1])
}
@@ -40,7 +40,7 @@ func runAbsorb(_ *cobra.Command, args []string) error {
return fmt.Errorf("target and source must be different entities")
}
if err := store.Absorb(targetID, sourceID); err != nil {
if err := store.Absorb(cmd.Context(), targetID, sourceID); err != nil {
if err == db.ErrTargetCrystallized {
return fmt.Errorf("invalid_absorb — target %s is crystallized, demote first",
display.FormatID(targetID))
+2 -2
View File
@@ -17,7 +17,7 @@ var addCmd = &cobra.Command{
RunE: runAdd,
}
func runAdd(_ *cobra.Command, args []string) error {
func runAdd(cmd *cobra.Command, args []string) error {
input := strings.Join(args, " ")
parsed, err := parse.Parse(input)
@@ -47,7 +47,7 @@ func runAdd(_ *cobra.Command, args []string) error {
e.CardType = &ct
}
if err := store.Create(e); err != nil {
if err := store.Create(cmd.Context(), e); err != nil {
return err
}
+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.000"))
if len(args) > 0 {
dst = args[0]
}
store, err := db.Open(srcPath)
if err != nil {
return err
}
defer store.Close()
if err := store.Backup(dst); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
fmt.Printf("backed up to %s\n", dst)
return nil
}
+2 -2
View File
@@ -26,7 +26,7 @@ func init() {
rootCmd.AddCommand(cardsCmd)
}
func runCards(_ *cobra.Command, _ []string) error {
func runCards(cmd *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
@@ -49,7 +49,7 @@ func runCards(_ *cobra.Command, _ []string) error {
p.CardTypeFilter = &ct
}
entities, err := store.List(p)
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
+286
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)
}
func runCopy(_ *cobra.Command, args []string) error {
func runCopy(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
@@ -40,7 +40,7 @@ func runCopy(_ *cobra.Command, args []string) error {
return fmt.Errorf("clipboard: %w", err)
}
if err := store.IncrementUse(id); err != nil {
if err := store.IncrementUse(cmd.Context(), id); err != nil {
return err
}
+3 -3
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(deleteCmd)
}
func runDelete(_ *cobra.Command, args []string) error {
func runDelete(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
result, err := store.SoftDelete(id)
result, err := store.SoftDelete(cmd.Context(), id)
if err != nil {
return err
}
+7 -6
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -35,7 +36,7 @@ type demoEntity struct {
Tags []string `json:"tags"`
}
func runDemo(_ *cobra.Command, _ []string) error {
func runDemo(cmd *cobra.Command, _ []string) error {
tmpDir, err := os.MkdirTemp("", "nib-demo-*")
if err != nil {
return err
@@ -48,7 +49,7 @@ func runDemo(_ *cobra.Command, _ []string) error {
return err
}
if err := seedDemo(store); err != nil {
if err := seedDemo(cmd.Context(), store); err != nil {
store.Close()
return fmt.Errorf("seed demo data: %w", err)
}
@@ -58,7 +59,7 @@ func runDemo(_ *cobra.Command, _ []string) error {
return runServe(nil, nil)
}
func seedDemo(store *db.Store) error {
func seedDemo(ctx context.Context, store *db.Store) error {
data, err := findDemoFile()
if err != nil {
return err
@@ -94,19 +95,19 @@ func seedDemo(store *db.Store) error {
e.CompletedAt = &t
}
if err := store.Create(e); err != nil {
if err := store.Create(ctx, e); err != nil {
return fmt.Errorf("entity %d: %w", i, err)
}
if entry.CardType != nil {
ct := db.CardType(*entry.CardType)
if err := store.Promote(e.ID, ct, entry.CardData); err != nil {
if err := store.Promote(ctx, e.ID, ct, entry.CardData); err != nil {
return fmt.Errorf("promote entity %d: %w", i, err)
}
}
if entry.Deleted {
store.SoftDelete(e.ID)
store.SoftDelete(ctx, e.ID)
}
}
+3 -3
View File
@@ -19,19 +19,19 @@ func init() {
rootCmd.AddCommand(demoteCmd)
}
func runDemote(_ *cobra.Command, args []string) error {
func runDemote(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
if err := store.Demote(id); err != nil {
if err := store.Demote(cmd.Context(), id); err != nil {
if err == db.ErrAlreadyFluid {
return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id))
}
+9 -9
View File
@@ -21,19 +21,19 @@ func init() {
rootCmd.AddCommand(editCmd)
}
func runEdit(_ *cobra.Command, args []string) error {
func runEdit(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
@@ -55,11 +55,11 @@ func runEdit(_ *cobra.Command, args []string) error {
editor = "vi"
}
cmd := exec.Command(editor, tmpfile.Name())
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
editorCmd := exec.Command(editor, tmpfile.Name())
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
return fmt.Errorf("editor: %w", err)
}
@@ -74,7 +74,7 @@ func runEdit(_ *cobra.Command, args []string) error {
return nil
}
if err := store.Update(id, &db.EntityUpdate{Body: &body}); err != nil {
if err := store.Update(cmd.Context(), id, &db.EntityUpdate{Body: &body}); err != nil {
return err
}
+167
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")
}
func runLs(_ *cobra.Command, _ []string) error {
func runLs(cmd *cobra.Command, _ []string) error {
store, err := openStore()
if err != nil {
return err
@@ -88,7 +88,7 @@ func runLs(_ *cobra.Command, _ []string) error {
p.Since = &since
}
entities, err := store.List(p)
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
+4 -4
View File
@@ -20,14 +20,14 @@ func init() {
rootCmd.AddCommand(promoteCmd)
}
func runPromote(_ *cobra.Command, args []string) error {
func runPromote(cmd *cobra.Command, args []string) error {
store, err := openStore()
if err != nil {
return err
}
defer store.Close()
id, err := store.Resolve(args[0])
id, err := store.Resolve(cmd.Context(), args[0])
if err != nil {
return fmt.Errorf("not_found — no entity with id %s", args[0])
}
@@ -40,14 +40,14 @@ func runPromote(_ *cobra.Command, args []string) error {
cardType = db.CardType(args[1])
}
e, err := store.Get(id)
e, err := store.Get(cmd.Context(), id)
if err != nil {
return err
}
cd := carddata.GenerateCardData(cardType, e.Body)
if err := store.Promote(id, cardType, cd); err != nil {
if err := store.Promote(cmd.Context(), id, cardType, cd); err != nil {
if err == db.ErrAlreadyPromoted {
return fmt.Errorf("invalid_promote — entity %s is already a %s",
display.FormatID(id), *e.CardType)
+3
View File
@@ -90,6 +90,9 @@ func runServe(_ *cobra.Command, _ []string) error {
if serveDev {
fmt.Println(" CORS enabled (dev mode)")
}
if serveHost != "127.0.0.1" && serveHost != "localhost" && serveHost != "::1" {
fmt.Fprintln(os.Stderr, " WARNING: binding to non-localhost with no authentication — API is open to the network")
}
var listenErr error
if useTLS {
+14 -2
View File
@@ -6,7 +6,7 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/cobra v1.10.2
@@ -14,33 +14,45 @@ require (
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/glamour v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
@@ -23,6 +33,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -33,6 +45,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -41,12 +55,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -56,6 +75,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -65,21 +86,34 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+32 -15
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")
return
}
if limit > 200 {
limit = 200
}
p.Limit = limit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -106,13 +109,13 @@ func listEntities(store *db.Store) http.HandlerFunc {
p.Limit = 50
}
total, err := store.Count(p)
total, err := store.Count(r.Context(), p)
if err != nil {
writeInternalError(w, err)
return
}
entities, err := store.List(p)
entities, err := store.List(r.Context(), p)
if err != nil {
writeInternalError(w, err)
return
@@ -174,7 +177,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
e.CardData = req.CardData
}
if err := store.Create(e); err != nil {
if err := store.Create(r.Context(), e); err != nil {
if err == db.ErrInvalidCardData {
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
return
@@ -190,7 +193,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
func getEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
@@ -240,7 +243,7 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u.CardType = &ct
}
if err := store.Update(id, u); err != nil {
if err := store.Update(r.Context(), id, u); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -253,7 +256,7 @@ func updateEntity(store *db.Store) http.HandlerFunc {
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeInternalError(w, err)
return
@@ -269,7 +272,21 @@ type DeleteResponse struct {
func deleteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
result, err := store.SoftDelete(id)
if r.URL.Query().Get("permanent") == "true" {
if err := store.HardDelete(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, DeleteResponse{Result: "hard"})
return
}
result, err := store.SoftDelete(r.Context(), id)
if err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
@@ -304,7 +321,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
return
}
if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil {
if err := store.Promote(r.Context(), id, db.CardType(req.CardType), req.CardData); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -321,7 +338,7 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeInternalError(w, err)
return
@@ -334,7 +351,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.Demote(id); err != nil {
if err := store.Demote(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -347,7 +364,7 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeInternalError(w, err)
return
@@ -378,7 +395,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
return
}
if err := store.Absorb(id, req.SourceID); err != nil {
if err := store.Absorb(r.Context(), id, req.SourceID); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "target or source entity not found")
return
@@ -391,7 +408,7 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeInternalError(w, err)
return
@@ -404,7 +421,7 @@ func useEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := store.IncrementUse(id); err != nil {
if err := store.IncrementUse(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
@@ -413,7 +430,7 @@ func useEntity(store *db.Store) http.HandlerFunc {
return
}
e, err := store.Get(id)
e, err := store.Get(r.Context(), id)
if err != nil {
writeInternalError(w, err)
return
+3 -1
View File
@@ -38,7 +38,9 @@ type EntityResponse struct {
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON encode error: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
+1 -1
View File
@@ -47,7 +47,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
indexHTML, _ := fs.ReadFile(fsys, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
p := path.Clean(r.URL.Path)
if p == "/" || path.Ext(p) == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
+1 -1
View File
@@ -14,7 +14,7 @@ type TagResponse struct {
func listTags(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardsOnly := r.URL.Query().Get("cards_only") == "true"
tags, err := store.ListTags(cardsOnly)
tags, err := store.ListTags(r.Context(), cardsOnly)
if err != nil {
writeInternalError(w, err)
return
+49 -11
View File
@@ -51,7 +51,10 @@ func (s *Store) Close() error {
return s.db.Close()
}
const currentSchema = 3
func (s *Store) Backup(dst string) error {
_, err := s.db.Exec("VACUUM INTO ?", dst)
return err
}
var migrations = []func(db *sql.DB) error{
// v1: initial schema
@@ -92,24 +95,29 @@ var migrations = []func(db *sql.DB) error{
// v2: add title and description columns
func(db *sql.DB) error {
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil {
return fmt.Errorf("add title column: %w", err)
}
if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil {
return fmt.Errorf("add description column: %w", err)
}
return nil
},
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
func(db *sql.DB) error {
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
tx, err := db.Begin()
if err != nil {
db.Exec(`PRAGMA foreign_keys = ON`)
return err
}
defer tx.Rollback()
// Disable FK checks during rebuild to avoid dangling references
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
return fmt.Errorf("migrate rename: %w", err)
}
@@ -160,11 +168,41 @@ var migrations = []func(db *sql.DB) error{
return fmt.Errorf("migrate tags drop: %w", err)
}
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
if err := tx.Commit(); err != nil {
db.Exec(`PRAGMA foreign_keys = ON`)
return err
}
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("migrate fk on: %w", err)
}
return nil
},
return tx.Commit()
// v4: add indexes for common query filters
func(db *sql.DB) error {
for _, idx := range []string{
`CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`,
`CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`,
} {
if _, err := db.Exec(idx); err != nil {
return fmt.Errorf("create index: %w", err)
}
}
return nil
},
// v5: add entity_links table for wiki-links
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE entity_links (
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
link_text TEXT NOT NULL,
PRIMARY KEY (from_id, link_text)
);
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
`)
return err
},
}
@@ -200,7 +238,7 @@ func DefaultPath() (string, error) {
return "", err
}
dir := filepath.Join(home, ".nib")
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return filepath.Join(dir, "nib.db"), nil
+91 -49
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -71,6 +72,7 @@ type ListParams struct {
From *string
To *string
Since *time.Time
ModifiedBefore *time.Time
CardsOnly bool
IncludeDeleted bool
CardTypeFilter *CardType
@@ -103,7 +105,7 @@ type EntityUpdate struct {
Tags *[]string
}
func (s *Store) Create(e *Entity) error {
func (s *Store) Create(ctx context.Context, e *Entity) error {
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
return ErrInvalidCardData
}
@@ -115,13 +117,13 @@ func (s *Store) Create(e *Entity) error {
e.Tags = []string{}
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`
_, err = tx.ExecContext(ctx, `
INSERT INTO entities (id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at)
@@ -146,18 +148,22 @@ func (s *Store) Create(e *Entity) error {
return err
}
if err := insertTags(tx, e.ID, e.Tags); err != nil {
if err := insertTags(ctx, tx, e.ID, e.Tags); err != nil {
return err
}
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) Get(id string) (*Entity, error) {
func (s *Store) Get(ctx context.Context, id string) (*Entity, error) {
e := &Entity{}
row := newEntityRow()
err := s.db.QueryRow(`
err := s.db.QueryRowContext(ctx, `
SELECT id, created_at, modified_at, body, title, description,
glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at
@@ -173,7 +179,7 @@ func (s *Store) Get(id string) (*Entity, error) {
return nil, fmt.Errorf("scan entity %s: %w", id, err)
}
tags, err := s.loadTags(id)
tags, err := s.loadTags(ctx, id)
if err != nil {
return nil, err
}
@@ -216,6 +222,10 @@ func listWhere(params ListParams) (string, []any) {
where = append(where, "e.card_type = ?")
args = append(args, string(*params.CardTypeFilter))
}
if params.ModifiedBefore != nil {
where = append(where, "e.modified_at < ?")
args = append(args, params.ModifiedBefore.Format(time.RFC3339))
}
clause := ""
if len(where) > 0 {
@@ -224,21 +234,23 @@ func listWhere(params ListParams) (string, []any) {
return clause, args
}
func (s *Store) Count(params ListParams) (int, error) {
func (s *Store) Count(ctx context.Context, params ListParams) (int, error) {
whereClause, args := listWhere(params)
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
var count int
err := s.db.QueryRow(query, args...).Scan(&count)
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
func (s *Store) List(params ListParams) ([]*Entity, error) {
func (s *Store) List(ctx context.Context, params ListParams) ([]*Entity, error) {
whereClause, args := listWhere(params)
orderCol := "e.created_at"
switch params.Sort {
case "use_count":
orderCol = "e.use_count"
case "modified_at":
orderCol = "e.modified_at"
case "created_at", "":
orderCol = "e.created_at"
default:
@@ -268,7 +280,7 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
args = append(args, limit, params.Offset)
rows, err := s.db.Query(query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -290,20 +302,20 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
return nil, err
}
if err := s.batchLoadTags(entities); err != nil {
if err := s.batchLoadTags(ctx, entities); err != nil {
return nil, err
}
return entities, nil
}
func (s *Store) Update(id string, u *EntityUpdate) error {
existing, err := s.Get(id)
func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
existing, err := s.Get(ctx, id)
if err != nil {
return err
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -362,15 +374,21 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
args = append(args, existing.ID)
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
if _, err := tx.Exec(query, args...); err != nil {
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if u.Tags != nil {
if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
return err
}
if err := insertTags(tx, existing.ID, *u.Tags); err != nil {
if err := insertTags(ctx, tx, existing.ID, *u.Tags); err != nil {
return err
}
}
if u.Body != nil {
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
return err
}
}
@@ -378,8 +396,8 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
return tx.Commit()
}
func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
e, err := s.Get(id)
func (s *Store) Promote(ctx context.Context, id string, cardType CardType, cardData *string) error {
e, err := s.Get(ctx, id)
if err != nil {
return err
}
@@ -395,15 +413,15 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
dataVal = *cardData
}
_, err = s.db.Exec(`
_, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
WHERE id = ?`,
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
return err
}
func (s *Store) Demote(id string) error {
e, err := s.Get(id)
func (s *Store) Demote(ctx context.Context, id string) error {
e, err := s.Get(ctx, id)
if err != nil {
return err
}
@@ -411,7 +429,7 @@ func (s *Store) Demote(id string) error {
return ErrAlreadyFluid
}
_, err = s.db.Exec(`
_, err = s.db.ExecContext(ctx, `
UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ?
WHERE id = ?`,
@@ -426,9 +444,9 @@ const (
DeletedHard
)
func (s *Store) SoftDelete(id string) (DeleteResult, error) {
func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error) {
var deletedAt sql.NullString
err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
err := s.db.QueryRowContext(ctx, "SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
if err == sql.ErrNoRows {
return 0, ErrNotFound
}
@@ -437,21 +455,33 @@ func (s *Store) SoftDelete(id string) (DeleteResult, error) {
}
if deletedAt.Valid {
_, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id)
_, err = s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
return DeletedHard, err
}
_, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
_, err = s.db.ExecContext(ctx, "UPDATE entities SET deleted_at = ? WHERE id = ?",
time.Now().UTC().Format(time.RFC3339), id)
return DeletedSoft, err
}
func (s *Store) Absorb(targetID, sourceID string) error {
target, err := s.Get(targetID)
func (s *Store) HardDelete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
if err != nil {
return err
}
source, err := s.Get(sourceID)
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
target, err := s.Get(ctx, targetID)
if err != nil {
return err
}
source, err := s.Get(ctx, sourceID)
if err != nil {
return err
}
@@ -460,7 +490,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
return ErrTargetCrystallized
}
tx, err := s.db.Begin()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -469,8 +499,18 @@ func (s *Store) Absorb(targetID, sourceID string) error {
now := time.Now().UTC().Format(time.RFC3339)
merged := target.Body + "\n" + source.Body
if _, err := tx.Exec("UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
merged, now, targetID); err != nil {
title := target.Title
if title == nil {
title = source.Title
}
desc := target.Description
if desc == nil {
desc = source.Description
}
if _, err := tx.ExecContext(ctx,
"UPDATE entities SET body = ?, title = ?, description = ?, modified_at = ? WHERE id = ?",
merged, title, desc, now, targetID); err != nil {
return err
}
@@ -480,15 +520,19 @@ func (s *Store) Absorb(targetID, sourceID string) error {
}
for _, t := range source.Tags {
if !seen[t] {
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
targetID, t); err != nil {
return err
}
}
}
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
return err
}
if source.CardType != nil {
if _, err := tx.Exec(`UPDATE entities SET card_type = NULL, card_data = NULL,
if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
now, sourceID); err != nil {
return err
@@ -496,7 +540,7 @@ func (s *Store) Absorb(targetID, sourceID string) error {
}
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
absorbNote, now, now, sourceID); err != nil {
return err
}
@@ -504,8 +548,8 @@ func (s *Store) Absorb(targetID, sourceID string) error {
return tx.Commit()
}
func (s *Store) IncrementUse(id string) error {
res, err := s.db.Exec(`
func (s *Store) IncrementUse(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
@@ -519,8 +563,8 @@ func (s *Store) IncrementUse(id string) error {
return nil
}
func (s *Store) Resolve(prefix string) (string, error) {
rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
func (s *Store) Resolve(ctx context.Context, prefix string) (string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
if err != nil {
return "", err
}
@@ -586,9 +630,7 @@ func (r *entityRow) apply(e *Entity) error {
return nil
}
// helpers
func (s *Store) batchLoadTags(entities []*Entity) error {
func (s *Store) batchLoadTags(ctx context.Context, entities []*Entity) error {
if len(entities) == 0 {
return nil
}
@@ -608,7 +650,7 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
strings.Join(placeholders, ","),
)
rows, err := s.db.Query(query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
@@ -626,8 +668,8 @@ func (s *Store) batchLoadTags(entities []*Entity) error {
return rows.Err()
}
func (s *Store) loadTags(entityID string) ([]string, error) {
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
func (s *Store) loadTags(ctx context.Context, entityID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
if err != nil {
return nil, err
}
@@ -650,9 +692,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
return tags, nil
}
func insertTags(tx *sql.Tx, entityID string, tags []string) error {
func insertTags(ctx context.Context, tx *sql.Tx, entityID string, tags []string) error {
for _, tag := range tags {
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
entityID, tag); err != nil {
return err
}
+116 -87
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"testing"
"time"
)
@@ -11,15 +12,16 @@ func ptr[T any](v T) *T {
func TestCreate_Note(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "hello world", Glyph: GlyphNote}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
if e.ID == "" {
t.Fatal("ID not set")
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -33,12 +35,13 @@ func TestCreate_Note(t *testing.T) {
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -49,12 +52,13 @@ func TestCreate_TodoWithTimeAnchor(t *testing.T) {
func TestCreate_WithTags(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -65,13 +69,14 @@ func TestCreate_WithTags(t *testing.T) {
func TestCreate_WithCardType(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -82,7 +87,7 @@ func TestCreate_WithCardType(t *testing.T) {
func TestGet_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.Get("01NONEXISTENT0000000000000")
_, err := s.Get(context.Background(), "01NONEXISTENT0000000000000")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
@@ -90,11 +95,12 @@ func TestGet_NotFound(t *testing.T) {
func TestList_DefaultParams(t *testing.T) {
s := testStore(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
}
entities, err := s.List(DefaultListParams())
entities, err := s.List(ctx, DefaultListParams())
if err != nil {
t.Fatal(err)
}
@@ -109,15 +115,16 @@ func TestList_DefaultParams(t *testing.T) {
func TestList_FilterByTag(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
p := DefaultListParams()
tag := "ops"
p.Tag = &tag
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -128,13 +135,14 @@ func TestList_FilterByTag(t *testing.T) {
func TestList_FilterByDate(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "today", Glyph: GlyphNote})
p := DefaultListParams()
date := time.Now().UTC().Format("2006-01-02")
p.Date = &date
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -144,7 +152,7 @@ func TestList_FilterByDate(t *testing.T) {
otherDate := "2020-01-01"
p.Date = &otherDate
entities, err = s.List(p)
entities, err = s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -155,13 +163,14 @@ func TestList_FilterByDate(t *testing.T) {
func TestList_CardsOnly(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote})
ct := CardSnippet
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
p := DefaultListParams()
p.CardsOnly = true
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -175,12 +184,13 @@ func TestList_CardsOnly(t *testing.T) {
func TestList_IncludeDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.SoftDelete(e.ID)
s.Create(ctx, e)
s.SoftDelete(ctx, e.ID)
p := DefaultListParams()
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -189,7 +199,7 @@ func TestList_IncludeDeleted(t *testing.T) {
}
p.IncludeDeleted = true
entities, err = s.List(p)
entities, err = s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -200,17 +210,18 @@ func TestList_IncludeDeleted(t *testing.T) {
func TestList_SortByUseCount(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
s.Create(e1)
s.Create(e2)
s.IncrementUse(e2.ID)
s.IncrementUse(e2.ID)
s.Create(ctx, e1)
s.Create(ctx, e2)
s.IncrementUse(ctx, e2.ID)
s.IncrementUse(ctx, e2.ID)
p := DefaultListParams()
p.Sort = "use_count"
entities, err := s.List(p)
entities, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -221,14 +232,15 @@ func TestList_SortByUseCount(t *testing.T) {
func TestList_Pagination(t *testing.T) {
s := testStore(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
s.Create(ctx, &Entity{Body: "note", Glyph: GlyphNote})
}
p := DefaultListParams()
p.Limit = 3
p.Offset = 0
page1, err := s.List(p)
page1, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -237,7 +249,7 @@ func TestList_Pagination(t *testing.T) {
}
p.Offset = 3
page2, err := s.List(p)
page2, err := s.List(ctx, p)
if err != nil {
t.Fatal(err)
}
@@ -251,16 +263,17 @@ func TestList_Pagination(t *testing.T) {
func TestUpdate_Body(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "old", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
time.Sleep(1100 * time.Millisecond)
newBody := "new"
if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Body: &newBody}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.Body != "new" {
t.Errorf("body not updated: %q", got.Body)
}
@@ -271,15 +284,16 @@ func TestUpdate_Body(t *testing.T) {
func TestUpdate_Tags(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
s.Create(e)
s.Create(ctx, e)
newTags := []string{"new1", "new2"}
if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if len(got.Tags) != 2 {
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
}
@@ -287,14 +301,15 @@ func TestUpdate_Tags(t *testing.T) {
func TestPromote_Success(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
if err := s.Promote(e.ID, CardSnippet, nil); err != nil {
if err := s.Promote(ctx, e.ID, CardSnippet, nil); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.CardType == nil || *got.CardType != CardSnippet {
t.Errorf("expected snippet, got %v", got.CardType)
}
@@ -302,26 +317,28 @@ func TestPromote_Success(t *testing.T) {
func TestPromote_AlreadyPromoted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
s.Create(e)
s.Create(ctx, e)
if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
if err := s.Promote(ctx, e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
}
}
func TestDemote_Success(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Promote(e.ID, CardSnippet, nil)
s.Create(ctx, e)
s.Promote(ctx, e.ID, CardSnippet, nil)
if err := s.Demote(e.ID); err != nil {
if err := s.Demote(ctx, e.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.CardType != nil {
t.Errorf("expected nil card_type, got %v", got.CardType)
}
@@ -332,20 +349,22 @@ func TestDemote_Success(t *testing.T) {
func TestDemote_AlreadyFluid(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "trick", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
if err := s.Demote(e.ID); err != ErrAlreadyFluid {
if err := s.Demote(ctx, e.ID); err != ErrAlreadyFluid {
t.Errorf("expected ErrAlreadyFluid, got %v", err)
}
}
func TestSoftDelete_First(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
result, err := s.SoftDelete(e.ID)
result, err := s.SoftDelete(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -353,7 +372,7 @@ func TestSoftDelete_First(t *testing.T) {
t.Errorf("expected DeletedSoft, got %d", result)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.DeletedAt == nil {
t.Error("expected deleted_at to be set")
}
@@ -361,11 +380,12 @@ func TestSoftDelete_First(t *testing.T) {
func TestSoftDelete_Second(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
s.SoftDelete(e.ID)
result, err := s.SoftDelete(e.ID)
s.SoftDelete(ctx, e.ID)
result, err := s.SoftDelete(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -373,7 +393,7 @@ func TestSoftDelete_Second(t *testing.T) {
t.Errorf("expected DeletedHard, got %d", result)
}
_, err = s.Get(e.ID)
_, err = s.Get(ctx, e.ID)
if err != ErrNotFound {
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
}
@@ -381,7 +401,7 @@ func TestSoftDelete_Second(t *testing.T) {
func TestSoftDelete_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
_, err := s.SoftDelete(context.Background(), "01NONEXISTENT0000000000000")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
@@ -389,15 +409,16 @@ func TestSoftDelete_NotFound(t *testing.T) {
func TestIncrementUse(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ct := CardSnippet
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
s.Create(e)
s.Create(ctx, e)
if err := s.IncrementUse(e.ID); err != nil {
if err := s.IncrementUse(ctx, e.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.UseCount != 1 {
t.Errorf("expected use_count=1, got %d", got.UseCount)
}
@@ -408,10 +429,11 @@ func TestIncrementUse(t *testing.T) {
func TestResolve_FullID(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
got, err := s.Resolve(e.ID)
got, err := s.Resolve(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -422,10 +444,11 @@ func TestResolve_FullID(t *testing.T) {
func TestResolve_Prefix(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "test", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
got, err := s.Resolve(e.ID[:6])
got, err := s.Resolve(ctx, e.ID[:6])
if err != nil {
t.Fatal(err)
}
@@ -436,7 +459,7 @@ func TestResolve_Prefix(t *testing.T) {
func TestResolve_NotFound(t *testing.T) {
s := testStore(t)
_, err := s.Resolve("ZZZZZZZZZ")
_, err := s.Resolve(context.Background(), "ZZZZZZZZZ")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
@@ -444,24 +467,25 @@ func TestResolve_NotFound(t *testing.T) {
func TestAbsorb_SourceIsCard(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
s.Create(target)
s.Create(ctx, target)
source := &Entity{Body: "source", Glyph: GlyphNote}
s.Create(source)
s.Promote(source.ID, CardSnippet, nil)
s.IncrementUse(source.ID)
s.Create(ctx, source)
s.Promote(ctx, source.ID, CardSnippet, nil)
s.IncrementUse(ctx, source.ID)
if err := s.Absorb(target.ID, source.ID); err != nil {
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(target.ID)
got, _ := s.Get(ctx, target.ID)
if got.Body != "target\nsource" {
t.Errorf("merged body: %q", got.Body)
}
src, _ := s.Get(source.ID)
src, _ := s.Get(ctx, source.ID)
if src.CardType != nil {
t.Error("source card_type should be cleared after absorb")
}
@@ -475,6 +499,7 @@ func TestAbsorb_SourceIsCard(t *testing.T) {
func TestCreate_WithTitleAndDescription(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{
Body: "body text",
Title: ptr("nginx trick"),
@@ -482,11 +507,11 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
Glyph: GlyphNote,
Tags: []string{"ops"},
}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, err := s.Get(e.ID)
got, err := s.Get(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
@@ -503,12 +528,13 @@ func TestCreate_WithTitleAndDescription(t *testing.T) {
func TestCreate_WithoutTitle(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "just body", Glyph: GlyphNote}
if err := s.Create(e); err != nil {
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.Title != nil {
t.Errorf("expected nil title, got %v", got.Title)
}
@@ -519,15 +545,16 @@ func TestCreate_WithoutTitle(t *testing.T) {
func TestUpdate_Title(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
newTitle := "new title"
if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.Title == nil || *got.Title != "new title" {
t.Errorf("title: got %v", got.Title)
}
@@ -535,15 +562,16 @@ func TestUpdate_Title(t *testing.T) {
func TestUpdate_Description(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "body", Glyph: GlyphNote}
s.Create(e)
s.Create(ctx, e)
newDesc := "new desc"
if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
if err := s.Update(ctx, e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
t.Fatal(err)
}
got, _ := s.Get(e.ID)
got, _ := s.Get(ctx, e.ID)
if got.Description == nil || *got.Description != "new desc" {
t.Errorf("description: got %v", got.Description)
}
@@ -551,16 +579,17 @@ func TestUpdate_Description(t *testing.T) {
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
s.Create(target)
s.Create(source)
s.Create(ctx, target)
s.Create(ctx, source)
if err := s.Absorb(target.ID, source.ID); err != nil {
if err := s.Absorb(ctx, target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(target.ID)
got, _ := s.Get(ctx, target.ID)
if got.Title == nil || *got.Title != "target title" {
t.Errorf("target title should be preserved, got %v", got.Title)
}
+88
View File
@@ -0,0 +1,88 @@
package db
import (
"context"
"database/sql"
"strings"
"github.com/lerko/nib/internal/link"
)
type Backlink struct {
EntityID string
Title *string
Body string
LinkText string
}
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
lower := strings.ToLower(linkText)
var id string
err := tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
if err == nil {
return &id
}
err = tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
if err == nil {
return &id
}
return nil
}
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
return err
}
linkTexts := link.ExtractLinks(body)
for _, lt := range linkTexts {
toID := s.resolveLink(ctx, tx, lt, entityID)
if _, err := tx.ExecContext(ctx,
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
entityID, toID, lt); err != nil {
return err
}
}
return nil
}
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.id, e.title, e.body, el.link_text
FROM entity_links el
JOIN entities e ON e.id = el.from_id
WHERE el.to_id = ? AND e.deleted_at IS NULL
ORDER BY e.created_at DESC`, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
var backlinks []Backlink
for rows.Next() {
var bl Backlink
var title sql.NullString
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
return nil, err
}
if title.Valid {
bl.Title = &title.String
}
backlinks = append(backlinks, bl)
}
return backlinks, rows.Err()
}
+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
import "context"
type TagCount struct {
Tag string
Count int
}
func (s *Store) ListTags(cardsOnly bool) ([]TagCount, error) {
func (s *Store) ListTags(ctx context.Context, cardsOnly bool) ([]TagCount, error) {
where := "WHERE e.deleted_at IS NULL"
if cardsOnly {
where += " AND e.card_type IS NOT NULL"
}
rows, err := s.db.Query(`
rows, err := s.db.QueryContext(ctx, `
SELECT t.tag, COUNT(*) as cnt
FROM entity_tags t
JOIN entities e ON t.entity_id = e.id
` + where + `
`+where+`
GROUP BY t.tag
ORDER BY t.tag`)
if err != nil {
+20 -14
View File
@@ -1,10 +1,13 @@
package db
import "testing"
import (
"context"
"testing"
)
func TestListTags_Empty(t *testing.T) {
s := testStore(t)
tags, err := s.ListTags(false)
tags, err := s.ListTags(context.Background(), false)
if err != nil {
t.Fatal(err)
}
@@ -15,11 +18,12 @@ func TestListTags_Empty(t *testing.T) {
func TestListTags_Counts(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
s.Create(ctx, &Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
s.Create(ctx, &Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
tags, err := s.ListTags(false)
tags, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
@@ -44,13 +48,14 @@ func TestListTags_Counts(t *testing.T) {
func TestListTags_ExcludesDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
s.Create(e)
s.SoftDelete(e.ID)
s.Create(ctx, e)
s.SoftDelete(ctx, e.ID)
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
s.Create(ctx, &Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
tags, err := s.ListTags(false)
tags, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
@@ -64,12 +69,13 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
func TestListTags_CardsOnly(t *testing.T) {
s := testStore(t)
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
ctx := context.Background()
s.Create(ctx, &Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
ct := CardSnippet
s.Create(&Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
s.Create(ctx, &Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
all, err := s.ListTags(false)
all, err := s.ListTags(ctx, false)
if err != nil {
t.Fatal(err)
}
@@ -77,7 +83,7 @@ func TestListTags_CardsOnly(t *testing.T) {
t.Fatalf("all tags: expected 3, got %d", len(all))
}
cards, err := s.ListTags(true)
cards, err := s.ListTags(ctx, true)
if err != nil {
t.Fatal(err)
}
+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)
}
}
})
}
}
+53 -12
View File
@@ -4,19 +4,23 @@ import (
"fmt"
"strconv"
"strings"
"time"
)
type Result struct {
Body string
Glyph string
Title *string
Description *string
TimeAnchor *string
Tags []string
FilterTags []string
CardSuffix *string
Pin bool
Query bool
Body string
Glyph string
Title *string
Description *string
TimeAnchor *string
Tags []string
FilterTags []string
CardSuffix *string
Pin bool
Query bool
QueryDateFrom *string
QueryDateTo *string
QueryCardType *string
}
var validCardTypes = map[string]string{
@@ -66,11 +70,48 @@ func Parse(input string) (*Result, error) {
r.Glyph = ""
tokens := strings.Fields(remaining)
var bodyParts []string
now := time.Now()
for _, tok := range tokens {
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
switch {
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
tag := strings.ToLower(tok[1:])
r.FilterTags = append(r.FilterTags, tag)
} else {
case tok == "@today":
d := now.Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@yesterday":
d := now.AddDate(0, 0, -1).Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@week":
d := now.AddDate(0, 0, -7).Format("2006-01-02")
r.QueryDateFrom = &d
case tok == "@month":
d := now.AddDate(0, -1, 0).Format("2006-01-02")
r.QueryDateFrom = &d
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateTo = &d
} else {
bodyParts = append(bodyParts, tok)
}
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateFrom = &d
} else {
bodyParts = append(bodyParts, tok)
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
if ct, ok := validCardTypes[suffix]; ok {
r.QueryCardType = &ct
} else {
bodyParts = append(bodyParts, tok)
}
default:
bodyParts = append(bodyParts, tok)
}
}
+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 {
if a == nil && b == nil {
return true
+7 -4
View File
@@ -108,12 +108,14 @@ func (a absorbModel) visibleCount() int {
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var tags string
if len(e.Tags) > 0 {
@@ -125,11 +127,12 @@ func renderAbsorbSource(e *db.Entity, maxWidth int) string {
tags = " " + strings.Join(tagParts, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
line := fmt.Sprintf("%s %s%s", glyph, body, tags)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, tags)
}
return line
+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)
}
}
})
}
}
+124 -17
View File
@@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool {
return false
}
type cardGroup struct {
label string
start int
count int
}
type cardsModel struct {
entities []*db.Entity
filtered []*db.Entity
groups []cardGroup
cursor int
offset int
height int
@@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) {
}
func (c *cardsModel) applyFilter() {
c.filtered = nil
var pinned, rest []*db.Entity
for _, e := range c.entities {
if !matchesIntent(e, c.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
c.filtered = append(pinned, rest...)
c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent)
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
}
}
func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) {
if intentFilter != intentAll {
var pinned, rest []*db.Entity
for _, e := range entities {
if !matchesIntent(e, intentFilter) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
return append(pinned, rest...), nil
}
var pinned, grab, read, fill []*db.Entity
for _, e := range entities {
if e.Pinned {
pinned = append(pinned, e)
} else {
switch {
case matchesIntent(e, intentGrab):
grab = append(grab, e)
case matchesIntent(e, intentRead):
read = append(read, e)
case matchesIntent(e, intentFill):
fill = append(fill, e)
}
}
}
var filtered []*db.Entity
var groups []cardGroup
for _, bucket := range []struct {
label string
entities []*db.Entity
}{
{"pinned", pinned},
{"grab", grab},
{"read", read},
{"fill", fill},
} {
if len(bucket.entities) == 0 {
continue
}
groups = append(groups, cardGroup{
label: bucket.label,
start: len(filtered),
count: len(bucket.entities),
})
filtered = append(filtered, bucket.entities...)
}
return filtered, groups
}
func (c *cardsModel) setSize(width, height int) {
c.width = width
c.height = height
@@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 {
return statusStyle.Render("no cards")
}
if len(c.groups) > 0 {
return c.groupedView(width)
}
var b strings.Builder
visible := c.visibleCount()
@@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string {
return b.String()
}
func (c cardsModel) groupedView(width int) string {
entityWidth := width - 4 - dateGutterWidth
type displayLine struct {
text string
entityIdx int
}
var lines []displayLine
for _, g := range c.groups {
for i := 0; i < g.count; i++ {
eIdx := g.start + i
var gutter string
if i == 0 {
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
} else {
gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderCard(c.filtered[eIdx], entityWidth)
lines = append(lines, displayLine{text: line, entityIdx: eIdx})
}
}
visible := c.visibleCount()
offset := c.offset
if c.cursor < offset {
offset = c.cursor
}
if c.cursor >= offset+visible {
offset = c.cursor - visible + 1
}
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.entityIdx == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) visibleCount() int {
if c.height <= 0 {
return 20
@@ -197,12 +301,14 @@ func (c cardsModel) visibleCount() int {
func renderCard(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
affordance := detectAffordance(e)
affordStr := ""
@@ -231,11 +337,12 @@ func renderCard(e *db.Entity, maxWidth int) string {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
}
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
line := fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-30)
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s%s%s", glyph, body, affordStr, extraStr, useStr)
}
return line
+94 -16
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"os"
"os/exec"
"strings"
@@ -54,10 +55,28 @@ type stepsPersistedMsg struct{}
type templateCopiedMsg struct{}
type backlinksLoadedMsg struct {
backlinks []db.Backlink
}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type staleEntitiesLoadedMsg struct {
entities []*db.Entity
}
type stumbleActionMsg struct {
action string
}
type statusClearMsg struct{ seq int }
type editorFinishedMsg struct {
err error
}
@@ -68,7 +87,7 @@ type errMsg struct {
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(params)
entities, err := store.List(context.Background(), params)
if err != nil {
return errMsg{err}
}
@@ -78,7 +97,7 @@ func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := store.Create(e); err != nil {
if err := store.Create(context.Background(), e); err != nil {
return errMsg{err}
}
return entityCreatedMsg{e}
@@ -87,7 +106,7 @@ func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
func deleteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(id); err != nil {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDeletedMsg{id}
@@ -104,10 +123,10 @@ func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
update = db.EntityUpdate{ClearCompleted: true}
}
if err := store.Update(e.ID, &update); err != nil {
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
@@ -123,10 +142,10 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(e.ID, &update); err != nil {
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(e.ID)
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
@@ -141,7 +160,7 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg {
cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(id, ct, cd); err != nil {
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id, ct}
@@ -150,7 +169,7 @@ func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.
func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Demote(id); err != nil {
if err := store.Demote(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDemotedMsg{id}
@@ -162,7 +181,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(e.ID); err != nil {
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
return errMsg{err}
}
return entityCopiedMsg{}
@@ -171,7 +190,7 @@ func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(false)
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
@@ -179,8 +198,31 @@ func loadTags(store *db.Store) tea.Cmd {
}
}
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
return func() tea.Msg {
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
if err != nil {
return errMsg{err}
}
return backlinksLoadedMsg{backlinks}
}
}
func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
editorEnv = os.Getenv("VISUAL")
}
if editorEnv == "" {
editorEnv = "vi"
}
@@ -216,7 +258,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
}
update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(e.ID, &update); updateErr != nil {
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr}
}
@@ -226,7 +268,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(db.DefaultListParams())
entities, err := store.List(context.Background(), db.DefaultListParams())
if err != nil {
return errMsg{err}
}
@@ -236,7 +278,7 @@ func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return func() tea.Msg {
if err := store.Absorb(targetID, sourceID); err != nil {
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
return errMsg{err}
}
return entityAbsorbedMsg{targetID}
@@ -246,7 +288,7 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
return func() tea.Msg {
update := db.EntityUpdate{CardData: &stepsJSON}
if err := store.Update(entityID, &update); err != nil {
if err := store.Update(context.Background(), entityID, &update); err != nil {
return errMsg{err}
}
return stepsPersistedMsg{}
@@ -258,9 +300,45 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
if err := clipboard.WriteAll(resolved); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(entityID); err != nil {
if err := store.IncrementUse(context.Background(), entityID); err != nil {
return errMsg{err}
}
return templateCopiedMsg{}
}
}
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return statusClearMsg{seq: seq}
})
}
func loadStaleEntities(store *db.Store) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), staleParams())
if err != nil {
return errMsg{err}
}
return staleEntitiesLoadedMsg{entities}
}
}
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"dismissed"}
}
}
func stumblePin(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
pinned := true
update := db.EntityUpdate{Pinned: &pinned}
if err := store.Update(context.Background(), id, &update); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"pinned"}
}
}
+72 -10
View File
@@ -6,6 +6,7 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
@@ -20,13 +21,14 @@ const (
)
type detailModel struct {
entity *db.Entity
scroll int
height int
width int
mode detailMode
run runModel
fill fillModel
entity *db.Entity
backlinks []db.Backlink
scroll int
height int
width int
mode detailMode
run runModel
fill fillModel
}
func newDetailModel() detailModel {
@@ -35,6 +37,7 @@ func newDetailModel() detailModel {
func (d *detailModel) setEntity(e *db.Entity) {
d.entity = e
d.backlinks = nil
d.scroll = 0
d.mode = detailPreview
}
@@ -61,6 +64,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
}
case "down", "j":
d.scroll++
case "pgdown", "ctrl+d":
d.scroll += d.height
case "pgup", "ctrl+u":
d.scroll -= d.height
if d.scroll < 0 {
d.scroll = 0
}
case "home", "g":
d.scroll = 0
case "end", "G":
d.scroll = 1<<31 - 1
}
return d, nil
}
@@ -98,7 +112,20 @@ func (d detailModel) previewView(width int) string {
b.WriteString("\n")
}
b.WriteString(detailBodyStyle.Render(e.Body))
bodyWidth := width - 4
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath(glamourStyle()),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(detailBodyStyle.Render(rendered))
b.WriteString("\n")
if e.CardType != nil {
@@ -119,6 +146,25 @@ func (d detailModel) previewView(width int) string {
b.WriteString("\n")
}
if len(d.backlinks) > 0 {
b.WriteString("\n")
b.WriteString(detailLabelStyle.Render(" ← backlinks"))
b.WriteString("\n")
for _, bl := range d.backlinks {
label := bl.Body
if bl.Title != nil {
label = *bl.Title
} else if len(label) > 40 {
label = label[:40] + "…"
}
line := " " + backlinkStyle.Render(label)
if bl.LinkText != "" {
line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")")
}
b.WriteString(line + "\n")
}
}
b.WriteString("\n")
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
if e.ModifiedAt != e.CreatedAt {
@@ -142,8 +188,24 @@ func (d detailModel) previewView(width int) string {
b.WriteString(idStyle.Render(meta))
lines := strings.Split(b.String(), "\n")
if d.scroll > 0 && d.scroll < len(lines) {
lines = lines[d.scroll:]
totalLines := len(lines)
maxScroll := totalLines - d.height
if maxScroll < 0 {
maxScroll = 0
}
scroll := d.scroll
if scroll > maxScroll {
scroll = maxScroll
}
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
lines[0] = lines[0] + indicator
}
if scroll > 0 && scroll < totalLines {
lines = lines[scroll:]
}
if d.height > 0 && len(lines) > d.height {
lines = lines[:d.height]
+34 -3
View File
@@ -7,27 +7,50 @@ func renderHelp(width, height int) string {
title string
binds [][2]string
}{
{"Focus", [][2]string{
{"tab", "toggle capture ↔ list"},
{"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"},
{"h", "focus tag rail (from list)"},
{"l", "focus detail (split view)"},
{"ctrl+b", "toggle tag rail"},
}},
{"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"},
{"?…", "search (type ?query)"},
{"#…", "tag (autocomplete with tab)"},
{"-", "todo prefix"},
{"@", "event prefix"},
{"!", "reminder prefix"},
}},
{"Query Operators", [][2]string{
{"?text", "substring search"},
{"?#tag1 #tag2", "filter by tags (AND)"},
{"?@today @week", "date filter (@yesterday @month)"},
{"?<7d >30d", "newer/older than N days"},
{"?^snippet", "card type filter"},
}},
{"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"},
{"enter", "view detail"},
{"esc", "back / clear filter"},
}},
{"Views", [][2]string{
{"1", "stream view"},
{"2", "cards view"},
{"s", "cycle sort (cards)"},
{"tab", "cycle intent (cards)"},
{"i", "cycle intent (cards)"},
{"T", "cycle theme"},
}},
{"Actions", [][2]string{
{"a", "add entity (or ?query to search)"},
{"d", "delete (with confirm)"},
{"x", "toggle todo completion"},
{"!", "toggle pin"},
{"#", "filter by tag"},
{"m", "absorb (merge into target)"},
{"p", "promote to card"},
{"S", "stumble (resurface stale entries)"},
}},
{"Detail View", [][2]string{
{"p", "promote to card"},
@@ -38,6 +61,14 @@ func renderHelp(width, height int) string {
{"r", "run checklist"},
{"f", "fill template"},
}},
{"Stumble", [][2]string{
{"n / →", "skip to next"},
{"d", "dismiss (soft delete)"},
{"!", "pin"},
{"p", "promote to card"},
{"m", "absorb"},
{"esc", "exit"},
}},
{"Run Mode", [][2]string{
{"j/k", "move between steps"},
{"space", "toggle step"},
+45 -44
View File
@@ -11,15 +11,17 @@ import (
)
type inputResult struct {
entity *db.Entity
query bool
body string
tags []string
entity *db.Entity
query bool
body string
tags []string
dateFrom *string
dateTo *string
cardType *db.CardType
}
type inputModel struct {
ti textinput.Model
active bool
preview *parse.Result
}
@@ -31,15 +33,8 @@ func newInputModel() inputModel {
return inputModel{ti: ti}
}
func (i *inputModel) focus() {
i.active = true
i.ti.Focus()
}
func (i *inputModel) reset() {
i.active = false
func (i *inputModel) clearText() {
i.ti.SetValue("")
i.ti.Blur()
i.preview = nil
}
@@ -55,11 +50,18 @@ func (i inputModel) submit() *inputResult {
}
if parsed.Query {
return &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
r := &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
dateFrom: parsed.QueryDateFrom,
dateTo: parsed.QueryDateTo,
}
if parsed.QueryCardType != nil {
ct := db.CardType(*parsed.QueryCardType)
r.cardType = &ct
}
return r
}
e := &db.Entity{
@@ -101,21 +103,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
return i
}
func (i inputModel) view(width int) string {
var b strings.Builder
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
b.WriteString("\n")
b.WriteString(i.ti.View())
b.WriteString("\n")
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder"))
b.WriteString("\n")
b.WriteString(i.renderPreview(width))
return b.String()
func (i inputModel) viewBar(width int, focused bool) string {
tiView := i.ti.View()
if focused {
return tiView
}
val := i.ti.Value()
if val != "" {
return hintDescStyle.Render(" " + val)
}
return hintDescStyle.Render(" capture a thought…")
}
func (i inputModel) renderPreview(width int) string {
func (i inputModel) previewText() string {
if i.preview == nil {
return drawerPreviewStyle.Render("")
return ""
}
p := i.preview
@@ -128,7 +130,16 @@ func (i inputModel) renderPreview(width int) string {
for _, t := range p.FilterTags {
q += " #" + t
}
return drawerPreviewStyle.Render("search: " + q)
if p.QueryDateFrom != nil {
q += " from:" + *p.QueryDateFrom
}
if p.QueryDateTo != nil {
q += " to:" + *p.QueryDateTo
}
if p.QueryCardType != nil {
q += " ^" + *p.QueryCardType
}
return "search: " + q
}
glyph := glyphForParsed(p.Glyph)
@@ -140,22 +151,16 @@ func (i inputModel) renderPreview(width int) string {
var parts []string
parts = append(parts, glyph, body)
for _, t := range p.Tags {
parts = append(parts, tagStyle.Render("#"+t))
parts = append(parts, "#"+t)
}
if p.Pin {
parts = append(parts, pinnedStyle.Render("•"))
parts = append(parts, "•")
}
if p.CardSuffix != nil {
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
parts = append(parts, *p.CardSuffix)
}
line := strings.Join(parts, " ")
maxW := width - 4
if maxW > 0 && len(stripAnsi(line)) > maxW {
line = truncate(line, maxW)
}
return drawerPreviewStyle.Render(line)
return strings.Join(parts, " ")
}
func glyphForParsed(glyph string) string {
@@ -170,7 +175,3 @@ func glyphForParsed(glyph string) string {
return "—"
}
}
func drawerLines() int {
return 3
}
+11 -3
View File
@@ -7,7 +7,7 @@ type keyMap struct {
Down key.Binding
Enter key.Binding
Back key.Binding
Add key.Binding
Capture key.Binding
Delete key.Binding
Quit key.Binding
Help key.Binding
@@ -31,6 +31,10 @@ type keyMap struct {
Fill key.Binding
FocusLeft key.Binding
FocusRight key.Binding
Tab key.Binding
ToggleRail key.Binding
Stumble key.Binding
Theme key.Binding
}
var keys = keyMap{
@@ -38,7 +42,7 @@ var keys = keyMap{
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
@@ -56,10 +60,14 @@ var keys = keyMap{
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")),
Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")),
Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")),
}
+10 -9
View File
@@ -204,23 +204,23 @@ func renderEntity(e *db.Entity, maxWidth int) string {
}
glyph := style.Render(glyphStr)
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
body = body[:idx]
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
limit := min(2, len(e.Tags))
for _, t := range e.Tags[:limit] {
extras = append(extras, tagStyle.Render("#"+t))
}
extras = append(extras, strings.Join(tagParts, " "))
}
extraStr := ""
@@ -228,11 +228,12 @@ func renderEntity(e *db.Entity, maxWidth int) string {
extraStr = " " + strings.Join(extras, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
line := fmt.Sprintf("%s %s%s", glyph, body, extraStr)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
overhead := len(stripAnsi(line)) - len([]rune(body))
body = truncate(body, maxWidth-overhead)
line = fmt.Sprintf("%s %s%s", glyph, body, extraStr)
}
return line
+486 -156
View File
@@ -1,8 +1,10 @@
package tui
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -10,16 +12,18 @@ import (
"github.com/lerko/nib/internal/db"
)
const statusTimeout = 2 * time.Second
type viewState int
const (
stateList viewState = iota
stateDetail
stateInput
stateTagFilter
stateConfirm
statePromote
stateAbsorb
stateStumble
)
type viewMode int
@@ -62,7 +66,9 @@ func (s cardsSort) next() cardsSort {
type focusPane int
const (
focusList focusPane = iota
focusCapture focusPane = iota
focusTagRail
focusList
focusDetail
)
@@ -73,43 +79,73 @@ type model struct {
width int
height int
list listModel
cards cardsModel
detail detailModel
input inputModel
filter filterModel
promote promoteModel
absorb absorbModel
showHelp bool
list listModel
cards cardsModel
detail detailModel
input inputModel
filter filterModel
promote promoteModel
absorb absorbModel
tagRail tagRailModel
stumble stumbleModel
showHelp bool
autocomplete autocompleteModel
focus focusPane
splitDetail bool
showTagRail bool
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
queryDateFrom *string
queryDateTo *string
queryCardType *db.CardType
status string
err error
status string
statusSeq int
err error
}
func newModel(store *db.Store) model {
loadTheme()
applyTheme()
inp := newInputModel()
inp.ti.Focus()
return model{
store: store,
state: stateList,
mode: modeStream,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
store: store,
state: stateList,
mode: modeStream,
focus: focusCapture,
showTagRail: true,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: inp,
filter: newFilterModel(),
tagRail: newTagRailModel(),
}
}
func (m *model) setFocus(f focusPane) tea.Cmd {
m.focus = f
if f == focusCapture {
return m.input.ti.Focus()
}
m.input.ti.Blur()
return nil
}
func (m *model) setStatus(msg string) tea.Cmd {
m.statusSeq++
m.status = msg
return clearStatusAfter(statusTimeout, m.statusSeq)
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, m.listParams())
return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
}
func (m model) listParams() db.ListParams {
@@ -117,6 +153,15 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" {
p.Tag = &m.filterTag
}
if m.queryDateFrom != nil {
p.From = m.queryDateFrom
}
if m.queryDateTo != nil {
p.To = m.queryDateTo
}
if m.queryCardType != nil {
p.CardTypeFilter = m.queryCardType
}
if m.mode == modeCards {
p.CardsOnly = true
switch m.cardsSort {
@@ -135,25 +180,19 @@ func (m model) listParams() db.ListParams {
}
func (m model) hasSearch() bool {
return m.searchQuery != "" || len(m.searchTags) > 0
return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
}
func (m *model) applySearch() {
if m.mode == modeCards {
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
m.cards.filtered = nil
var pinned, rest []*db.Entity
for _, e := range filtered {
if !matchesIntent(e, m.cards.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
var intentFiltered []*db.Entity
for _, e := range searchFiltered {
if matchesIntent(e, m.cards.intent) {
intentFiltered = append(intentFiltered, e)
}
}
m.cards.filtered = append(pinned, rest...)
m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent)
if m.cards.cursor >= len(m.cards.filtered) {
m.cards.cursor = max(0, len(m.cards.filtered)-1)
}
@@ -172,6 +211,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splitDetail = false
m.focus = focusList
}
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
@@ -187,42 +229,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil
return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg:
m.state = stateList
m.input.reset()
m.recalcSizes()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
m.input.clearText()
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
case entityDeletedMsg:
m.status = "deleted"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action))
case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList
}
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType)))
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid"))
case entityCopiedMsg:
m.status = "copied"
return m, nil
return m, m.setStatus("copied")
case entityAbsorbedMsg:
m.status = "absorbed"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
if !m.stumble.done && len(m.stumble.entries) > 0 {
m.stumble.advance()
m.state = stateStumble
} else {
m.state = stateList
}
return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID)
@@ -232,27 +280,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case stepsPersistedMsg:
m.status = "steps saved"
m.detail.mode = detailPreview
return m, m.reloadDetail(m.detail.entity.ID)
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved"))
case templateCopiedMsg:
m.status = "copied resolved"
m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
case backlinksLoadedMsg:
if m.detail.entity != nil {
m.detail.backlinks = msg.backlinks
}
return m, nil
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case staleEntitiesLoadedMsg:
m.stumble = newStumbleModel()
m.stumble.setEntries(msg.entities)
m.stumble.setSize(m.width, m.contentHeight())
m.state = stateStumble
return m, nil
case stumbleActionMsg:
return m, m.setStatus(msg.action)
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
return m, m.reloadAfterEdit()
}
return m, m.reloadAfterEdit()
return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated"))
case confirmTimeoutMsg:
if m.state == stateConfirm {
@@ -261,6 +323,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case statusClearMsg:
if msg.seq == m.statusSeq {
m.status = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
@@ -268,8 +336,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
@@ -278,9 +344,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
case stateStumble:
return m.updateStumble(msg)
default:
return m.updateKeys(msg)
}
default:
if m.focus == focusCapture {
var cmd tea.Cmd
m.input.ti, cmd = m.input.ti.Update(msg)
return m, cmd
}
}
return m, nil
@@ -294,8 +369,168 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.focus == focusCapture {
return m.updateCapture(msg)
}
return m.updateBrowse(msg)
}
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
val := m.input.ti.Value()
if val == "" {
cmd := m.setFocus(focusList)
return m, cmd
}
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.queryDateFrom = result.dateFrom
m.queryDateTo = result.dateTo
m.queryCardType = result.cardType
m.input.clearText()
m.autocomplete.active = false
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
cmd := m.setFocus(focusList)
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
}
m.applySearch()
cmd := m.setFocus(focusList)
return m, cmd
}
if result.entity != nil {
m.autocomplete.active = false
return m, createEntity(m.store, result.entity)
}
return m, nil
case "tab":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "esc":
if m.autocomplete.active {
m.autocomplete.active = false
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "up":
if m.autocomplete.active {
m.autocomplete.moveUp()
return m, nil
}
case "down":
if m.autocomplete.active {
m.autocomplete.moveDown()
return m, nil
}
}
m.input = m.input.updateKey(msg)
m.updateAutocompleteSuggestions()
return m, nil
}
func (m *model) updateAutocompleteSuggestions() {
val := m.input.ti.Value()
pos := m.input.ti.Position()
start, end, prefix, ok := tagTokenAtCursor(val, pos)
if !ok || prefix == "" {
m.autocomplete.active = false
return
}
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
if len(suggestions) == 0 {
m.autocomplete.active = false
return
}
m.autocomplete.suggestions = suggestions
m.autocomplete.prefix = prefix
m.autocomplete.tokenStart = start
m.autocomplete.tokenEnd = end
m.autocomplete.active = true
if m.autocomplete.cursor >= len(suggestions) {
m.autocomplete.cursor = 0
}
}
func (m *model) acceptAutocomplete() {
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
return
}
selected := m.autocomplete.selected()
if selected == "" {
return
}
val := m.input.ti.Value()
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
m.input.ti.SetValue(newVal)
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
m.autocomplete.active = false
}
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList {
switch msg.String() {
case "j", "k", "up", "down":
m.tagRail = m.tagRail.update(msg.String())
return m, nil
case "enter":
tag := m.tagRail.selectedTag()
if tag != "" {
if tag == m.filterTag {
m.filterTag = ""
m.tagRail.activeTag = ""
} else {
m.filterTag = tag
m.tagRail.activeTag = tag
}
m.focus = focusList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "l", "tab", "esc":
m.focus = focusList
return m, nil
case "ctrl+b":
m.showTagRail = false
m.focus = focusList
m.recalcSizes()
return m, nil
}
}
// ctrl+b toggle from any browse focus
if msg.String() == "ctrl+b" && m.state == stateList {
m.showTagRail = !m.showTagRail
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
}
if m.splitDetail && m.state == stateList {
switch msg.String() {
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "l":
if m.focus == focusList {
m.focus = focusDetail
@@ -306,6 +541,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = focusList
return m, nil
}
if m.focus == focusList && m.railVisible() {
m.focus = focusTagRail
return m, nil
}
case "esc":
if m.focus == focusDetail {
m.focus = focusList
@@ -380,9 +619,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "q":
if m.state == stateList {
return m, tea.Quit
@@ -414,12 +650,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "s":
if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String()
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String()))
}
return m, nil
case "tab":
case "S":
if m.state == stateList {
return m, loadStaleEntities(m.store)
}
return m, nil
case "T":
t := cycleTheme()
return m, m.setStatus("theme: " + t.Name)
case "i":
if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next())
if m.hasSearch() {
@@ -429,12 +674,21 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "h":
if m.state == stateList && m.railVisible() && m.focus == focusList {
m.focus = focusTagRail
return m, nil
}
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
m.input.focus()
m.recalcSizes()
return m, m.input.ti.Focus()
cmd := m.setFocus(focusCapture)
return m, cmd
}
case "esc":
@@ -474,9 +728,16 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.state == stateList && m.hasSearch() {
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
m.searchQuery = ""
m.searchTags = nil
m.queryDateFrom = nil
m.queryDateTo = nil
m.queryCardType = nil
m.status = ""
if hadDBFilters {
return m, loadEntities(m.store, m.listParams())
}
if m.mode == modeCards {
m.cards.applyFilter()
} else {
@@ -489,6 +750,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
if m.state == stateList {
cmd := m.setFocus(focusCapture)
return m, cmd
}
return m, nil
case "d":
@@ -531,8 +796,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
m.status = "target must be fluid"
return m, nil
return m, m.setStatus("target must be fluid")
}
return m, loadAbsorbSources(m.store, e.ID)
}
@@ -542,8 +806,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
m.status = "already a card"
return m, nil
return m, m.setStatus("already a card")
}
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
@@ -554,8 +817,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "D":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
m.status = "already fluid"
return m, nil
return m, m.setStatus("already fluid")
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
@@ -609,6 +871,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else {
m.state = stateDetail
}
return m, loadBacklinks(m.store, e.ID)
}
}
return m, nil
@@ -634,37 +897,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateList
m.input.reset()
m.recalcSizes()
return m, nil
case "enter":
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.state = stateList
m.input.reset()
m.recalcSizes()
m.applySearch()
return m, nil
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
@@ -726,6 +958,57 @@ func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.stumble.done {
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case "n", "right":
m.stumble.advance()
if m.stumble.done {
return m, m.setStatus("all caught up")
}
return m, nil
case "d":
e := m.stumble.current()
if e != nil {
m.stumble.removeCurrent()
return m, stumbleDismiss(m.store, e.ID)
}
return m, nil
case "!":
e := m.stumble.current()
if e != nil {
m.stumble.advance()
return m, stumblePin(m.store, e.ID)
}
return m, nil
case "p":
e := m.stumble.current()
if e != nil {
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
case "m":
e := m.stumble.current()
if e != nil {
if e.CardType != nil {
return m, m.setStatus("target must be fluid")
}
return m, loadAbsorbSources(m.store, e.ID)
}
return m, nil
}
return m, nil
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
@@ -733,7 +1016,7 @@ func (m model) View() string {
var content string
switch m.state {
case stateList, stateInput, stateConfirm:
case stateList, stateConfirm:
listContent := m.listContent()
if m.splitDetail {
lw, rw := m.splitWidths()
@@ -745,6 +1028,13 @@ func (m model) View() string {
} else {
content = listContent
}
if m.railVisible() {
rw := m.railWidth()
ch := m.contentHeight()
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
sep := m.renderSeparator()
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
}
case stateDetail:
content = m.detail.view(m.width)
case stateTagFilter:
@@ -753,40 +1043,51 @@ func (m model) View() string {
content = m.promote.view(m.width)
case stateAbsorb:
content = m.absorb.view(m.width)
case stateStumble:
content = m.stumble.view()
}
header := m.headerView()
footer := m.footerView()
captureBar := m.input.viewBar(m.width, m.focus == focusCapture)
statusLine := m.statusLine()
return header + "\n" + content + "\n" + footer
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
acView := m.autocomplete.view(m.width)
if acView != "" {
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
}
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
}
func (m model) listWidth() int {
if m.splitDetail {
lw, _ := m.splitWidths()
return lw
}
w := m.width - m.railWidth()
if m.railVisible() {
w--
}
return w
}
func (m model) listContent() string {
lw := m.listWidth()
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.list.view(lw)
}
func (m model) headerView() string {
header := titleStyle.Render("nib")
modeName := "stream"
if m.mode == modeCards {
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
header := titleStyle.Render("nib") + " "
header += renderTab("stream", "1", m.mode == modeStream)
header += " " + separatorStyle.Render("│") + " "
header += renderTab("cards", "2", m.mode == modeCards)
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
if m.hasSearch() {
@@ -797,6 +1098,15 @@ func (m model) headerView() string {
for _, t := range m.searchTags {
pill += " #" + t
}
if m.queryDateFrom != nil {
pill += " from:" + *m.queryDateFrom
}
if m.queryDateTo != nil {
pill += " to:" + *m.queryDateTo
}
if m.queryCardType != nil {
pill += " ^" + string(*m.queryCardType)
}
header += " " + searchPillStyle.Render(pill)
}
@@ -811,11 +1121,7 @@ func (m model) headerView() string {
return header
}
func (m model) footerView() string {
if m.state == stateInput {
return m.input.view(m.width)
}
func (m model) statusLine() string {
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
@@ -824,46 +1130,69 @@ func (m model) footerView() string {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
return m.height - 3 - m.drawerHeight()
}
func (m model) drawerHeight() int {
if m.state == stateInput {
return drawerLines()
h := m.height - 4
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
n := m.autocomplete.visibleCount()
if len(m.autocomplete.suggestions) > maxSuggestions {
n++
}
h -= n + 1
}
return 0
if h < 1 {
h = 1
}
return h
}
func (m *model) recalcSizes() {
ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths()
_, rw := m.splitWidths()
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch)
} else {
m.list.setSize(m.width, ch)
m.cards.setSize(m.width, ch)
m.detail.setSize(m.width, ch)
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(lw, ch)
}
m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
}
func (m model) isSplit() bool {
return m.width >= 100
}
func (m model) railVisible() bool {
return m.showTagRail && m.width >= 100
}
func (m model) railWidth() int {
if !m.railVisible() {
return 0
}
w := m.width * 18 / 100
if w < 16 {
w = 16
}
return w
}
func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100
right := m.width - left - 1
avail := m.width - m.railWidth()
if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right
}
@@ -890,8 +1219,9 @@ func (m model) selectedEntity() *db.Entity {
func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch(
loadEntities(m.store, m.listParams()),
loadBacklinks(m.store, id),
func() tea.Msg {
e, err := m.store.Get(id)
e, err := m.store.Get(context.Background(), id)
if err != nil {
return errMsg{err}
}
+59 -22
View File
@@ -2,24 +2,52 @@ package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type hint struct {
key string
desc string
}
func renderHints(hints []hint) string {
parts := make([]string, len(hints))
for i, h := range hints {
parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc)
}
return strings.Join(parts, " ")
}
func renderTab(label, key string, active bool) string {
if active {
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
}
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
}
func renderStatusBar(m model, width int) string {
left := countText(m)
right := contextHints(m)
var leftParts []string
leftRendered := statusStyle.Render(left)
rightRendered := helpStyle.Render(right)
if m.status != "" {
leftParts = append(leftParts, statusStyle.Render(m.status))
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
} else {
leftParts = append(leftParts, statusStyle.Render(countText(m)))
}
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered)
leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ")
right := renderHints(contextHints(m))
gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + rightRendered
return leftRendered + pad + right
}
func countText(m model) string {
@@ -35,37 +63,46 @@ func countText(m model) string {
return fmt.Sprintf("%d entities", total)
}
func contextHints(m model) string {
func contextHints(m model) []hint {
switch m.state {
case stateDetail:
switch m.detail.mode {
case detailRun:
return "space:toggle j/k:nav r:reset esc:save+exit"
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
case detailFill:
return "tab:next shift+tab:prev enter:copy esc:cancel"
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
default:
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
}
case stateInput:
return ""
case stateTagFilter:
return "j/k:nav enter:select esc:cancel"
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm:
return "y:confirm n:cancel"
return []hint{{"y", "confirm"}, {"n", "cancel"}}
case statePromote:
return "j/k:nav enter:select esc:cancel"
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel"
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
case stateStumble:
return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}}
}
switch m.focus {
case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusTagRail:
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"l", "list"}, {"ctrl+b", "hide"}}
case focusDetail:
if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
}
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
default:
if m.splitDetail {
if m.focus == focusDetail {
return "h:list c:copy e:edit p:promote D:demote !:pin esc:back"
}
return "l:detail a:add d:del #:filter esc:close ?:help q:quit"
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
}
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit"
return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
}
+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"
var (
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(highlight).
PaddingLeft(1)
statusStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
listItemStyle = lipgloss.NewStyle().
PaddingLeft(2)
selectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Bold(true).
Foreground(highlight).
SetString("")
glyphStyle = lipgloss.NewStyle().
Width(2)
completedGlyphStyle = lipgloss.NewStyle().
Width(2).
Foreground(dim)
tagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
idStyle = lipgloss.NewStyle().
Foreground(dim)
inputPromptStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(highlight).
MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().
PaddingLeft(2).
PaddingTop(1)
helpStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"})
filterPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
helpKeyStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true).
Width(18)
helpDescStyle = lipgloss.NewStyle().
Foreground(dim)
affordanceStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
Bold(true)
useCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
modeStyle = lipgloss.NewStyle().
Foreground(dim).
Bold(true)
detailLabelStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
checkDoneStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
checkPendingStyle = lipgloss.NewStyle().
Foreground(dim)
searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true)
gutterStyle = lipgloss.NewStyle().
Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().
Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().
Foreground(dim)
titleStyle lipgloss.Style
statusStyle lipgloss.Style
listItemStyle lipgloss.Style
selectedItemStyle lipgloss.Style
glyphStyle lipgloss.Style
completedGlyphStyle lipgloss.Style
tagStyle lipgloss.Style
idStyle lipgloss.Style
inputPromptStyle lipgloss.Style
detailHeaderStyle lipgloss.Style
detailBodyStyle lipgloss.Style
helpStyle lipgloss.Style
errorStyle lipgloss.Style
dateHeaderStyle lipgloss.Style
pinnedStyle lipgloss.Style
filterPillStyle lipgloss.Style
helpKeyStyle lipgloss.Style
helpDescStyle lipgloss.Style
affordanceStyle lipgloss.Style
useCountStyle lipgloss.Style
modeStyle lipgloss.Style
detailLabelStyle lipgloss.Style
detailValueStyle lipgloss.Style
checkDoneStyle lipgloss.Style
checkPendingStyle lipgloss.Style
searchPillStyle lipgloss.Style
gutterStyle lipgloss.Style
drawerBorderStyle lipgloss.Style
drawerHintsStyle lipgloss.Style
drawerPreviewStyle lipgloss.Style
separatorStyle lipgloss.Style
hintKeyStyle lipgloss.Style
hintDescStyle lipgloss.Style
railHeaderStyle lipgloss.Style
railTagStyle lipgloss.Style
railActiveTagStyle lipgloss.Style
railCountStyle lipgloss.Style
stumbleAgeStyle lipgloss.Style
acSelectedStyle lipgloss.Style
acItemStyle lipgloss.Style
backlinkStyle lipgloss.Style
)
func init() {
applyTheme()
}
func applyTheme() {
t := activeTheme()
accent := lipgloss.Color(t.Accent)
dim := lipgloss.Color(t.Dim)
muted := lipgloss.Color(t.Muted)
ok := lipgloss.Color(t.Ok)
todo := lipgloss.Color(t.Todo)
event := lipgloss.Color(t.Event)
remind := lipgloss.Color(t.Remind)
danger := lipgloss.Color(t.Danger)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1)
statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
listItemStyle = lipgloss.NewStyle().PaddingLeft(4)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("")
glyphStyle = lipgloss.NewStyle().Width(2)
completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim)
tagStyle = lipgloss.NewStyle().Foreground(ok)
idStyle = lipgloss.NewStyle().Foreground(dim)
inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1)
detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1)
helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1)
dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1)
pinnedStyle = lipgloss.NewStyle().Foreground(todo)
filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18)
helpDescStyle = lipgloss.NewStyle().Foreground(dim)
affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true)
useCountStyle = lipgloss.NewStyle().Foreground(remind)
modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true)
detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
detailValueStyle = lipgloss.NewStyle().Foreground(muted)
checkDoneStyle = lipgloss.NewStyle().Foreground(ok)
checkPendingStyle = lipgloss.NewStyle().Foreground(dim)
searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true)
gutterStyle = lipgloss.NewStyle().Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().Foreground(dim)
hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
hintDescStyle = lipgloss.NewStyle().Foreground(dim)
railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim)
railTagStyle = lipgloss.NewStyle().Foreground(ok)
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
railCountStyle = lipgloss.NewStyle().Foreground(dim)
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
acItemStyle = lipgloss.NewStyle().Foreground(muted)
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
}
+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)
}
+4 -2
View File
@@ -8,13 +8,15 @@ import (
)
var (
entropy *ulid.MonotonicEntropy
entropy *ulid.LockedMonotonicReader
entropyOnce sync.Once
)
func New() string {
entropyOnce.Do(func() {
entropy = ulid.Monotonic(rand.Reader, 0)
entropy = &ulid.LockedMonotonicReader{
MonotonicReader: ulid.Monotonic(rand.Reader, 0),
}
})
return ulid.MustNew(ulid.Now(), entropy).String()
}
+23
View File
@@ -1,6 +1,7 @@
package ulid
import (
"sync"
"testing"
)
@@ -26,3 +27,25 @@ func TestNew_Sortable(t *testing.T) {
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
}
}
func TestNew_ConcurrentUnique(t *testing.T) {
const n = 100
ids := make([]string, n)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
ids[idx] = New()
}(i)
}
wg.Wait()
seen := make(map[string]struct{}, n)
for _, id := range ids {
if _, dup := seen[id]; dup {
t.Fatalf("duplicate ULID under concurrency: %s", id)
}
seen[id] = struct{}{}
}
}
+150 -115
View File
@@ -44,6 +44,15 @@
// ========== API ==========
async function checkedJSON(resp) {
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const msg = body.message || body.error || `HTTP ${resp.status}`;
throw new Error(msg);
}
return resp.json();
}
const api = {
async listEntities(params = {}) {
const q = new URLSearchParams();
@@ -57,7 +66,7 @@
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
const resp = await fetch('/api/entities?' + q);
return resp.json();
return checkedJSON(resp);
},
async createEntity(data) {
const resp = await fetch('/api/entities', {
@@ -65,11 +74,11 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
return checkedJSON(resp);
},
async getEntity(id) {
const resp = await fetch('/api/entities/' + id);
return resp.json();
return checkedJSON(resp);
},
async updateEntity(id, data) {
const resp = await fetch('/api/entities/' + id, {
@@ -77,10 +86,11 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
return checkedJSON(resp);
},
async deleteEntity(id) {
return fetch('/api/entities/' + id, { method: 'DELETE' });
const resp = await fetch('/api/entities/' + id, { method: 'DELETE' });
return checkedJSON(resp);
},
async promoteEntity(id, cardType, cardData) {
const resp = await fetch('/api/entities/' + id + '/promote', {
@@ -88,7 +98,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
});
return resp.json();
return checkedJSON(resp);
},
async demoteEntity(id) {
const resp = await fetch('/api/entities/' + id + '/demote', {
@@ -96,7 +106,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
return checkedJSON(resp);
},
async useEntity(id) {
const resp = await fetch('/api/entities/' + id + '/use', {
@@ -104,7 +114,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
return checkedJSON(resp);
},
async absorbEntity(targetId, sourceId) {
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
@@ -112,13 +122,13 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: sourceId }),
});
return resp.json();
return checkedJSON(resp);
},
async listTags(params = {}) {
const q = new URLSearchParams();
if (params.cards_only) q.set('cards_only', 'true');
const resp = await fetch('/api/tags?' + q);
return resp.json();
return checkedJSON(resp);
},
};
@@ -658,6 +668,71 @@
</div>`;
}
function renderInlineEditMode(e) {
return `<div class="exp-inner exp-inner--edit">
<div class="peek-edit-fields">
<div class="peek-edit-field"><label class="peek-edit-lbl">title</label>
<input class="peek-edit-in" id="edit-title" value="${escAttr(e.title || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">description</label>
<input class="peek-edit-in" id="edit-desc" value="${escAttr(e.description || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">content</label>
<textarea class="peek-edit-ta" id="edit-body" rows="7">${escHtml(e.body || '')}</textarea></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">tags</label>
<input class="peek-edit-in" id="edit-tags" value="${escAttr((e.tags || []).join(' '))}" placeholder="space-separated"></div>
</div>
<div class="exp-acts">
<button class="action-btn primary" onclick="event.stopPropagation();nibApp.saveEdit('${e.id}')">save</button>
<button class="action-btn" onclick="event.stopPropagation();nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
function renderCardSections(e, bodyClass) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const hasDecision = data.chose != null;
const hasSteps = data.steps && data.steps.length;
const hasLink = !!data.url;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
let sections = '';
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="${bodyClass} md">${renderMd(e.body)}</div>`;
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
return { sections, data, hasDecision, hasSteps, hasLink, hasFill };
}
function renderInlineDetail(e) {
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = '';
@@ -675,51 +750,10 @@
let content = '';
if (e.card_type) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const hasDecision = data.chose != null;
const hasSteps = data.steps && data.steps.length;
const hasLink = !!data.url;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
content += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
}
if (hasFill) {
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
content += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
const cs = renderCardSections(e, 'exp-body');
content = cs.sections;
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
} else {
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
}
@@ -842,15 +876,15 @@
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = '';
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
if (!e.card_type) {
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote →</button>`;
}
if (e.card_type) {
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
}
return `<div class="peek-scroll">
@@ -880,61 +914,17 @@
const glyph = GLYPHS[e.card_type] || '◆';
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
const affs = detectAffordances(e);
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
const hasSteps = data.steps && data.steps.length;
const hasDecision = data.chose != null;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
const hasLink = !!data.url;
let sections = '';
const cs = renderCardSections(e, 'peek-body');
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="peek-body md">${renderMd(e.body)}</div>`;
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
return `<div class="peek-scroll">
<div class="peek-card">
@@ -950,7 +940,7 @@
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
</div>
${sections}
${cs.sections}
</div>
<div class="peek-acts">${actions}</div>
</div>`;
@@ -1457,11 +1447,31 @@
state.peekMode = mode;
if (mode === 'run') state.runChecked = new Set();
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
if (mode === 'edit' && isMobileBreakpoint()) {
const e = state.entities[state.selectedIndex];
const sel = $(`.entity-item.selected, .card-row.selected`);
if (!e || !sel) return;
const clip = sel.querySelector('.entity-exp-clip');
if (clip) clip.innerHTML = renderInlineEditMode(e);
sel.classList.add('exp-full');
const titleInput = sel.querySelector('#edit-title');
if (titleInput) titleInput.focus();
return;
}
renderDetailPane();
},
exitMode() {
state.peekMode = 'preview';
if (isMobileBreakpoint()) {
const e = state.entities[state.selectedIndex];
const sel = $(`.entity-item.selected, .card-row.selected`);
if (sel && e) {
const clip = sel.querySelector('.entity-exp-clip');
if (clip) clip.innerHTML = renderInlineDetail(e);
}
return;
}
renderDetailPane();
},
@@ -1500,7 +1510,21 @@
await loadEntities();
await loadTags();
const idx = state.entities.findIndex(x => x.id === id);
if (idx >= 0) selectEntity(idx);
if (idx >= 0) {
if (isMobileBreakpoint()) {
state.selectedIndex = idx;
renderEntityList();
const sel = $(`.entity-item[data-id="${id}"], .card-row[data-id="${id}"]`);
if (sel) {
sel.classList.add('selected');
const clip = sel.querySelector('.entity-exp-clip');
const e = state.entities[idx];
if (clip && e) clip.innerHTML = renderInlineDetail(e);
}
} else {
selectEntity(idx);
}
}
showToast('saved');
},
@@ -1580,7 +1604,17 @@
document.addEventListener('keydown', (ev) => {
const tag = (ev.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') {
if (ev.key === 'Escape') ev.target.blur();
if (ev.key === 'Escape' && state.peekMode === 'edit') {
ev.target.blur();
nibApp.exitMode();
return;
}
if (ev.key === 'Escape') { ev.target.blur(); return; }
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter' && state.peekMode === 'edit') {
const e = state.entities[state.selectedIndex];
if (e) nibApp.saveEdit(e.id);
return;
}
return;
}
@@ -1883,7 +1917,8 @@
function renderMd(s) {
if (!s) return '';
if (typeof marked === 'undefined') return escHtml(s);
return marked.parse(s, { breaks: true });
const html = marked.parse(s, { breaks: true });
return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : escHtml(s);
}
function isSafeUrl(url) {
+1
View File
@@ -97,6 +97,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
<script src="/app.js"></script>
</body>
+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; }
.entity-item.exp-full .exp-inner,
.card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; }
.exp-inner--edit { display: flex; flex-direction: column; min-height: 100%; }
.exp-inner--edit .peek-edit-fields { flex: 1; padding: 16px; }
.exp-inner--edit .peek-edit-ta { flex: 1; min-height: 150px; }
.exp-inner--edit .exp-acts { padding: 12px 16px; border-top: 1px solid var(--border); position: sticky; bottom: 0; background: var(--bg); }
main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; }
}