f5b46585c3
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
194 lines
4.2 KiB
Go
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
|
|
}
|