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 e477e8d512
commit c8e18f0bc1
11 changed files with 677 additions and 159 deletions
+8 -1
View File
@@ -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) {
+38 -21
View File
@@ -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
+96
View File
@@ -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)
}
}