1 Commits

Author SHA1 Message Date
lerko f5b46585c3 feat: add title and description fields to capture grammar
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
2026-05-15 20:52:58 -04:00
12 changed files with 114 additions and 504 deletions
-151
View File
@@ -1,151 +0,0 @@
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)
}
}
+5 -8
View File
@@ -3,24 +3,21 @@ module github.com/lerko/nib
go 1.24.4
require (
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/atotto/clipboard v0.1.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/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
)
-24
View File
@@ -5,8 +5,6 @@ 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=
@@ -28,37 +26,15 @@ 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=
+44 -115
View File
@@ -25,22 +25,15 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store
}
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)
}
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))
return resp
}
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
t.Helper()
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"body": body,
"tags": tags,
})
@@ -56,7 +49,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
func TestCreateEntity_Note(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"body": "test note",
"tags": []string{"demo"},
})
@@ -83,7 +76,7 @@ func TestCreateEntity_Note(t *testing.T) {
func TestCreateEntity_MissingBody(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(t, srv, "/api/entities", map[string]any{})
resp := postJSON(srv, "/api/entities", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -100,7 +93,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
func TestCreateEntity_InvalidGlyph(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"body": "test",
"glyph": "invalid",
})
@@ -115,10 +108,7 @@ func TestGetEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "test", nil)
resp, err := http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -135,10 +125,7 @@ func TestGetEntity_Success(t *testing.T) {
func TestGetEntity_NotFound(t *testing.T) {
srv, _ := testServer(t)
resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT")
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
@@ -151,10 +138,7 @@ func TestListEntities_Default(t *testing.T) {
createTestEntity(t, srv, "one", nil)
createTestEntity(t, srv, "two", nil)
resp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/entities")
defer resp.Body.Close()
var entities []EntityResponse
@@ -169,10 +153,7 @@ func TestListEntities_FilterTag(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"home"})
resp, err := http.Get(srv.URL + "/api/entities?tag=ops")
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
defer resp.Body.Close()
var entities []EntityResponse
@@ -186,16 +167,13 @@ func TestListEntities_CardsOnly(t *testing.T) {
srv, _ := testServer(t)
createTestEntity(t, srv, "fluid", nil)
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"body": "card",
"card_type": "snippet",
})
resp.Body.Close()
resp, err := http.Get(srv.URL + "/api/entities?cards_only=true")
if err != nil {
t.Fatal(err)
}
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
defer resp.Body.Close()
var entities []EntityResponse
@@ -211,18 +189,12 @@ func TestListEntities_Pagination(t *testing.T) {
createTestEntity(t, srv, "note", nil)
}
resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
var page1 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page1)
resp.Body.Close()
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil {
t.Fatal(err)
}
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
var page2 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page2)
resp.Body.Close()
@@ -239,16 +211,10 @@ func TestUpdateEntity_Body(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "old", nil)
req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
req, _ := 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, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -267,14 +233,8 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
created := createTestEntity(t, srv, "doomed", nil)
// Soft delete
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)
}
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
resp, _ := http.DefaultClient.Do(req)
var delResp DeleteResponse
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
@@ -286,14 +246,7 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Hard delete
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)
}
resp, _ = http.DefaultClient.Do(req)
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -304,10 +257,7 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Gone
resp, err = http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
@@ -318,7 +268,7 @@ func TestPromoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
})
defer resp.Body.Close()
@@ -338,11 +288,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "template",
})
defer resp.Body.Close()
@@ -362,7 +312,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "bogus",
})
defer resp.Body.Close()
@@ -376,11 +326,11 @@ func TestDemoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -398,7 +348,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -410,7 +360,7 @@ func TestUseEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -429,10 +379,7 @@ func TestListTags_WithCounts(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
resp, err := http.Get(srv.URL + "/api/tags")
if err != nil {
t.Fatal(err)
}
resp, _ := http.Get(srv.URL + "/api/tags")
defer resp.Body.Close()
var tags []TagResponse
@@ -444,23 +391,14 @@ func TestListTags_WithCounts(t *testing.T) {
func TestCORS_DevMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
store, err := db.Open(path)
if err != nil {
t.Fatal(err)
}
store, _ := db.Open(path)
defer store.Close()
router := NewRouter(store, true)
srv := httptest.NewServer(router)
defer srv.Close()
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)
}
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
@@ -474,14 +412,8 @@ func TestCORS_DevMode(t *testing.T) {
func TestCORS_ProdMode(t *testing.T) {
srv, _ := testServer(t) // devMode=false
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)
}
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
@@ -494,7 +426,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(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -513,10 +445,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
}
// Source should be soft-deleted (not in default list)
listResp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
listResp, _ := http.Get(srv.URL + "/api/entities")
var entities []EntityResponse
json.NewDecoder(listResp.Body).Decode(&entities)
listResp.Body.Close()
@@ -532,11 +461,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
target := createTestEntity(t, srv, "target", nil)
source := createTestEntity(t, srv, "source", nil)
postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{
postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -556,7 +485,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "self", nil)
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
"source_id": e.ID,
})
defer resp.Body.Close()
@@ -570,7 +499,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "target", nil)
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -581,7 +510,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
func TestCreateEntity_WithTitle(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"body": "body text",
"title": "nginx trick",
"description": "always forget this",
@@ -607,7 +536,7 @@ func TestCreateEntity_TitleOnly(t *testing.T) {
srv, _ := testServer(t)
title := "title only"
resp := postJSON(t, srv, "/api/entities", map[string]any{
resp := postJSON(srv, "/api/entities", map[string]any{
"title": title,
})
defer resp.Body.Close()
@@ -669,7 +598,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
srv, _ := testServer(t)
title := "list title"
postJSON(t, srv, "/api/entities", map[string]any{
postJSON(srv, "/api/entities", map[string]any{
"body": "body",
"title": title,
}).Body.Close()
+14 -14
View File
@@ -104,7 +104,7 @@ func listEntities(store *db.Store) http.HandlerFunc {
entities, err := store.List(p)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
@@ -157,7 +157,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
}
if err := store.Create(e); err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
@@ -174,7 +174,7 @@ func getEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -223,13 +223,13 @@ func updateEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -249,7 +249,7 @@ func deleteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
label := "soft"
@@ -287,13 +287,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -313,13 +313,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -357,13 +357,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -379,13 +379,13 @@ func useEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
e, err := store.Get(id)
if err != nil {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
-9
View File
@@ -2,15 +2,12 @@ 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"`
@@ -46,7 +43,6 @@ 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
@@ -54,11 +50,6 @@ 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,
+1 -1
View File
@@ -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 {
writeInternalError(w, err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
+49 -63
View File
@@ -148,13 +148,21 @@ func (s *Store) Create(e *Entity) error {
func (s *Store) Get(id string) (*Entity, error) {
e := &Entity{}
row := newEntityRow()
var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int
err := s.db.QueryRow(`
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)...)
FROM entities WHERE id = ?`, id).Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&cardType, &cardData, &e.UseCount, &lastUsedAt,
)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -162,9 +170,17 @@ func (s *Store) Get(id string) (*Entity, error) {
return nil, err
}
if err := row.apply(e); err != nil {
return nil, fmt.Errorf("scan entity %s: %w", id, err)
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0
e.DeletedAt = parseTimePtr(deletedAt)
e.CardType = nullToCardType(cardType)
e.CardData = nullToPtr(cardData)
e.LastUsedAt = parseTimePtr(lastUsedAt)
tags, err := s.loadTags(id)
if err != nil {
@@ -249,18 +265,34 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
var entities []*Entity
for rows.Next() {
e := &Entity{}
row := newEntityRow()
if err := rows.Scan(row.ptrs(e)...); err != nil {
return nil, err
}
if err := row.apply(e); err != nil {
var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int
if err := rows.Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&cardType, &cardData, &e.UseCount, &lastUsedAt,
); err != nil {
return nil, err
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
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
@@ -493,9 +525,6 @@ 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:
@@ -507,44 +536,6 @@ func (s *Store) Resolve(prefix string) (string, error) {
}
}
type entityRow struct {
createdAt, modifiedAt string
completedAt, deletedAt, lastUsedAt sql.NullString
timeAnchor, cardType, cardData sql.NullString
title, description sql.NullString
pinned int
}
func newEntityRow() *entityRow { return &entityRow{} }
func (r *entityRow) ptrs(e *Entity) []any {
return []any{
&e.ID, &r.createdAt, &r.modifiedAt, &e.Body, &r.title, &r.description,
&e.Glyph, &r.timeAnchor, &r.completedAt, &r.pinned, &r.deletedAt,
&r.cardType, &r.cardData, &e.UseCount, &r.lastUsedAt,
}
}
func (r *entityRow) apply(e *Entity) error {
var err error
if e.CreatedAt, err = time.Parse(time.RFC3339, r.createdAt); err != nil {
return fmt.Errorf("created_at: %w", err)
}
if e.ModifiedAt, err = time.Parse(time.RFC3339, r.modifiedAt); err != nil {
return fmt.Errorf("modified_at: %w", err)
}
e.Title = nullToPtr(r.title)
e.Description = nullToPtr(r.description)
e.TimeAnchor = nullToPtr(r.timeAnchor)
e.CompletedAt = parseTimePtr(r.completedAt)
e.Pinned = r.pinned != 0
e.DeletedAt = parseTimePtr(r.deletedAt)
e.CardType = nullToCardType(r.cardType)
e.CardData = nullToPtr(r.cardData)
e.LastUsedAt = parseTimePtr(r.lastUsedAt)
return nil
}
// helpers
func (s *Store) batchLoadTags(entities []*Entity) error {
@@ -600,9 +591,6 @@ 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{}
}
@@ -666,13 +654,11 @@ func boolToInt(b bool) int {
return 0
}
func (e *Entity) CardDataJSON() (map[string]interface{}, error) {
func (e *Entity) CardDataJSON() map[string]interface{} {
if e.CardData == nil {
return nil, nil
return nil
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(*e.CardData), &m); err != nil {
return nil, fmt.Errorf("card_data: %w", err)
}
return m, nil
json.Unmarshal([]byte(*e.CardData), &m)
return m
}
-31
View File
@@ -442,37 +442,6 @@ func TestResolve_NotFound(t *testing.T) {
}
}
func TestAbsorb_SourceIsCard(t *testing.T) {
s := testStore(t)
target := &Entity{Body: "target", Glyph: GlyphNote, Tags: []string{"a"}}
s.Create(target)
source := &Entity{Body: "source", Glyph: GlyphNote}
s.Create(source)
s.Promote(source.ID, CardSnippet, nil)
s.IncrementUse(source.ID)
if err := s.Absorb(target.ID, source.ID); err != nil {
t.Fatal(err)
}
got, _ := s.Get(target.ID)
if got.Body != "target\nsource" {
t.Errorf("merged body: %q", got.Body)
}
src, _ := s.Get(source.ID)
if src.CardType != nil {
t.Error("source card_type should be cleared after absorb")
}
if src.UseCount != 0 {
t.Errorf("source use_count should be reset, got %d", src.UseCount)
}
if src.DeletedAt == nil {
t.Error("source should be soft-deleted")
}
}
func TestCreate_WithTitleAndDescription(t *testing.T) {
s := testStore(t)
e := &Entity{
-3
View File
@@ -26,9 +26,6 @@ func (s *Store) ListTags() ([]TagCount, error) {
}
tags = append(tags, tc)
}
if err := rows.Err(); err != nil {
return nil, err
}
if tags == nil {
tags = []TagCount{}
}
-80
View File
@@ -1,80 +0,0 @@
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)
}
}
+1 -5
View File
@@ -421,7 +421,7 @@
</div>`;
case 'link':
if (data.url && isSafeUrl(data.url)) {
if (data.url) {
return `<div style="margin-bottom:12px">
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
</div>`;
@@ -863,10 +863,6 @@
return escHtml(s).replace(/'/g, '&#39;');
}
function isSafeUrl(url) {
return /^https?:\/\//i.test(url);
}
// ========== Theme ==========
const themeToggle = $('#theme-toggle');