Merge pull request 'feat: UI redesign, capture grammar, demo command' (#14) from develop into main

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-05-16 20:07:28 +00:00
26 changed files with 3433 additions and 593 deletions
+21
View File
@@ -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.
+156
View File
@@ -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 <id> snippet # code trick, copy-to-clipboard
nib promote <id> template # has ${slots} to fill
nib promote <id> checklist # step-through items
nib promote <id> decision # chose/why/rejected
nib promote <id> link # URL with an open button
```
Or use `^type` inline: `nib "proxy trick #nginx ^card"`
## CLI commands
| Command | What it does |
|---------|-------------|
| `nib <input>` | 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 <id>` | Open in `$EDITOR` |
| `nib copy <id>` | Copy body to clipboard |
| `nib promote <id> [type]` | Promote to card |
| `nib demote <id>` | Strip card, back to fluid |
| `nib absorb <target> <source>` | Merge source into target |
| `nib delete <id>` | 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
+37
View File
@@ -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
+7
View File
@@ -33,8 +33,11 @@ func runAdd(_ *cobra.Command, args []string) error {
e := &db.Entity{ e := &db.Entity{
Body: parsed.Body, Body: parsed.Body,
Title: parsed.Title,
Description: parsed.Description,
Glyph: db.Glyph(parsed.Glyph), Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags, Tags: parsed.Tags,
Pinned: parsed.Pin,
} }
if parsed.TimeAnchor != nil { if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor e.TimeAnchor = parsed.TimeAnchor
@@ -53,7 +56,11 @@ func runAdd(_ *cobra.Command, args []string) error {
var parts []string var parts []string
parts = append(parts, glyph) parts = append(parts, glyph)
if e.Title != nil {
parts = append(parts, " "+*e.Title)
} else {
parts = append(parts, " "+e.Body) parts = append(parts, " "+e.Body)
}
if e.TimeAnchor != nil { if e.TimeAnchor != nil {
parts = append(parts, " @"+*e.TimeAnchor) parts = append(parts, " @"+*e.TimeAnchor)
} }
+6 -1
View File
@@ -63,8 +63,13 @@ func runCards(_ *cobra.Command, _ []string) error {
tagStr += " #" + tag tagStr += " #" + tag
} }
label := e.Body
if e.Title != nil {
label = *e.Title
}
fmt.Printf("%s %-40s %-16s %3d× %s\n", fmt.Printf("%s %-40s %-16s %3d× %s\n",
glyph, e.Body, glyph, label,
strings.TrimSpace(tagStr), strings.TrimSpace(tagStr),
e.UseCount, shortID) e.UseCount, shortID)
} }
+139
View File
@@ -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)
}
+6 -1
View File
@@ -142,8 +142,13 @@ func printEntity(e *db.Entity) {
glyph := display.DisplayGlyph(e.Glyph, e.CardType) glyph := display.DisplayGlyph(e.Glyph, e.CardType)
shortID := display.FormatID(e.ID) shortID := display.FormatID(e.ID)
label := e.Body
if e.Title != nil {
label = *e.Title
}
var line strings.Builder var line strings.Builder
fmt.Fprintf(&line, "%s %-40s", glyph, e.Body) fmt.Fprintf(&line, "%s %-40s", glyph, label)
if e.TimeAnchor != nil { if e.TimeAnchor != nil {
fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor) fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor)
+151
View File
@@ -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)
}
}
+8 -5
View File
@@ -3,21 +3,24 @@ module github.com/lerko/nib
go 1.24.4 go 1.24.4
require ( 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/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/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
) )
+24
View File
@@ -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/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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= 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 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 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 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= 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=
+221 -41
View File
@@ -25,15 +25,22 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store return srv, store
} }
func postJSON(srv *httptest.Server, path string, body any) *http.Response { func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
b, _ := json.Marshal(body) t.Helper()
resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) 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 return resp
} }
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse { func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
t.Helper() t.Helper()
resp := postJSON(srv, "/api/entities", map[string]any{ resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": body, "body": body,
"tags": tags, "tags": tags,
}) })
@@ -49,7 +56,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
func TestCreateEntity_Note(t *testing.T) { func TestCreateEntity_Note(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{ resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test note", "body": "test note",
"tags": []string{"demo"}, "tags": []string{"demo"},
}) })
@@ -76,7 +83,7 @@ func TestCreateEntity_Note(t *testing.T) {
func TestCreateEntity_MissingBody(t *testing.T) { func TestCreateEntity_MissingBody(t *testing.T) {
srv, _ := testServer(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() defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest { if resp.StatusCode != http.StatusBadRequest {
@@ -93,7 +100,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
func TestCreateEntity_InvalidGlyph(t *testing.T) { func TestCreateEntity_InvalidGlyph(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{ resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test", "body": "test",
"glyph": "invalid", "glyph": "invalid",
}) })
@@ -108,7 +115,10 @@ func TestGetEntity_Success(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "test", nil) 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -125,7 +135,10 @@ func TestGetEntity_Success(t *testing.T) {
func TestGetEntity_NotFound(t *testing.T) { func TestGetEntity_NotFound(t *testing.T) {
srv, _ := testServer(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() defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
@@ -138,7 +151,10 @@ func TestListEntities_Default(t *testing.T) {
createTestEntity(t, srv, "one", nil) createTestEntity(t, srv, "one", nil)
createTestEntity(t, srv, "two", 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() defer resp.Body.Close()
var entities []EntityResponse var entities []EntityResponse
@@ -153,7 +169,10 @@ func TestListEntities_FilterTag(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"home"}) 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() defer resp.Body.Close()
var entities []EntityResponse var entities []EntityResponse
@@ -167,13 +186,16 @@ func TestListEntities_CardsOnly(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
createTestEntity(t, srv, "fluid", nil) createTestEntity(t, srv, "fluid", nil)
resp := postJSON(srv, "/api/entities", map[string]any{ resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "card", "body": "card",
"card_type": "snippet", "card_type": "snippet",
}) })
resp.Body.Close() 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() defer resp.Body.Close()
var entities []EntityResponse var entities []EntityResponse
@@ -189,12 +211,18 @@ func TestListEntities_Pagination(t *testing.T) {
createTestEntity(t, srv, "note", nil) 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 var page1 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page1) json.NewDecoder(resp.Body).Decode(&page1)
resp.Body.Close() 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 var page2 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page2) json.NewDecoder(resp.Body).Decode(&page2)
resp.Body.Close() resp.Body.Close()
@@ -211,10 +239,16 @@ func TestUpdateEntity_Body(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "old", nil) 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"}))) mustJSON(map[string]any{"body": "new"})))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -233,8 +267,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
created := createTestEntity(t, srv, "doomed", nil) created := createTestEntity(t, srv, "doomed", nil)
// Soft delete // Soft delete
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
resp, _ := http.DefaultClient.Do(req) if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var delResp DeleteResponse var delResp DeleteResponse
json.NewDecoder(resp.Body).Decode(&delResp) json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close() resp.Body.Close()
@@ -246,7 +286,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
} }
// Hard delete // 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) json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -257,7 +304,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
} }
// Gone // 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() resp.Body.Close()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode) t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
@@ -268,7 +318,7 @@ func TestPromoteEntity_Success(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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", "card_type": "snippet",
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -288,11 +338,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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", "card_type": "snippet",
}).Body.Close() }).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", "card_type": "template",
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -312,7 +362,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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", "card_type": "bogus",
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -326,11 +376,11 @@ func TestDemoteEntity_Success(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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", "card_type": "snippet",
}).Body.Close() }).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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -348,7 +398,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest { if resp.StatusCode != http.StatusBadRequest {
@@ -360,7 +410,7 @@ func TestUseEntity_Success(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil) 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -379,7 +429,10 @@ func TestListTags_WithCounts(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"ops", "nginx"}) 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() defer resp.Body.Close()
var tags []TagResponse var tags []TagResponse
@@ -391,14 +444,23 @@ func TestListTags_WithCounts(t *testing.T) {
func TestCORS_DevMode(t *testing.T) { func TestCORS_DevMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db") 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() defer store.Close()
router := NewRouter(store, true) router := NewRouter(store, true)
srv := httptest.NewServer(router) srv := httptest.NewServer(router)
defer srv.Close() defer srv.Close()
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req) if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close() defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "*" { if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
@@ -412,8 +474,14 @@ func TestCORS_DevMode(t *testing.T) {
func TestCORS_ProdMode(t *testing.T) { func TestCORS_ProdMode(t *testing.T) {
srv, _ := testServer(t) // devMode=false srv, _ := testServer(t) // devMode=false
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req) if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close() defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "" { 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"}) target := createTestEntity(t, srv, "target body", []string{"ops"})
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"}) 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, "source_id": source.ID,
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -445,7 +513,10 @@ func TestAbsorbEntity_Success(t *testing.T) {
} }
// Source should be soft-deleted (not in default list) // 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 var entities []EntityResponse
json.NewDecoder(listResp.Body).Decode(&entities) json.NewDecoder(listResp.Body).Decode(&entities)
listResp.Body.Close() listResp.Body.Close()
@@ -461,11 +532,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
target := createTestEntity(t, srv, "target", nil) target := createTestEntity(t, srv, "target", nil)
source := createTestEntity(t, srv, "source", 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", "card_type": "snippet",
}).Body.Close() }).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, "source_id": source.ID,
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -485,7 +556,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
e := createTestEntity(t, srv, "self", nil) 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, "source_id": e.ID,
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -499,7 +570,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
srv, _ := testServer(t) srv, _ := testServer(t)
e := createTestEntity(t, srv, "target", nil) 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest { 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 { func mustJSON(v any) []byte {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
return b return b
+28 -16
View File
@@ -11,15 +11,20 @@ import (
type CreateEntityRequest struct { type CreateEntityRequest struct {
Body string `json:"body"` Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph *string `json:"glyph"` Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned *bool `json:"pinned"`
CardType *string `json:"card_type"` CardType *string `json:"card_type"`
CardData *string `json:"card_data"` CardData *string `json:"card_data"`
} }
type UpdateEntityRequest struct { type UpdateEntityRequest struct {
Body *string `json:"body"` Body *string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph *string `json:"glyph"` Glyph *string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
Tags *[]string `json:"tags"` Tags *[]string `json:"tags"`
@@ -100,7 +105,7 @@ func listEntities(store *db.Store) http.HandlerFunc {
entities, err := store.List(p) entities, err := store.List(p)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
@@ -119,8 +124,8 @@ func createEntity(store *db.Store) http.HandlerFunc {
return return
} }
if req.Body == "" { if req.Body == "" && req.Title == nil {
writeError(w, http.StatusBadRequest, "invalid_input", "body is required") writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
return return
} }
@@ -135,10 +140,15 @@ func createEntity(store *db.Store) http.HandlerFunc {
e := &db.Entity{ e := &db.Entity{
Body: req.Body, Body: req.Body,
Title: req.Title,
Description: req.Description,
Glyph: glyph, Glyph: glyph,
TimeAnchor: req.TimeAnchor, TimeAnchor: req.TimeAnchor,
Tags: req.Tags, Tags: req.Tags,
} }
if req.Pinned != nil && *req.Pinned {
e.Pinned = true
}
if req.CardType != nil { if req.CardType != nil {
if !db.ValidCardType(*req.CardType) { if !db.ValidCardType(*req.CardType) {
@@ -151,7 +161,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
} }
if err := store.Create(e); err != nil { if err := store.Create(e); err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
@@ -168,7 +178,7 @@ func getEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -186,6 +196,8 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u := &db.EntityUpdate{} u := &db.EntityUpdate{}
u.Body = req.Body u.Body = req.Body
u.Title = req.Title
u.Description = req.Description
u.Tags = req.Tags u.Tags = req.Tags
u.Pinned = req.Pinned u.Pinned = req.Pinned
u.CardData = req.CardData 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) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
e, err := store.Get(id) e, err := store.Get(id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) 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) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
label := "soft" label := "soft"
@@ -279,13 +291,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
e, err := store.Get(id) e, err := store.Get(id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) 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") writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
e, err := store.Get(id) e, err := store.Get(id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) 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") writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
e, err := store.Get(id) e, err := store.Get(id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) 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) writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return return
} }
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
e, err := store.Get(id) e, err := store.Get(id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
writeJSON(w, http.StatusOK, entityToResponse(e)) writeJSON(w, http.StatusOK, entityToResponse(e))
+13
View File
@@ -2,12 +2,15 @@ package api
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"time" "time"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
) )
const maxBodySize = 1 << 20 // 1 MB
type ErrorResponse struct { type ErrorResponse struct {
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"` Message string `json:"message"`
@@ -18,6 +21,8 @@ type EntityResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"` ModifiedAt string `json:"modified_at"`
Body string `json:"body"` Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph string `json:"glyph"` Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
CompletedAt *string `json:"completed_at"` 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 { 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 { if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error()) writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
return false return false
@@ -48,12 +54,19 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
return true 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 { func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{ resp := EntityResponse{
ID: e.ID, ID: e.ID,
CreatedAt: e.CreatedAt.Format(time.RFC3339), CreatedAt: e.CreatedAt.Format(time.RFC3339),
ModifiedAt: e.ModifiedAt.Format(time.RFC3339), ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
Body: e.Body, Body: e.Body,
Title: e.Title,
Description: e.Description,
Glyph: string(e.Glyph), Glyph: string(e.Glyph),
Pinned: e.Pinned, Pinned: e.Pinned,
Tags: e.Tags, Tags: e.Tags,
+1 -1
View File
@@ -15,7 +15,7 @@ func listTags(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tags, err := store.ListTags() tags, err := store.ListTags()
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeInternalError(w, err)
return return
} }
+7
View File
@@ -84,9 +84,16 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
ON entity_tags(tag); ON entity_tags(tag);
`) `)
if err != nil {
return err 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) { func DefaultPath() (string, error) {
if env := os.Getenv("NIB_DB"); env != "" { if env := os.Getenv("NIB_DB"); env != "" {
return env, nil return env, nil
+89 -51
View File
@@ -16,6 +16,7 @@ const (
GlyphNote Glyph = "note" GlyphNote Glyph = "note"
GlyphTodo Glyph = "todo" GlyphTodo Glyph = "todo"
GlyphEvent Glyph = "event" GlyphEvent Glyph = "event"
GlyphReminder Glyph = "reminder"
) )
type CardType string type CardType string
@@ -30,7 +31,7 @@ const (
func ValidGlyph(s string) bool { func ValidGlyph(s string) bool {
switch Glyph(s) { switch Glyph(s) {
case GlyphNote, GlyphTodo, GlyphEvent: case GlyphNote, GlyphTodo, GlyphEvent, GlyphReminder:
return true return true
} }
return false return false
@@ -49,6 +50,8 @@ type Entity struct {
CreatedAt time.Time CreatedAt time.Time
ModifiedAt time.Time ModifiedAt time.Time
Body string Body string
Title *string
Description *string
Glyph Glyph Glyph Glyph
TimeAnchor *string TimeAnchor *string
CompletedAt *time.Time CompletedAt *time.Time
@@ -86,6 +89,8 @@ func DefaultListParams() ListParams {
type EntityUpdate struct { type EntityUpdate struct {
Body *string Body *string
Title *string
Description *string
Glyph *Glyph Glyph *Glyph
TimeAnchor *string TimeAnchor *string
ClearTime bool ClearTime bool
@@ -111,13 +116,16 @@ func (s *Store) Create(e *Entity) error {
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, INSERT INTO entities (id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) glyph, time_anchor, completed_at, pinned, deleted_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, card_type, card_data, use_count, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.ID,
e.CreatedAt.Format(time.RFC3339), e.CreatedAt.Format(time.RFC3339),
e.ModifiedAt.Format(time.RFC3339), e.ModifiedAt.Format(time.RFC3339),
e.Body, e.Body,
e.Title,
e.Description,
string(e.Glyph), string(e.Glyph),
e.TimeAnchor, e.TimeAnchor,
formatTimePtr(e.CompletedAt), formatTimePtr(e.CompletedAt),
@@ -141,18 +149,13 @@ func (s *Store) Create(e *Entity) error {
func (s *Store) Get(id string) (*Entity, error) { func (s *Store) Get(id string) (*Entity, error) {
e := &Entity{} e := &Entity{}
var createdAt, modifiedAt string row := newEntityRow()
var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString
var pinned int
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id, created_at, modified_at, body, glyph, time_anchor, SELECT id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at glyph, time_anchor, completed_at, pinned, deleted_at,
FROM entities WHERE id = ?`, id).Scan( card_type, card_data, use_count, last_used_at
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...)
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -160,15 +163,9 @@ func (s *Store) Get(id string) (*Entity, error) {
return nil, err return nil, err
} }
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) if err := row.apply(e); err != nil {
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) return nil, fmt.Errorf("scan entity %s: %w", id, err)
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)
tags, err := s.loadTags(id) tags, err := s.loadTags(id)
if err != nil { if err != nil {
@@ -234,9 +231,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description,
e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at,
e.use_count, e.last_used_at e.card_type, e.card_data, e.use_count, e.last_used_at
FROM entities e FROM entities e
%s %s
ORDER BY %s %s ORDER BY %s %s
@@ -253,31 +250,18 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
var entities []*Entity var entities []*Entity
for rows.Next() { for rows.Next() {
e := &Entity{} e := &Entity{}
var createdAt, modifiedAt string row := newEntityRow()
var completedAt, deletedAt, lastUsedAt sql.NullString if err := rows.Scan(row.ptrs(e)...); err != nil {
var timeAnchor, cardType, cardData sql.NullString return nil, err
var pinned int }
if err := row.apply(e); err != nil {
if err := rows.Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
&completedAt, &pinned, &deletedAt, &cardType, &cardData,
&e.UseCount, &lastUsedAt,
); err != nil {
return nil, err 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) entities = append(entities, e)
} }
if err := rows.Err(); err != nil {
return nil, err
}
if err := s.batchLoadTags(entities); err != nil { if err := s.batchLoadTags(entities); err != nil {
return nil, err return nil, err
@@ -308,6 +292,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "body = ?") sets = append(sets, "body = ?")
args = append(args, *u.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 { if u.Glyph != nil {
sets = append(sets, "glyph = ?") sets = append(sets, "glyph = ?")
args = append(args, string(*u.Glyph)) args = append(args, string(*u.Glyph))
@@ -502,6 +494,9 @@ func (s *Store) Resolve(prefix string) (string, error) {
} }
ids = append(ids, id) ids = append(ids, id)
} }
if err := rows.Err(); err != nil {
return "", err
}
switch len(ids) { switch len(ids) {
case 0: 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 // helpers
func (s *Store) batchLoadTags(entities []*Entity) error { func (s *Store) batchLoadTags(entities []*Entity) error {
@@ -568,6 +601,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) {
} }
tags = append(tags, tag) tags = append(tags, tag)
} }
if err := rows.Err(); err != nil {
return nil, err
}
if tags == nil { if tags == nil {
tags = []string{} tags = []string{}
} }
@@ -631,11 +667,13 @@ func boolToInt(b bool) int {
return 0 return 0
} }
func (e *Entity) CardDataJSON() map[string]interface{} { func (e *Entity) CardDataJSON() (map[string]interface{}, error) {
if e.CardData == nil { if e.CardData == nil {
return nil return nil, nil
} }
var m map[string]interface{} var m map[string]interface{}
json.Unmarshal([]byte(*e.CardData), &m) if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil {
return m return nil, fmt.Errorf("card_data: %w", err)
}
return m, nil
} }
+127
View File
@@ -441,3 +441,130 @@ func TestResolve_NotFound(t *testing.T) {
t.Errorf("expected ErrNotFound, got %v", err) 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)
}
}
+3
View File
@@ -26,6 +26,9 @@ func (s *Store) ListTags() ([]TagCount, error) {
} }
tags = append(tags, tc) tags = append(tags, tc)
} }
if err := rows.Err(); err != nil {
return nil, err
}
if tags == nil { if tags == nil {
tags = []TagCount{} tags = []TagCount{}
} }
+1
View File
@@ -6,6 +6,7 @@ var glyphMap = map[db.Glyph]string{
db.GlyphNote: "—", db.GlyphNote: "—",
db.GlyphTodo: "○", db.GlyphTodo: "○",
db.GlyphEvent: "◇", db.GlyphEvent: "◇",
db.GlyphReminder: "△",
} }
var cardGlyphMap = map[db.CardType]string{ var cardGlyphMap = map[db.CardType]string{
+80
View File
@@ -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)
}
}
+202 -32
View File
@@ -9,9 +9,14 @@ import (
type Result struct { type Result struct {
Body string Body string
Glyph string Glyph string
Title *string
Description *string
TimeAnchor *string TimeAnchor *string
Tags []string Tags []string
FilterTags []string
CardSuffix *string CardSuffix *string
Pin bool
Query bool
} }
var validCardTypes = map[string]string{ var validCardTypes = map[string]string{
@@ -35,38 +40,207 @@ func Parse(input string) (*Result, error) {
Tags: []string{}, Tags: []string{},
} }
tokens := strings.Fields(input) remaining := input
if len(tokens) == 0 {
return nil, fmt.Errorf("empty input") // Step 1: Escape check — `\` prefix → thought, no prefix detection
} if strings.HasPrefix(remaining, `\`) {
remaining = remaining[1:]
first := tokens[0] r.Glyph = "note"
switch first { clean, err := extractModifiers(r, remaining, false)
case "-", "▸": if err != nil {
r.Glyph = "todo" return nil, err
tokens = tokens[1:] }
case "*", "◇": r.Body = clean
r.Glyph = "event" if r.Body == "" && r.Title == nil {
tokens = tokens[1:] return nil, fmt.Errorf("empty body after extracting modifiers")
}
return r, nil
} }
// 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 var bodyParts []string
seen := map[string]bool{}
for _, tok := range tokens { for _, tok := range tokens {
switch { if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
case strings.HasPrefix(tok, "@") && len(tok) > 1: tag := strings.ToLower(tok[1:])
timeStr := tok[1:] r.FilterTags = append(r.FilterTags, tag)
if err := validateTime(timeStr); err != nil { } else {
return nil, fmt.Errorf("invalid time %q: %w", timeStr, err) bodyParts = append(bodyParts, tok)
} }
if r.TimeAnchor != nil { }
return nil, fmt.Errorf("multiple time anchors") 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"
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
}
}
}
// 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 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: case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := tok[1:] tag := strings.ToLower(tok[1:])
if !seen[tag] { if !seen[tag] {
r.Tags = append(r.Tags, tag) r.Tags = append(r.Tags, tag)
seen[tag] = true seen[tag] = true
@@ -76,24 +250,20 @@ func Parse(input string) (*Result, error) {
suffix := tok[1:] suffix := tok[1:]
cardType, ok := validCardTypes[suffix] cardType, ok := validCardTypes[suffix]
if !ok { if !ok {
return nil, fmt.Errorf("invalid card type %q", suffix) return "", fmt.Errorf("invalid card type %q", suffix)
} }
if r.CardSuffix != nil { if r.CardSuffix != nil {
return nil, fmt.Errorf("multiple card suffixes") return "", fmt.Errorf("multiple card suffixes")
} }
r.CardSuffix = &cardType r.CardSuffix = &cardType
default: default:
bodyParts = append(bodyParts, tok) lineParts = append(lineParts, tok)
} }
} }
outLines = append(outLines, strings.Join(lineParts, " "))
r.Body = strings.Join(bodyParts, " ")
if r.Body == "" {
return nil, fmt.Errorf("empty body after extracting modifiers")
} }
return strings.Join(outLines, "\n"), nil
return r, nil
} }
func validateTime(s string) error { func validateTime(s string) error {
+94 -28
View File
@@ -13,46 +13,97 @@ func TestParse(t *testing.T) {
input string input string
wantBody string wantBody string
wantGlyph string wantGlyph string
wantTitle *string
wantDesc *string
wantTime *string wantTime *string
wantTags []string wantTags []string
wantCard *string wantCard *string
wantPin bool
wantQuery bool
wantFilter []string
wantErrSub string wantErrSub string
}{ }{
// Glyph detection // Kind prefixes
{"plain note", "hello world", "hello world", "note", nil, nil, nil, ""}, {"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, ""}, {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, false, false, nil, ""},
{"unicode todo", "deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, {"dash todo requires space", "-deploy", "-deploy", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""}, {"event prefix", "@14:00 dentist", "dentist", "event", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, 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 // Event/reminder with invalid time — @ stays as body token, ! stays as body token
{"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""}, {"at-sign not time", "@nottime hello", "@nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""}, {"bang not time", "!nottime hello", "!nottime hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"},
// Tags // Escape prefix
{"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"escape dash", `\- this is not a todo`, "- this is not a todo", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""}, {"escape at", `\@14:00 not event`, "not event", "note", nil, nil, sp("14:00"), nil, nil, false, false, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"escape plain", `\hello`, "hello", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""},
// Card suffix // Query mode
{"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, {"query basic", "? proxy config", "proxy config", "", nil, nil, nil, nil, nil, false, true, nil, ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""}, {"query with tags", "? proxy config #ops #infra", "proxy config", "", nil, nil, nil, nil, nil, false, true, []string{"ops", "infra"}, ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""}, {"query tags only", "? #ops", "", "", nil, nil, nil, nil, nil, false, true, []string{"ops"}, ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"}, // 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 // Combined
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""}, {"full todo", "- deploy nginx @15:00 #ops", "deploy nginx", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, false, false, 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 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 // Edge cases
{"empty input", "", "", "", nil, nil, nil, "empty"}, {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, "empty body"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"}, {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, "empty"}, {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -79,6 +130,12 @@ func TestParse(t *testing.T) {
if got.Glyph != tt.wantGlyph { if got.Glyph != tt.wantGlyph {
t.Errorf("glyph: got %q, want %q", 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) { if !ptrEq(got.TimeAnchor, tt.wantTime) {
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(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) { if !ptrEq(got.CardSuffix, tt.wantCard) {
t.Errorf("card_suffix: got %v, want %v", strPtr(got.CardSuffix), strPtr(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)
}
}) })
} }
} }
+133
View File
@@ -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"]
}
]
+978 -162
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -6,28 +6,22 @@
<title>nib</title> <title>nib</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<style>
@font-face { font-family: 'Monaspace Neon'; font-weight: 300; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Light.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 400; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 500; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Monaspace Neon'; font-weight: 700; src: url('https://cdn.jsdelivr.net/gh/githubnext/monaspace@v1.000/fonts/webfonts/MonaspaceNeon-Bold.woff2') format('woff2'); }
</style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1 class="logo">nib</h1> <span class="logo">nib</span>
<nav> <nav>
<button data-view="stream" class="nav-btn active">stream</button> <button data-view="stream" class="nav-btn active">stream</button>
<button data-view="cards" class="nav-btn">cards</button> <button data-view="cards" class="nav-btn">cards</button>
</nav> </nav>
</div> </div>
<form id="capture-bar" autocomplete="off"> <div class="header-search">
<input type="text" id="capture-input" placeholder="capture — - todo # note * event" spellcheck="false"> <input type="text" id="search-input" placeholder="? search #tag" spellcheck="false">
</form> </div>
<button class="theme-toggle" id="theme-toggle" title="toggle theme"></button> <button class="theme-toggle" id="theme-toggle" title="toggle theme"></button>
</header> </header>
<main> <main>
@@ -35,6 +29,7 @@
<section id="entity-panel"> <section id="entity-panel">
<div id="month-nav"></div> <div id="month-nav"></div>
<div id="entity-list"></div> <div id="entity-list"></div>
<div id="capture-bar"></div>
</section> </section>
<aside id="detail-pane"> <aside id="detail-pane">
<div class="detail-empty">select an entity</div> <div class="detail-empty">select an entity</div>
@@ -46,26 +41,32 @@
<div class="modal-backdrop"></div> <div class="modal-backdrop"></div>
<div class="modal-content"> <div class="modal-content">
<h3>promote to card</h3> <h3>promote to card</h3>
<div class="modal-sub" id="promote-sub"></div>
<div class="type-picker"> <div class="type-picker">
<button data-type="snippet" class="type-btn"> <button data-type="snippet" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-snippet"></span>
<span>snippet</span> <span class="type-name">snippet</span>
<span class="type-hint">quick reference, command, code</span>
</button> </button>
<button data-type="template" class="type-btn"> <button data-type="template" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-template"></span>
<span>template</span> <span class="type-name">template</span>
<span class="type-hint">fillable with ${slot}s</span>
</button> </button>
<button data-type="checklist" class="type-btn"> <button data-type="checklist" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-checklist"></span>
<span>checklist</span> <span class="type-name">checklist</span>
<span class="type-hint">step-by-step process</span>
</button> </button>
<button data-type="decision" class="type-btn"> <button data-type="decision" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-decision"></span>
<span>decision</span> <span class="type-name">decision</span>
<span class="type-hint">record a choice + rationale</span>
</button> </button>
<button data-type="link" class="type-btn"> <button data-type="link" class="type-btn">
<span class="type-glyph"></span> <span class="type-glyph glyph-link"></span>
<span>link</span> <span class="type-name">link</span>
<span class="type-hint">reference URL</span>
</button> </button>
</div> </div>
<button class="modal-close">esc to cancel</button> <button class="modal-close">esc to cancel</button>
+799 -154
View File
File diff suppressed because it is too large Load Diff