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:
+8
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user