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
This commit is contained in:
2026-05-15 20:52:58 -04:00
parent e708ea5c13
commit f5b46585c3
11 changed files with 683 additions and 159 deletions
+109
View File
@@ -507,6 +507,115 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
}
}
func TestCreateEntity_WithTitle(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(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(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(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
+27 -19
View File
@@ -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
+12 -8
View File
@@ -18,6 +18,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"`
@@ -50,14 +52,16 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
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{}