Files
nib-v1/internal/parse/grammar.go
T
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

194 lines
4.2 KiB
Go

package parse
import (
"fmt"
"strconv"
"strings"
)
type Result struct {
Body string
Glyph string
Title *string
Description *string
TimeAnchor *string
Tags []string
CardSuffix *string
}
var validCardTypes = map[string]string{
"card": "snippet",
"c": "snippet",
"snippet": "snippet",
"template": "template",
"checklist": "checklist",
"decision": "decision",
"link": "link",
}
func Parse(input string) (*Result, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, fmt.Errorf("empty input")
}
r := &Result{
Glyph: "note",
Tags: []string{},
}
remaining := input
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 = ""
}
}
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")
}
return r, nil
}
func validateTime(s string) error {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("expected HH:MM format")
}
h, err := strconv.Atoi(parts[0])
if err != nil || h < 0 || h > 23 {
return fmt.Errorf("hours must be 0-23")
}
m, err := strconv.Atoi(parts[1])
if err != nil || m < 0 || m > 59 {
return fmt.Errorf("minutes must be 0-59")
}
return nil
}