From 6278cb10227fc58ba1c065d9c025d3797963d1cb Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 17:41:30 -0400 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20code=20principles=20audit=20?= =?UTF-8?q?=E2=80=94=20correctness,=20security,=20testability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rows.Err() checks after all scan loops (entities, tags, resolve) - Surface time.Parse errors instead of silently discarding - Extract entityRow scan helper to eliminate Get/List duplication - Cap request body at 1MB via MaxBytesReader - Stop leaking internal errors to API clients (log server-side only) - Block javascript: URIs in link card open button (XSS) - Fix all go vet failures in api_test.go (unchecked http errors) - Add tests for display package, generateCardData, absorb-source-card - Run go mod tidy to fix direct/indirect dep markers --- cmd/promote_test.go | 151 ++++++++++++++++++++++++++++++++ go.mod | 13 +-- go.sum | 24 ++++++ internal/api/api_test.go | 153 ++++++++++++++++++++++++--------- internal/api/entities.go | 28 +++--- internal/api/helpers.go | 9 ++ internal/api/tags.go | 2 +- internal/db/entities.go | 104 +++++++++++++--------- internal/db/entities_test.go | 31 +++++++ internal/db/tags.go | 3 + internal/display/glyph_test.go | 80 +++++++++++++++++ web/app.js | 6 +- 12 files changed, 500 insertions(+), 104 deletions(-) create mode 100644 cmd/promote_test.go create mode 100644 internal/display/glyph_test.go diff --git a/cmd/promote_test.go b/cmd/promote_test.go new file mode 100644 index 0000000..b200278 --- /dev/null +++ b/cmd/promote_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestGenerateCardData_Snippet(t *testing.T) { + data := generateCardData(db.CardSnippet, "some snippet") + if data == nil || *data != "{}" { + t.Errorf("snippet should produce {}, got %v", data) + } +} + +func TestGenerateCardData_Template(t *testing.T) { + data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Slots []struct { + Name string `json:"name"` + Default string `json:"default"` + } `json:"slots"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Slots) != 2 { + t.Fatalf("expected 2 slots, got %d", len(parsed.Slots)) + } + if parsed.Slots[0].Name != "host" { + t.Errorf("first slot: %q", parsed.Slots[0].Name) + } + if parsed.Slots[1].Name != "env" { + t.Errorf("second slot: %q", parsed.Slots[1].Name) + } +} + +func TestGenerateCardData_TemplateDedupe(t *testing.T) { + data := generateCardData(db.CardTemplate, "${x} and ${x}") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 1 { + t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_TemplateNoSlots(t *testing.T) { + data := generateCardData(db.CardTemplate, "no placeholders here") + var parsed struct { + Slots []struct { + Name string `json:"name"` + } `json:"slots"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Slots) != 0 { + t.Errorf("expected empty slots, got %d", len(parsed.Slots)) + } +} + +func TestGenerateCardData_Checklist(t *testing.T) { + body := "[ ] step one\n[x] step two\n[ ] step three" + data := generateCardData(db.CardChecklist, body) + if data == nil { + t.Fatal("expected non-nil data") + } + + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done { + t.Errorf("step 0: %+v", parsed.Steps[0]) + } + if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done { + t.Errorf("step 1: %+v", parsed.Steps[1]) + } +} + +func TestGenerateCardData_ChecklistFallback(t *testing.T) { + data := generateCardData(db.CardChecklist, "no checkbox syntax") + var parsed struct { + Steps []struct { + Text string `json:"text"` + Done bool `json:"done"` + } `json:"steps"` + } + json.Unmarshal([]byte(*data), &parsed) + if len(parsed.Steps) != 1 { + t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps)) + } + if parsed.Steps[0].Text != "no checkbox syntax" { + t.Errorf("fallback step text: %q", parsed.Steps[0].Text) + } +} + +func TestGenerateCardData_Decision(t *testing.T) { + data := generateCardData(db.CardDecision, "which db?") + var parsed struct { + Chose string `json:"chose"` + Why string `json:"why"` + Rejected []string `json:"rejected"` + } + if err := json.Unmarshal([]byte(*data), &parsed); err != nil { + t.Fatal(err) + } + if parsed.Chose != "" || parsed.Why != "" { + t.Error("decision fields should start empty") + } + if len(parsed.Rejected) != 0 { + t.Error("rejected should start empty") + } +} + +func TestGenerateCardData_Link(t *testing.T) { + data := generateCardData(db.CardLink, "check https://example.com/path for details") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "https://example.com/path" { + t.Errorf("url: %q", parsed.URL) + } +} + +func TestGenerateCardData_LinkNoURL(t *testing.T) { + data := generateCardData(db.CardLink, "no url here") + var parsed struct { + URL string `json:"url"` + } + json.Unmarshal([]byte(*data), &parsed) + if parsed.URL != "" { + t.Errorf("expected empty url, got %q", parsed.URL) + } +} diff --git a/go.mod b/go.mod index 8ee951d..9972a46 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,24 @@ module github.com/lerko/nib go 1.24.4 require ( - github.com/atotto/clipboard v0.1.4 // indirect + github.com/atotto/clipboard v0.1.4 + github.com/go-chi/chi/v5 v5.2.5 + github.com/oklog/ulid/v2 v2.1.1 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.37.1 +) + +require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.33.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.37.1 // indirect ) diff --git a/go.sum b/go.sum index 7fe58f6..d394f26 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -26,15 +28,37 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 303e508..2ff96a3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -25,15 +25,22 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) { return srv, store } -func postJSON(srv *httptest.Server, path string, body any) *http.Response { - b, _ := json.Marshal(body) - resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) +func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } return resp } func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse { t.Helper() - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": body, "tags": tags, }) @@ -49,7 +56,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st func TestCreateEntity_Note(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "test note", "tags": []string{"demo"}, }) @@ -76,7 +83,7 @@ func TestCreateEntity_Note(t *testing.T) { func TestCreateEntity_MissingBody(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{}) + resp := postJSON(t, srv, "/api/entities", map[string]any{}) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -93,7 +100,7 @@ func TestCreateEntity_MissingBody(t *testing.T) { func TestCreateEntity_InvalidGlyph(t *testing.T) { srv, _ := testServer(t) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "test", "glyph": "invalid", }) @@ -108,7 +115,10 @@ func TestGetEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "test", nil) - resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID) + resp, err := http.Get(srv.URL + "/api/entities/" + created.ID) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -125,7 +135,10 @@ func TestGetEntity_Success(t *testing.T) { func TestGetEntity_NotFound(t *testing.T) { srv, _ := testServer(t) - resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT") + resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { @@ -138,7 +151,10 @@ func TestListEntities_Default(t *testing.T) { createTestEntity(t, srv, "one", nil) createTestEntity(t, srv, "two", nil) - resp, _ := http.Get(srv.URL + "/api/entities") + resp, err := http.Get(srv.URL + "/api/entities") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -153,7 +169,10 @@ func TestListEntities_FilterTag(t *testing.T) { createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "b", []string{"home"}) - resp, _ := http.Get(srv.URL + "/api/entities?tag=ops") + resp, err := http.Get(srv.URL + "/api/entities?tag=ops") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -167,13 +186,16 @@ func TestListEntities_CardsOnly(t *testing.T) { srv, _ := testServer(t) createTestEntity(t, srv, "fluid", nil) - resp := postJSON(srv, "/api/entities", map[string]any{ + resp := postJSON(t, srv, "/api/entities", map[string]any{ "body": "card", "card_type": "snippet", }) resp.Body.Close() - resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true") + resp, err := http.Get(srv.URL + "/api/entities?cards_only=true") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var entities []EntityResponse @@ -189,12 +211,18 @@ func TestListEntities_Pagination(t *testing.T) { createTestEntity(t, srv, "note", nil) } - resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0") + resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0") + if err != nil { + t.Fatal(err) + } var page1 []EntityResponse json.NewDecoder(resp.Body).Decode(&page1) resp.Body.Close() - resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2") + resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2") + if err != nil { + t.Fatal(err) + } var page2 []EntityResponse json.NewDecoder(resp.Body).Decode(&page2) resp.Body.Close() @@ -211,10 +239,16 @@ func TestUpdateEntity_Body(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "old", nil) - req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( mustJSON(map[string]any{"body": "new"}))) + if err != nil { + t.Fatal(err) + } req.Header.Set("Content-Type", "application/json") - resp, _ := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -233,8 +267,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { created := createTestEntity(t, srv, "doomed", nil) // Soft delete - req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } var delResp DeleteResponse json.NewDecoder(resp.Body).Decode(&delResp) resp.Body.Close() @@ -246,7 +286,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { } // Hard delete - resp, _ = http.DefaultClient.Do(req) + req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) + if err != nil { + t.Fatal(err) + } + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } json.NewDecoder(resp.Body).Decode(&delResp) resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -257,7 +304,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) { } // Gone - resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID) + resp, err = http.Get(srv.URL + "/api/entities/" + created.ID) + if err != nil { + t.Fatal(err) + } resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode) @@ -268,7 +318,7 @@ func TestPromoteEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }) defer resp.Body.Close() @@ -288,11 +338,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "template", }) defer resp.Body.Close() @@ -312,7 +362,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "bogus", }) defer resp.Body.Close() @@ -326,11 +376,11 @@ func TestDemoteEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -348,7 +398,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -360,7 +410,7 @@ func TestUseEntity_Success(t *testing.T) { srv, _ := testServer(t) created := createTestEntity(t, srv, "trick", nil) - resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil) + resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -379,7 +429,10 @@ func TestListTags_WithCounts(t *testing.T) { createTestEntity(t, srv, "a", []string{"ops"}) createTestEntity(t, srv, "b", []string{"ops", "nginx"}) - resp, _ := http.Get(srv.URL + "/api/tags") + resp, err := http.Get(srv.URL + "/api/tags") + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() var tags []TagResponse @@ -391,14 +444,23 @@ func TestListTags_WithCounts(t *testing.T) { func TestCORS_DevMode(t *testing.T) { path := filepath.Join(t.TempDir(), "test.db") - store, _ := db.Open(path) + store, err := db.Open(path) + if err != nil { + t.Fatal(err) + } defer store.Close() router := NewRouter(store, true) srv := httptest.NewServer(router) defer srv.Close() - req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.Header.Get("Access-Control-Allow-Origin") != "*" { @@ -412,8 +474,14 @@ func TestCORS_DevMode(t *testing.T) { func TestCORS_ProdMode(t *testing.T) { srv, _ := testServer(t) // devMode=false - req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) - resp, _ := http.DefaultClient.Do(req) + req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } defer resp.Body.Close() if resp.Header.Get("Access-Control-Allow-Origin") != "" { @@ -426,7 +494,7 @@ func TestAbsorbEntity_Success(t *testing.T) { target := createTestEntity(t, srv, "target body", []string{"ops"}) source := createTestEntity(t, srv, "source body", []string{"ops", "infra"}) - resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ "source_id": source.ID, }) defer resp.Body.Close() @@ -445,7 +513,10 @@ func TestAbsorbEntity_Success(t *testing.T) { } // Source should be soft-deleted (not in default list) - listResp, _ := http.Get(srv.URL + "/api/entities") + listResp, err := http.Get(srv.URL + "/api/entities") + if err != nil { + t.Fatal(err) + } var entities []EntityResponse json.NewDecoder(listResp.Body).Decode(&entities) listResp.Body.Close() @@ -461,11 +532,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) { target := createTestEntity(t, srv, "target", nil) source := createTestEntity(t, srv, "source", nil) - postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{ + postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{ "card_type": "snippet", }).Body.Close() - resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{ "source_id": source.ID, }) defer resp.Body.Close() @@ -485,7 +556,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) { srv, _ := testServer(t) e := createTestEntity(t, srv, "self", nil) - resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{ + resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{ "source_id": e.ID, }) defer resp.Body.Close() @@ -499,7 +570,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) { srv, _ := testServer(t) e := createTestEntity(t, srv, "target", nil) - resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{}) + resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{}) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { diff --git a/internal/api/entities.go b/internal/api/entities.go index 592bb2e..1d40595 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -100,7 +100,7 @@ func listEntities(store *db.Store) http.HandlerFunc { entities, err := store.List(p) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } @@ -151,7 +151,7 @@ func createEntity(store *db.Store) http.HandlerFunc { } if err := store.Create(e); err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } @@ -168,7 +168,7 @@ func getEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -215,13 +215,13 @@ func updateEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -241,7 +241,7 @@ func deleteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } label := "soft" @@ -279,13 +279,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -305,13 +305,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -349,13 +349,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first") return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) @@ -371,13 +371,13 @@ func useEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } e, err := store.Get(id) if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } writeJSON(w, http.StatusOK, entityToResponse(e)) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index d9723ad..5a3a56f 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -2,12 +2,15 @@ package api import ( "encoding/json" + "log" "net/http" "time" "github.com/lerko/nib/internal/db" ) +const maxBodySize = 1 << 20 // 1 MB + type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` @@ -41,6 +44,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) { } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) if err := json.NewDecoder(r.Body).Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error()) return false @@ -48,6 +52,11 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { return true } +func writeInternalError(w http.ResponseWriter, err error) { + log.Printf("internal error: %v", err) + writeError(w, http.StatusInternalServerError, "internal", "internal server error") +} + func entityToResponse(e *db.Entity) EntityResponse { resp := EntityResponse{ ID: e.ID, diff --git a/internal/api/tags.go b/internal/api/tags.go index 9d26ca1..79607ed 100644 --- a/internal/api/tags.go +++ b/internal/api/tags.go @@ -15,7 +15,7 @@ func listTags(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tags, err := store.ListTags() if err != nil { - writeError(w, http.StatusInternalServerError, "internal", err.Error()) + writeInternalError(w, err) return } diff --git a/internal/db/entities.go b/internal/db/entities.go index fbc180f..ff5f6c5 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -141,18 +141,12 @@ func (s *Store) Create(e *Entity) error { func (s *Store) Get(id string) (*Entity, error) { e := &Entity{} - var createdAt, modifiedAt string - var completedAt, deletedAt, lastUsedAt sql.NullString - var timeAnchor, cardType, cardData sql.NullString - var pinned int + row := newEntityRow() err := s.db.QueryRow(` SELECT id, created_at, modified_at, body, glyph, time_anchor, completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at - FROM entities WHERE id = ?`, id).Scan( - &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, - &completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt, - ) + FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -160,15 +154,9 @@ func (s *Store) Get(id string) (*Entity, error) { return nil, err } - e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) - e.TimeAnchor = nullToPtr(timeAnchor) - e.CompletedAt = parseTimePtr(completedAt) - e.Pinned = pinned != 0 - e.DeletedAt = parseTimePtr(deletedAt) - e.CardType = nullToCardType(cardType) - e.CardData = nullToPtr(cardData) - e.LastUsedAt = parseTimePtr(lastUsedAt) + if err := row.apply(e); err != nil { + return nil, fmt.Errorf("scan entity %s: %w", id, err) + } tags, err := s.loadTags(id) if err != nil { @@ -253,31 +241,18 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { var entities []*Entity for rows.Next() { e := &Entity{} - var createdAt, modifiedAt string - var completedAt, deletedAt, lastUsedAt sql.NullString - var timeAnchor, cardType, cardData sql.NullString - var pinned int - - if err := rows.Scan( - &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, - &completedAt, &pinned, &deletedAt, &cardType, &cardData, - &e.UseCount, &lastUsedAt, - ); err != nil { + row := newEntityRow() + if err := rows.Scan(row.ptrs(e)...); err != nil { + return nil, err + } + if err := row.apply(e); err != nil { return nil, err } - - e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) - e.TimeAnchor = nullToPtr(timeAnchor) - e.CompletedAt = parseTimePtr(completedAt) - e.Pinned = pinned != 0 - e.DeletedAt = parseTimePtr(deletedAt) - e.CardType = nullToCardType(cardType) - e.CardData = nullToPtr(cardData) - e.LastUsedAt = parseTimePtr(lastUsedAt) - entities = append(entities, e) } + if err := rows.Err(); err != nil { + return nil, err + } if err := s.batchLoadTags(entities); err != nil { return nil, err @@ -502,6 +477,9 @@ func (s *Store) Resolve(prefix string) (string, error) { } ids = append(ids, id) } + if err := rows.Err(); err != nil { + return "", err + } switch len(ids) { case 0: @@ -513,6 +491,43 @@ func (s *Store) Resolve(prefix string) (string, error) { } } +// entityRow holds intermediate scan values for a single entity row. +// Both Get and List use this to avoid duplicating the 14-var scan + mapping logic. +type entityRow struct { + createdAt, modifiedAt string + completedAt, deletedAt, lastUsedAt sql.NullString + timeAnchor, cardType, cardData 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, &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.TimeAnchor = nullToPtr(r.timeAnchor) + e.CompletedAt = parseTimePtr(r.completedAt) + e.Pinned = r.pinned != 0 + e.DeletedAt = parseTimePtr(r.deletedAt) + e.CardType = nullToCardType(r.cardType) + e.CardData = nullToPtr(r.cardData) + e.LastUsedAt = parseTimePtr(r.lastUsedAt) + return nil +} + // helpers func (s *Store) batchLoadTags(entities []*Entity) error { @@ -568,6 +583,9 @@ func (s *Store) loadTags(entityID string) ([]string, error) { } tags = append(tags, tag) } + if err := rows.Err(); err != nil { + return nil, err + } if tags == nil { tags = []string{} } @@ -631,11 +649,13 @@ func boolToInt(b bool) int { return 0 } -func (e *Entity) CardDataJSON() map[string]interface{} { +func (e *Entity) CardDataJSON() (map[string]interface{}, error) { if e.CardData == nil { - return nil + return nil, nil } var m map[string]interface{} - json.Unmarshal([]byte(*e.CardData), &m) - return m + if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil { + return nil, fmt.Errorf("card_data: %w", err) + } + return m, nil } diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 19ef9e2..1592d95 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -441,3 +441,34 @@ func TestResolve_NotFound(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestAbsorb_SourceIsCard(t *testing.T) { + s := testStore(t) + target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}} + s.Create(target) + + source := &Entity{Body: "source", Glyph: GlyphNote} + s.Create(source) + s.Promote(source.ID, CardSnippet, nil) + s.IncrementUse(source.ID) + + if err := s.Absorb(target.ID, source.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(target.ID) + if got.Body != "target\nsource" { + t.Errorf("merged body: %q", got.Body) + } + + src, _ := s.Get(source.ID) + if src.CardType != nil { + t.Error("source card_type should be cleared after absorb") + } + if src.UseCount != 0 { + t.Errorf("source use_count should be reset, got %d", src.UseCount) + } + if src.DeletedAt == nil { + t.Error("source should be soft-deleted") + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go index 29b2787..52b8f56 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -26,6 +26,9 @@ func (s *Store) ListTags() ([]TagCount, error) { } tags = append(tags, tc) } + if err := rows.Err(); err != nil { + return nil, err + } if tags == nil { tags = []TagCount{} } diff --git a/internal/display/glyph_test.go b/internal/display/glyph_test.go new file mode 100644 index 0000000..5ad53a9 --- /dev/null +++ b/internal/display/glyph_test.go @@ -0,0 +1,80 @@ +package display + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestDisplayGlyph_Fluid(t *testing.T) { + tests := []struct { + glyph db.Glyph + want string + }{ + {db.GlyphNote, "—"}, + {db.GlyphTodo, "○"}, + {db.GlyphEvent, "◇"}, + } + for _, tt := range tests { + got := DisplayGlyph(tt.glyph, nil) + if got != tt.want { + t.Errorf("DisplayGlyph(%q, nil) = %q, want %q", tt.glyph, got, tt.want) + } + } +} + +func TestDisplayGlyph_Card(t *testing.T) { + tests := []struct { + cardType db.CardType + want string + }{ + {db.CardSnippet, "◆"}, + {db.CardTemplate, "◈"}, + {db.CardChecklist, "☐"}, + {db.CardDecision, "⚖"}, + {db.CardLink, "↗"}, + } + for _, tt := range tests { + ct := tt.cardType + got := DisplayGlyph(db.GlyphNote, &ct) + if got != tt.want { + t.Errorf("DisplayGlyph(note, %q) = %q, want %q", tt.cardType, got, tt.want) + } + } +} + +func TestDisplayGlyph_CardOverridesGlyph(t *testing.T) { + ct := db.CardSnippet + got := DisplayGlyph(db.GlyphTodo, &ct) + if got != "◆" { + t.Errorf("card_type should override glyph, got %q", got) + } +} + +func TestDisplayGlyph_UnknownFallback(t *testing.T) { + got := DisplayGlyph(db.Glyph("unknown"), nil) + if got != "—" { + t.Errorf("unknown glyph should fall back to —, got %q", got) + } +} + +func TestFormatID_Long(t *testing.T) { + got := FormatID("01HXYZ1234567890ABCDEFGH") + if got != "01HXYZ123456" { + t.Errorf("expected 12-char truncation, got %q", got) + } +} + +func TestFormatID_Short(t *testing.T) { + got := FormatID("ABC") + if got != "ABC" { + t.Errorf("short ID should pass through, got %q", got) + } +} + +func TestFormatID_Exact12(t *testing.T) { + got := FormatID("123456789012") + if got != "123456789012" { + t.Errorf("exact 12-char should pass through, got %q", got) + } +} diff --git a/web/app.js b/web/app.js index 1db5e94..14572ad 100644 --- a/web/app.js +++ b/web/app.js @@ -349,7 +349,7 @@ `; case 'link': - if (data.url) { + if (data.url && isSafeUrl(data.url)) { return `
`; @@ -754,6 +754,10 @@ return escHtml(s).replace(/'/g, '''); } + function isSafeUrl(url) { + return /^https?:\/\//i.test(url); + } + // ========== Theme ========== const themeToggle = $('#theme-toggle'); From c8e18f0bc1eaf01abdef04e2599f293b55324cda Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 20:52:58 -0400 Subject: [PATCH 02/16] feat: add title and description fields to capture grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement | prefix for titles and // separator for descriptions across the full stack: parser, schema, API, CLI, and web frontend. - Parser: line-aware extraction for |title, |title // desc, // leading desc, body // inline desc. URL-safe (skips :// lines). Modifiers (#tag, @time, ^card) extracted from all segments. - Schema: ALTER TABLE migration adds title, description columns - DB: Entity/EntityUpdate structs, all CRUD queries updated - API: title/description on create/update/response, body validation relaxed (title-only entries valid) - CLI: shows title as scan label when present - Web: parseInput mirrors Go parser, list shows title, detail pane renders title + description with double-click inline editing - Tests: 10 new cases (grammar, entity, API) — 71 total, all pass --- cmd/add.go | 18 ++-- internal/api/api_test.go | 109 +++++++++++++++++++ internal/api/entities.go | 46 ++++---- internal/api/helpers.go | 20 ++-- internal/db/db.go | 9 +- internal/db/entities.go | 59 ++++++---- internal/db/entities_test.go | 96 +++++++++++++++++ internal/parse/grammar.go | 192 +++++++++++++++++++++++---------- internal/parse/grammar_test.go | 69 +++++++----- web/app.js | 157 ++++++++++++++++++++++----- web/style.css | 61 +++++++++++ 11 files changed, 677 insertions(+), 159 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 9e7cb8a..74edfb9 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -32,9 +32,11 @@ func runAdd(_ *cobra.Command, args []string) error { defer store.Close() e := &db.Entity{ - Body: parsed.Body, - Glyph: db.Glyph(parsed.Glyph), - Tags: parsed.Tags, + Body: parsed.Body, + Title: parsed.Title, + Description: parsed.Description, + Glyph: db.Glyph(parsed.Glyph), + Tags: parsed.Tags, } if parsed.TimeAnchor != nil { e.TimeAnchor = parsed.TimeAnchor @@ -53,12 +55,16 @@ func runAdd(_ *cobra.Command, args []string) error { var parts []string parts = append(parts, glyph) - parts = append(parts, " "+e.Body) + if e.Title != nil { + parts = append(parts, " "+*e.Title) + } else { + parts = append(parts, " "+e.Body) + } if e.TimeAnchor != nil { - parts = append(parts, " @"+*e.TimeAnchor) + parts = append(parts, " @"+*e.TimeAnchor) } for _, tag := range e.Tags { - parts = append(parts, " #"+tag) + parts = append(parts, " #"+tag) } parts = append(parts, " ["+shortID+"]") if e.CardType != nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2ff96a3..862e5af 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -578,6 +578,115 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) { } } +func TestCreateEntity_WithTitle(t *testing.T) { + srv, _ := testServer(t) + + resp := postJSON(t, srv, "/api/entities", map[string]any{ + "body": "body text", + "title": "nginx trick", + "description": "always forget this", + "tags": []string{"ops"}, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "nginx trick" { + t.Errorf("title: %v", e.Title) + } + if e.Description == nil || *e.Description != "always forget this" { + t.Errorf("description: %v", e.Description) + } +} + +func TestCreateEntity_TitleOnly(t *testing.T) { + srv, _ := testServer(t) + + title := "title only" + resp := postJSON(t, srv, "/api/entities", map[string]any{ + "title": title, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "title only" { + t.Errorf("title: %v", e.Title) + } +} + +func TestUpdateEntity_Title(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "body", nil) + + req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + mustJSON(map[string]any{"title": "new title"}))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Title == nil || *e.Title != "new title" { + t.Errorf("title: %v", e.Title) + } +} + +func TestUpdateEntity_Description(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "body", nil) + + req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + mustJSON(map[string]any{"description": "new desc"}))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Description == nil || *e.Description != "new desc" { + t.Errorf("description: %v", e.Description) + } +} + +func TestListEntities_TitleInResponse(t *testing.T) { + srv, _ := testServer(t) + + title := "list title" + postJSON(t, srv, "/api/entities", map[string]any{ + "body": "body", + "title": title, + }).Body.Close() + + resp, _ := http.Get(srv.URL + "/api/entities") + defer resp.Body.Close() + + var entities []EntityResponse + json.NewDecoder(resp.Body).Decode(&entities) + if len(entities) != 1 { + t.Fatalf("expected 1, got %d", len(entities)) + } + if entities[0].Title == nil || *entities[0].Title != "list title" { + t.Errorf("title: %v", entities[0].Title) + } +} + func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b diff --git a/internal/api/entities.go b/internal/api/entities.go index 1d40595..d31f9cf 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -10,22 +10,26 @@ import ( ) type CreateEntityRequest struct { - Body string `json:"body"` - Glyph *string `json:"glyph"` - TimeAnchor *string `json:"time_anchor"` - Tags []string `json:"tags"` - CardType *string `json:"card_type"` - CardData *string `json:"card_data"` + Body string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags []string `json:"tags"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` } type UpdateEntityRequest struct { - Body *string `json:"body"` - Glyph *string `json:"glyph"` - TimeAnchor *string `json:"time_anchor"` - Tags *[]string `json:"tags"` - Pinned *bool `json:"pinned"` - CardType *string `json:"card_type"` - CardData *string `json:"card_data"` + Body *string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags *[]string `json:"tags"` + Pinned *bool `json:"pinned"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` } type PromoteRequest struct { @@ -119,8 +123,8 @@ func createEntity(store *db.Store) http.HandlerFunc { return } - if req.Body == "" { - writeError(w, http.StatusBadRequest, "invalid_input", "body is required") + if req.Body == "" && req.Title == nil { + writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required") return } @@ -134,10 +138,12 @@ func createEntity(store *db.Store) http.HandlerFunc { } e := &db.Entity{ - Body: req.Body, - Glyph: glyph, - TimeAnchor: req.TimeAnchor, - Tags: req.Tags, + Body: req.Body, + Title: req.Title, + Description: req.Description, + Glyph: glyph, + TimeAnchor: req.TimeAnchor, + Tags: req.Tags, } if req.CardType != nil { @@ -186,6 +192,8 @@ func updateEntity(store *db.Store) http.HandlerFunc { u := &db.EntityUpdate{} u.Body = req.Body + u.Title = req.Title + u.Description = req.Description u.Tags = req.Tags u.Pinned = req.Pinned u.CardData = req.CardData diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 5a3a56f..f95b72d 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -21,6 +21,8 @@ type EntityResponse struct { CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` Body string `json:"body"` + Title *string `json:"title"` + Description *string `json:"description"` Glyph string `json:"glyph"` TimeAnchor *string `json:"time_anchor"` CompletedAt *string `json:"completed_at"` @@ -59,14 +61,16 @@ func writeInternalError(w http.ResponseWriter, err error) { func entityToResponse(e *db.Entity) EntityResponse { resp := EntityResponse{ - ID: e.ID, - CreatedAt: e.CreatedAt.Format(time.RFC3339), - ModifiedAt: e.ModifiedAt.Format(time.RFC3339), - Body: e.Body, - Glyph: string(e.Glyph), - Pinned: e.Pinned, - Tags: e.Tags, - UseCount: e.UseCount, + ID: e.ID, + CreatedAt: e.CreatedAt.Format(time.RFC3339), + ModifiedAt: e.ModifiedAt.Format(time.RFC3339), + Body: e.Body, + Title: e.Title, + Description: e.Description, + Glyph: string(e.Glyph), + Pinned: e.Pinned, + Tags: e.Tags, + UseCount: e.UseCount, } if resp.Tags == nil { resp.Tags = []string{} diff --git a/internal/db/db.go b/internal/db/db.go index 46795af..15530d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -84,7 +84,14 @@ func (s *Store) migrate() error { CREATE INDEX IF NOT EXISTS idx_entity_tags_tag ON entity_tags(tag); `) - return err + if err != nil { + return err + } + + s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) + s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + + return nil } func DefaultPath() (string, error) { diff --git a/internal/db/entities.go b/internal/db/entities.go index ff5f6c5..074d6e6 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -49,6 +49,8 @@ type Entity struct { CreatedAt time.Time ModifiedAt time.Time Body string + Title *string + Description *string Glyph Glyph TimeAnchor *string CompletedAt *time.Time @@ -85,14 +87,16 @@ func DefaultListParams() ListParams { } type EntityUpdate struct { - Body *string - Glyph *Glyph - TimeAnchor *string - ClearTime bool - Pinned *bool - CardType *CardType - CardData *string - Tags *[]string + Body *string + Title *string + Description *string + Glyph *Glyph + TimeAnchor *string + ClearTime bool + Pinned *bool + CardType *CardType + CardData *string + Tags *[]string } func (s *Store) Create(e *Entity) error { @@ -111,13 +115,16 @@ func (s *Store) Create(e *Entity) error { defer tx.Rollback() _, err = tx.Exec(` - INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, - completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO entities (id, created_at, modified_at, body, title, description, + glyph, time_anchor, completed_at, pinned, deleted_at, + card_type, card_data, use_count, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.CreatedAt.Format(time.RFC3339), e.ModifiedAt.Format(time.RFC3339), e.Body, + e.Title, + e.Description, string(e.Glyph), e.TimeAnchor, formatTimePtr(e.CompletedAt), @@ -144,8 +151,9 @@ func (s *Store) Get(id string) (*Entity, error) { row := newEntityRow() err := s.db.QueryRow(` - SELECT id, created_at, modified_at, body, glyph, time_anchor, - completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at + SELECT id, created_at, modified_at, body, title, description, + glyph, time_anchor, completed_at, pinned, deleted_at, + card_type, card_data, use_count, last_used_at FROM entities WHERE id = ?`, id).Scan(row.ptrs(e)...) if err == sql.ErrNoRows { return nil, ErrNotFound @@ -222,9 +230,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { } query := fmt.Sprintf(` - SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, - e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, - e.use_count, e.last_used_at + SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description, + e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at, + e.card_type, e.card_data, e.use_count, e.last_used_at FROM entities e %s ORDER BY %s %s @@ -283,6 +291,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error { sets = append(sets, "body = ?") args = append(args, *u.Body) } + if u.Title != nil { + sets = append(sets, "title = ?") + args = append(args, *u.Title) + } + if u.Description != nil { + sets = append(sets, "description = ?") + args = append(args, *u.Description) + } if u.Glyph != nil { sets = append(sets, "glyph = ?") args = append(args, string(*u.Glyph)) @@ -491,12 +507,11 @@ func (s *Store) Resolve(prefix string) (string, error) { } } -// entityRow holds intermediate scan values for a single entity row. -// Both Get and List use this to avoid duplicating the 14-var scan + mapping logic. type entityRow struct { createdAt, modifiedAt string completedAt, deletedAt, lastUsedAt sql.NullString timeAnchor, cardType, cardData sql.NullString + title, description sql.NullString pinned int } @@ -504,9 +519,9 @@ func newEntityRow() *entityRow { return &entityRow{} } func (r *entityRow) ptrs(e *Entity) []any { return []any{ - &e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &e.Glyph, &r.timeAnchor, - &r.completedAt, &r.pinned, &r.deletedAt, &r.cardType, &r.cardData, - &e.UseCount, &r.lastUsedAt, + &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, } } @@ -518,6 +533,8 @@ func (r *entityRow) apply(e *Entity) error { 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 diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 1592d95..8817edc 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -472,3 +472,99 @@ func TestAbsorb_SourceIsCard(t *testing.T) { t.Error("source should be soft-deleted") } } + +func TestCreate_WithTitleAndDescription(t *testing.T) { + s := testStore(t) + e := &Entity{ + Body: "body text", + Title: ptr("nginx trick"), + Description: ptr("always forget this"), + Glyph: GlyphNote, + Tags: []string{"ops"}, + } + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if got.Title == nil || *got.Title != "nginx trick" { + t.Errorf("title: got %v", got.Title) + } + if got.Description == nil || *got.Description != "always forget this" { + t.Errorf("description: got %v", got.Description) + } + if got.Body != "body text" { + t.Errorf("body: got %q", got.Body) + } +} + +func TestCreate_WithoutTitle(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "just body", Glyph: GlyphNote} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Title != nil { + t.Errorf("expected nil title, got %v", got.Title) + } + if got.Description != nil { + t.Errorf("expected nil description, got %v", got.Description) + } +} + +func TestUpdate_Title(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "body", Glyph: GlyphNote} + s.Create(e) + + newTitle := "new title" + if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Title == nil || *got.Title != "new title" { + t.Errorf("title: got %v", got.Title) + } +} + +func TestUpdate_Description(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "body", Glyph: GlyphNote} + s.Create(e) + + newDesc := "new desc" + if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Description == nil || *got.Description != "new desc" { + t.Errorf("description: got %v", got.Description) + } +} + +func TestAbsorb_PreservesTargetTitle(t *testing.T) { + s := testStore(t) + target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote} + source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote} + s.Create(target) + s.Create(source) + + if err := s.Absorb(target.ID, source.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(target.ID) + if got.Title == nil || *got.Title != "target title" { + t.Errorf("target title should be preserved, got %v", got.Title) + } + if got.Body != "target body\nsource body" { + t.Errorf("body: got %q", got.Body) + } +} diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index 879aec5..a7a8024 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -7,11 +7,13 @@ import ( ) type Result struct { - Body string - Glyph string - TimeAnchor *string - Tags []string - CardSuffix *string + Body string + Glyph string + Title *string + Description *string + TimeAnchor *string + Tags []string + CardSuffix *string } var validCardTypes = map[string]string{ @@ -35,61 +37,139 @@ func Parse(input string) (*Result, error) { Tags: []string{}, } - tokens := strings.Fields(input) - if len(tokens) == 0 { - return nil, fmt.Errorf("empty input") - } + remaining := input - first := tokens[0] - switch first { - case "-", "▸": - r.Glyph = "todo" - tokens = tokens[1:] - case "*", "◇": - r.Glyph = "event" - tokens = tokens[1:] - } - - var bodyParts []string - seen := map[string]bool{} - - for _, tok := range tokens { - switch { - case strings.HasPrefix(tok, "@") && len(tok) > 1: - timeStr := tok[1:] - if err := validateTime(timeStr); err != nil { - return nil, fmt.Errorf("invalid time %q: %w", timeStr, err) - } - if r.TimeAnchor != nil { - return nil, fmt.Errorf("multiple time anchors") - } - r.TimeAnchor = &timeStr - - case strings.HasPrefix(tok, "#") && len(tok) > 1: - tag := tok[1:] - if !seen[tag] { - r.Tags = append(r.Tags, tag) - seen[tag] = true - } - - case strings.HasPrefix(tok, "^") && len(tok) > 1: - suffix := tok[1:] - cardType, ok := validCardTypes[suffix] - if !ok { - return nil, fmt.Errorf("invalid card type %q", suffix) - } - if r.CardSuffix != nil { - return nil, fmt.Errorf("multiple card suffixes") - } - r.CardSuffix = &cardType - - default: - bodyParts = append(bodyParts, tok) + if sp := strings.IndexByte(remaining, ' '); sp >= 0 { + switch remaining[:sp] { + case "-", "▸": + r.Glyph = "todo" + remaining = strings.TrimSpace(remaining[sp+1:]) + case "*", "◇": + r.Glyph = "event" + remaining = strings.TrimSpace(remaining[sp+1:]) + } + } else { + switch remaining { + case "-", "▸": + r.Glyph = "todo" + remaining = "" + case "*", "◇": + r.Glyph = "event" + remaining = "" } } - r.Body = strings.Join(bodyParts, " ") - if r.Body == "" { + 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] + } + } + } + } + + seen := map[string]bool{} + extract := func(text string) (string, error) { + tokens := strings.Fields(text) + var parts []string + for _, tok := range tokens { + switch { + case strings.HasPrefix(tok, "@") && len(tok) > 1: + timeStr := tok[1:] + if err := validateTime(timeStr); err != nil { + return "", fmt.Errorf("invalid time %q: %w", timeStr, err) + } + if r.TimeAnchor != nil { + return "", fmt.Errorf("multiple time anchors") + } + r.TimeAnchor = &timeStr + case strings.HasPrefix(tok, "#") && len(tok) > 1: + tag := tok[1:] + if !seen[tag] { + r.Tags = append(r.Tags, tag) + seen[tag] = true + } + case strings.HasPrefix(tok, "^") && len(tok) > 1: + suffix := tok[1:] + cardType, ok := validCardTypes[suffix] + if !ok { + return "", fmt.Errorf("invalid card type %q", suffix) + } + if r.CardSuffix != nil { + return "", fmt.Errorf("multiple card suffixes") + } + r.CardSuffix = &cardType + default: + parts = append(parts, tok) + } + } + return strings.Join(parts, " "), nil + } + + if hasTitle { + clean, err := extract(titleRaw) + if err != nil { + return nil, err + } + if clean != "" { + r.Title = &clean + } + } + if descRaw != "" { + clean, err := extract(descRaw) + if err != nil { + return nil, err + } + if clean != "" { + r.Description = &clean + } + } + + clean, err := extract(remaining) + if err != nil { + return nil, err + } + r.Body = clean + + if r.Body == "" && r.Title == nil { return nil, fmt.Errorf("empty body after extracting modifiers") } diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 2262e9e..46ec311 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -13,46 +13,61 @@ func TestParse(t *testing.T) { input string wantBody string wantGlyph string + wantTitle *string + wantDesc *string wantTime *string wantTags []string wantCard *string wantErrSub string }{ // Glyph detection - {"plain note", "hello world", "hello world", "note", nil, nil, nil, ""}, - {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, - {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, - {"star event", "* dentist", "dentist", "event", nil, nil, nil, ""}, - {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, ""}, + {"plain note", "hello world", "hello world", "note", nil, nil, nil, nil, nil, ""}, + {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, + {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""}, + {"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, + {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""}, // Time anchor - {"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""}, - {"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""}, - {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"}, - {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"}, + {"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""}, + {"time at start", "@9:30 standup", "standup", "note", nil, nil, sp("9:30"), nil, nil, ""}, + {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, nil, nil, "invalid time"}, + {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"}, // Tags - {"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, - {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""}, - {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, - {"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""}, + {"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, + {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""}, + {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""}, + {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""}, // Card suffix - {"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, - {"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""}, - {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""}, - {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""}, - {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"}, + {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, + {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""}, + {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""}, + {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""}, + {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"}, // Combined - {"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""}, - {"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, + {"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", nil, nil, sp("15:00"), []string{"ops"}, nil, ""}, + {"full with card", "figured out the proxy_pass trick #nginx ^card", "figured out the proxy_pass trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""}, + + // Title + {"title with body", "|nginx trick\nproxy_pass trailing slash #ops", "proxy_pass trailing slash", "note", sp("nginx trick"), nil, nil, []string{"ops"}, nil, ""}, + {"no title", "no pipe here #ops", "no pipe here", "note", nil, nil, nil, []string{"ops"}, nil, ""}, + {"todo with title", "- |deploy staging\nrebuild docker #ops", "rebuild docker", "todo", sp("deploy staging"), nil, nil, []string{"ops"}, nil, ""}, + {"title only", "|title only", "", "note", sp("title only"), nil, nil, nil, nil, ""}, + {"title and desc", "|title // description #ops\nbody here", "body here", "note", sp("title"), sp("description"), nil, []string{"ops"}, nil, ""}, + {"todo title desc", "- |deploy staging // rebuild and push #ops", "", "todo", sp("deploy staging"), sp("rebuild and push"), nil, []string{"ops"}, nil, ""}, + + // Description without title + {"leading desc", "// leading desc\nbody content", "body content", "note", nil, sp("leading desc"), nil, nil, nil, ""}, + {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, ""}, + {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, ""}, // Edge cases - {"empty input", "", "", "", nil, nil, nil, "empty"}, - {"only glyph", "-", "", "", nil, nil, nil, "empty body"}, - {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"}, - {"whitespace only", " ", "", "", nil, nil, nil, "empty"}, + {"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"}, + {"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"}, + {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"}, + {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"}, } for _, tt := range tests { @@ -79,6 +94,12 @@ func TestParse(t *testing.T) { if got.Glyph != tt.wantGlyph { t.Errorf("glyph: got %q, want %q", got.Glyph, tt.wantGlyph) } + if !ptrEq(got.Title, tt.wantTitle) { + t.Errorf("title: got %v, want %v", strPtr(got.Title), strPtr(tt.wantTitle)) + } + if !ptrEq(got.Description, tt.wantDesc) { + t.Errorf("description: got %v, want %v", strPtr(got.Description), strPtr(tt.wantDesc)) + } if !ptrEq(got.TimeAnchor, tt.wantTime) { t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime)) } diff --git a/web/app.js b/web/app.js index 14572ad..10c1ec6 100644 --- a/web/app.js +++ b/web/app.js @@ -115,37 +115,92 @@ input = input.trim(); if (!input) return null; - const tokens = input.split(/\s+/); let glyph = 'note'; + let remaining = input; - const first = tokens[0]; - if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); } - else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); } + const sp = remaining.indexOf(' '); + if (sp >= 0) { + const first = remaining.slice(0, sp); + if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); } + else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); } + } else { + if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; } + else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; } + } - const bodyParts = []; - let timeAnchor = null; - const tags = []; - const seenTags = {}; - let cardSuffix = null; + let titleRaw = null, descRaw = null, hasTitle = false; + const lines = remaining.split('\n'); + const firstLine = (lines[0] || '').trim(); - for (const tok of tokens) { - if (tok.startsWith('@') && tok.length > 1) { - timeAnchor = tok.slice(1); - } else if (tok.startsWith('#') && tok.length > 1) { - const tag = tok.slice(1); - if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } - } else if (tok.startsWith('^') && tok.length > 1) { - const suffix = tok.slice(1); - if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix]; + if (firstLine.startsWith('|')) { + hasTitle = true; + const titleContent = firstLine.slice(1); + const descIdx = titleContent.indexOf(' // '); + if (descIdx >= 0) { + titleRaw = titleContent.slice(0, descIdx).trim(); + descRaw = titleContent.slice(descIdx + 4).trim(); } else { - bodyParts.push(tok); + titleRaw = titleContent.trim(); + } + remaining = lines.slice(1).join('\n'); + } else { + let descParts = [], startBody = 0; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('// ') || trimmed === '//') { + descParts.push(trimmed.slice(2).trim()); + startBody = i + 1; + } else { break; } + } + if (descParts.length) { + descRaw = descParts.join(' '); + remaining = lines.slice(startBody).join('\n'); + } else if (!firstLine.includes('://')) { + const dIdx = firstLine.indexOf(' // '); + if (dIdx >= 0) { + descRaw = firstLine.slice(dIdx + 4).trim(); + remaining = firstLine.slice(0, dIdx).trim(); + if (lines.length > 1) remaining += '\n' + lines.slice(1).join('\n'); + } } } - const body = bodyParts.join(' '); - if (!body) return null; + let timeAnchor = null, cardSuffix = null; + const tags = [], seenTags = {}; - return { body, glyph, timeAnchor, tags, cardSuffix }; + function extract(text) { + const tokens = text.split(/\s+/).filter(Boolean); + const parts = []; + for (const tok of tokens) { + if (tok.startsWith('@') && tok.length > 1) { + timeAnchor = tok.slice(1); + } else if (tok.startsWith('#') && tok.length > 1) { + const tag = tok.slice(1); + if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } + } else if (tok.startsWith('^') && tok.length > 1) { + const suffix = tok.slice(1); + if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix]; + } else { + parts.push(tok); + } + } + return parts.join(' '); + } + + let title = null, description = null; + if (hasTitle) { + const clean = extract(titleRaw || ''); + if (clean) title = clean; + } + if (descRaw) { + const clean = extract(descRaw); + if (clean) description = clean; + } + + const body = extract(remaining); + if (!body && !title) return null; + + return { body, glyph, title, description, timeAnchor, tags, cardSuffix }; } function detectCardType(body) { @@ -258,9 +313,17 @@ const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; + let label; + if (e.title) { + const preview = e.body ? `${escHtml(e.body)}` : ''; + label = `${escHtml(e.title)}${preview}`; + } else { + label = `${escHtml(e.body)}`; + } + return `
${glyph} - ${escHtml(e.body)} + ${label} ${time} ${tags} ${useBadge} @@ -296,18 +359,27 @@ } actions += ``; + const descHtml = e.description ? `
${escHtml(e.description)}
` : ''; + const titleHtml = e.title ? `

${escHtml(e.title)}

` : ''; + pane.innerHTML = `
${glyph} ${shortId} ${e.time_anchor ? `@${e.time_anchor}` : ''}
+ ${descHtml} + ${titleHtml}
${escHtml(e.body)}
${tags ? `
${tags}
` : ''} ${cardContent}
${actions}
`; + const titleEl = pane.querySelector('.detail-title'); + if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); + const descEl = pane.querySelector('.detail-desc'); + if (descEl) descEl.addEventListener('dblclick', () => startEditField('description')); const bodyEl = pane.querySelector('.detail-body'); if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); } @@ -395,6 +467,40 @@ }); } + function startEditField(field) { + const e = state.entities[state.selectedIndex]; + if (!e) return; + const cls = field === 'title' ? '.detail-title' : '.detail-desc'; + const el = $(`${cls}[data-id="${e.id}"]`); + if (!el || el.tagName === 'INPUT') return; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'detail-field-edit'; + input.value = e[field] || ''; + input.placeholder = field; + el.replaceWith(input); + input.focus(); + + async function save() { + const val = input.value.trim(); + if (val !== (e[field] || '')) { + await api.updateEntity(e.id, { [field]: val || null }); + await loadEntities(); + const idx = state.entities.findIndex(x => x.id === e.id); + if (idx >= 0) selectEntity(idx); + } else { + renderDetailPane(); + } + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); } + if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); } + }); + } + // ========== Actions ========== function selectEntity(idx) { @@ -565,9 +671,10 @@ list.innerHTML = sources.map(e => { const g = displayGlyph(e); const gc = glyphClass(e); + const label = e.title ? escHtml(e.title) : escHtml(e.body); return `
${g} - ${escHtml(e.body)} + ${label}
`; }).join(''); } @@ -615,6 +722,8 @@ glyph: parsed.glyph, tags: parsed.tags, }; + if (parsed.title) data.title = parsed.title; + if (parsed.description) data.description = parsed.description; if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; diff --git a/web/style.css b/web/style.css index d2fe488..45c452f 100644 --- a/web/style.css +++ b/web/style.css @@ -309,6 +309,25 @@ main { .glyph-decision { color: var(--note); } .glyph-link { color: var(--danger); } +.entity-title { + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entity-preview { + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 8px; +} + .entity-body { flex: 1; font-family: var(--mono); @@ -385,6 +404,48 @@ main { color: var(--dim); } +.detail-desc { + font-family: var(--sans); + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; + cursor: text; + padding: 2px 6px; + margin-left: -6px; + border-radius: var(--r2); + transition: background var(--t-fast); +} + +.detail-desc:hover { background: var(--raised); } + +.detail-title { + font-family: var(--sans); + font-size: 16px; + font-weight: 500; + margin-bottom: 12px; + cursor: text; + padding: 2px 6px; + margin-left: -6px; + border-radius: var(--r2); + transition: background var(--t-fast); +} + +.detail-title:hover { background: var(--raised); } + +.detail-field-edit { + display: block; + width: 100%; + font-family: var(--sans); + font-size: 14px; + margin-bottom: 12px; + padding: 4px 8px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--accent); + border-radius: var(--r2); + outline: none; +} + .detail-body { font-family: var(--mono); font-size: 13px; From b2d6603dcf774491f0966e809bffe49250bffe17 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 22:01:47 -0400 Subject: [PATCH 03/16] docs: add README and MIT license --- LICENSE | 21 ++++++++ README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42fa632 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tyler Koenig + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0380ff8 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# nib + +Capture-first note system. Catch thoughts, todos, and events before they slip — from terminal or browser. Everything lives in a single SQLite file. + +nib uses a tiny grammar to extract structure from plain text. You type naturally, and nib figures out what it is: a thought, a todo with a due date, an event, a titled reference. Tags, descriptions, and time anchors are pulled out automatically. The body stays as markdown. + +When something proves useful, promote it to a card (snippet, template, checklist, decision, link) for quick reuse. Cards track usage and float to the top. + +## Install + +Requires Go 1.24+. + +``` +go build -o nib . +``` + +Data lives at `~/.nib/nib.db` by default. Override with `NIB_DB=/path/to/file.db`. + +## Quick start + +```bash +# capture a thought +nib "proxy_pass needs a trailing slash" + +# todo +nib "- buy mass gainer @tomorrow #errands" + +# titled entry with description +nib "|nginx proxy trick // always forget this #ops" + +# todo with a title +nib "- |deploy staging // rebuild docker image #ops" + +# list recent entries +nib ls + +# list by tag +nib ls --tag ops + +# list a specific month +nib ls --month 2026-05 + +# start the web UI +nib serve +``` + +Open `http://localhost:4444` for the browser interface. + +## Grammar + +The full grammar fits on an index card. Here's what matters: + +### Kind prefixes + +The first character decides what kind of entry you're creating. + +| Input | Kind | Example | +|-------|------|---------| +| bare text | thought | `just an idea` | +| `- text` | todo | `- buy milk @tomorrow` | +| `@time text` | event | `@friday 2pm lunch with alex` | +| `!time text` | reminder | `!3pm call dentist` | + +The dash needs a space after it. `-deploy` is a thought, `- deploy` is a todo. + +### Titles and descriptions + +Give an entry a name with `|` at the start. Add a description with `//`. + +``` +|nginx proxy trick +proxy_pass http://backend/; + +|deploy checklist // for staging #ops +1. docker build +2. docker push +3. ssh prod && restart + +// quick reference for proxy config +the actual body text goes here + +body text // this part becomes the description +``` + +Title shows as the scan label in list view. Description appears in the detail pane. Both are optional — most captures won't need them. + +### Tags and flags + +Tags and flags work anywhere in the input. They're extracted and removed from the body. + +``` +deploy nginx #ops #infra → tags: ops, infra +important thing !pin → pinned to top +use ##channel in slack → literal #channel in body (escaped) +``` + +### Cards + +Promote a fluid entry to a card for reuse: + +```bash +nib promote snippet # code trick, copy-to-clipboard +nib promote template # has ${slots} to fill +nib promote checklist # step-through items +nib promote decision # chose/why/rejected +nib promote link # URL with an open button +``` + +Or use `^type` inline: `nib "proxy trick #nginx ^card"` + +## CLI commands + +| Command | What it does | +|---------|-------------| +| `nib ` | Capture (shorthand for `nib add`) | +| `nib ls` | List entries — filter with `--tag`, `--date`, `--month`, `--from`/`--to` | +| `nib cards` | List cards sorted by usage | +| `nib edit ` | Open in `$EDITOR` | +| `nib copy ` | Copy body to clipboard | +| `nib promote [type]` | Promote to card | +| `nib demote ` | Strip card, back to fluid | +| `nib absorb ` | Merge source into target | +| `nib delete ` | Soft delete (repeat to hard delete) | +| `nib serve` | Start web UI on `:4444` (or `--port`) | + +IDs are prefix-matchable. If `01KRQ4` is unique, that's enough. + +## Web UI + +`nib serve` starts a local web interface with: + +- **Capture bar** — same grammar as the CLI +- **Stream view** — entries grouped by date, newest first +- **Cards view** — promoted cards sorted by use count +- **Tag rail** — filter by tag +- **Month navigator** — browse by date range +- **Detail pane** — full entry view, double-click to edit +- **Keyboard shortcuts** — `j`/`k` navigate, `n` to capture, `p` to promote, `e` to edit, `dd` to delete + +Dark and light themes. Toggle with the button in the header. + +## Data + +Everything is one SQLite file. Back it up, sync it, move it between machines — it's just a file. WAL mode is on for concurrent reads. + +```bash +# back up +cp ~/.nib/nib.db ~/.nib/nib.db.bak + +# use a different database +NIB_DB=/path/to/other.db nib ls +``` + +## License + +MIT From fadc6d9a2ae2e48d870533d8e63a8e719510419f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 22:30:59 -0400 Subject: [PATCH 04/16] fix(ls): show title instead of body when present --- cmd/ls.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/ls.go b/cmd/ls.go index d3d76b2..3e6f995 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -142,8 +142,13 @@ func printEntity(e *db.Entity) { glyph := display.DisplayGlyph(e.Glyph, e.CardType) shortID := display.FormatID(e.ID) + label := e.Body + if e.Title != nil { + label = *e.Title + } + var line strings.Builder - fmt.Fprintf(&line, "%s %-40s", glyph, e.Body) + fmt.Fprintf(&line, "%s %-40s", glyph, label) if e.TimeAnchor != nil { fmt.Fprintf(&line, " @%-5s", *e.TimeAnchor) From f4e178e3ee398bc8f8a90014d2ca01d1cd24cb41 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 22:31:28 -0400 Subject: [PATCH 05/16] fix(cards): show title instead of body when present --- cmd/cards.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/cards.go b/cmd/cards.go index 7f85fff..c612159 100644 --- a/cmd/cards.go +++ b/cmd/cards.go @@ -63,8 +63,13 @@ func runCards(_ *cobra.Command, _ []string) error { tagStr += " #" + tag } + label := e.Body + if e.Title != nil { + label = *e.Title + } + fmt.Printf("%s %-40s %-16s %3d× %s\n", - glyph, e.Body, + glyph, label, strings.TrimSpace(tagStr), e.UseCount, shortID) } From dda84261135b748ed641bdd8a3a9118cbc643a5c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 09:25:35 -0400 Subject: [PATCH 06/16] =?UTF-8?q?feat(ui):=20phase=201=20=E2=80=94=20layou?= =?UTF-8?q?t,=20tokens,=20header,=20rail=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch mono font from Monaspace Neon to JetBrains Mono - Grid layout 192px | 1fr | 400px (was 180/320) - Move capture bar from header to bottom of center panel - Add search input to header center - Redesign tag rail: grid items with arrow/dot/name/count - Add intent section (grab/read/fill) in cards view rail - Add --a-str token, toast component - Logo 16px 700 weight --- TODO.md | 37 +++++ web/app.js | 221 ++++++++++++++++++------- web/index.html | 17 +- web/style.css | 428 +++++++++++++++++++++++++++++++++---------------- 4 files changed, 495 insertions(+), 208 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a93ac00 --- /dev/null +++ b/TODO.md @@ -0,0 +1,37 @@ +# UI Redesign — Design Handoff Implementation + +## Phase 1: Layout + Tokens + Header + Rail +- [ ] Update CSS tokens (add --a-str, switch mono font to JetBrains Mono) +- [ ] Fix grid dimensions (192px rail, 400px peek) +- [ ] Move capture bar from header to bottom of center panel +- [ ] Add search bar to header (centered, max-width 400px) +- [ ] Redesign tag rail: grid layout (arrow ▸ + dot + name + count) +- [ ] Add intent section (grab/read/fill) for cards view in rail + +## Phase 2: Stream + Cards Views +- [ ] Stream rows: promoted entries get card-style border/radius + card-type badge +- [ ] Card rows: rich single-line with title — preview — affordance badges — tag pills — pin — use count +- [ ] Affordance detection client-side (fill, steps, decide, link, code) +- [ ] Affordance badge components +- [ ] Cards sub-header (scope label + card count + sort dropdown) +- [ ] Section labels (★ pinned, recent) +- [ ] Flash animation on copy +- [ ] Bottom capture bar styling per view (different placeholders) + +## Phase 3: Peek Pane + Modes +- [ ] Idle state with keyboard shortcuts display +- [ ] Stream entry peek: eyebrow, body, tags, context, actions +- [ ] Card peek: card container with eyebrow, title, desc, meta, content sections +- [ ] Code block with syntax highlighting +- [ ] Decision section display +- [ ] Steps section display +- [ ] Link section display +- [ ] Run mode (interactive checklist with progress bar) +- [ ] Fill mode (inline slot editor with tab navigation) +- [ ] Edit mode (form fields) +- [ ] Toast notifications + +## Phase 4: Polish +- [ ] Promote modal enhancement (add hint text per type) +- [ ] Remaining keyboard shortcuts (r=run, f=fill) +- [ ] Scroll behavior and edge cases diff --git a/web/app.js b/web/app.js index 10c1ec6..4b3970f 100644 --- a/web/app.js +++ b/web/app.js @@ -16,6 +16,8 @@ const PAGE_SIZE = 50; + const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' }; + const state = { view: 'stream', entities: [], @@ -24,6 +26,7 @@ activeTag: null, hasMore: false, activeMonth: null, + intent: 'grab', }; const $ = (sel) => document.querySelector(sel); @@ -223,32 +226,128 @@ function formatDate(dateStr) { const d = new Date(dateStr); - const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; return months[d.getMonth()] + ' ' + d.getDate(); } + // ── Tag Rail ── + function renderTagRail() { const rail = $('#tag-rail'); - const allItem = `
- all -
`; + const total = state.tags.reduce((s, t) => s + t.count, 0); - rail.innerHTML = allItem + state.tags.map(t => - `
- ${t.tag} - ${t.count} -
` - ).join(''); + let html = `
nib
`; + html += '
'; - rail.querySelectorAll('.tag-item').forEach(el => { + if (state.view === 'cards') { + html += '
'; + html += '
intent
'; + for (const k of ['grab', 'read', 'fill']) { + const on = state.intent === k ? ' on' : ''; + const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length; + html += `'; + } + html += '
'; + } + + html += '
'; + html += '
tags
'; + + const allOn = !state.activeTag ? ' on' : ''; + html += `'; + + for (const t of state.tags) { + const on = state.activeTag === t.tag ? ' on' : ''; + html += `'; + } + + html += '
'; + rail.innerHTML = html; + + rail.querySelectorAll('.rail-item[data-tag]').forEach(el => { el.addEventListener('click', () => { state.activeTag = el.dataset.tag || null; loadEntities(); renderTagRail(); }); }); + + rail.querySelectorAll('.rail-item[data-intent]').forEach(el => { + el.addEventListener('click', () => { + state.intent = el.dataset.intent; + renderTagRail(); + }); + }); } + // ── Capture Bar ── + + function renderCaptureBar() { + const bar = $('#capture-bar'); + const placeholder = state.view === 'stream' + ? 'capture · - todo @time event !time reminder #tag |title' + : '|title // desc #tag ${slot} 1. step'; + + bar.innerHTML = ` +
+ + + ⏎ save +
+ `; + + const input = $('#capture-input'); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + handleCapture(); + } + }); + } + + async function handleCapture() { + const input = $('#capture-input'); + const val = input.value.trim(); + if (!val) return; + + const parsed = parseInput(val); + if (!parsed) return; + + const data = { + body: parsed.body, + glyph: parsed.glyph, + tags: parsed.tags, + }; + if (parsed.title) data.title = parsed.title; + if (parsed.description) data.description = parsed.description; + if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; + if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; + + await api.createEntity(data); + input.value = ''; + await loadEntities(); + await loadTags(); + showToast('captured'); + } + + // ── Entity List ── + function groupByDate(entities) { const groups = []; let current = null; @@ -308,10 +407,12 @@ function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); - const selected = idx === state.selectedIndex ? 'selected' : ''; - const tags = (e.tags || []).map(t => `${t}`).join(''); + const selected = idx === state.selectedIndex ? ' selected' : ''; + const isCard = e.card_type ? ' is-card' : ''; + const tags = (e.tags || []).slice(0, 2).map(t => `${t}`).join(''); const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; + const cardBadge = e.card_type ? `${e.card_type}` : ''; let label; if (e.title) { @@ -321,11 +422,11 @@ label = `${escHtml(e.body)}`; } - return `
+ return `
${glyph} ${label} ${time} - ${tags} + ${tags}${cardBadge} ${useBadge}
`; } @@ -351,10 +452,10 @@ if (e.card_type) { cardContent = renderCardContent(e); - actions += ``; + actions += ``; actions += ``; } else { - actions += ``; + actions += ``; actions += ``; } actions += ``; @@ -363,17 +464,19 @@ const titleHtml = e.title ? `

${escHtml(e.title)}

` : ''; pane.innerHTML = ` -
- ${glyph} - ${shortId} - ${e.time_anchor ? `@${e.time_anchor}` : ''} +
+
+ ${glyph} + ${shortId} + ${e.time_anchor ? `@${e.time_anchor}` : ''} +
+ ${descHtml} + ${titleHtml} +
${escHtml(e.body)}
+ ${tags ? `
${tags}
` : ''} + ${cardContent} +
${actions}
- ${descHtml} - ${titleHtml} -
${escHtml(e.body)}
- ${tags ? `
${tags}
` : ''} - ${cardContent} -
${actions}
`; const titleEl = pane.querySelector('.detail-title'); @@ -560,13 +663,10 @@ ${label} - ${state.activeMonth ? '' : ''} `; $('#month-prev').addEventListener('click', () => shiftMonth(-1)); $('#month-next').addEventListener('click', () => shiftMonth(1)); - const clearBtn = nav.querySelector('.month-nav-clear'); - if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); }); } function shiftMonth(dir) { @@ -590,10 +690,25 @@ function switchView(view) { state.view = view; state.activeMonth = null; + state.selectedIndex = -1; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; loadEntities(); renderMonthNav(); + renderTagRail(); + renderCaptureBar(); + } + + // ========== Toast ========== + + function showToast(msg) { + let el = $('.toast'); + if (el) el.remove(); + el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1600); } // ========== Public API (for inline handlers) ========== @@ -606,6 +721,7 @@ await navigator.clipboard.writeText(e.body); await api.useEntity(id); await loadEntities(); + showToast('copied'); } catch (err) { console.error('clipboard:', err); } @@ -630,12 +746,14 @@ await api.demoteEntity(id); await loadEntities(); await loadTags(); + showToast('demoted'); }, async deleteEntity(id) { await api.deleteEntity(id); await loadEntities(); await loadTags(); + showToast('deleted'); }, async resolveTemplate(id) { @@ -651,6 +769,7 @@ await navigator.clipboard.writeText(resolved); await api.useEntity(id); await loadEntities(); + showToast('copied'); } catch (err) { console.error('clipboard:', err); } @@ -688,6 +807,7 @@ await loadTags(); const idx = state.entities.findIndex(x => x.id === targetId); if (idx >= 0) selectEntity(idx); + showToast('absorbed'); }); }); @@ -706,33 +826,6 @@ }, }; - // ========== Capture bar ========== - - $('#capture-bar').addEventListener('submit', async (ev) => { - ev.preventDefault(); - const input = $('#capture-input'); - const val = input.value.trim(); - if (!val) return; - - const parsed = parseInput(val); - if (!parsed) return; - - const data = { - body: parsed.body, - glyph: parsed.glyph, - tags: parsed.tags, - }; - if (parsed.title) data.title = parsed.title; - if (parsed.description) data.description = parsed.description; - if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor; - if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; - - await api.createEntity(data); - input.value = ''; - await loadEntities(); - await loadTags(); - }); - // ========== Promote modal ========== $$('.type-btn').forEach(btn => { @@ -745,6 +838,7 @@ await api.promoteEntity(id, btn.dataset.type); await loadEntities(); await loadTags(); + showToast('promoted → ' + btn.dataset.type); }); }); @@ -761,12 +855,11 @@ // ========== Keyboard shortcuts ========== let lastDTime = 0; - const captureInput = $('#capture-input'); document.addEventListener('keydown', (ev) => { - if (document.activeElement === captureInput || - document.activeElement.classList.contains('detail-body-edit')) { - if (ev.key === 'Escape') document.activeElement.blur(); + const tag = (ev.target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea') { + if (ev.key === 'Escape') ev.target.blur(); return; } @@ -789,7 +882,7 @@ break; case 'n': ev.preventDefault(); - captureInput.focus(); + $('#capture-input').focus(); break; case 'p': { const e = state.entities[state.selectedIndex]; @@ -848,6 +941,9 @@ } $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view)); loadEntities(); + renderMonthNav(); + renderTagRail(); + renderCaptureBar(); } window.addEventListener('hashchange', handleHash); @@ -884,6 +980,7 @@ // ========== Init ========== async function init() { + renderCaptureBar(); await Promise.all([loadEntities(), loadTags()]); handleHash(); renderMonthNav(); diff --git a/web/index.html b/web/index.html index 0c8e0fd..c9ede77 100644 --- a/web/index.html +++ b/web/index.html @@ -6,28 +6,22 @@ nib - + -
-

nib

+
-
- -
+
@@ -35,6 +29,7 @@
+
`; } + function fmtDateLong(dateStr) { + const d = new Date(dateStr); + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + function renderDetailPane() { const pane = $('#detail-pane'); const e = state.entities[state.selectedIndex]; if (!e) { - pane.innerHTML = '
select an entity
'; + pane.innerHTML = renderPeekIdle(); pane.classList.remove('visible'); return; } pane.classList.add('visible'); + + if (state.view === 'stream' || !e.card_type) { + pane.innerHTML = renderStreamPeek(e); + } else if (state.peekMode === 'run') { + pane.innerHTML = renderRunMode(e); + } else if (state.peekMode === 'fill') { + pane.innerHTML = renderFillMode(e); + } else if (state.peekMode === 'edit') { + pane.innerHTML = renderEditMode(e); + } else { + pane.innerHTML = renderCardPeek(e); + } + + bindPeekEvents(e); + } + + function renderPeekIdle() { + const v = state.view; + return `
+
peek
+
Select ${v === 'cards' ? 'a card' : 'an entry'}.
+
${v === 'cards' + ? 'Full detail lives here. Run checklists, fill templates, edit in place.' + : 'Entry detail lives here. Promote any capture to a card when it earns a permanent home.'}
+
+
+
navigate
+
jknext / prev
+
12stream / cards
+
+ ${v === 'stream' ? `
+
stream grammar
+
(bare text) = thought
+
- todo · @time event · !time reminder
+
#tag · |title · // desc · !pin
+
` : `
+
act
+
copy
+
rrun checklist
+
ffill template
+
eedit
+
ppin
+
`} +
+
`; + } + + function renderStreamPeek(e) { + const kind = e.card_type || e.glyph; const glyph = displayGlyph(e); const gc = glyphClass(e); + const kindLbl = { note: 'thought', todo: 'todo', event: 'event', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }[kind] || kind; const tags = (e.tags || []).map(t => `#${t}`).join(''); - const shortId = e.id.slice(0, 12); - let cardContent = ''; let actions = ''; - - if (e.card_type) { - cardContent = renderCardContent(e); - actions += ``; - actions += ``; - } else { + if (!e.card_type) { actions += ``; - actions += ``; } actions += ``; - const descHtml = e.description ? `
${escHtml(e.description)}
` : ''; - const titleHtml = e.title ? `

${escHtml(e.title)}

` : ''; - - pane.innerHTML = ` -
-
- ${glyph} - ${shortId} - ${e.time_anchor ? `@${e.time_anchor}` : ''} -
- ${descHtml} - ${titleHtml} -
${escHtml(e.body)}
- ${tags ? `
${tags}
` : ''} - ${cardContent} -
${actions}
+ return `
+
+ ${glyph} + ${kindLbl} + · + ${e.id.slice(-10)} + ${fmtDateLong(e.created_at)}
- `; - - const titleEl = pane.querySelector('.detail-title'); - if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); - const descEl = pane.querySelector('.detail-desc'); - if (descEl) descEl.addEventListener('dblclick', () => startEditField('description')); - const bodyEl = pane.querySelector('.detail-body'); - if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); + ${e.title ? `
${escHtml(e.title)}
` : ''} +
${escHtml(e.body)}
+ ${tags ? `
tags
${tags}
` : ''} +
+
context
+
+ created${fmtDateLong(e.created_at)} + ${e.time_anchor ? `time@${e.time_anchor}` : ''} + ${e.card_type ? `statuspromoted → ${e.card_type}` : ''} +
+
+
${actions}
+
`; } - function renderCardContent(e) { - if (!e.card_data) return ''; - let data; - try { data = JSON.parse(e.card_data); } catch { return ''; } + function renderCardPeek(e) { + const glyph = GLYPHS[e.card_type] || '◆'; + const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet'; + const affs = detectAffordances(e); + const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; + const tags = (e.tags || []).map(t => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + const hasSteps = data.steps && data.steps.length; + const hasDecision = data.chose != null; + const hasFill = /\$\{[^}]+\}/.test(e.body || ''); + const hasLink = !!data.url; - switch (e.card_type) { - case 'template': - if (!data.slots || !data.slots.length) return ''; - return `
- ${data.slots.map(s => ` -
- \${${s.name}} - -
- `).join('')} - -
`; + let sections = ''; - case 'checklist': - if (!data.steps || !data.steps.length) return ''; - return `
- ${data.steps.map((s, i) => ` -
- - ${escHtml(s.text)} -
- `).join('')} -
`; - - case 'decision': - return `
-
chose
${escHtml(data.chose || '—')}
-
why
${escHtml(data.why || '—')}
- ${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''} -
`; - - case 'link': - if (data.url && isSafeUrl(data.url)) { - return `
- -
`; - } - return ''; - - default: - return ''; + if (hasDecision) { + const rejected = (data.rejected || []).map(r => `${escHtml(r)}`).join(''); + sections += `
+
decision${data.status || 'decided'}
+
+
${escHtml(data.chose)}
+
why${escHtml(data.why || '')}
+ ${rejected ? `
considered
${rejected}
` : ''} +
+
`; } + + if (hasLink && !hasDecision) { + sections += `
+
link
+
+
`; + } + + if (hasSteps) { + const steps = data.steps.map((s, i) => `
${escHtml(s.text || s)}
`).join(''); + sections += `
+
steps · ${data.steps.length}
+
${steps}
+
`; + } + + if (!hasDecision && e.body) { + const lang = data.lang || ''; + sections += `
+
content${lang ? `${lang}` : ''}${hasFill ? `` : ''}
+
${escHtml(e.body)}
+
`; + } + + let actions = ``; + if (hasFill) actions += ``; + if (hasSteps) actions += ``; + actions += ``; + actions += ``; + actions += ``; + + return `
+
+
+
+ ${glyph} + ${e.card_type} + · + ${e.id.slice(-10)} + ${e.use_count > 0 ? `${e.use_count}× used` : ''} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${affHtml}${tags}${e.pinned ? '' : ''}
+
+ ${sections} +
+
${actions}
+
`; + } + + function renderRunMode(e) { + const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; + if (!data.steps) return renderCardPeek(e); + const total = data.steps.length; + const checked = state.runChecked || new Set(); + const done = checked.size; + const pct = total > 0 ? Math.round(done / total * 100) : 0; + + const steps = data.steps.map((s, i) => { + const isDone = checked.has(i); + const text = s.text || s; + return `
+ ${isDone ? '●' : '○'} + ${escHtml(text)} +
`; + }).join(''); + + return `
+
+ ▶ running + ${done}/${total} done +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
+
+ ${pct}% +
+
${steps}
+
Space toggler resetEsc exit
+
+ + +
+
`; + } + + function renderFillMode(e) { + const slots = []; + const re = /\$\{([^}]+)\}/g; + let m; + const seen = new Set(); + while ((m = re.exec(e.body || '')) !== null) { + const name = m[1].trim(); + if (!seen.has(name)) { seen.add(name); slots.push(name); } + } + if (!slots.length) return renderCardPeek(e); + const fill = state.fillValues || {}; + const active = state.fillActive || 0; + + let content = escHtml(e.body); + for (const name of slots) { + const val = fill[name] || ''; + const idx = slots.indexOf(name); + const cls = idx === active ? 'fill-slot active' : (val ? 'fill-slot filled' : 'fill-slot'); + const width = Math.max(name.length, val.length, 4) * 8 + 16; + content = content.replace(`\${${name}}`, ``); + } + + const allFilled = slots.every(s => fill[s]); + + return `
+
+ ⤓ filling + slot ${active + 1} / ${slots.length} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${content}
+
Tab next⇧Tab prev copyEsc cancel
+
+ + +
+
`; + } + + function renderEditMode(e) { + return `
+
✎ editing
+
${escHtml(e.title || 'untitled')}
+
+
+
+
+
+
+
+
+
+
+
⌘⏎ saveEsc cancel
+
+ + +
+
`; + } + + function bindPeekEvents(e) { + const pane = $('#detail-pane'); + if (!e) return; + + if (state.peekMode === 'run') { + pane.querySelectorAll('.peek-run-step').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.step); + if (!state.runChecked) state.runChecked = new Set(); + if (state.runChecked.has(idx)) state.runChecked.delete(idx); + else state.runChecked.add(idx); + renderDetailPane(); + }); + }); + } + + if (state.peekMode === 'fill') { + pane.querySelectorAll('.fill-slot input').forEach(input => { + input.addEventListener('input', () => { + if (!state.fillValues) state.fillValues = {}; + state.fillValues[input.dataset.slot] = input.value; + }); + input.addEventListener('focus', () => { + state.fillActive = parseInt(input.dataset.idx); + }); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Tab') { + ev.preventDefault(); + const slots = pane.querySelectorAll('.fill-slot input'); + const cur = parseInt(input.dataset.idx); + const next = ev.shiftKey ? Math.max(0, cur - 1) : Math.min(slots.length - 1, cur + 1); + state.fillActive = next; + renderDetailPane(); + setTimeout(() => { + const el = pane.querySelector(`.fill-slot input[data-idx="${next}"]`); + if (el) el.focus(); + }, 0); + } else if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + nibApp.completeFill(); + } else if (ev.key === 'Escape') { + ev.preventDefault(); + nibApp.exitMode(); + } + }); + }); + setTimeout(() => { + const el = pane.querySelector(`.fill-slot input[data-idx="${state.fillActive || 0}"]`); + if (el) el.focus(); + }, 0); + } + + if (state.peekMode === 'edit') { + const bodyTa = pane.querySelector('#edit-body'); + if (bodyTa) { + bodyTa.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); nibApp.saveEdit(e.id); } + if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); } + }); + } + } + + // Double-click to edit (stream peek) + const titleEl = pane.querySelector('.peek-title[data-id]'); + if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); + const bodyEl = pane.querySelector('.peek-body[data-id]'); + if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); } // ========== Inline edit ========== @@ -713,6 +956,10 @@ function selectEntity(idx) { state.selectedIndex = idx; + state.peekMode = 'preview'; + state.runChecked = new Set(); + state.fillValues = {}; + state.fillActive = 0; renderEntityList(); renderDetailPane(); } @@ -932,6 +1179,67 @@ await loadEntities(); selectEntity(state.entities.findIndex(x => x.id === id)); }, + + enterMode(mode) { + state.peekMode = mode; + if (mode === 'run') state.runChecked = new Set(); + if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; } + renderDetailPane(); + }, + + exitMode() { + state.peekMode = 'preview'; + renderDetailPane(); + }, + + resetRun() { + state.runChecked = new Set(); + renderDetailPane(); + }, + + async completeFill() { + const e = state.entities[state.selectedIndex]; + if (!e) return; + let resolved = e.body || ''; + const fill = state.fillValues || {}; + for (const [name, val] of Object.entries(fill)) { + resolved = resolved.replace(new RegExp('\\$\\{' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g'), val); + } + try { + await navigator.clipboard.writeText(resolved); + await api.useEntity(e.id); + state.peekMode = 'preview'; + await loadEntities(); + showToast('copied resolved'); + } catch (err) { + console.error('clipboard:', err); + } + }, + + async saveEdit(id) { + const title = ($('#edit-title') || {}).value || null; + const desc = ($('#edit-desc') || {}).value || null; + const body = ($('#edit-body') || {}).value || ''; + const tagsStr = ($('#edit-tags') || {}).value || ''; + const tags = tagsStr.split(/\s+/).filter(Boolean); + await api.updateEntity(id, { body, title, description: desc, tags }); + state.peekMode = 'preview'; + await loadEntities(); + await loadTags(); + const idx = state.entities.findIndex(x => x.id === id); + if (idx >= 0) selectEntity(idx); + showToast('saved'); + }, + + async togglePin(id) { + const e = state.entities.find(x => x.id === id); + if (!e) return; + await api.updateEntity(id, { pinned: !e.pinned }); + await loadEntities(); + const idx = state.entities.findIndex(x => x.id === id); + if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); } + showToast(e.pinned ? 'unpinned' : 'pinned'); + }, }; // ========== Promote modal ========== @@ -977,6 +1285,13 @@ return; } + if (state.peekMode !== 'preview' && ev.key === 'Escape') { + nibApp.exitMode(); + return; + } + + const sel = state.entities[state.selectedIndex]; + switch (ev.key) { case 'j': ev.preventDefault(); @@ -992,36 +1307,42 @@ ev.preventDefault(); $('#capture-input').focus(); break; - case 'p': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showPromote(e.id); + case 'p': + if (sel && sel.card_type && state.view === 'cards') { + nibApp.togglePin(sel.id); + } else if (sel && !sel.card_type) { + nibApp.showPromote(sel.id); + } break; - } - case 'Enter': { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.copyEntity(e.id); + case 'Enter': + if (sel) nibApp.copyEntity(sel.id); + break; + case 'r': + if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('run'); + break; + case 'f': + if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('fill'); + break; + case 'e': + if (sel && sel.card_type && state.view === 'cards') { + nibApp.enterMode('edit'); + } else { + startEditBody(); + } break; - } case 'd': { const now = Date.now(); if (now - lastDTime < 400) { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.deleteEntity(e.id); + if (sel) nibApp.deleteEntity(sel.id); lastDTime = 0; } else { lastDTime = now; } break; } - case 'e': { - startEditBody(); + case 'a': + if (sel && !sel.card_type) nibApp.showAbsorb(sel.id); break; - } - case 'a': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showAbsorb(e.id); - break; - } case '1': switchView('stream'); break; case '2': switchView('cards'); break; } diff --git a/web/style.css b/web/style.css index 9f37905..21a35be 100644 --- a/web/style.css +++ b/web/style.css @@ -660,7 +660,7 @@ main { letter-spacing: .04em; } -/* ── DETAIL PANE ────────────────────────────────────── */ +/* ── PEEK PANE ──────────────────────────────────────── */ #detail-pane { background: var(--surf); border-left: 1px solid var(--border); @@ -669,10 +669,11 @@ main { overflow: hidden; } -.detail-scroll { +.peek-scroll { flex: 1; overflow-y: auto; - padding: 20px; + display: flex; + flex-direction: column; } .detail-empty { @@ -683,49 +684,283 @@ main { font-family: var(--mono); } -.detail-header { +/* peek idle */ +.peek-idle { + padding: 18px; display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; + flex-direction: column; + gap: 14px; + flex: 1; + overflow-y: auto; } -.detail-glyph { font-size: 16px; } - -.detail-id { +.peek-idle-eyebrow { font-family: var(--mono); - font-size: 10px; - color: var(--dim); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .16em; + color: var(--accent); + margin-bottom: 6px; } -.detail-desc { - font-family: var(--sans); - font-size: 11px; - color: var(--muted); - margin-bottom: 4px; - cursor: text; - padding: 2px 6px; - margin-left: -6px; - border-radius: var(--r2); - transition: background var(--t-fast); -} - -.detail-desc:hover { background: var(--raised); } - -.detail-title { +.peek-idle-title { font-family: var(--sans); font-size: 15px; font-weight: 600; - margin-bottom: 12px; + color: var(--text); + margin-bottom: 4px; +} + +.peek-idle-sub { + font-family: var(--sans); + font-size: 12px; + color: var(--muted); + line-height: 1.55; +} + +.peek-shortcuts { display: flex; flex-direction: column; gap: 10px; } +.peek-sc-sec { margin-bottom: 2px; } +.peek-sc-lbl { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); margin-bottom: 5px; } +.peek-sc-row { display: flex; align-items: center; gap: 5px; padding: 2px 0; font-family: var(--mono); font-size: 11px; color: var(--muted); } +.peek-sc-row span { color: var(--dim); margin-left: 2px; } +.peek-sc-code { font-family: var(--mono); font-size: 10px; color: var(--accent); background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 4px 8px; margin-bottom: 5px; } +.peek-sc-hint { font-family: var(--mono); font-size: 9px; color: var(--dim); padding-bottom: 3px; } +kbd { background: var(--raised); border: 1px solid var(--border); border-radius: 2px; padding: 1px 4px; font-size: 9px; font-family: var(--mono); color: var(--muted); display: inline-block; line-height: 1.4; } + +/* peek eyebrow */ +.peek-brow { + padding: 14px 20px 0; + display: flex; + align-items: center; + gap: 7px; + flex-shrink: 0; + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--dim); +} + +.peek-brow-g { font-size: 13px; margin-right: 1px; flex-shrink: 0; } +.peek-brow-kind { color: var(--muted); } +.peek-brow-sep { color: var(--dim); opacity: .4; } +.peek-brow-id { color: var(--dim); } +.peek-brow-ts { margin-left: auto; color: var(--dim); letter-spacing: 0; text-transform: none; white-space: nowrap; } + +/* peek title / desc / body */ +.peek-title { + padding: 9px 20px 4px; + font-family: var(--sans); + font-size: 15px; + font-weight: 600; + color: var(--text); + line-height: 1.3; + flex-shrink: 0; +} + +.peek-desc { + padding: 0 20px 10px; + font-family: var(--sans); + font-size: 12px; + color: var(--muted); + line-height: 1.55; + flex-shrink: 0; +} + +.peek-body { + padding: 10px 20px 14px; + font-family: var(--mono); + font-size: 13px; + line-height: 1.72; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + flex-shrink: 0; cursor: text; - padding: 2px 6px; - margin-left: -6px; border-radius: var(--r2); transition: background var(--t-fast); } -.detail-title:hover { background: var(--raised); } +.peek-body:hover { background: var(--raised); } +.peek-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + padding: 0 20px 12px; + flex-shrink: 0; +} + +.peek-pin { color: var(--accent); font-size: 11px; } + +/* peek sections */ +.peek-sec { border-top: 1px solid var(--soft); flex-shrink: 0; } +.peek-sec-lbl { + padding: 8px 20px 5px; + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .16em; + color: var(--dim); + display: flex; + align-items: center; + gap: 6px; +} + +.peek-sec-lang { color: var(--accent); letter-spacing: 0; text-transform: none; font-size: 9px; } +.peek-sec-status { + color: var(--ok); + letter-spacing: 0; + text-transform: none; + font-size: 9px; + border: 1px solid rgba(122,171,114,.4); + background: rgba(122,171,114,.06); + padding: 0 6px; + border-radius: var(--r1); +} + +.peek-sec-run { + margin-left: auto; + font-family: var(--mono); + font-size: 9px; + color: var(--ok); + border: 1px solid rgba(122,171,114,.4); + padding: 1px 8px; + border-radius: var(--r1); + transition: background var(--t-fast); +} + +.peek-sec-run:hover { background: rgba(122,171,114,.1); } + +.peek-sec-inner { padding: 0 20px 14px; } +.tag-pills { display: flex; flex-wrap: wrap; gap: 5px; } + +/* peek context */ +.peek-ctx { display: flex; flex-direction: column; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--muted); } +.peek-ctx-lbl { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--dim); margin-right: 5px; } +.peek-ctx-promoted { color: var(--ok); } + +/* peek card container */ +.peek-card { + margin: 12px; + border: 1px solid var(--border); + border-radius: var(--r3); + overflow: hidden; + flex-shrink: 0; +} + +.peek-card-head { + background: var(--bg); + border-bottom: 1px solid var(--soft); + padding-bottom: 0; +} + +.peek-card .peek-sec { border-top-color: var(--border); } +.peek-card .peek-sec-inner { padding: 0 16px 14px; } +.peek-card .peek-sec-lbl { padding: 8px 16px 5px; } + +/* peek code block */ +.peek-code { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r2); + padding: 10px 12px; + overflow-x: auto; +} + +.peek-code pre { + font-family: var(--mono); + font-size: 11px; + line-height: 1.65; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +/* peek steps */ +.peek-steps { display: flex; flex-direction: column; gap: 3px; } +.peek-step { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; font-family: var(--mono); font-size: 11px; line-height: 1.45; } +.peek-step-mark { flex-shrink: 0; margin-top: 1px; } +.peek-step-text { color: var(--text); } + +/* peek decision */ +.peek-decision { padding: 0; } +.peek-dec-choice { font-family: var(--sans); font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 6px; } +.peek-dec-choice::before { content: '▸ '; color: var(--accent); } +.peek-dec-why { font-family: var(--sans); font-size: 12px; color: var(--muted); line-height: 1.55; margin-bottom: 8px; } +.peek-dec-key { color: var(--dim); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; font-family: var(--mono); margin-right: 5px; } +.peek-dec-rejected { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } +.peek-dec-rej { font-family: var(--mono); font-size: 10px; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: var(--r1); text-decoration: line-through; opacity: .6; } + +/* peek link */ +.peek-link-url { + display: flex; + align-items: flex-start; + gap: 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--event); + border: 1px solid var(--soft); + padding: 8px 10px; + border-radius: var(--r2); + background: var(--bg); + word-break: break-all; + line-height: 1.5; +} + +/* peek actions */ +.peek-acts { + display: flex; + gap: 5px; + flex-wrap: wrap; + padding: 12px 20px 18px; + border-top: 1px solid var(--soft); + flex-shrink: 0; +} + +/* peek mode pills */ +.peek-run-pill { font-family: var(--mono); font-size: 9px; color: var(--ok); border: 1px solid rgba(122,171,114,.4); background: rgba(122,171,114,.06); padding: 1px 7px; border-radius: var(--r1); } +.peek-fill-pill { font-family: var(--mono); font-size: 9px; color: var(--lineage); border: 1px solid rgba(152,120,188,.4); background: rgba(152,120,188,.06); padding: 1px 7px; border-radius: var(--r1); } +.peek-edit-pill { font-family: var(--mono); font-size: 9px; color: var(--todo); border: 1px solid rgba(212,168,75,.4); background: rgba(212,168,75,.06); padding: 1px 7px; border-radius: var(--r1); } + +/* run mode */ +.peek-run-prog-wrap { display: flex; align-items: center; gap: 10px; padding: 0 20px 14px; flex-shrink: 0; } +.peek-run-prog-track { flex: 1; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; } +.peek-run-prog { height: 100%; background: var(--ok); border-radius: 2px; transition: width var(--t-base); } +.peek-run-pct { font-family: var(--mono); font-size: 10px; color: var(--ok); min-width: 28px; } +.peek-run-steps { flex-shrink: 0; } +.peek-run-step { display: flex; align-items: flex-start; gap: 10px; padding: 7px 20px; cursor: pointer; border-left: 2px solid transparent; transition: background var(--t-fast), border-left-color var(--t-fast); } +.peek-run-step:hover { background: var(--raised); } +.peek-run-step.done .peek-run-text { text-decoration: line-through; color: var(--dim); } +.peek-run-mark { font-family: var(--mono); font-size: 12px; flex-shrink: 0; margin-top: 1px; } +.peek-run-text { font-family: var(--sans); font-size: 12px; line-height: 1.5; color: var(--text); } + +/* fill mode */ +.peek-fill-canvas { padding: 14px 20px; flex-shrink: 0; } +.peek-fill-canvas code { font-family: var(--mono); font-size: 12px; line-height: 2; color: var(--text); white-space: pre-wrap; word-break: break-word; } + +.fill-slot { display: inline-block; border-bottom: 1.5px solid var(--lineage); } +.fill-slot.active { border-color: var(--accent); border-bottom-width: 2px; } +.fill-slot.filled { border-color: var(--ok); } +.fill-slot input { background: transparent; border: none; outline: none; color: var(--lineage); font-family: var(--mono); font-size: 12px; padding: 0 2px; min-width: 30px; line-height: 2; } +.fill-slot.active input { color: var(--text); } +.fill-slot.filled input { color: var(--ok); } + +/* edit mode */ +.peek-edit-fields { padding: 12px 20px; display: flex; flex-direction: column; gap: 12px; flex-shrink: 0; } +.peek-edit-field { display: flex; flex-direction: column; gap: 4px; } +.peek-edit-lbl { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); } +.peek-edit-in { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 6px 9px; font-family: var(--mono); font-size: 12px; color: var(--text); outline: none; transition: border-color var(--t-fast); } +.peek-edit-in:focus { border-color: var(--accent); } +.peek-edit-ta { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 6px 9px; font-family: var(--mono); font-size: 12px; color: var(--text); outline: none; resize: vertical; min-height: 100px; line-height: 1.55; transition: border-color var(--t-fast); } +.peek-edit-ta:focus { border-color: var(--accent); } + +/* hints bar */ +.peek-hints { display: flex; gap: 12px; padding: 10px 20px; font-family: var(--mono); font-size: 9px; color: var(--dim); border-top: 1px solid var(--soft); flex-shrink: 0; } +.peek-hints span { display: flex; align-items: center; gap: 3px; } + +/* legacy detail support (inline edit) */ .detail-field-edit { display: block; width: 100%; @@ -740,22 +975,6 @@ main { outline: none; } -.detail-body { - font-family: var(--mono); - font-size: 13px; - line-height: 1.72; - margin-bottom: 16px; - white-space: pre-wrap; - word-break: break-word; - cursor: text; - border-radius: var(--r2); - padding: 4px 6px; - margin-left: -6px; - transition: background var(--t-fast); -} - -.detail-body:hover { background: var(--raised); } - .detail-body-edit { display: block; width: 100%; @@ -777,9 +996,8 @@ main { .detail-tags { display: flex; - gap: 6px; + gap: 5px; flex-wrap: wrap; - margin-bottom: 16px; } .detail-tag { @@ -792,14 +1010,6 @@ main { border-radius: var(--r1); } -.detail-actions { - display: flex; - gap: 5px; - flex-wrap: wrap; - border-top: 1px solid var(--soft); - padding-top: 12px; -} - .action-btn { font-family: var(--sans); font-size: 11px; @@ -816,8 +1026,10 @@ main { .action-btn:hover { color: var(--accent); border-color: var(--accent); } .action-btn.primary { color: var(--accent); border-color: var(--accent); background: var(--a-bg); } +.action-btn.dim { opacity: .45; } .action-btn.danger { color: var(--danger); border-color: rgba(184,88,88,.4); } .action-btn.danger:hover { border-color: var(--danger); } +.action-btn kbd { font-size: 9px; background: rgba(0,0,0,.2); border: 1px solid rgba(0,0,0,.3); border-radius: 2px; padding: 0 3px; opacity: .65; } /* ── TEMPLATE SLOTS ─────────────────────────────────── */ .slot-form { margin: 16px 0; } From f26716a9eecf9e90912647a91b9e0a73a8f6c8e9 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 09:37:32 -0400 Subject: [PATCH 09/16] =?UTF-8?q?feat(ui):=20phase=204=20=E2=80=94=20promo?= =?UTF-8?q?te=20modal=20polish,=20TODO=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Promote modal: colored glyphs, type names, hint descriptions per type - Show truncated entry body in promote modal subtitle - Mark all redesign phases complete in TODO.md --- TODO.md | 64 +++++++++++++++++++++++++------------------------- web/app.js | 4 ++++ web/index.html | 26 ++++++++++++-------- web/style.css | 1 + 4 files changed, 53 insertions(+), 42 deletions(-) diff --git a/TODO.md b/TODO.md index a93ac00..e532696 100644 --- a/TODO.md +++ b/TODO.md @@ -1,37 +1,37 @@ # UI Redesign — Design Handoff Implementation -## Phase 1: Layout + Tokens + Header + Rail -- [ ] Update CSS tokens (add --a-str, switch mono font to JetBrains Mono) -- [ ] Fix grid dimensions (192px rail, 400px peek) -- [ ] Move capture bar from header to bottom of center panel -- [ ] Add search bar to header (centered, max-width 400px) -- [ ] Redesign tag rail: grid layout (arrow ▸ + dot + name + count) -- [ ] Add intent section (grab/read/fill) for cards view in rail +## 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 -- [ ] Stream rows: promoted entries get card-style border/radius + card-type badge -- [ ] Card rows: rich single-line with title — preview — affordance badges — tag pills — pin — use count -- [ ] Affordance detection client-side (fill, steps, decide, link, code) -- [ ] Affordance badge components -- [ ] Cards sub-header (scope label + card count + sort dropdown) -- [ ] Section labels (★ pinned, recent) -- [ ] Flash animation on copy -- [ ] Bottom capture bar styling per view (different placeholders) +## 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 -- [ ] Idle state with keyboard shortcuts display -- [ ] Stream entry peek: eyebrow, body, tags, context, actions -- [ ] Card peek: card container with eyebrow, title, desc, meta, content sections -- [ ] Code block with syntax highlighting -- [ ] Decision section display -- [ ] Steps section display -- [ ] Link section display -- [ ] Run mode (interactive checklist with progress bar) -- [ ] Fill mode (inline slot editor with tab navigation) -- [ ] Edit mode (form fields) -- [ ] Toast notifications +## 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 -- [ ] Promote modal enhancement (add hint text per type) -- [ ] Remaining keyboard shortcuts (r=run, f=fill) -- [ ] Scroll behavior and edge cases +## Phase 4: Polish ✓ +- [x] Promote modal enhancement (add hint text per type, show entry body preview) +- [x] Keyboard shortcuts (r=run, f=fill, p=pin in cards view) +- [x] Escape exits active modes diff --git a/web/app.js b/web/app.js index 79b8032..1e5fe57 100644 --- a/web/app.js +++ b/web/app.js @@ -1091,6 +1091,10 @@ modal.classList.add('visible'); modal.dataset.entityId = id; + const sub = $('#promote-sub'); + const label = (e.body || '').slice(0, 64) + ((e.body || '').length > 64 ? '…' : ''); + sub.textContent = label; + const suggested = detectCardType(e.body); $$('.type-btn').forEach(btn => { btn.classList.toggle('suggested', btn.dataset.type === suggested); diff --git a/web/index.html b/web/index.html index c9ede77..42aed17 100644 --- a/web/index.html +++ b/web/index.html @@ -41,26 +41,32 @@