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{}
+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) {
+44 -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,14 +151,17 @@ func (s *Store) Get(id string) (*Entity, error) {
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, 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(
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
&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,6 +172,8 @@ func (s *Store) Get(id string) (*Entity, error) {
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
@@ -234,9 +246,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
@@ -256,18 +268,21 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
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, &e.Glyph, &timeAnchor,
&completedAt, &pinned, &deletedAt, &cardType, &cardData,
&e.UseCount, &lastUsedAt,
&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
@@ -308,6 +323,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))
+96
View File
@@ -441,3 +441,99 @@ func TestResolve_NotFound(t *testing.T) {
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 {
Body string
Glyph string
TimeAnchor *string
Tags []string
CardSuffix *string
Body string
Glyph string
Title *string
Description *string
TimeAnchor *string
Tags []string
CardSuffix *string
}
var validCardTypes = map[string]string{
@@ -35,61 +37,139 @@ func Parse(input string) (*Result, error) {
Tags: []string{},
}
tokens := strings.Fields(input)
if len(tokens) == 0 {
return nil, fmt.Errorf("empty input")
}
remaining := input
first := tokens[0]
switch first {
case "-", "▸":
r.Glyph = "todo"
tokens = tokens[1:]
case "*", "◇":
r.Glyph = "event"
tokens = tokens[1:]
}
var bodyParts []string
seen := map[string]bool{}
for _, tok := range tokens {
switch {
case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:]
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)
if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
switch remaining[:sp] {
case "-", "▸":
r.Glyph = "todo"
remaining = strings.TrimSpace(remaining[sp+1:])
case "*", "◇":
r.Glyph = "event"
remaining = strings.TrimSpace(remaining[sp+1:])
}
} else {
switch remaining {
case "-", "▸":
r.Glyph = "todo"
remaining = ""
case "*", "◇":
r.Glyph = "event"
remaining = ""
}
}
r.Body = strings.Join(bodyParts, " ")
if r.Body == "" {
var titleRaw, descRaw string
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")
}
+45 -24
View File
@@ -13,46 +13,61 @@ func TestParse(t *testing.T) {
input string
wantBody string
wantGlyph string
wantTitle *string
wantDesc *string
wantTime *string
wantTags []string
wantCard *string
wantErrSub string
}{
// Glyph detection
{"plain note", "hello world", "hello world", "note", nil, nil, nil, ""},
{"dash todo", "- deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", 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, nil, nil, ""},
{"unicode todo", "▸ deploy nginx", "deploy nginx", "todo", nil, nil, nil, nil, nil, ""},
{"star event", "* dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
{"unicode event", "◇ dentist", "dentist", "event", nil, nil, nil, nil, nil, ""},
// Time anchor
{"with time", "meeting @14:00", "meeting", "note", sp("14:00"), nil, nil, ""},
{"time at start", "@9:30 standup", "standup", "note", sp("9:30"), nil, nil, ""},
{"invalid hours", "meeting @25:00", "", "", nil, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, "invalid time"},
{"with time", "meeting @14:00", "meeting", "note", nil, nil, sp("14:00"), 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, nil, nil, "invalid time"},
{"invalid minutes", "meeting @14:60", "", "", nil, nil, nil, nil, nil, "invalid time"},
// Tags
{"single tag", "deploy #ops", "deploy", "note", nil, []string{"ops"}, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, []string{"ops", "infra"}, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, []string{"ops"}, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, []string{"dev-ops"}, nil, ""},
{"single tag", "deploy #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"multiple tags", "deploy #ops #infra", "deploy", "note", nil, nil, nil, []string{"ops", "infra"}, nil, ""},
{"duplicate tags", "deploy #ops #ops", "deploy", "note", nil, nil, nil, []string{"ops"}, nil, ""},
{"tag with hyphen", "task #dev-ops", "task", "note", nil, nil, nil, []string{"dev-ops"}, nil, ""},
// Card suffix
{"caret card", "trick #nginx ^card", "trick", "note", nil, []string{"nginx"}, sp("snippet"), ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, sp("snippet"), ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, sp("template"), ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, "invalid card type"},
{"caret card", "trick #nginx ^card", "trick", "note", nil, nil, nil, []string{"nginx"}, sp("snippet"), ""},
{"caret c", "trick ^c", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"caret template", "deploy ${host} ^template", "deploy ${host}", "note", nil, nil, nil, nil, sp("template"), ""},
{"caret snippet explicit", "trick ^snippet", "trick", "note", nil, nil, nil, nil, sp("snippet"), ""},
{"invalid card type", "thing ^bogus", "", "", nil, nil, nil, nil, nil, "invalid card type"},
// Combined
{"full input", "- deploy nginx to staging @15:00 #ops", "deploy nginx to staging", "todo", 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 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, 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
{"empty input", "", "", "", nil, nil, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, "empty"},
{"empty input", "", "", "", nil, nil, nil, nil, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, nil, nil, "empty body"},
{"only modifiers", "#ops @14:00", "", "", nil, nil, nil, nil, nil, "empty body"},
{"whitespace only", " ", "", "", nil, nil, nil, nil, nil, "empty"},
}
for _, tt := range tests {
@@ -79,6 +94,12 @@ func TestParse(t *testing.T) {
if 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) {
t.Errorf("time_anchor: got %v, want %v", strPtr(got.TimeAnchor), strPtr(tt.wantTime))
}