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
11 changed files with 683 additions and 159 deletions
+12 -6
View File
@@ -32,9 +32,11 @@ func runAdd(_ *cobra.Command, args []string) error {
defer store.Close() defer store.Close()
e := &db.Entity{ e := &db.Entity{
Body: parsed.Body, Body: parsed.Body,
Glyph: db.Glyph(parsed.Glyph), Title: parsed.Title,
Tags: parsed.Tags, Description: parsed.Description,
Glyph: db.Glyph(parsed.Glyph),
Tags: parsed.Tags,
} }
if parsed.TimeAnchor != nil { if parsed.TimeAnchor != nil {
e.TimeAnchor = parsed.TimeAnchor e.TimeAnchor = parsed.TimeAnchor
@@ -53,12 +55,16 @@ func runAdd(_ *cobra.Command, args []string) error {
var parts []string var parts []string
parts = append(parts, glyph) 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 { if e.TimeAnchor != nil {
parts = append(parts, " @"+*e.TimeAnchor) parts = append(parts, " @"+*e.TimeAnchor)
} }
for _, tag := range e.Tags { for _, tag := range e.Tags {
parts = append(parts, " #"+tag) parts = append(parts, " #"+tag)
} }
parts = append(parts, " ["+shortID+"]") parts = append(parts, " ["+shortID+"]")
if e.CardType != nil { if e.CardType != nil {
+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 { func mustJSON(v any) []byte {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
return b return b
+27 -19
View File
@@ -10,22 +10,26 @@ import (
) )
type CreateEntityRequest struct { type CreateEntityRequest struct {
Body string `json:"body"` Body string `json:"body"`
Glyph *string `json:"glyph"` Title *string `json:"title"`
TimeAnchor *string `json:"time_anchor"` Description *string `json:"description"`
Tags []string `json:"tags"` Glyph *string `json:"glyph"`
CardType *string `json:"card_type"` TimeAnchor *string `json:"time_anchor"`
CardData *string `json:"card_data"` Tags []string `json:"tags"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
} }
type UpdateEntityRequest struct { type UpdateEntityRequest struct {
Body *string `json:"body"` Body *string `json:"body"`
Glyph *string `json:"glyph"` Title *string `json:"title"`
TimeAnchor *string `json:"time_anchor"` Description *string `json:"description"`
Tags *[]string `json:"tags"` Glyph *string `json:"glyph"`
Pinned *bool `json:"pinned"` TimeAnchor *string `json:"time_anchor"`
CardType *string `json:"card_type"` Tags *[]string `json:"tags"`
CardData *string `json:"card_data"` Pinned *bool `json:"pinned"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
} }
type PromoteRequest struct { type PromoteRequest struct {
@@ -119,8 +123,8 @@ func createEntity(store *db.Store) http.HandlerFunc {
return return
} }
if req.Body == "" { if req.Body == "" && req.Title == nil {
writeError(w, http.StatusBadRequest, "invalid_input", "body is required") writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required")
return return
} }
@@ -134,10 +138,12 @@ func createEntity(store *db.Store) http.HandlerFunc {
} }
e := &db.Entity{ e := &db.Entity{
Body: req.Body, Body: req.Body,
Glyph: glyph, Title: req.Title,
TimeAnchor: req.TimeAnchor, Description: req.Description,
Tags: req.Tags, Glyph: glyph,
TimeAnchor: req.TimeAnchor,
Tags: req.Tags,
} }
if req.CardType != nil { if req.CardType != nil {
@@ -186,6 +192,8 @@ func updateEntity(store *db.Store) http.HandlerFunc {
u := &db.EntityUpdate{} u := &db.EntityUpdate{}
u.Body = req.Body u.Body = req.Body
u.Title = req.Title
u.Description = req.Description
u.Tags = req.Tags u.Tags = req.Tags
u.Pinned = req.Pinned u.Pinned = req.Pinned
u.CardData = req.CardData u.CardData = req.CardData
+12 -8
View File
@@ -18,6 +18,8 @@ type EntityResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"` ModifiedAt string `json:"modified_at"`
Body string `json:"body"` Body string `json:"body"`
Title *string `json:"title"`
Description *string `json:"description"`
Glyph string `json:"glyph"` Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"` TimeAnchor *string `json:"time_anchor"`
CompletedAt *string `json:"completed_at"` 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 { func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{ resp := EntityResponse{
ID: e.ID, ID: e.ID,
CreatedAt: e.CreatedAt.Format(time.RFC3339), CreatedAt: e.CreatedAt.Format(time.RFC3339),
ModifiedAt: e.ModifiedAt.Format(time.RFC3339), ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
Body: e.Body, Body: e.Body,
Glyph: string(e.Glyph), Title: e.Title,
Pinned: e.Pinned, Description: e.Description,
Tags: e.Tags, Glyph: string(e.Glyph),
UseCount: e.UseCount, Pinned: e.Pinned,
Tags: e.Tags,
UseCount: e.UseCount,
} }
if resp.Tags == nil { if resp.Tags == nil {
resp.Tags = []string{} resp.Tags = []string{}
+8 -1
View File
@@ -84,7 +84,14 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
ON 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) { func DefaultPath() (string, error) {
+44 -21
View File
@@ -49,6 +49,8 @@ type Entity struct {
CreatedAt time.Time CreatedAt time.Time
ModifiedAt time.Time ModifiedAt time.Time
Body string Body string
Title *string
Description *string
Glyph Glyph Glyph Glyph
TimeAnchor *string TimeAnchor *string
CompletedAt *time.Time CompletedAt *time.Time
@@ -85,14 +87,16 @@ func DefaultListParams() ListParams {
} }
type EntityUpdate struct { type EntityUpdate struct {
Body *string Body *string
Glyph *Glyph Title *string
TimeAnchor *string Description *string
ClearTime bool Glyph *Glyph
Pinned *bool TimeAnchor *string
CardType *CardType ClearTime bool
CardData *string Pinned *bool
Tags *[]string CardType *CardType
CardData *string
Tags *[]string
} }
func (s *Store) Create(e *Entity) error { func (s *Store) Create(e *Entity) error {
@@ -111,13 +115,16 @@ func (s *Store) Create(e *Entity) error {
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, INSERT INTO entities (id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) glyph, time_anchor, completed_at, pinned, deleted_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, card_type, card_data, use_count, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.ID,
e.CreatedAt.Format(time.RFC3339), e.CreatedAt.Format(time.RFC3339),
e.ModifiedAt.Format(time.RFC3339), e.ModifiedAt.Format(time.RFC3339),
e.Body, e.Body,
e.Title,
e.Description,
string(e.Glyph), string(e.Glyph),
e.TimeAnchor, e.TimeAnchor,
formatTimePtr(e.CompletedAt), formatTimePtr(e.CompletedAt),
@@ -144,14 +151,17 @@ func (s *Store) Get(id string) (*Entity, error) {
var createdAt, modifiedAt string var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int var pinned int
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id, created_at, modified_at, body, glyph, time_anchor, SELECT id, created_at, modified_at, body, title, description,
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at glyph, time_anchor, completed_at, pinned, deleted_at,
card_type, card_data, use_count, last_used_at
FROM entities WHERE id = ?`, id).Scan( FROM entities WHERE id = ?`, id).Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, &e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt, &e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&cardType, &cardData, &e.UseCount, &lastUsedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrNotFound return nil, ErrNotFound
@@ -162,6 +172,8 @@ func (s *Store) Get(id string) (*Entity, error) {
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
e.TimeAnchor = nullToPtr(timeAnchor) e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt) e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0 e.Pinned = pinned != 0
@@ -234,9 +246,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, SELECT e.id, e.created_at, e.modified_at, e.body, e.title, e.description,
e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, e.glyph, e.time_anchor, e.completed_at, e.pinned, e.deleted_at,
e.use_count, e.last_used_at e.card_type, e.card_data, e.use_count, e.last_used_at
FROM entities e FROM entities e
%s %s
ORDER BY %s %s ORDER BY %s %s
@@ -256,18 +268,21 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
var createdAt, modifiedAt string var createdAt, modifiedAt string
var completedAt, deletedAt, lastUsedAt sql.NullString var completedAt, deletedAt, lastUsedAt sql.NullString
var timeAnchor, cardType, cardData sql.NullString var timeAnchor, cardType, cardData sql.NullString
var title, description sql.NullString
var pinned int var pinned int
if err := rows.Scan( if err := rows.Scan(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, &e.ID, &createdAt, &modifiedAt, &e.Body, &title, &description,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.Glyph, &timeAnchor, &completedAt, &pinned, &deletedAt,
&e.UseCount, &lastUsedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
e.Title = nullToPtr(title)
e.Description = nullToPtr(description)
e.TimeAnchor = nullToPtr(timeAnchor) e.TimeAnchor = nullToPtr(timeAnchor)
e.CompletedAt = parseTimePtr(completedAt) e.CompletedAt = parseTimePtr(completedAt)
e.Pinned = pinned != 0 e.Pinned = pinned != 0
@@ -308,6 +323,14 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
sets = append(sets, "body = ?") sets = append(sets, "body = ?")
args = append(args, *u.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 { if u.Glyph != nil {
sets = append(sets, "glyph = ?") sets = append(sets, "glyph = ?")
args = append(args, string(*u.Glyph)) args = append(args, string(*u.Glyph))
+96
View File
@@ -441,3 +441,99 @@ func TestResolve_NotFound(t *testing.T) {
t.Errorf("expected ErrNotFound, got %v", err) t.Errorf("expected ErrNotFound, got %v", err)
} }
} }
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)
}
}
+136 -56
View File
@@ -7,11 +7,13 @@ import (
) )
type Result struct { type Result struct {
Body string Body string
Glyph string Glyph string
TimeAnchor *string Title *string
Tags []string Description *string
CardSuffix *string TimeAnchor *string
Tags []string
CardSuffix *string
} }
var validCardTypes = map[string]string{ var validCardTypes = map[string]string{
@@ -35,61 +37,139 @@ func Parse(input string) (*Result, error) {
Tags: []string{}, Tags: []string{},
} }
tokens := strings.Fields(input) remaining := input
if len(tokens) == 0 {
return nil, fmt.Errorf("empty input")
}
first := tokens[0] if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
switch first { switch remaining[:sp] {
case "-", "▸": case "-", "▸":
r.Glyph = "todo" r.Glyph = "todo"
tokens = tokens[1:] remaining = strings.TrimSpace(remaining[sp+1:])
case "*", "◇": case "*", "◇":
r.Glyph = "event" r.Glyph = "event"
tokens = tokens[1:] remaining = strings.TrimSpace(remaining[sp+1:])
} }
} else {
var bodyParts []string switch remaining {
seen := map[string]bool{} case "-", "▸":
r.Glyph = "todo"
for _, tok := range tokens { remaining = ""
switch { case "*", "◇":
case strings.HasPrefix(tok, "@") && len(tok) > 1: r.Glyph = "event"
timeStr := tok[1:] remaining = ""
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)
} }
} }
r.Body = strings.Join(bodyParts, " ") var titleRaw, descRaw string
if r.Body == "" { 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") return nil, fmt.Errorf("empty body after extracting modifiers")
} }
+45 -24
View File
@@ -13,46 +13,61 @@ func TestParse(t *testing.T) {
input string input string
wantBody string wantBody string
wantGlyph string wantGlyph string
wantTitle *string
wantDesc *string
wantTime *string wantTime *string
wantTags []string wantTags []string
wantCard *string wantCard *string
wantErrSub string wantErrSub string
}{ }{
// Glyph detection // Glyph detection
{"plain note", "hello world", "hello world", "note", 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, ""}, {"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""}, {"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""}, {"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, ""}, {"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
// Time anchor // Time anchor
{"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""}, {"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), nil, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), 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, "invalid time"}, {"invalid hours", "meeting @25:00", "", "", nil, nil, nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"}, {"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"},
// Tags // Tags
{"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""}, {"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""}, {"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""}, {"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""},
// Card suffix // Card suffix
{"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""}, {"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""}, {"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""}, {"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""}, {"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"}, {"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"},
// Combined // Combined
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", sp("15:00"), []string{"ops"}, nil, ""}, {"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, []string{"nginx"}, sp("snippet"), ""}, {"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 // Edge cases
{"empty input", "", "", "", nil, nil, nil, "empty"}, {"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, "empty body"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"}, {"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, "empty"}, {"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -79,6 +94,12 @@ func TestParse(t *testing.T) {
if got.Glyph != tt.wantGlyph { if got.Glyph != tt.wantGlyph {
t.Errorf("glyph: got %q, want %q", 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) { if !ptrEq(got.TimeAnchor, tt.wantTime) {
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime)) t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime))
} }
+133 -24
View File
@@ -115,37 +115,92 @@
input = input.trim(); input = input.trim();
if (!input) return null; if (!input) return null;
const tokens = input.split(/\s+/);
let glyph = 'note'; let glyph = 'note';
let remaining = input;
const first = tokens[0]; const sp = remaining.indexOf(' ');
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); } if (sp >= 0) {
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); } 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 titleRaw = null, descRaw = null, hasTitle = false;
let timeAnchor = null; const lines = remaining.split('\n');
const tags = []; const firstLine = (lines[0] || '').trim();
const seenTags = {};
let cardSuffix = null;
for (const tok of tokens) { if (firstLine.startsWith('|')) {
if (tok.startsWith('@') && tok.length > 1) { hasTitle = true;
timeAnchor = tok.slice(1); const titleContent = firstLine.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) { const descIdx = titleContent.indexOf(' // ');
const tag = tok.slice(1); if (descIdx >= 0) {
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; } titleRaw = titleContent.slice(0, descIdx).trim();
} else if (tok.startsWith('^') && tok.length > 1) { descRaw = titleContent.slice(descIdx + 4).trim();
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else { } 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(' '); let timeAnchor = null, cardSuffix = null;
if (!body) return 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) { function detectCardType(body) {
@@ -258,9 +313,17 @@
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''; const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : ''; const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
let label;
if (e.title) {
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
label = `<span class="entity-title">${escHtml(e.title)}</span>${preview}`;
} else {
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
}
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}"> return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span> <span class="entity-glyph ${gc}">${glyph}</span>
<span class="entity-body">${escHtml(e.body)}</span> ${label}
${time} ${time}
<span class="entity-tags">${tags}</span> <span class="entity-tags">${tags}</span>
<span class="entity-meta">${useBadge}</span> <span class="entity-meta">${useBadge}</span>
@@ -296,18 +359,27 @@
} }
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`; actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
pane.innerHTML = ` pane.innerHTML = `
<div class="detail-header"> <div class="detail-header">
<span class="detail-glyph ${gc}">${glyph}</span> <span class="detail-glyph ${gc}">${glyph}</span>
<span class="detail-id">${shortId}</span> <span class="detail-id">${shortId}</span>
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''} ${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
</div> </div>
${descHtml}
${titleHtml}
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div> <div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
${tags ? `<div class="detail-tags">${tags}</div>` : ''} ${tags ? `<div class="detail-tags">${tags}</div>` : ''}
${cardContent} ${cardContent}
<div class="detail-actions">${actions}</div> <div class="detail-actions">${actions}</div>
`; `;
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'); const bodyEl = pane.querySelector('.detail-body');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); 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 ========== // ========== Actions ==========
function selectEntity(idx) { function selectEntity(idx) {
@@ -565,9 +671,10 @@
list.innerHTML = sources.map(e => { list.innerHTML = sources.map(e => {
const g = displayGlyph(e); const g = displayGlyph(e);
const gc = glyphClass(e); const gc = glyphClass(e);
const label = e.title ? escHtml(e.title) : escHtml(e.body);
return `<div class="absorb-source-item" data-id="${e.id}"> return `<div class="absorb-source-item" data-id="${e.id}">
<span class="entity-glyph ${gc}">${g}</span> <span class="entity-glyph ${gc}">${g}</span>
<span class="entity-body">${escHtml(e.body)}</span> <span class="entity-body">${label}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -615,6 +722,8 @@
glyph: parsed.glyph, glyph: parsed.glyph,
tags: parsed.tags, 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.timeAnchor) data.time_anchor = parsed.timeAnchor;
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix; if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
+61
View File
@@ -309,6 +309,25 @@ main {
.glyph-decision { color: var(--note); } .glyph-decision { color: var(--note); }
.glyph-link { color: var(--danger); } .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 { .entity-body {
flex: 1; flex: 1;
font-family: var(--mono); font-family: var(--mono);
@@ -385,6 +404,48 @@ main {
color: var(--dim); 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 { .detail-body {
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; font-size: 13px;