diff --git a/cmd/add.go b/cmd/add.go index 9e7cb8a..74edfb9 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -32,9 +32,11 @@ func runAdd(_ *cobra.Command, args []string) error { defer store.Close() e := &db.Entity{ - Body: parsed.Body, - Glyph: db.Glyph(parsed.Glyph), - Tags: parsed.Tags, + Body: parsed.Body, + Title: parsed.Title, + Description: parsed.Description, + Glyph: db.Glyph(parsed.Glyph), + Tags: parsed.Tags, } if parsed.TimeAnchor != nil { e.TimeAnchor = parsed.TimeAnchor @@ -53,12 +55,16 @@ func runAdd(_ *cobra.Command, args []string) error { var parts []string 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 { - parts = append(parts, " @"+*e.TimeAnchor) + parts = append(parts, " @"+*e.TimeAnchor) } for _, tag := range e.Tags { - parts = append(parts, " #"+tag) + parts = append(parts, " #"+tag) } parts = append(parts, " ["+shortID+"]") if e.CardType != nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 303e508..382bb53 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 diff --git a/internal/api/entities.go b/internal/api/entities.go index 592bb2e..b2cdfb7 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -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 diff --git a/internal/api/helpers.go b/internal/api/helpers.go index d9723ad..b435805 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -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{} diff --git a/internal/db/db.go b/internal/db/db.go index 46795af..15530d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) { diff --git a/internal/db/entities.go b/internal/db/entities.go index fbc180f..372d2ef 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -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)) diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go index 19ef9e2..749351f 100644 --- a/internal/db/entities_test.go +++ b/internal/db/entities_test.go @@ -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) + } +} diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index 879aec5..a7a8024 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -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") } diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 2262e9e..46ec311 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -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)) } diff --git a/web/app.js b/web/app.js index 1db5e94..cebb877 100644 --- a/web/app.js +++ b/web/app.js @@ -115,37 +115,92 @@ input = input.trim(); if (!input) return null; - const tokens = input.split(/\s+/); let glyph = 'note'; + let remaining = input; - const first = tokens[0]; - if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); } - else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); } + const sp = remaining.indexOf(' '); + if (sp >= 0) { + 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 timeAnchor = null; - const tags = []; - const seenTags = {}; - let cardSuffix = null; + let titleRaw = null, descRaw = null, hasTitle = false; + const lines = remaining.split('\n'); + const firstLine = (lines[0] || '').trim(); - 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]; + if (firstLine.startsWith('|')) { + hasTitle = true; + const titleContent = firstLine.slice(1); + const descIdx = titleContent.indexOf(' // '); + if (descIdx >= 0) { + titleRaw = titleContent.slice(0, descIdx).trim(); + descRaw = titleContent.slice(descIdx + 4).trim(); } 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(' '); - if (!body) return null; + let timeAnchor = null, cardSuffix = 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) { @@ -258,9 +313,17 @@ const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; + let label; + if (e.title) { + const preview = e.body ? `${escHtml(e.body)}` : ''; + label = `${escHtml(e.title)}${preview}`; + } else { + label = `${escHtml(e.body)}`; + } + return `