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