diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42fa632 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tyler Koenig + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0380ff8 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# nib + +Capture-first note system. Catch thoughts, todos, and events before they slip — from terminal or browser. Everything lives in a single SQLite file. + +nib uses a tiny grammar to extract structure from plain text. You type naturally, and nib figures out what it is: a thought, a todo with a due date, an event, a titled reference. Tags, descriptions, and time anchors are pulled out automatically. The body stays as markdown. + +When something proves useful, promote it to a card (snippet, template, checklist, decision, link) for quick reuse. Cards track usage and float to the top. + +## Install + +Requires Go 1.24+. + +``` +go build -o nib . +``` + +Data lives at `~/.nib/nib.db` by default. Override with `NIB_DB=/path/to/file.db`. + +## Quick start + +```bash +# capture a thought +nib "proxy_pass needs a trailing slash" + +# todo +nib "- buy mass gainer @tomorrow #errands" + +# titled entry with description +nib "|nginx proxy trick // always forget this #ops" + +# todo with a title +nib "- |deploy staging // rebuild docker image #ops" + +# list recent entries +nib ls + +# list by tag +nib ls --tag ops + +# list a specific month +nib ls --month 2026-05 + +# start the web UI +nib serve +``` + +Open `http://localhost:4444` for the browser interface. + +## Grammar + +The full grammar fits on an index card. Here's what matters: + +### Kind prefixes + +The first character decides what kind of entry you're creating. + +| Input | Kind | Example | +|-------|------|---------| +| bare text | thought | `just an idea` | +| `- text` | todo | `- buy milk @tomorrow` | +| `@time text` | event | `@friday 2pm lunch with alex` | +| `!time text` | reminder | `!3pm call dentist` | + +The dash needs a space after it. `-deploy` is a thought, `- deploy` is a todo. + +### Titles and descriptions + +Give an entry a name with `|` at the start. Add a description with `//`. + +``` +|nginx proxy trick +proxy_pass http://backend/; + +|deploy checklist // for staging #ops +1. docker build +2. docker push +3. ssh prod && restart + +// quick reference for proxy config +the actual body text goes here + +body text // this part becomes the description +``` + +Title shows as the scan label in list view. Description appears in the detail pane. Both are optional — most captures won't need them. + +### Tags and flags + +Tags and flags work anywhere in the input. They're extracted and removed from the body. + +``` +deploy nginx #ops #infra → tags: ops, infra +important thing !pin → pinned to top +use ##channel in slack → literal #channel in body (escaped) +``` + +### Cards + +Promote a fluid entry to a card for reuse: + +```bash +nib promote snippet # code trick, copy-to-clipboard +nib promote template # has ${slots} to fill +nib promote checklist # step-through items +nib promote decision # chose/why/rejected +nib promote link # URL with an open button +``` + +Or use `^type` inline: `nib "proxy trick #nginx ^card"` + +## CLI commands + +| Command | What it does | +|---------|-------------| +| `nib ` | Capture (shorthand for `nib add`) | +| `nib ls` | List entries — filter with `--tag`, `--date`, `--month`, `--from`/`--to` | +| `nib cards` | List cards sorted by usage | +| `nib edit ` | Open in `$EDITOR` | +| `nib copy ` | Copy body to clipboard | +| `nib promote [type]` | Promote to card | +| `nib demote ` | Strip card, back to fluid | +| `nib absorb ` | Merge source into target | +| `nib delete ` | Soft delete (repeat to hard delete) | +| `nib serve` | Start web UI on `:4444` (or `--port`) | + +IDs are prefix-matchable. If `01KRQ4` is unique, that's enough. + +## Web UI + +`nib serve` starts a local web interface with: + +- **Capture bar** — same grammar as the CLI +- **Stream view** — entries grouped by date, newest first +- **Cards view** — promoted cards sorted by use count +- **Tag rail** — filter by tag +- **Month navigator** — browse by date range +- **Detail pane** — full entry view, double-click to edit +- **Keyboard shortcuts** — `j`/`k` navigate, `n` to capture, `p` to promote, `e` to edit, `dd` to delete + +Dark and light themes. Toggle with the button in the header. + +## Data + +Everything is one SQLite file. Back it up, sync it, move it between machines — it's just a file. WAL mode is on for concurrent reads. + +```bash +# back up +cp ~/.nib/nib.db ~/.nib/nib.db.bak + +# use a different database +NIB_DB=/path/to/other.db nib ls +``` + +## License + +MIT diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e532696 --- /dev/null +++ b/TODO.md @@ -0,0 +1,37 @@ +# UI Redesign — Design Handoff Implementation + +## Phase 1: Layout + Tokens + Header + Rail ✓ +- [x] Update CSS tokens (add --a-str, switch mono font to JetBrains Mono) +- [x] Fix grid dimensions (192px rail, 400px peek) +- [x] Move capture bar from header to bottom of center panel +- [x] Add search bar to header (centered, max-width 400px) +- [x] Redesign tag rail: grid layout (arrow ▸ + dot + name + count) +- [x] Add intent section (grab/read/fill) for cards view in rail + +## Phase 2: Stream + Cards Views ✓ +- [x] Stream rows: promoted entries get card-style border/radius + card-type badge +- [x] Card rows: rich single-line with title — preview — affordance badges — tag pills — pin — use count +- [x] Affordance detection client-side (fill, steps, decide, link, code) +- [x] Affordance badge components +- [x] Cards sub-header (scope label + card count + sort dropdown) +- [x] Section labels (★ pinned, recent) +- [x] Flash animation on copy +- [x] Bottom capture bar styling per view (different placeholders) + +## Phase 3: Peek Pane + Modes ✓ +- [x] Idle state with keyboard shortcuts display +- [x] Stream entry peek: eyebrow, body, tags, context, actions +- [x] Card peek: card container with eyebrow, title, desc, meta, content sections +- [x] Code block with content display +- [x] Decision section display +- [x] Steps section display +- [x] Link section display +- [x] Run mode (interactive checklist with progress bar) +- [x] Fill mode (inline slot editor with tab navigation) +- [x] Edit mode (form fields) +- [x] Toast notifications + +## Phase 4: Polish ✓ +- [x] Promote modal enhancement (add hint text per type, show entry body preview) +- [x] Keyboard shortcuts (r=run, f=fill, p=pin in cards view) +- [x] Escape exits active modes diff --git a/cmd/add.go b/cmd/add.go index 9e7cb8a..f95bc51 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -32,9 +32,12 @@ func runAdd(_ *cobra.Command, args []string) error { defer store.Close() e := &db.Entity{ - Body: parsed.Body, - Glyph: db.Glyph(parsed.Glyph), - Tags: parsed.Tags, + Body: parsed.Body, + Title: parsed.Title, + Description: parsed.Description, + Glyph: db.Glyph(parsed.Glyph), + Tags: parsed.Tags, + Pinned: parsed.Pin, } if parsed.TimeAnchor != nil { e.TimeAnchor = parsed.TimeAnchor @@ -53,12 +56,16 @@ func runAdd(_ *cobra.Command, args []string) error { var parts []string parts = append(parts, glyph) - parts = append(parts, " "+e.Body) + if e.Title != nil { + parts = append(parts, " "+*e.Title) + } else { + parts = append(parts, " "+e.Body) + } if e.TimeAnchor != nil { - parts = append(parts, " @"+*e.TimeAnchor) + parts = append(parts, " @"+*e.TimeAnchor) } for _, tag := range e.Tags { - parts = append(parts, " #"+tag) + parts = append(parts, " #"+tag) } parts = append(parts, " ["+shortID+"]") if e.CardType != nil { diff --git a/cmd/cards.go b/cmd/cards.go index 7f85fff..c612159 100644 --- a/cmd/cards.go +++ b/cmd/cards.go @@ -63,8 +63,13 @@ func runCards(_ *cobra.Command, _ []string) error { tagStr += " #" + tag } + label := e.Body + if e.Title != nil { + label = *e.Title + } + fmt.Printf("%s %-40s %-16s %3d× %s\n", - glyph, e.Body, + glyph, label, strings.TrimSpace(tagStr), e.UseCount, shortID) } diff --git a/cmd/demo.go b/cmd/demo.go new file mode 100644 index 0000000..ca3bf50 --- /dev/null +++ b/cmd/demo.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +var demoCmd = &cobra.Command{ + Use: "demo", + Short: "start server with pre-populated demo data", + RunE: runDemo, +} + +func init() { + rootCmd.AddCommand(demoCmd) +} + +type demoEntity struct { + Body string `json:"body"` + Glyph string `json:"glyph"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + TimeAnchor *string `json:"time_anchor,omitempty"` + Pinned bool `json:"pinned"` + Completed bool `json:"completed"` + Deleted bool `json:"deleted"` + CardType *string `json:"card_type,omitempty"` + CardData *string `json:"card_data,omitempty"` + Tags []string `json:"tags"` +} + +func runDemo(_ *cobra.Command, _ []string) error { + tmpDir, err := os.MkdirTemp("", "nib-demo-*") + if err != nil { + return err + } + dbPath := filepath.Join(tmpDir, "demo.db") + fmt.Printf("demo db: %s\n", dbPath) + + store, err := db.Open(dbPath) + if err != nil { + return err + } + + if err := seedDemo(store); err != nil { + store.Close() + return fmt.Errorf("seed demo data: %w", err) + } + store.Close() + + os.Setenv("NIB_DB", dbPath) + return runServe(nil, nil) +} + +func seedDemo(store *db.Store) error { + data, err := findDemoFile() + if err != nil { + return err + } + + var entries []demoEntity + if err := json.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("parse demo.json: %w", err) + } + + now := time.Now().UTC() + for i, entry := range entries { + e := &db.Entity{ + Body: entry.Body, + Glyph: db.Glyph(entry.Glyph), + Tags: entry.Tags, + } + + if entry.Title != nil { + e.Title = entry.Title + } + if entry.Description != nil { + e.Description = entry.Description + } + if entry.TimeAnchor != nil { + e.TimeAnchor = entry.TimeAnchor + } + if entry.Pinned { + e.Pinned = true + } + if entry.Completed { + t := now.Add(-time.Duration(i) * time.Hour) + e.CompletedAt = &t + } + + if err := store.Create(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 { + return fmt.Errorf("promote entity %d: %w", i, err) + } + } + + if entry.Deleted { + store.SoftDelete(e.ID) + } + } + + fmt.Printf("seeded %d entities\n", len(entries)) + return nil +} + +func findDemoFile() ([]byte, error) { + candidates := []string{ + "testdata/demo.json", + filepath.Join(execDir(), "testdata", "demo.json"), + } + + for _, path := range candidates { + data, err := os.ReadFile(path) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("demo.json not found (looked in: %v)", candidates) +} + +func execDir() string { + exe, err := os.Executable() + if err != nil { + return "." + } + return filepath.Dir(exe) +} diff --git a/cmd/ls.go b/cmd/ls.go index d3d76b2..3e6f995 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -142,8 +142,13 @@ func printEntity(e *db.Entity) { glyph := display.DisplayGlyph(e.Glyph, e.CardType) shortID := display.FormatID(e.ID) + label := e.Body + if e.Title != nil { + label = *e.Title + } + var line strings.Builder - fmt.Fprintf(&line, "%s %-40s", glyph, e.Body) + fmt.Fprintf(&line, "%s %-40s", glyph, label) if e.TimeAnchor != nil { fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor) diff --git a/cmd/promote_test.go b/cmd/promote_test.go new file mode 100644 index 0000000..b200278 --- /dev/null +++ b/cmd/promote_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestGenerateCardData_Snippet(t *testing.T) { + data := generateCardData(db.CardSnippet, "some snippet") + if data == nil || *data != "{}" { + t.Errorf("snippet should produce {}, got %v", data) + } +} + +func TestGenerateCardData_Template(t *testing.T) { + data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Slots []struct { + Name string `json:"name"` + Default string `json:"default"` + } `json:"slots"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Slots) != 2 { + t.Fatalf("expected 2 slots, got %d", len(parsed.Slots)) + } + if parsed.Slots[0].Name != "host" { + t.Errorf("first slot: %q", parsed.Slots[0].Name) + } + if parsed.Slots[1].Name != "env" { + t.Errorf("second slot: %q", parsed.Slots[1].Name) + } +} + +func TestGenerateCardData_TemplateDedupe(t *testing.T) { + data := generateCardData(db.CardTemplate, "${x} and ${x}") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 1 { + t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_TemplateNoSlots(t *testing.T) { + data := generateCardData(db.CardTemplate, "no placeholders here") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 0 { + t.Errorf("expected empty slots, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_Checklist(t *testing.T) { + body := "[ ] step one\n[x] step two\n[ ] step three" + data := generateCardData(db.CardChecklist, body) + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done { + t.Errorf("step 0: %+v", parsed.Steps[0]) + } + if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done { + t.Errorf("step 1: %+v", parsed.Steps[1]) + } +} + +func TestGenerateCardData_ChecklistFallback(t *testing.T) { + data := generateCardData(db.CardChecklist, "no checkbox syntax") + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Steps) != 1 { + t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "no checkbox syntax" { + t.Errorf("fallback step text: %q", parsed.Steps[0].Text) + } +} + +func TestGenerateCardData_Decision(t *testing.T) { + data := generateCardData(db.CardDecision, "which db?") + var parsed struct { + Chose string `json:"chose"` + Why string `json:"why"` + Rejected []string `json:"rejected"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if parsed.Chose != "" || parsed.Why != "" { + t.Error("decision fields should start empty") + } + if len(parsed.Rejected) != 0 { + t.Error("rejected should start empty") + } +} + +func TestGenerateCardData_Link(t *testing.T) { + data := generateCardData(db.CardLink, "check https://example.com/path for details") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "https://example.com/path" { + t.Errorf("url: %q", parsed.URL) + } +} + +func TestGenerateCardData_LinkNoURL(t *testing.T) { + data := generateCardData(db.CardLink, "no url here") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "" { + t.Errorf("expected empty url, got %q", parsed.URL) + } +} diff --git a/go.mod b/go.mod index 8ee951d..9972a46 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,24 @@ module github.com/lerko/nib go 1.24.4 require ( - github.com/atotto/clipboard v0.1.4 // indirect + github.com/atotto/clipboard v0.1.4 + github.com/go-chi/chi/v5 v5.2.5 + github.com/oklog/ulid/v2 v2.1.1 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.37.1 +) + +require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.33.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.37.1 // indirect ) diff --git a/go.sum b/go.sum index 7fe58f6..d394f26 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -26,15 +28,37 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An 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/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 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= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 303e508..862e5af 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -25,15 +25,22 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) { return srv, store } -func postJSON(srv *httptest.Server, path string, body any) *http.Response { - b, _ := json.Marshal(body) - resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) +func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } return resp } func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse { t.Helper() - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": body, "tags": tags, }) @@ -49,7 +56,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st func TestCreateEntity_Note(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "test note", "tags": []string{"demo"}, }) @@ -76,7 +83,7 @@ func TestCreateEntity_Note(t *testing.T) { func TestCreateEntity_MissingBody(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{}) + resp := postJSON(t, srv, "/api/entities", map[string]any{}) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -93,7 +100,7 @@ func TestCreateEntity_MissingBody(t *testing.T) { func TestCreateEntity_InvalidGlyph(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "test", "glyph": "invalid", }) @@ -108,7 +115,10 @@ func TestGetEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "test", nil) - resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID) + resp, err := http.Get(srv.URL + "/api/entities/" + created.ID) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -125,7 +135,10 @@ func TestGetEntity_Success(t *testing.T) { func TestGetEntity_NotFound(t *testing.T) { srv, _ := testServer(t) - resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT") + resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { @@ -138,7 +151,10 @@ func TestListEntities_Default(t *testing.T) { createTestEntity(t, srv, "one", nil) createTestEntity(t, srv, "two", nil) - resp, _ := http.Get(srv.URL + "/api/entities") + resp, err := http.Get(srv.URL + "/api/entities") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -153,7 +169,10 @@ func TestListEntities_FilterTag(t *testing.T) { createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "b", []string{"home"}) - resp, _ := http.Get(srv.URL + "/api/entities?tag=ops") + resp, err := http.Get(srv.URL + "/api/entities?tag=ops") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -167,13 +186,16 @@ func TestListEntities_CardsOnly(t *testing.T) { srv, _ := testServer(t) createTestEntity(t, srv, "fluid", nil) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "card", "card_type": "snippet", }) resp.Body.Close() - resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true") + resp, err := http.Get(srv.URL + "/api/entities?cards_only=true") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -189,12 +211,18 @@ func TestListEntities_Pagination(t *testing.T) { createTestEntity(t, srv, "note", nil) } - resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0") + resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0") + if err != nil { + t.Fatal(err) + } var page1 []EntityResponse json.NewDecoder(resp.Body).Decode(&page1) resp.Body.Close() - resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2") + resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2") + if err != nil { + t.Fatal(err) + } var page2 []EntityResponse json.NewDecoder(resp.Body).Decode(&page2) resp.Body.Close() @@ -211,10 +239,16 @@ func TestUpdateEntity_Body(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "old", nil) - req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( mustJSON(map[string]any{"body": "new"}))) + if err != nil { + t.Fatal(err) + } req.Header.Set("Content-Type", "application/json") - resp, _ := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -233,8 +267,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { created := createTestEntity(t, srv, "doomed", nil) // Soft delete - req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } var delResp DeleteResponse json.NewDecoder(resp.Body).Decode(&delResp) resp.Body.Close() @@ -246,7 +286,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { } // Hard delete - resp, _ = http.DefaultClient.Do(req) + req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) + if err != nil { + t.Fatal(err) + } + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } json.NewDecoder(resp.Body).Decode(&delResp) resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -257,7 +304,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { } // Gone - resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID) + resp, err = http.Get(srv.URL + "/api/entities/" + created.ID) + if err != nil { + t.Fatal(err) + } resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode) @@ -268,7 +318,7 @@ func TestPromoteEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }) defer resp.Body.Close() @@ -288,11 +338,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "template", }) defer resp.Body.Close() @@ -312,7 +362,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "bogus", }) defer resp.Body.Close() @@ -326,11 +376,11 @@ func TestDemoteEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -348,7 +398,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -360,7 +410,7 @@ func TestUseEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -379,7 +429,10 @@ func TestListTags_WithCounts(t *testing.T) { createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "b", []string{"ops", "nginx"}) - resp, _ := http.Get(srv.URL + "/api/tags") + resp, err := http.Get(srv.URL + "/api/tags") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var tags []TagResponse @@ -391,14 +444,23 @@ func TestListTags_WithCounts(t *testing.T) { func TestCORS_DevMode(t *testing.T) { path := filepath.Join(t.TempDir(), "test.db") - store, _ := db.Open(path) + store, err := db.Open(path) + if err != nil { + t.Fatal(err) + } defer store.Close() router := NewRouter(store, true) srv := httptest.NewServer(router) defer srv.Close() - req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.Header.Get("Access-Control-Allow-Origin") != "*" { @@ -412,8 +474,14 @@ func TestCORS_DevMode(t *testing.T) { func TestCORS_ProdMode(t *testing.T) { srv, _ := testServer(t) // devMode=false - req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.Header.Get("Access-Control-Allow-Origin") != "" { @@ -426,7 +494,7 @@ func TestAbsorbEntity_Success(t *testing.T) { target := createTestEntity(t, srv, "target body", []string{"ops"}) source := createTestEntity(t, srv, "source body", []string{"ops", "infra"}) - resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ "source_id": source.ID, }) defer resp.Body.Close() @@ -445,7 +513,10 @@ func TestAbsorbEntity_Success(t *testing.T) { } // Source should be soft-deleted (not in default list) - listResp, _ := http.Get(srv.URL + "/api/entities") + listResp, err := http.Get(srv.URL + "/api/entities") + if err != nil { + t.Fatal(err) + } var entities []EntityResponse json.NewDecoder(listResp.Body).Decode(&entities) listResp.Body.Close() @@ -461,11 +532,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) { target := createTestEntity(t, srv, "target", nil) source := createTestEntity(t, srv, "source", nil) - postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ "source_id": source.ID, }) defer resp.Body.Close() @@ -485,7 +556,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) { srv, _ := testServer(t) e := createTestEntity(t, srv, "self", nil) - resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{ "source_id": e.ID, }) defer resp.Body.Close() @@ -499,7 +570,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) { srv, _ := testServer(t) e := createTestEntity(t, srv, "target", nil) - resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{}) + resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{}) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -507,6 +578,115 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) { } } +func TestCreateEntity_WithTitle(t *testing.T) { + srv, _ := testServer(t) + + resp := postJSON(t, srv, "/api/entities", map[string]any{ + "body": "body text", + "title": "nginx trick", + "description": "always forget this", + "tags": []string{"ops"}, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "nginx trick" { + t.Errorf("title: %v", e.Title) + } + if e.Description == nil || *e.Description != "always forget this" { + t.Errorf("description: %v", e.Description) + } +} + +func TestCreateEntity_TitleOnly(t *testing.T) { + srv, _ := testServer(t) + + title := "title only" + resp := postJSON(t, srv, "/api/entities", map[string]any{ + "title": title, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "title only" { + t.Errorf("title: %v", e.Title) + } +} + +func TestUpdateEntity_Title(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "body", nil) + + req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + mustJSON(map[string]any{"title": "new title"}))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "new title" { + t.Errorf("title: %v", e.Title) + } +} + +func TestUpdateEntity_Description(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "body", nil) + + req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + mustJSON(map[string]any{"description": "new desc"}))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Description == nil || *e.Description != "new desc" { + t.Errorf("description: %v", e.Description) + } +} + +func TestListEntities_TitleInResponse(t *testing.T) { + srv, _ := testServer(t) + + title := "list title" + postJSON(t, srv, "/api/entities", map[string]any{ + "body": "body", + "title": title, + }).Body.Close() + + resp, _ := http.Get(srv.URL + "/api/entities") + defer resp.Body.Close() + + var entities []EntityResponse + json.NewDecoder(resp.Body).Decode(&entities) + if len(entities) != 1 { + t.Fatalf("expected 1, got %d", len(entities)) + } + if entities[0].Title == nil || *entities[0].Title != "list title" { + t.Errorf("title: %v", entities[0].Title) + } +} + func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b diff --git a/internal/api/entities.go b/internal/api/entities.go index 592bb2e..6e78e2e 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -10,22 +10,27 @@ import ( ) type CreateEntityRequest struct { - Body string `json:"body"` - Glyph *string `json:"glyph"` - TimeAnchor *string `json:"time_anchor"` - Tags []string `json:"tags"` - CardType *string `json:"card_type"` - CardData *string `json:"card_data"` + Body string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags []string `json:"tags"` + Pinned *bool `json:"pinned"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` } type UpdateEntityRequest struct { - Body *string `json:"body"` - Glyph *string `json:"glyph"` - TimeAnchor *string `json:"time_anchor"` - Tags *[]string `json:"tags"` - Pinned *bool `json:"pinned"` - CardType *string `json:"card_type"` - CardData *string `json:"card_data"` + Body *string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags *[]string `json:"tags"` + Pinned *bool `json:"pinned"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` } type PromoteRequest struct { @@ -100,7 +105,7 @@ func listEntities(store *db.Store) http.HandlerFunc { entities, err := store.List(p) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } @@ -119,8 +124,8 @@ func createEntity(store *db.Store) http.HandlerFunc { return } - if req.Body == "" { - writeError(w, http.StatusBadRequest, "invalid_input", "body is required") + if req.Body == "" && req.Title == nil { + writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required") return } @@ -134,10 +139,15 @@ func createEntity(store *db.Store) http.HandlerFunc { } e := &db.Entity{ - Body: req.Body, - Glyph: glyph, - TimeAnchor: req.TimeAnchor, - Tags: req.Tags, + Body: req.Body, + Title: req.Title, + Description: req.Description, + Glyph: glyph, + TimeAnchor: req.TimeAnchor, + Tags: req.Tags, + } + if req.Pinned != nil && *req.Pinned { + e.Pinned = true } if req.CardType != nil { @@ -151,7 +161,7 @@ func createEntity(store *db.Store) http.HandlerFunc { } if err := store.Create(e); err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } @@ -168,7 +178,7 @@ func getEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -186,6 +196,8 @@ func updateEntity(store *db.Store) http.HandlerFunc { u := &db.EntityUpdate{} u.Body = req.Body + u.Title = req.Title + u.Description = req.Description u.Tags = req.Tags u.Pinned = req.Pinned u.CardData = req.CardData @@ -215,13 +227,13 @@ func updateEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -241,7 +253,7 @@ func deleteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } label := "soft" @@ -279,13 +291,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -305,13 +317,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -349,13 +361,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -371,13 +383,13 @@ func useEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index d9723ad..f95b72d 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -2,12 +2,15 @@ package api import ( "encoding/json" + "log" "net/http" "time" "github.com/lerko/nib/internal/db" ) +const maxBodySize = 1 << 20 // 1 MB + type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` @@ -18,6 +21,8 @@ type EntityResponse struct { CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` Body string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` Glyph string `json:"glyph"` TimeAnchor *string `json:"time_anchor"` CompletedAt *string `json:"completed_at"` @@ -41,6 +46,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) { } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) if err := json.NewDecoder(r.Body).Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error()) return false @@ -48,16 +54,23 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { return true } +func writeInternalError(w http.ResponseWriter, err error) { + log.Printf("internal error: %v", err) + writeError(w, http.StatusInternalServerError, "internal", "internal server error") +} + func entityToResponse(e *db.Entity) EntityResponse { resp := EntityResponse{ - ID: e.ID, - CreatedAt: e.CreatedAt.Format(time.RFC3339), - ModifiedAt: e.ModifiedAt.Format(time.RFC3339), - Body: e.Body, - Glyph: string(e.Glyph), - Pinned: e.Pinned, - Tags: e.Tags, - UseCount: e.UseCount, + ID: e.ID, + CreatedAt: e.CreatedAt.Format(time.RFC3339), + ModifiedAt: e.ModifiedAt.Format(time.RFC3339), + Body: e.Body, + Title: e.Title, + Description: e.Description, + Glyph: string(e.Glyph), + Pinned: e.Pinned, + Tags: e.Tags, + UseCount: e.UseCount, } if resp.Tags == nil { resp.Tags = []string{} diff --git a/internal/api/tags.go b/internal/api/tags.go index 9d26ca1..79607ed 100644 --- a/internal/api/tags.go +++ b/internal/api/tags.go @@ -15,7 +15,7 @@ func listTags(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tags, err := store.ListTags() if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } diff --git a/internal/db/db.go b/internal/db/db.go index 46795af..15530d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -84,7 +84,14 @@ func (s *Store) migrate() error { CREATE INDEX IF NOT EXISTS idx_entity_tags_tag ON entity_tags(tag); `) - return err + if err != nil { + return err + } + + s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) + s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + + return nil } func DefaultPath() (string, error) { diff --git a/internal/db/entities.go b/internal/db/entities.go index fbc180f..4669416 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -13,9 +13,10 @@ import ( type Glyph string const ( - GlyphNote Glyph = "note" - GlyphTodo Glyph = "todo" - GlyphEvent Glyph = "event" + GlyphNote Glyph = "note" + GlyphTodo Glyph = "todo" + GlyphEvent Glyph = "event" + GlyphReminder Glyph = "reminder" ) type CardType string @@ -30,7 +31,7 @@ const ( func ValidGlyph(s string) bool { switch Glyph(s) { - case GlyphNote, GlyphTodo, GlyphEvent: + case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder: return true } return false @@ -49,6 +50,8 @@ type Entity struct { CreatedAt time.Time ModifiedAt time.Time Body string + Title *string + Description *string Glyph Glyph TimeAnchor *string CompletedAt *time.Time @@ -85,14 +88,16 @@ func DefaultListParams() ListParams { } type EntityUpdate struct { - Body *string - Glyph *Glyph - TimeAnchor *string - ClearTime bool - Pinned *bool - CardType *CardType - CardData *string - Tags *[]string + Body *string + Title *string + Description *string + Glyph *Glyph + TimeAnchor *string + ClearTime bool + Pinned *bool + CardType *CardType + CardData *string + Tags *[]string } func (s *Store) Create(e *Entity) error { @@ -111,13 +116,16 @@ func (s *Store) Create(e *Entity) error { defer tx.Rollback() _, err = tx.Exec(` - INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, - completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.CreatedAt.Format(time.RFC3339), e.ModifiedAt.Format(time.RFC3339), e.Body, + e.Title, + e.Description, string(e.Glyph), e.TimeAnchor, formatTimePtr(e.CompletedAt), @@ -141,18 +149,13 @@ func (s *Store) Create(e *Entity) error { func (s *Store) Get(id string) (*Entity, error) { e := &Entity{} - var createdAt, modifiedAt string - var completedAt, deletedAt, lastUsedAt sql.NullString - var timeAnchor, cardType, cardData sql.NullString - var pinned int + row := newEntityRow() err := s.db.QueryRow(` - SELECT id, created_at, modified_at, body, glyph, time_anchor, - completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at - FROM entities WHERE id = ?`, id).Scan( - &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, - &completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt, - ) + 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 + FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -160,15 +163,9 @@ func (s *Store) Get(id string) (*Entity, error) { return nil, err } - e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) - e.TimeAnchor = nullToPtr(timeAnchor) - e.CompletedAt = parseTimePtr(completedAt) - e.Pinned = pinned != 0 - e.DeletedAt = parseTimePtr(deletedAt) - e.CardType = nullToCardType(cardType) - e.CardData = nullToPtr(cardData) - e.LastUsedAt = parseTimePtr(lastUsedAt) + if err := row.apply(e); err != nil { + return nil, fmt.Errorf("scan entity %s: %w", id, err) + } tags, err := s.loadTags(id) if err != nil { @@ -234,9 +231,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { } query := fmt.Sprintf(` - SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, - e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, - e.use_count, e.last_used_at + SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description, + e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at, + e.card_type, e.card_data, e.use_count, e.last_used_at FROM entities e %s ORDER BY %s %s @@ -253,31 +250,18 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { var entities []*Entity for rows.Next() { e := &Entity{} - var createdAt, modifiedAt string - var completedAt, deletedAt, lastUsedAt sql.NullString - var timeAnchor, cardType, cardData sql.NullString - var pinned int - - if err := rows.Scan( - &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, - &completedAt, &pinned, &deletedAt, &cardType, &cardData, - &e.UseCount, &lastUsedAt, - ); err != nil { + row := newEntityRow() + if err := rows.Scan(row.ptrs(e)...); err != nil { + return nil, err + } + if err := row.apply(e); err != nil { return nil, err } - - e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) - e.TimeAnchor = nullToPtr(timeAnchor) - e.CompletedAt = parseTimePtr(completedAt) - e.Pinned = pinned != 0 - e.DeletedAt = parseTimePtr(deletedAt) - e.CardType = nullToCardType(cardType) - e.CardData = nullToPtr(cardData) - e.LastUsedAt = parseTimePtr(lastUsedAt) - entities = append(entities, e) } + if err := rows.Err(); err != nil { + return nil, err + } if err := s.batchLoadTags(entities); err != nil { return nil, err @@ -308,6 +292,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error { sets = append(sets, "body = ?") args = append(args, *u.Body) } + if u.Title != nil { + sets = append(sets, "title = ?") + args = append(args, *u.Title) + } + if u.Description != nil { + sets = append(sets, "description = ?") + args = append(args, *u.Description) + } if u.Glyph != nil { sets = append(sets, "glyph = ?") args = append(args, string(*u.Glyph)) @@ -502,6 +494,9 @@ func (s *Store) Resolve(prefix string) (string, error) { } ids = append(ids, id) } + if err := rows.Err(); err != nil { + return "", err + } switch len(ids) { case 0: @@ -513,6 +508,44 @@ func (s *Store) Resolve(prefix string) (string, error) { } } +type entityRow struct { + createdAt, modifiedAt string + completedAt, deletedAt, lastUsedAt sql.NullString + timeAnchor, cardType, cardData sql.NullString + title, description sql.NullString + pinned int +} + +func newEntityRow() *entityRow { return &entityRow{} } + +func (r *entityRow) ptrs(e *Entity) []any { + return []any{ + &e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &r.title, &r.description, + &e.Glyph, &r.timeAnchor, &r.completedAt, &r.pinned, &r.deletedAt, + &r.cardType, &r.cardData, &e.UseCount, &r.lastUsedAt, + } +} + +func (r *entityRow) apply(e *Entity) error { + var err error + if e.CreatedAt, err = time.Parse(time.RFC3339, r.createdAt); err != nil { + return fmt.Errorf("created_at: %w", err) + } + if e.ModifiedAt, err = time.Parse(time.RFC3339, r.modifiedAt); err != nil { + return fmt.Errorf("modified_at: %w", err) + } + e.Title = nullToPtr(r.title) + e.Description = nullToPtr(r.description) + e.TimeAnchor = nullToPtr(r.timeAnchor) + e.CompletedAt = parseTimePtr(r.completedAt) + e.Pinned = r.pinned != 0 + e.DeletedAt = parseTimePtr(r.deletedAt) + e.CardType = nullToCardType(r.cardType) + e.CardData = nullToPtr(r.cardData) + e.LastUsedAt = parseTimePtr(r.lastUsedAt) + return nil +} + // helpers func (s *Store) batchLoadTags(entities []*Entity) error { @@ -568,6 +601,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) { } tags = append(tags, tag) } + if err := rows.Err(); err != nil { + return nil, err + } if tags == nil { tags = []string{} } @@ -631,11 +667,13 @@ func boolToInt(b bool) int { return 0 } -func (e *Entity) CardDataJSON() map[string]interface{} { +func (e *Entity) CardDataJSON() (map[string]interface{}, error) { if e.CardData == nil { - return nil + return nil, nil } var m map[string]interface{} - json.Unmarshal([]byte(*e.CardData), &m) - return m + if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil { + return nil, fmt.Errorf("card_data: %w", err) + } + return m, nil } diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 19ef9e2..8817edc 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -441,3 +441,130 @@ func TestResolve_NotFound(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestAbsorb_SourceIsCard(t *testing.T) { + s := testStore(t) + target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}} + s.Create(target) + + source := &Entity{Body: "source", Glyph: GlyphNote} + s.Create(source) + s.Promote(source.ID, CardSnippet, nil) + s.IncrementUse(source.ID) + + if err := s.Absorb(target.ID, source.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(target.ID) + if got.Body != "target\nsource" { + t.Errorf("merged body: %q", got.Body) + } + + src, _ := s.Get(source.ID) + if src.CardType != nil { + t.Error("source card_type should be cleared after absorb") + } + if src.UseCount != 0 { + t.Errorf("source use_count should be reset, got %d", src.UseCount) + } + if src.DeletedAt == nil { + t.Error("source should be soft-deleted") + } +} + +func TestCreate_WithTitleAndDescription(t *testing.T) { + s := testStore(t) + e := &Entity{ + Body: "body text", + Title: ptr("nginx trick"), + Description: ptr("always forget this"), + Glyph: GlyphNote, + Tags: []string{"ops"}, + } + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if got.Title == nil || *got.Title != "nginx trick" { + t.Errorf("title: got %v", got.Title) + } + if got.Description == nil || *got.Description != "always forget this" { + t.Errorf("description: got %v", got.Description) + } + if got.Body != "body text" { + t.Errorf("body: got %q", got.Body) + } +} + +func TestCreate_WithoutTitle(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "just body", Glyph: GlyphNote} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Title != nil { + t.Errorf("expected nil title, got %v", got.Title) + } + if got.Description != nil { + t.Errorf("expected nil description, got %v", got.Description) + } +} + +func TestUpdate_Title(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "body", Glyph: GlyphNote} + s.Create(e) + + newTitle := "new title" + if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Title == nil || *got.Title != "new title" { + t.Errorf("title: got %v", got.Title) + } +} + +func TestUpdate_Description(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "body", Glyph: GlyphNote} + s.Create(e) + + newDesc := "new desc" + if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Description == nil || *got.Description != "new desc" { + t.Errorf("description: got %v", got.Description) + } +} + +func TestAbsorb_PreservesTargetTitle(t *testing.T) { + s := testStore(t) + 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) + + if err := s.Absorb(target.ID, source.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(target.ID) + if got.Title == nil || *got.Title != "target title" { + t.Errorf("target title should be preserved, got %v", got.Title) + } + if got.Body != "target body\nsource body" { + t.Errorf("body: got %q", got.Body) + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go index 29b2787..52b8f56 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -26,6 +26,9 @@ func (s *Store) ListTags() ([]TagCount, error) { } tags = append(tags, tc) } + if err := rows.Err(); err != nil { + return nil, err + } if tags == nil { tags = []TagCount{} } diff --git a/internal/display/glyph.go b/internal/display/glyph.go index f562d39..9ba2bfc 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -3,9 +3,10 @@ package display import "github.com/lerko/nib/internal/db" var glyphMap = map[db.Glyph]string{ - db.GlyphNote: "—", - db.GlyphTodo: "○", - db.GlyphEvent: "◇", + db.GlyphNote: "—", + db.GlyphTodo: "○", + db.GlyphEvent: "◇", + db.GlyphReminder: "△", } var cardGlyphMap = map[db.CardType]string{ diff --git a/internal/display/glyph_test.go b/internal/display/glyph_test.go new file mode 100644 index 0000000..5ad53a9 --- /dev/null +++ b/internal/display/glyph_test.go @@ -0,0 +1,80 @@ +package display + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestDisplayGlyph_Fluid(t *testing.T) { + tests := []struct { + glyph db.Glyph + want string + }{ + {db.GlyphNote, "—"}, + {db.GlyphTodo, "○"}, + {db.GlyphEvent, "◇"}, + } + for _, tt := range tests { + got := DisplayGlyph(tt.glyph, nil) + if got != tt.want { + t.Errorf("DisplayGlyph(%q, nil) = %q, want %q", tt.glyph, got, tt.want) + } + } +} + +func TestDisplayGlyph_Card(t *testing.T) { + tests := []struct { + cardType db.CardType + want string + }{ + {db.CardSnippet, "◆"}, + {db.CardTemplate, "◈"}, + {db.CardChecklist, "☐"}, + {db.CardDecision, "⚖"}, + {db.CardLink, "↗"}, + } + for _, tt := range tests { + ct := tt.cardType + got := DisplayGlyph(db.GlyphNote, &ct) + if got != tt.want { + t.Errorf("DisplayGlyph(note, %q) = %q, want %q", tt.cardType, got, tt.want) + } + } +} + +func TestDisplayGlyph_CardOverridesGlyph(t *testing.T) { + ct := db.CardSnippet + got := DisplayGlyph(db.GlyphTodo, &ct) + if got != "◆" { + t.Errorf("card_type should override glyph, got %q", got) + } +} + +func TestDisplayGlyph_UnknownFallback(t *testing.T) { + got := DisplayGlyph(db.Glyph("unknown"), nil) + if got != "—" { + t.Errorf("unknown glyph should fall back to —, got %q", got) + } +} + +func TestFormatID_Long(t *testing.T) { + got := FormatID("01HXYZ1234567890ABCDEFGH") + if got != "01HXYZ123456" { + t.Errorf("expected 12-char truncation, got %q", got) + } +} + +func TestFormatID_Short(t *testing.T) { + got := FormatID("ABC") + if got != "ABC" { + t.Errorf("short ID should pass through, got %q", got) + } +} + +func TestFormatID_Exact12(t *testing.T) { + got := FormatID("123456789012") + if got != "123456789012" { + t.Errorf("exact 12-char should pass through, got %q", got) + } +} diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index 879aec5..ee30ea0 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -7,11 +7,16 @@ import ( ) type Result struct { - Body string - Glyph string - TimeAnchor *string - Tags []string - CardSuffix *string + Body string + Glyph string + Title *string + Description *string + TimeAnchor *string + Tags []string + FilterTags []string + CardSuffix *string + Pin bool + Query bool } var validCardTypes = map[string]string{ @@ -35,67 +40,232 @@ func Parse(input string) (*Result, error) { Tags: []string{}, } - tokens := strings.Fields(input) - if len(tokens) == 0 { - return nil, fmt.Errorf("empty input") + remaining := input + + // Step 1: Escape check — `\` prefix → thought, no prefix detection + if strings.HasPrefix(remaining, `\`) { + remaining = remaining[1:] + r.Glyph = "note" + clean, err := extractModifiers(r, remaining, false) + if err != nil { + return nil, err + } + r.Body = clean + if r.Body == "" && r.Title == nil { + return nil, fmt.Errorf("empty body after extracting modifiers") + } + return r, nil } - first := tokens[0] - switch first { - case "-", "▸": + // Step 2: Query check — `?` prefix → search mode + if strings.HasPrefix(remaining, "?") { + remaining = strings.TrimSpace(remaining[1:]) + r.Query = true + r.Glyph = "" + tokens := strings.Fields(remaining) + var bodyParts []string + for _, tok := range tokens { + if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") { + tag := strings.ToLower(tok[1:]) + r.FilterTags = append(r.FilterTags, tag) + } else { + bodyParts = append(bodyParts, tok) + } + } + r.Body = strings.Join(bodyParts, " ") + return r, nil + } + + // Step 3: Kind prefix — `-`, `@time`, `!time` + // `@` and `!` are kind prefixes ONLY if followed by a valid time token. + // Otherwise the input is treated as a plain note. + if strings.HasPrefix(remaining, "- ") { r.Glyph = "todo" - tokens = tokens[1:] - case "*", "◇": - r.Glyph = "event" - tokens = tokens[1:] - } - - var bodyParts []string - seen := map[string]bool{} - - for _, tok := range tokens { - switch { - case strings.HasPrefix(tok, "@") && len(tok) > 1: - timeStr := tok[1:] - if err := validateTime(timeStr); err != nil { - return nil, fmt.Errorf("invalid time %q: %w", timeStr, err) + remaining = strings.TrimSpace(remaining[2:]) + } else if remaining == "-" { + r.Glyph = "todo" + remaining = "" + } else if strings.HasPrefix(remaining, "@") { + if rest, ok := tryPrefixTime(r, remaining[1:]); ok { + r.Glyph = "event" + remaining = rest + } + } else if strings.HasPrefix(remaining, "!") { + afterBang := remaining[1:] + // `!pin` is a flag, not a reminder prefix + firstWord := "" + if fields := strings.Fields(afterBang); len(fields) > 0 { + firstWord = fields[0] + } + if !strings.EqualFold(firstWord, "pin") { + if rest, ok := tryPrefixTime(r, afterBang); ok { + r.Glyph = "reminder" + remaining = rest } - if r.TimeAnchor != nil { - return nil, fmt.Errorf("multiple time anchors") - } - r.TimeAnchor = &timeStr - - case strings.HasPrefix(tok, "#") && len(tok) > 1: - tag := tok[1:] - if !seen[tag] { - r.Tags = append(r.Tags, tag) - seen[tag] = true - } - - case strings.HasPrefix(tok, "^") && len(tok) > 1: - suffix := tok[1:] - cardType, ok := validCardTypes[suffix] - if !ok { - return nil, fmt.Errorf("invalid card type %q", suffix) - } - if r.CardSuffix != nil { - return nil, fmt.Errorf("multiple card suffixes") - } - r.CardSuffix = &cardType - - default: - bodyParts = append(bodyParts, tok) } } - r.Body = strings.Join(bodyParts, " ") - if r.Body == "" { + // Steps 4-5: Title and description extraction + var titleRaw, descRaw string + hasTitle := false + + lines := strings.SplitN(remaining, "\n", 2) + firstLine := strings.TrimSpace(lines[0]) + + if strings.HasPrefix(firstLine, "|") { + hasTitle = true + titleContent := firstLine[1:] + if idx := strings.Index(titleContent, " // "); idx >= 0 { + titleRaw = strings.TrimSpace(titleContent[:idx]) + descRaw = strings.TrimSpace(titleContent[idx+4:]) + } else { + titleRaw = strings.TrimSpace(titleContent) + } + if len(lines) > 1 { + remaining = lines[1] + } else { + remaining = "" + } + } else { + allLines := strings.Split(remaining, "\n") + var descParts []string + startBody := 0 + for i, line := range allLines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "// ") || trimmed == "//" { + descParts = append(descParts, strings.TrimSpace(trimmed[2:])) + startBody = i + 1 + } else { + break + } + } + if len(descParts) > 0 { + descRaw = strings.Join(descParts, " ") + remaining = strings.Join(allLines[startBody:], "\n") + } else if !strings.Contains(firstLine, "://") { + if idx := strings.Index(firstLine, " // "); idx >= 0 { + descRaw = strings.TrimSpace(firstLine[idx+4:]) + remaining = strings.TrimSpace(firstLine[:idx]) + if len(lines) > 1 { + remaining += "\n" + lines[1] + } + } + } + } + + // Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body + if hasTitle { + clean, err := extractModifiers(r, titleRaw, false) + if err != nil { + return nil, err + } + if clean != "" { + r.Title = &clean + } + } + if descRaw != "" { + clean, err := extractModifiers(r, descRaw, false) + if err != nil { + return nil, err + } + if clean != "" { + r.Description = &clean + } + } + + clean, err := extractModifiers(r, remaining, true) + if err != nil { + return nil, err + } + r.Body = clean + + if r.Body == "" && r.Title == nil { return nil, fmt.Errorf("empty body after extracting modifiers") } return r, nil } +// tryPrefixTime attempts to extract a time token from the start of text. +// Returns (remaining text, true) on success, or ("", false) if no valid time found. +func tryPrefixTime(r *Result, text string) (string, bool) { + text = strings.TrimSpace(text) + if text == "" { + return "", false + } + sp := strings.IndexByte(text, ' ') + var timeStr, rest string + if sp >= 0 { + timeStr = text[:sp] + rest = strings.TrimSpace(text[sp+1:]) + } else { + timeStr = text + rest = "" + } + if validateTime(timeStr) != nil { + return "", false + } + r.TimeAnchor = &timeStr + return rest, true +} + +// extractModifiers extracts tags, flags, time anchors, and card suffixes from text. +// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts). +func extractModifiers(r *Result, text string, handleFlags bool) (string, error) { + seen := map[string]bool{} + for _, t := range r.Tags { + seen[strings.ToLower(t)] = true + } + + var outLines []string + for _, line := range strings.Split(text, "\n") { + tokens := strings.Fields(line) + var lineParts []string + for _, tok := range tokens { + switch { + case handleFlags && strings.EqualFold(tok, "!pin"): + r.Pin = true + + case strings.HasPrefix(tok, "##") && len(tok) > 2: + lineParts = append(lineParts, "#"+tok[2:]) + + case strings.HasPrefix(tok, "@") && len(tok) > 1: + timeStr := tok[1:] + if validateTime(timeStr) != nil { + lineParts = append(lineParts, tok) + } else if r.TimeAnchor != nil { + return "", fmt.Errorf("multiple time anchors") + } else { + r.TimeAnchor = &timeStr + } + + case strings.HasPrefix(tok, "#") && len(tok) > 1: + tag := strings.ToLower(tok[1:]) + if !seen[tag] { + r.Tags = append(r.Tags, tag) + seen[tag] = true + } + + case strings.HasPrefix(tok, "^") && len(tok) > 1: + suffix := tok[1:] + cardType, ok := validCardTypes[suffix] + if !ok { + return "", fmt.Errorf("invalid card type %q", suffix) + } + if r.CardSuffix != nil { + return "", fmt.Errorf("multiple card suffixes") + } + r.CardSuffix = &cardType + + default: + lineParts = append(lineParts, tok) + } + } + outLines = append(outLines, strings.Join(lineParts, " ")) + } + return strings.Join(outLines, "\n"), nil +} + func validateTime(s string) error { parts := strings.SplitN(s, ":", 2) if len(parts) != 2 { diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 2262e9e..0734dc5 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -13,46 +13,97 @@ func TestParse(t *testing.T) { input string wantBody string wantGlyph string + wantTitle *string + wantDesc *string wantTime *string wantTags []string wantCard *string + wantPin bool + wantQuery bool + wantFilter []string wantErrSub string }{ - // Glyph detection - {"plain note", "hello world", "hello world", "note", nil, nil, nil, ""}, - {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, - {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, - {"star event", "* dentist", "dentist", "event", nil, nil, nil, ""}, - {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, ""}, + // Kind prefixes + {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"dash todo requires space", "-deploy", "-deploy", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"event prefix", "@14:00 dentist", "dentist", "event", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"event no body", "@9:30", "", "event", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"}, + {"reminder prefix", "!15:00 call dentist", "call dentist", "reminder", nil, nil, sp("15:00"), nil, nil, false, false, nil, ""}, + {"reminder no body", "!9:30", "", "reminder", nil, nil, sp("9:30"), nil, nil, false, false, nil, "empty body"}, - // Time anchor - {"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""}, - {"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""}, - {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"}, - {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"}, + // Event/reminder with invalid time — @ stays as body token, ! stays as body token + {"at-sign not time", "@nottime hello", "@nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"bang not time", "!nottime hello", "!nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, - // Tags - {"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, - {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""}, - {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, - {"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""}, + // Escape prefix + {"escape dash", `\- this is not a todo`, "- this is not a todo", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"escape at", `\@14:00 not event`, "not event", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"escape plain", `\hello`, "hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, - // Card suffix - {"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, - {"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""}, - {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""}, - {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""}, - {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"}, + // Query mode + {"query basic", "? proxy config", "proxy config", "", nil, nil, nil, nil, nil, false, true, nil, ""}, + {"query with tags", "? proxy config #ops #infra", "proxy config", "", nil, nil, nil, nil, nil, false, true, []string{"ops", "infra"}, ""}, + {"query tags only", "? #ops", "", "", nil, nil, nil, nil, nil, false, true, []string{"ops"}, ""}, + + // Inline time anchor + {"inline time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""}, + {"todo due time", "- buy milk @9:30", "buy milk", "todo", nil, nil, sp("9:30"), nil, nil, false, false, nil, ""}, + {"invalid hours stays as body", "meeting @25:00", "meeting @25:00", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"invalid minutes stays as body", "meeting @14:60", "meeting @14:60", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + + // Tags (lowercased) + {"single tag", "deploy #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"multiple tags", "deploy #ops #Infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, false, false, nil, ""}, + {"duplicate tags", "deploy #ops #Ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, false, false, nil, ""}, + + // Hash escape + {"double hash escape", "use ##channel in slack", "use #channel in slack", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"double hash with tag", "use ##channel #ops", "use #channel", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + + // Pin flag + {"pin flag", "important thing !pin", "important thing", "note", nil, nil, nil, nil, nil, true, false, nil, ""}, + {"pin case insensitive", "important !Pin #work", "important", "note", nil, nil, nil, []string{"work"}, nil, true, false, nil, ""}, + {"pin with todo", "- urgent task !pin", "urgent task", "todo", nil, nil, nil, nil, nil, true, false, nil, ""}, + + // !pin at start — not a reminder, flag is extracted + {"bang pin only", "!pin important", "important", "note", nil, nil, nil, nil, nil, true, false, nil, ""}, + + // Card suffix (kept for now) + {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), false, false, nil, ""}, + {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), false, false, nil, ""}, + {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), false, false, nil, ""}, + {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, false, false, nil, "invalid card type"}, // Combined - {"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""}, - {"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, + {"full todo", "- deploy nginx @15:00 #ops", "deploy nginx", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, false, false, nil, ""}, + {"full event", "@14:00 lunch with alex #personal", "lunch with alex", "event", nil, nil, sp("14:00"), []string{"personal"}, nil, false, false, nil, ""}, + {"full reminder", "!15:00 call dentist #health", "call dentist", "reminder", nil, nil, sp("15:00"), []string{"health"}, nil, false, false, nil, ""}, + + // Title + {"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, false, false, nil, ""}, + {"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, false, false, nil, ""}, + {"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, false, false, nil, ""}, + + // Description without title + {"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, false, false, nil, ""}, + {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, false, false, nil, ""}, + {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + + // Multiline body preserves newlines + {"multiline body", "hello\nworld", "hello\nworld", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, + {"multiline with tags", "line one #ops\nline two", "line one\nline two", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""}, + {"title multiline body", "|my title\nfirst line\nsecond line", "first line\nsecond line", "note", sp("my title"), nil, nil, nil, nil, false, false, nil, ""}, // Edge cases - {"empty input", "", "", "", nil, nil, nil, "empty"}, - {"only glyph", "-", "", "", nil, nil, nil, "empty body"}, - {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"}, - {"whitespace only", " ", "", "", nil, nil, nil, "empty"}, + {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, + {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, + {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, + {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, } for _, tt := range tests { @@ -79,6 +130,12 @@ func TestParse(t *testing.T) { if got.Glyph != tt.wantGlyph { t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph) } + if !ptrEq(got.Title, tt.wantTitle) { + t.Errorf("title: got %v, want %v", strPtr(got.Title), strPtr(tt.wantTitle)) + } + if !ptrEq(got.Description, tt.wantDesc) { + t.Errorf("description: got %v, want %v", strPtr(got.Description), strPtr(tt.wantDesc)) + } if !ptrEq(got.TimeAnchor, tt.wantTime) { t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime)) } @@ -88,6 +145,15 @@ func TestParse(t *testing.T) { if !ptrEq(got.CardSuffix, tt.wantCard) { t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(tt.wantCard)) } + if got.Pin != tt.wantPin { + t.Errorf("pin: got %v, want %v", got.Pin, tt.wantPin) + } + if got.Query != tt.wantQuery { + t.Errorf("query: got %v, want %v", got.Query, tt.wantQuery) + } + if !tagsEq(got.FilterTags, tt.wantFilter) { + t.Errorf("filter_tags: got %v, want %v", got.FilterTags, tt.wantFilter) + } }) } } diff --git a/testdata/demo.json b/testdata/demo.json new file mode 100644 index 0000000..57c23ca --- /dev/null +++ b/testdata/demo.json @@ -0,0 +1,133 @@ +[ + { + "body": "Buy milk, eggs, and bread", + "glyph": "todo", + "tags": ["errands", "grocery"] + }, + { + "body": "Fix leaking kitchen faucet", + "glyph": "todo", + "tags": ["home", "plumbing"] + }, + { + "body": "Review pull request for auth refactor", + "glyph": "todo", + "tags": ["work", "code-review"], + "pinned": true + }, + { + "body": "Dentist appointment", + "glyph": "event", + "time_anchor": "2026-05-20T10:00:00Z", + "tags": ["health"] + }, + { + "body": "Team standup", + "glyph": "event", + "time_anchor": "2026-05-19T09:00:00Z", + "tags": ["work", "meetings"] + }, + { + "body": "Kubernetes clusters use etcd as the backing store for all cluster data including state, config, and metadata.", + "glyph": "note", + "tags": ["devops", "k8s"] + }, + { + "body": "The Go scheduler uses M:N threading — M goroutines multiplexed onto N OS threads.", + "glyph": "note", + "tags": ["golang", "til"] + }, + { + "body": "Solar panel installation — get 3 quotes before June", + "glyph": "note", + "tags": ["home", "solar"], + "pinned": true + }, + { + "body": "Submit quarterly tax estimate", + "glyph": "todo", + "time_anchor": "2026-06-15T00:00:00Z", + "tags": ["finance"] + }, + { + "body": "Backup NAS to offsite", + "glyph": "todo", + "completed": true, + "tags": ["homelab", "backups"] + }, + { + "body": "version: '3'\nservices:\n traefik:\n image: traefik:v2.10\n ports:\n - \"${host_port:-443}:443\"\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n environment:\n - CF_DNS_API_TOKEN=${cf_token}\n labels:\n - traefik.http.routers.dashboard.rule=Host(`${dashboard_domain}`)", + "glyph": "note", + "title": "Traefik Reverse Proxy", + "description": "Production-ready compose with auto-TLS renewal", + "card_type": "snippet", + "card_data": "{\"language\":\"yaml\",\"source\":\"personal\"}", + "tags": ["homelab", "docker", "traefik"] + }, + { + "body": "## Weekly Review\n- [ ] Clear inbox\n- [ ] Review calendar\n- [ ] Update project boards\n- [ ] Plan next week", + "glyph": "note", + "title": "Weekly Review Checklist", + "card_type": "checklist", + "card_data": "{\"items\":4,\"completed\":0}", + "tags": ["productivity", "routine"] + }, + { + "body": "PRAGMA journal_mode = WAL;\nPRAGMA busy_timeout = ${timeout_ms:-5000};\nPRAGMA synchronous = ${sync_mode:-NORMAL};", + "glyph": "note", + "title": "SQLite Concurrency", + "description": "Key settings for multi-reader single-writer", + "card_type": "snippet", + "card_data": "{\"language\":\"sql\",\"source\":\"docs\"}", + "tags": ["sqlite", "til"] + }, + { + "body": "Decided to use CalVer (YYYY.0M.MICRO) instead of SemVer for nib releases. Rationale: nib is an app not a library, no API stability contract needed.", + "glyph": "note", + "title": "Versioning Strategy", + "card_type": "decision", + "card_data": "{\"status\":\"accepted\",\"date\":\"2026-04-01\"}", + "tags": ["nib", "decisions"] + }, + { + "body": "https://github.com/charmbracelet/bubbletea", + "glyph": "note", + "title": "Bubbletea TUI Framework", + "description": "Go TUI framework based on Elm architecture", + "card_type": "link", + "card_data": "{\"url\":\"https://github.com/charmbracelet/bubbletea\",\"domain\":\"github.com\"}", + "tags": ["golang", "tui", "libraries"] + }, + { + "body": "Remember to rotate API keys every 90 days", + "glyph": "todo", + "time_anchor": "2026-07-01T00:00:00Z", + "tags": ["security", "homelab"] + }, + { + "body": "Interesting idea: build a CLI that converts natural language to nib captures using local LLM", + "glyph": "note", + "tags": ["ideas", "nib", "ai"] + }, + { + "body": "Garage door opener warranty expires in August", + "glyph": "event", + "time_anchor": "2026-08-15T00:00:00Z", + "tags": ["home"] + }, + { + "body": "Consolidate all docker services to single compose file", + "glyph": "todo", + "tags": ["homelab", "docker"], + "deleted": true + }, + { + "body": "## ${project_name}\n- [ ] Create repo at ${git_host}/${org}/${project_name}\n- [ ] Add CI pipeline\n- [ ] Write README\n- [ ] Add LICENSE (${license_type})\n- [ ] First release tag", + "glyph": "note", + "title": "Project Bootstrap", + "description": "Standard checklist for starting new projects", + "card_type": "template", + "card_data": "{\"items\":5}", + "tags": ["productivity", "dev"] + } +] diff --git a/web/app.js b/web/app.js index 1db5e94..89acec0 100644 --- a/web/app.js +++ b/web/app.js @@ -2,13 +2,13 @@ 'use strict'; const GLYPHS = { - note: '—', todo: '○', event: '◇', + note: '—', todo: '○', event: '◇', reminder: '△', snippet: '◆', template: '◈', checklist: '☐', decision: '⚖', link: '↗', }; const GLYPH_CLASSES = { - note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', + note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder', snippet: 'glyph-snippet', template: 'glyph-template', checklist: 'glyph-checklist', decision: 'glyph-decision', link: 'glyph-link', @@ -16,6 +16,8 @@ const PAGE_SIZE = 50; + const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' }; + const state = { view: 'stream', entities: [], @@ -24,6 +26,13 @@ activeTag: null, hasMore: false, activeMonth: null, + intent: 'grab', + flashId: null, + peekMode: 'preview', + runChecked: new Set(), + fillValues: {}, + fillActive: 0, + searchQuery: '', }; const $ = (sel) => document.querySelector(sel); @@ -111,41 +120,170 @@ const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }; + function validateTime(s) { + const parts = s.split(':'); + if (parts.length !== 2) return false; + const h = parseInt(parts[0], 10), m = parseInt(parts[1], 10); + return !isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59; + } + function parseInput(input) { input = input.trim(); if (!input) return null; - const tokens = input.split(/\s+/); let glyph = 'note'; + let remaining = input; + let timeAnchor = null, cardSuffix = null, pin = false, query = false; + const tags = [], seenTags = {}, filterTags = []; - const first = tokens[0]; - if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); } - else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); } + // Step 1: Escape check — `\` prefix → thought, skip prefix detection + if (remaining.startsWith('\\')) { + remaining = remaining.slice(1); + const result = extractModifiers(remaining, true); + if (!result.body) return null; + return { body: result.body, glyph: 'note', title: null, description: null, timeAnchor: result.timeAnchor, tags: result.tags, cardSuffix: result.cardSuffix, pin: result.pin, query: false, filterTags: [] }; + } - const bodyParts = []; - let timeAnchor = null; - const tags = []; - const seenTags = {}; - let cardSuffix = null; + // Step 2: Query check — `?` prefix → search mode + if (remaining.startsWith('?')) { + remaining = remaining.slice(1).trim(); + const tokens = remaining.split(/\s+/).filter(Boolean); + const bodyParts = []; + for (const tok of tokens) { + if (tok.startsWith('#') && tok.length > 1 && !tok.startsWith('##')) { + filterTags.push(tok.slice(1).toLowerCase()); + } else { + bodyParts.push(tok); + } + } + return { body: bodyParts.join(' '), glyph: '', title: null, description: null, timeAnchor: null, tags: [], cardSuffix: null, pin: false, query: true, filterTags }; + } - for (const tok of tokens) { - if (tok.startsWith('@') && tok.length > 1) { - timeAnchor = tok.slice(1); - } else if (tok.startsWith('#') && tok.length > 1) { - const tag = tok.slice(1); - if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } - } else if (tok.startsWith('^') && tok.length > 1) { - const suffix = tok.slice(1); - if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix]; - } else { - bodyParts.push(tok); + // Step 3: Kind prefix — `-`, `@time`, `!time` + if (remaining.startsWith('- ')) { + glyph = 'todo'; + remaining = remaining.slice(2).trim(); + } else if (remaining === '-') { + glyph = 'todo'; + remaining = ''; + } else if (remaining.startsWith('@')) { + const afterAt = remaining.slice(1).trim(); + const sp = afterAt.indexOf(' '); + const timeTok = sp >= 0 ? afterAt.slice(0, sp) : afterAt; + if (validateTime(timeTok)) { + glyph = 'event'; + timeAnchor = timeTok; + remaining = sp >= 0 ? afterAt.slice(sp + 1).trim() : ''; + } + } else if (remaining.startsWith('!')) { + const afterBang = remaining.slice(1).trim(); + const firstWord = afterBang.split(/\s+/)[0] || ''; + if (firstWord.toLowerCase() !== 'pin') { + const sp = afterBang.indexOf(' '); + const timeTok = sp >= 0 ? afterBang.slice(0, sp) : afterBang; + if (validateTime(timeTok)) { + glyph = 'reminder'; + timeAnchor = timeTok; + remaining = sp >= 0 ? afterBang.slice(sp + 1).trim() : ''; + } } } - const body = bodyParts.join(' '); - if (!body) return null; + // Steps 4-5: Title and description extraction + let titleRaw = null, descRaw = null, hasTitle = false; + const lines = remaining.split('\n'); + const firstLine = (lines[0] || '').trim(); - return { body, glyph, timeAnchor, tags, cardSuffix }; + if (firstLine.startsWith('|')) { + hasTitle = true; + const titleContent = firstLine.slice(1); + const descIdx = titleContent.indexOf(' // '); + if (descIdx >= 0) { + titleRaw = titleContent.slice(0, descIdx).trim(); + descRaw = titleContent.slice(descIdx + 4).trim(); + } else { + titleRaw = titleContent.trim(); + } + remaining = lines.slice(1).join('\n'); + } else { + let descParts = [], startBody = 0; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('// ') || trimmed === '//') { + descParts.push(trimmed.slice(2).trim()); + startBody = i + 1; + } else { break; } + } + if (descParts.length) { + descRaw = descParts.join(' '); + remaining = lines.slice(startBody).join('\n'); + } else if (!firstLine.includes('://')) { + const dIdx = firstLine.indexOf(' // '); + if (dIdx >= 0) { + descRaw = firstLine.slice(dIdx + 4).trim(); + remaining = firstLine.slice(0, dIdx).trim(); + if (lines.length > 1) remaining += '\n' + lines.slice(1).join('\n'); + } + } + } + + // Steps 6-8: Extract flags, tags, time, card suffix + function extractModifiers(text, handleFlags) { + let localTime = timeAnchor, localPin = pin, localCard = cardSuffix; + const localTags = [...tags]; + const localSeen = { ...seenTags }; + + const outLines = []; + for (const line of text.split('\n')) { + const tokens = line.split(/[ \t]+/).filter(Boolean); + const lineParts = []; + for (const tok of tokens) { + if (handleFlags && tok.toLowerCase() === '!pin') { + localPin = true; + } else if (tok.startsWith('##') && tok.length > 2) { + lineParts.push('#' + tok.slice(2)); + } else if (tok.startsWith('@') && tok.length > 1) { + const ts = tok.slice(1); + if (validateTime(ts) && localTime === null) { + localTime = ts; + } else { + lineParts.push(tok); + } + } else if (tok.startsWith('#') && tok.length > 1) { + const tag = tok.slice(1).toLowerCase(); + if (!localSeen[tag]) { localTags.push(tag); localSeen[tag] = true; } + } else if (tok.startsWith('^') && tok.length > 1) { + const suffix = tok.slice(1); + if (VALID_CARDS[suffix] && localCard === null) localCard = VALID_CARDS[suffix]; + else lineParts.push(tok); + } else { + lineParts.push(tok); + } + } + outLines.push(lineParts.join(' ')); + } + return { body: outLines.join('\n'), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin }; + } + + let title = null, description = null; + if (hasTitle) { + const r = extractModifiers(titleRaw || '', false); + if (r.body) title = r.body; + timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin; + } + if (descRaw) { + const r = extractModifiers(descRaw, false); + if (r.body) description = r.body; + timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin; + } + + const bodyResult = extractModifiers(remaining, true); + const body = bodyResult.body; + timeAnchor = bodyResult.timeAnchor; tags.length = 0; tags.push(...bodyResult.tags); cardSuffix = bodyResult.cardSuffix; pin = bodyResult.pin; + + if (!body && !title) return null; + + return { body, glyph, title, description, timeAnchor, tags, cardSuffix, pin, query: false, filterTags: [] }; } function detectCardType(body) { @@ -156,6 +294,42 @@ return null; } + function detectAffordances(entity) { + const affs = []; + const body = entity.body || ''; + const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; + if (data.lang || entity.card_type === 'snippet') affs.push('code'); + if (/\$\{[^}]+\}/.test(body)) affs.push('fill'); + if (data.steps && data.steps.length) affs.push('steps'); + if (data.chose != null || entity.card_type === 'decision') affs.push('decide'); + if (data.url || entity.card_type === 'link') affs.push('link'); + return affs; + } + + const AFF_LABELS = { code: 'code', fill: 'tpl', steps: 'steps', decide: 'dec', link: 'link' }; + const AFF_CLASSES = { code: 'aff-code', fill: 'aff-fill', steps: 'aff-steps', decide: 'aff-decide', link: 'aff-link' }; + + function cardPreview(entity) { + const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; + if (data.chose) return `▸ ${escHtml(data.chose)}`; + if (data.steps && data.steps.length) { + const done = data.steps.filter(s => s.done).length; + const total = data.steps.length; + const pct = total > 0 ? Math.round(done / total * 100) : 0; + return ` ${done}/${total} steps`; + } + if (/\$\{[^}]+\}/.test(entity.body || '')) { + const slots = []; + const re = /\$\{([^}]+)\}/g; + let m; + while ((m = re.exec(entity.body)) && slots.length < 2) slots.push(m[1]); + return slots.map(s => `\${${escHtml(s)}}`).join(' '); + } + if (data.url) return `${escHtml(data.url.replace(/^https?:\/\//, ''))}`; + const first = (entity.body || '').split('\n')[0] || ''; + return escHtml(first.slice(0, 60)); + } + // ========== Rendering ========== function displayGlyph(entity) { @@ -168,32 +342,178 @@ function formatDate(dateStr) { const d = new Date(dateStr); - const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; return months[d.getMonth()] + ' ' + d.getDate(); } + // ── Tag Rail ── + function renderTagRail() { const rail = $('#tag-rail'); - const allItem = `
- all -
`; + const total = state.tags.reduce((s, t) => s + t.count, 0); - rail.innerHTML = allItem + state.tags.map(t => - `
- ${t.tag} - ${t.count} -
` - ).join(''); + let html = `
nib
`; + html += '
'; - rail.querySelectorAll('.tag-item').forEach(el => { + if (state.view === 'cards') { + html += '
'; + html += '
intent
'; + for (const k of ['grab', 'read', 'fill']) { + const on = state.intent === k ? ' on' : ''; + const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length; + html += `'; + } + html += '
'; + } + + html += '
'; + html += '
tags
'; + + const allOn = !state.activeTag ? ' on' : ''; + html += `'; + + for (const t of state.tags) { + const on = state.activeTag === t.tag ? ' on' : ''; + html += `'; + } + + html += '
'; + rail.innerHTML = html; + + rail.querySelectorAll('.rail-item[data-tag]').forEach(el => { el.addEventListener('click', () => { state.activeTag = el.dataset.tag || null; loadEntities(); renderTagRail(); }); }); + + rail.querySelectorAll('.rail-item[data-intent]').forEach(el => { + el.addEventListener('click', () => { + state.intent = el.dataset.intent; + renderTagRail(); + renderEntityList(); + }); + }); } + // ── Capture Bar ── + + function renderCaptureBar() { + const bar = $('#capture-bar'); + const placeholder = state.view === 'stream' + ? 'capture · - todo @time event !time reminder #tag |title' + : '|title // desc #tag ${slot} 1. step'; + + bar.innerHTML = ` +
+
+ + + ⏎ save +
+ `; + + const input = $('#capture-input'); + function autoResize() { + input.style.height = 'auto'; + input.style.height = input.scrollHeight + 'px'; + } + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + handleCapture(); + } + }); + input.addEventListener('input', () => { autoResize(); updateCapturePreview(input.value); }); + } + + function updateCapturePreview(val) { + const el = $('#cap-preview'); + if (!el) return; + val = val.trim(); + if (!val) { el.innerHTML = ''; el.classList.remove('visible'); return; } + + const parsed = parseInput(val); + if (!parsed) { el.innerHTML = ''; el.classList.remove('visible'); return; } + + const pills = []; + if (parsed.query) { + pills.push('search'); + } else { + pills.push(`${escHtml(parsed.glyph)}`); + } + if (parsed.title) pills.push(`|${escHtml(parsed.title)}`); + if (parsed.description) pills.push(`${escHtml(parsed.description)}`); + for (const t of (parsed.query ? parsed.filterTags : parsed.tags)) { + pills.push(`#${escHtml(t)}`); + } + if (parsed.timeAnchor) pills.push(`@${escHtml(parsed.timeAnchor)}`); + if (parsed.pin) pills.push('pin'); + if (parsed.cardSuffix) pills.push(`^${escHtml(parsed.cardSuffix)}`); + + el.innerHTML = pills.join(''); + el.classList.add('visible'); + } + + async function handleCapture() { + const input = $('#capture-input'); + const val = input.value.trim(); + if (!val) return; + + const parsed = parseInput(val); + if (!parsed) return; + + // Query mode → switch to search + if (parsed.query) { + state.searchQuery = parsed.body; + const searchInput = $('#search-input'); + if (searchInput) searchInput.value = parsed.body + (parsed.filterTags.length ? ' ' + parsed.filterTags.map(t => '#' + t).join(' ') : ''); + input.value = ''; + input.style.height = 'auto'; + updateCapturePreview(''); + renderEntityList(); + return; + } + + const data = { + body: parsed.body, + glyph: parsed.glyph, + tags: parsed.tags, + }; + if (parsed.title) data.title = parsed.title; + if (parsed.description) data.description = parsed.description; + if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; + if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; + if (parsed.pin) data.pinned = true; + + await api.createEntity(data); + input.value = ''; + input.style.height = 'auto'; + updateCapturePreview(''); + await loadEntities(); + await loadTags(); + showToast('captured'); + } + + // ── Entity List ── + function groupByDate(entities) { const groups = []; let current = null; @@ -211,27 +531,43 @@ function renderEntityList() { const list = $('#entity-list'); + const filtered = filterBySearch(state.entities); - if (state.entities.length === 0) { - list.innerHTML = '
no entities yet
'; + if (filtered.length === 0) { + list.innerHTML = `
${state.searchQuery ? 'no matches' : 'no entities yet'}
`; + renderCardsHeader(state.view === 'cards'); return; } let html = ''; if (state.view === 'stream') { - const groups = groupByDate(state.entities); + renderCardsHeader(false); + const groups = groupByDate(filtered); let idx = 0; for (const g of groups) { html += `
${g.label}
`; for (const e of g.entities) { - html += renderEntityItem(e, idx); + const realIdx = state.entities.indexOf(e); + html += renderEntityItem(e, realIdx); idx++; } } } else { - state.entities.forEach((e, idx) => { - html += renderEntityItem(e, idx); - }); + renderCardsHeader(true); + const pinned = filtered.filter(e => e.pinned); + const rest = filtered.filter(e => !e.pinned); + if (pinned.length) { + html += '
★ pinned
'; + for (const e of pinned) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } + if (rest.length) { + if (pinned.length) html += '
recent
'; + for (const e of rest) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } } if (state.hasMore) { @@ -246,119 +582,439 @@ }); }); + list.querySelectorAll('.card-row').forEach(el => { + el.addEventListener('click', (ev) => { + if (!ev.target.closest('.aff')) { + selectEntity(parseInt(el.dataset.index)); + } + }); + }); + const loadMoreBtn = list.querySelector('.load-more-btn'); if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); } + function renderCardsHeader(show) { + let hdr = $('#cards-hdr'); + if (!show) { + if (hdr) hdr.remove(); + return; + } + if (!hdr) { + hdr = document.createElement('div'); + hdr.id = 'cards-hdr'; + hdr.className = 'cards-hdr'; + const panel = $('#entity-panel'); + const list = $('#entity-list'); + panel.insertBefore(hdr, list); + } + const scope = state.activeTag ? `${state.intent} · #${state.activeTag}` : state.intent; + hdr.innerHTML = ` + ${scope} + ${state.entities.length} cards + + `; + } + + function renderCardRow(e, idx) { + const selected = idx === state.selectedIndex ? ' selected' : ''; + const pinCls = e.pinned ? ' pinned' : ''; + const flashCls = state.flashId === e.id ? ' flashing' : ''; + const title = e.title || (e.body || '').split('\n')[0].slice(0, 50); + const affs = detectAffordances(e); + const preview = cardPreview(e); + const tags = (e.tags || []).slice(0, 2).map(t => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + + return `
+ ${escHtml(title)} + + ${preview} +
+ ${affHtml} + ${tags} + ${e.pinned ? '' : ''} + ${e.use_count > 0 ? `${e.use_count}×` : ''} +
+
`; + } + function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); - const selected = idx === state.selectedIndex ? 'selected' : ''; - const tags = (e.tags || []).map(t => `${t}`).join(''); + const selected = idx === state.selectedIndex ? ' selected' : ''; + const isCard = e.card_type ? ' is-card' : ''; + const tags = (e.tags || []).slice(0, 2).map(t => `${t}`).join(''); const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; + const cardBadge = e.card_type ? `${e.card_type}` : ''; - return `
+ let label; + const descSnip = e.description ? `${escHtml(e.description)}` : ''; + if (e.title) { + const preview = e.body ? `${escHtml(e.body)}` : ''; + label = `${escHtml(e.title)}${descSnip}${preview}`; + } else { + label = `${escHtml(e.body)}${descSnip}`; + } + + return `
${glyph} - ${escHtml(e.body)} + ${label} ${time} - ${tags} + ${tags}${cardBadge} ${useBadge}
`; } + function fmtDateLong(dateStr) { + const d = new Date(dateStr); + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + function renderDetailPane() { const pane = $('#detail-pane'); const e = state.entities[state.selectedIndex]; if (!e) { - pane.innerHTML = '
select an entity
'; + pane.innerHTML = renderPeekIdle(); pane.classList.remove('visible'); return; } pane.classList.add('visible'); - const glyph = displayGlyph(e); - const gc = glyphClass(e); - const tags = (e.tags || []).map(t => `#${t}`).join(''); - const shortId = e.id.slice(0, 12); - let cardContent = ''; - let actions = ''; - - if (e.card_type) { - cardContent = renderCardContent(e); - actions += ``; - actions += ``; + if (state.peekMode === 'edit') { + pane.innerHTML = renderEditMode(e); + } else if (state.view === 'stream' || !e.card_type) { + pane.innerHTML = renderStreamPeek(e); + } else if (state.peekMode === 'run') { + pane.innerHTML = renderRunMode(e); + } else if (state.peekMode === 'fill') { + pane.innerHTML = renderFillMode(e); } else { - actions += ``; - actions += ``; + pane.innerHTML = renderCardPeek(e); } - actions += ``; - pane.innerHTML = ` -
- ${glyph} - ${shortId} - ${e.time_anchor ? `@${e.time_anchor}` : ''} -
-
${escHtml(e.body)}
- ${tags ? `
${tags}
` : ''} - ${cardContent} -
${actions}
- `; - - const bodyEl = pane.querySelector('.detail-body'); - if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); + bindPeekEvents(e); } - function renderCardContent(e) { - if (!e.card_data) return ''; - let data; - try { data = JSON.parse(e.card_data); } catch { return ''; } + function renderPeekIdle() { + const v = state.view; + return `
+
peek
+
Select ${v === 'cards' ? 'a card' : 'an entry'}.
+
${v === 'cards' + ? 'Full detail lives here. Run checklists, fill templates, edit in place.' + : 'Entry detail lives here. Promote any capture to a card when it earns a permanent home.'}
+
+
+
navigate
+
jknext / prev
+
12stream / cards
+
+ ${v === 'stream' ? `
+
stream grammar
+
(bare text) = thought
+
- todo · @time event · !time reminder
+
#tag · |title · // desc · !pin
+
` : `
+
act
+
copy
+
rrun checklist
+
ffill template
+
eedit
+
ppin
+
`} +
+
`; + } - switch (e.card_type) { - case 'template': - if (!data.slots || !data.slots.length) return ''; - return `
- ${data.slots.map(s => ` -
- \${${s.name}} - -
- `).join('')} - -
`; + function renderStreamPeek(e) { + const kind = e.card_type || e.glyph; + const glyph = displayGlyph(e); + const gc = glyphClass(e); + const kindLbl = { note: 'thought', todo: 'todo', event: 'event', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }[kind] || kind; + const tags = (e.tags || []).map(t => `#${t}`).join(''); - case 'checklist': - if (!data.steps || !data.steps.length) return ''; - return `
- ${data.steps.map((s, i) => ` -
- - ${escHtml(s.text)} -
- `).join('')} -
`; - - case 'decision': - return `
-
chose
${escHtml(data.chose || '—')}
-
why
${escHtml(data.why || '—')}
- ${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''} -
`; - - case 'link': - if (data.url) { - return `
- -
`; - } - return ''; - - default: - return ''; + let actions = ''; + actions += ``; + if (!e.card_type) { + actions += ``; + actions += ``; } + if (e.card_type) { + actions += ``; + } else { + actions += ``; + } + + return `
+
+ ${glyph} + ${kindLbl} + · + ${e.id.slice(-10)} + ${fmtDateLong(e.created_at)} +
+ ${e.title ? `
${escHtml(e.title)}
` : ''} +
${escHtml(e.body)}
+ ${tags ? `
tags
${tags}
` : ''} +
+
context
+
+ created${fmtDateLong(e.created_at)} + ${e.time_anchor ? `time@${e.time_anchor}` : ''} + ${e.card_type ? `statuspromoted → ${e.card_type}` : ''} +
+
+
${actions}
+
`; + } + + function renderCardPeek(e) { + 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 => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + const hasSteps = data.steps && data.steps.length; + const hasDecision = data.chose != null; + const hasFill = /\$\{[^}]+\}/.test(e.body || ''); + const hasLink = !!data.url; + + let sections = ''; + + if (hasDecision) { + const rejected = (data.rejected || []).map(r => `${escHtml(r)}`).join(''); + sections += `
+
decision${data.status || 'decided'}
+
+
${escHtml(data.chose)}
+
why${escHtml(data.why || '')}
+ ${rejected ? `
considered
${rejected}
` : ''} +
+
`; + } + + if (hasLink && !hasDecision) { + sections += `
+
link
+
+
`; + } + + if (hasSteps) { + const steps = data.steps.map((s, i) => `
${escHtml(s.text || s)}
`).join(''); + sections += `
+
steps · ${data.steps.length}
+
${steps}
+
`; + } + + if (!hasDecision && e.body) { + const lang = data.lang || ''; + sections += `
+
content${lang ? `${lang}` : ''}${hasFill ? `` : ''}
+
${escHtml(e.body)}
+
`; + } + + let actions = ``; + if (hasFill) actions += ``; + if (hasSteps) actions += ``; + actions += ``; + actions += ``; + actions += ``; + + return `
+
+
+
+ ${glyph} + ${e.card_type} + · + ${e.id.slice(-10)} + ${e.use_count > 0 ? `${e.use_count}× used` : ''} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${affHtml}${tags}${e.pinned ? '' : ''}
+
+ ${sections} +
+
${actions}
+
`; + } + + function renderRunMode(e) { + const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; + if (!data.steps) return renderCardPeek(e); + const total = data.steps.length; + const checked = state.runChecked || new Set(); + const done = checked.size; + const pct = total > 0 ? Math.round(done / total * 100) : 0; + + const steps = data.steps.map((s, i) => { + const isDone = checked.has(i); + const text = s.text || s; + return `
+ ${isDone ? '●' : '○'} + ${escHtml(text)} +
`; + }).join(''); + + return `
+
+ ▶ running + ${done}/${total} done +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
+
+ ${pct}% +
+
${steps}
+
Space toggler resetEsc exit
+
+ + +
+
`; + } + + function renderFillMode(e) { + const slots = []; + const re = /\$\{([^}]+)\}/g; + let m; + const seen = new Set(); + while ((m = re.exec(e.body || '')) !== null) { + const name = m[1].trim(); + if (!seen.has(name)) { seen.add(name); slots.push(name); } + } + if (!slots.length) return renderCardPeek(e); + const fill = state.fillValues || {}; + const active = state.fillActive || 0; + + let content = escHtml(e.body); + for (const name of slots) { + const val = fill[name] || ''; + const idx = slots.indexOf(name); + const cls = idx === active ? 'fill-slot active' : (val ? 'fill-slot filled' : 'fill-slot'); + const width = Math.max(name.length, val.length, 4) * 8 + 16; + content = content.replace(`\${${name}}`, ``); + } + + const allFilled = slots.every(s => fill[s]); + + return `
+
+ ⤓ filling + slot ${active + 1} / ${slots.length} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${content}
+
Tab next⇧Tab prev copyEsc cancel
+
+ + +
+
`; + } + + function renderEditMode(e) { + return `
+
✎ editing
+
${escHtml(e.title || 'untitled')}
+
+
+
+
+
+
+
+
+
+
+
⌘⏎ saveEsc cancel
+
+ + +
+
`; + } + + function bindPeekEvents(e) { + const pane = $('#detail-pane'); + if (!e) return; + + if (state.peekMode === 'run') { + pane.querySelectorAll('.peek-run-step').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.step); + if (!state.runChecked) state.runChecked = new Set(); + if (state.runChecked.has(idx)) state.runChecked.delete(idx); + else state.runChecked.add(idx); + renderDetailPane(); + }); + }); + } + + if (state.peekMode === 'fill') { + pane.querySelectorAll('.fill-slot input').forEach(input => { + input.addEventListener('input', () => { + if (!state.fillValues) state.fillValues = {}; + state.fillValues[input.dataset.slot] = input.value; + }); + input.addEventListener('focus', () => { + state.fillActive = parseInt(input.dataset.idx); + }); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Tab') { + ev.preventDefault(); + const slots = pane.querySelectorAll('.fill-slot input'); + const cur = parseInt(input.dataset.idx); + const next = ev.shiftKey ? Math.max(0, cur - 1) : Math.min(slots.length - 1, cur + 1); + state.fillActive = next; + renderDetailPane(); + setTimeout(() => { + const el = pane.querySelector(`.fill-slot input[data-idx="${next}"]`); + if (el) el.focus(); + }, 0); + } else if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + nibApp.completeFill(); + } else if (ev.key === 'Escape') { + ev.preventDefault(); + nibApp.exitMode(); + } + }); + }); + setTimeout(() => { + const el = pane.querySelector(`.fill-slot input[data-idx="${state.fillActive || 0}"]`); + if (el) el.focus(); + }, 0); + } + + if (state.peekMode === 'edit') { + const bodyTa = pane.querySelector('#edit-body'); + if (bodyTa) { + bodyTa.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); nibApp.saveEdit(e.id); } + if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); } + }); + } + } + + // Double-click to edit (stream peek) + const titleEl = pane.querySelector('.peek-title[data-id]'); + if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); + const bodyEl = pane.querySelector('.peek-body[data-id]'); + if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); } // ========== Inline edit ========== @@ -366,7 +1022,7 @@ function startEditBody() { const e = state.entities[state.selectedIndex]; if (!e) return; - const el = $(`.detail-body[data-id="${e.id}"]`); + const el = $(`.peek-body[data-id="${e.id}"]`); if (!el || el.tagName === 'TEXTAREA') return; const ta = document.createElement('textarea'); @@ -395,10 +1051,47 @@ }); } + function startEditField(field) { + const e = state.entities[state.selectedIndex]; + if (!e) return; + const el = $(`.peek-title[data-id="${e.id}"]`); + if (!el || el.tagName === 'INPUT') return; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'detail-field-edit'; + input.value = e[field] || ''; + input.placeholder = field; + el.replaceWith(input); + input.focus(); + + async function save() { + const val = input.value.trim(); + if (val !== (e[field] || '')) { + await api.updateEntity(e.id, { [field]: val || null }); + await loadEntities(); + const idx = state.entities.findIndex(x => x.id === e.id); + if (idx >= 0) selectEntity(idx); + } else { + renderDetailPane(); + } + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); } + if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); } + }); + } + // ========== Actions ========== function selectEntity(idx) { state.selectedIndex = idx; + state.peekMode = 'preview'; + state.runChecked = new Set(); + state.fillValues = {}; + state.fillActive = 0; renderEntityList(); renderDetailPane(); } @@ -443,7 +1136,8 @@ function renderMonthNav() { const nav = $('#month-nav'); - if (state.view !== 'stream') { nav.innerHTML = ''; return; } + if (state.view !== 'stream') { nav.innerHTML = ''; nav.style.display = 'none'; return; } + nav.style.display = ''; const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const label = state.activeMonth @@ -454,13 +1148,10 @@ ${label} - ${state.activeMonth ? '' : ''} `; $('#month-prev').addEventListener('click', () => shiftMonth(-1)); $('#month-next').addEventListener('click', () => shiftMonth(1)); - const clearBtn = nav.querySelector('.month-nav-clear'); - if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); }); } function shiftMonth(dir) { @@ -484,10 +1175,25 @@ function switchView(view) { state.view = view; state.activeMonth = null; + state.selectedIndex = -1; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; loadEntities(); renderMonthNav(); + renderTagRail(); + renderCaptureBar(); + } + + // ========== Toast ========== + + function showToast(msg) { + let el = $('.toast'); + if (el) el.remove(); + el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1600); } // ========== Public API (for inline handlers) ========== @@ -499,7 +1205,10 @@ try { await navigator.clipboard.writeText(e.body); await api.useEntity(id); + state.flashId = id; await loadEntities(); + showToast('copied'); + setTimeout(() => { state.flashId = null; renderEntityList(); }, 360); } catch (err) { console.error('clipboard:', err); } @@ -514,6 +1223,10 @@ modal.classList.add('visible'); modal.dataset.entityId = id; + const sub = $('#promote-sub'); + const label = (e.body || '').slice(0, 64) + ((e.body || '').length > 64 ? '…' : ''); + sub.textContent = label; + const suggested = detectCardType(e.body); $$('.type-btn').forEach(btn => { btn.classList.toggle('suggested', btn.dataset.type === suggested); @@ -524,12 +1237,14 @@ await api.demoteEntity(id); await loadEntities(); await loadTags(); + showToast('demoted'); }, async deleteEntity(id) { await api.deleteEntity(id); await loadEntities(); await loadTags(); + showToast('deleted'); }, async resolveTemplate(id) { @@ -545,6 +1260,7 @@ await navigator.clipboard.writeText(resolved); await api.useEntity(id); await loadEntities(); + showToast('copied'); } catch (err) { console.error('clipboard:', err); } @@ -565,9 +1281,11 @@ list.innerHTML = sources.map(e => { const g = displayGlyph(e); const gc = glyphClass(e); + const rawLabel = e.title || (e.body || '').split('\n').find(l => l.trim()) || ''; + const label = escHtml(rawLabel.length > 80 ? rawLabel.slice(0, 80) + '…' : rawLabel); return `
${g} - ${escHtml(e.body)} + ${label}
`; }).join(''); } @@ -581,6 +1299,7 @@ await loadTags(); const idx = state.entities.findIndex(x => x.id === targetId); if (idx >= 0) selectEntity(idx); + showToast('absorbed'); }); }); @@ -597,33 +1316,69 @@ await loadEntities(); selectEntity(state.entities.findIndex(x => x.id === id)); }, + + enterMode(mode) { + state.peekMode = mode; + if (mode === 'run') state.runChecked = new Set(); + if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; } + renderDetailPane(); + }, + + exitMode() { + state.peekMode = 'preview'; + renderDetailPane(); + }, + + resetRun() { + state.runChecked = new Set(); + renderDetailPane(); + }, + + async completeFill() { + const e = state.entities[state.selectedIndex]; + if (!e) return; + let resolved = e.body || ''; + const fill = state.fillValues || {}; + for (const [name, val] of Object.entries(fill)) { + resolved = resolved.replace(new RegExp('\\$\\{' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g'), val); + } + try { + await navigator.clipboard.writeText(resolved); + await api.useEntity(e.id); + state.peekMode = 'preview'; + await loadEntities(); + showToast('copied resolved'); + } catch (err) { + console.error('clipboard:', err); + } + }, + + async saveEdit(id) { + const title = ($('#edit-title') || {}).value || null; + const desc = ($('#edit-desc') || {}).value || null; + const body = ($('#edit-body') || {}).value || ''; + const tagsStr = ($('#edit-tags') || {}).value || ''; + const tags = tagsStr.split(/\s+/).filter(Boolean); + await api.updateEntity(id, { body, title, description: desc, tags }); + state.peekMode = 'preview'; + await loadEntities(); + await loadTags(); + const idx = state.entities.findIndex(x => x.id === id); + if (idx >= 0) selectEntity(idx); + showToast('saved'); + }, + + async togglePin(id) { + const e = state.entities.find(x => x.id === id); + if (!e) return; + await api.updateEntity(id, { pinned: !e.pinned }); + await loadEntities(); + const idx = state.entities.findIndex(x => x.id === id); + if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); } + showToast(e.pinned ? 'unpinned' : 'pinned'); + }, }; - // ========== Capture bar ========== - - $('#capture-bar').addEventListener('submit', async (ev) => { - ev.preventDefault(); - const input = $('#capture-input'); - const val = input.value.trim(); - if (!val) return; - - const parsed = parseInput(val); - if (!parsed) return; - - const data = { - body: parsed.body, - glyph: parsed.glyph, - tags: parsed.tags, - }; - if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; - if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; - - await api.createEntity(data); - input.value = ''; - await loadEntities(); - await loadTags(); - }); - // ========== Promote modal ========== $$('.type-btn').forEach(btn => { @@ -636,6 +1391,7 @@ await api.promoteEntity(id, btn.dataset.type); await loadEntities(); await loadTags(); + showToast('promoted → ' + btn.dataset.type); }); }); @@ -652,12 +1408,11 @@ // ========== Keyboard shortcuts ========== let lastDTime = 0; - const captureInput = $('#capture-input'); document.addEventListener('keydown', (ev) => { - if (document.activeElement === captureInput || - document.activeElement.classList.contains('detail-body-edit')) { - if (ev.key === 'Escape') document.activeElement.blur(); + const tag = (ev.target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea') { + if (ev.key === 'Escape') ev.target.blur(); return; } @@ -667,6 +1422,13 @@ return; } + if (state.peekMode !== 'preview' && ev.key === 'Escape') { + nibApp.exitMode(); + return; + } + + const sel = state.entities[state.selectedIndex]; + switch (ev.key) { case 'j': ev.preventDefault(); @@ -680,38 +1442,43 @@ break; case 'n': ev.preventDefault(); - captureInput.focus(); + $('#capture-input').focus(); break; - case 'p': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showPromote(e.id); + case 'p': + if (sel && sel.card_type && state.view === 'cards') { + nibApp.togglePin(sel.id); + } else if (sel && !sel.card_type) { + nibApp.showPromote(sel.id); + } break; - } - case 'Enter': { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.copyEntity(e.id); + case 'Enter': + if (sel) nibApp.copyEntity(sel.id); + break; + case 'r': + if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('run'); + break; + case 'f': + if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('fill'); + break; + case 'e': + if (sel) nibApp.enterMode('edit'); break; - } case 'd': { const now = Date.now(); if (now - lastDTime < 400) { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.deleteEntity(e.id); + if (sel) { + if (sel.card_type) nibApp.demoteEntity(sel.id); + else nibApp.deleteEntity(sel.id); + } lastDTime = 0; } else { lastDTime = now; } break; } - case 'e': { - startEditBody(); + case 'a': + if (sel && !sel.card_type) nibApp.showAbsorb(sel.id); break; - } - case 'a': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showAbsorb(e.id); - break; - } case '1': switchView('stream'); break; case '2': switchView('cards'); break; } @@ -739,10 +1506,54 @@ } $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view)); loadEntities(); + renderMonthNav(); + renderTagRail(); + renderCaptureBar(); } window.addEventListener('hashchange', handleHash); + // ========== Search ========== + + const searchInput = $('#search-input'); + let searchDebounce = null; + + searchInput.addEventListener('input', () => { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + state.searchQuery = searchInput.value.trim().toLowerCase(); + renderEntityList(); + }, 150); + }); + + searchInput.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { searchInput.value = ''; state.searchQuery = ''; renderEntityList(); searchInput.blur(); } + }); + + function filterByIntent(entities) { + if (state.view !== 'cards' || state.intent === 'grab') return entities; + if (state.intent === 'read') return entities.filter(e => e.card_data); + if (state.intent === 'fill') return entities.filter(e => e.body && /\$\{.+\}/.test(e.body)); + return entities; + } + + function filterBySearch(entities) { + const intentFiltered = filterByIntent(entities); + if (!state.searchQuery) return intentFiltered; + let query = state.searchQuery; + let filterTags = []; + query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim(); + return intentFiltered.filter(e => { + if (filterTags.length) { + const eTags = (e.tags || []).map(t => t.toLowerCase()); + if (!filterTags.every(ft => eTags.includes(ft))) return false; + } + if (!query) return true; + const haystack = ((e.body || '') + ' ' + (e.title || '') + ' ' + (e.description || '')).toLowerCase(); + return haystack.includes(query); + }); + } + // ========== Utils ========== function escHtml(s) { @@ -754,6 +1565,10 @@ return escHtml(s).replace(/'/g, '''); } + function isSafeUrl(url) { + return /^https?:\/\//i.test(url); + } + // ========== Theme ========== const themeToggle = $('#theme-toggle'); @@ -771,6 +1586,7 @@ // ========== Init ========== async function init() { + renderCaptureBar(); await Promise.all([loadEntities(), loadTags()]); handleHash(); renderMonthNav(); diff --git a/web/index.html b/web/index.html index 0c8e0fd..42aed17 100644 --- a/web/index.html +++ b/web/index.html @@ -6,28 +6,22 @@ nib - + -
-

nib

+
-
- -
+
@@ -35,6 +29,7 @@
+