97ad71d66b
Kind prefixes now follow the canonical grammar: `-` for todo, `@time` for event, `!time` for reminder. Removed `*`/`◇`/`▸` as capture aliases (display-layer only). Added `\` escape prefix, `?` query mode, `!pin` flag extraction, `##word` hash escape, and tag lowercasing. Both parsers produce identical results.
286 lines
7.0 KiB
Go
286 lines
7.0 KiB
Go
package parse
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type Result struct {
|
|
Body string
|
|
Glyph string
|
|
Title *string
|
|
Description *string
|
|
TimeAnchor *string
|
|
Tags []string
|
|
FilterTags []string
|
|
CardSuffix *string
|
|
Pin bool
|
|
Query bool
|
|
}
|
|
|
|
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
|
|
|
|
// Step 1: Escape check — `\` prefix → thought, no prefix detection
|
|
if strings.HasPrefix(remaining, `\`) {
|
|
remaining = remaining[1:]
|
|
r.Glyph = "note"
|
|
clean, err := extractModifiers(r, remaining, false)
|
|
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
|
|
}
|
|
|
|
// Step 2: Query check — `?` prefix → search mode
|
|
if strings.HasPrefix(remaining, "?") {
|
|
remaining = strings.TrimSpace(remaining[1:])
|
|
r.Query = true
|
|
r.Glyph = ""
|
|
tokens := strings.Fields(remaining)
|
|
var bodyParts []string
|
|
for _, tok := range tokens {
|
|
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
|
|
tag := strings.ToLower(tok[1:])
|
|
r.FilterTags = append(r.FilterTags, tag)
|
|
} else {
|
|
bodyParts = append(bodyParts, tok)
|
|
}
|
|
}
|
|
r.Body = strings.Join(bodyParts, " ")
|
|
return r, nil
|
|
}
|
|
|
|
// Step 3: Kind prefix — `-`, `@time`, `!time`
|
|
// `@` and `!` are kind prefixes ONLY if followed by a valid time token.
|
|
// Otherwise the input is treated as a plain note.
|
|
if strings.HasPrefix(remaining, "- ") {
|
|
r.Glyph = "todo"
|
|
remaining = strings.TrimSpace(remaining[2:])
|
|
} else if remaining == "-" {
|
|
r.Glyph = "todo"
|
|
remaining = ""
|
|
} else if strings.HasPrefix(remaining, "@") {
|
|
if rest, ok := tryPrefixTime(r, remaining[1:]); ok {
|
|
r.Glyph = "event"
|
|
remaining = rest
|
|
}
|
|
} else if strings.HasPrefix(remaining, "!") {
|
|
afterBang := remaining[1:]
|
|
// `!pin` is a flag, not a reminder prefix
|
|
firstWord := ""
|
|
if fields := strings.Fields(afterBang); len(fields) > 0 {
|
|
firstWord = fields[0]
|
|
}
|
|
if !strings.EqualFold(firstWord, "pin") {
|
|
if rest, ok := tryPrefixTime(r, afterBang); ok {
|
|
r.Glyph = "reminder"
|
|
remaining = rest
|
|
}
|
|
}
|
|
}
|
|
|
|
// Steps 4-5: Title and description extraction
|
|
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]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
|
|
if hasTitle {
|
|
clean, err := extractModifiers(r, titleRaw, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if clean != "" {
|
|
r.Title = &clean
|
|
}
|
|
}
|
|
if descRaw != "" {
|
|
clean, err := extractModifiers(r, descRaw, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if clean != "" {
|
|
r.Description = &clean
|
|
}
|
|
}
|
|
|
|
clean, err := extractModifiers(r, remaining, true)
|
|
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
|
|
}
|
|
|
|
// tryPrefixTime attempts to extract a time token from the start of text.
|
|
// Returns (remaining text, true) on success, or ("", false) if no valid time found.
|
|
func tryPrefixTime(r *Result, text string) (string, bool) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return "", false
|
|
}
|
|
sp := strings.IndexByte(text, ' ')
|
|
var timeStr, rest string
|
|
if sp >= 0 {
|
|
timeStr = text[:sp]
|
|
rest = strings.TrimSpace(text[sp+1:])
|
|
} else {
|
|
timeStr = text
|
|
rest = ""
|
|
}
|
|
if validateTime(timeStr) != nil {
|
|
return "", false
|
|
}
|
|
r.TimeAnchor = &timeStr
|
|
return rest, true
|
|
}
|
|
|
|
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
|
|
// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts).
|
|
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
|
|
tokens := strings.Fields(text)
|
|
var parts []string
|
|
seen := map[string]bool{}
|
|
for _, t := range r.Tags {
|
|
seen[strings.ToLower(t)] = true
|
|
}
|
|
|
|
for _, tok := range tokens {
|
|
switch {
|
|
// !pin flag
|
|
case handleFlags && strings.EqualFold(tok, "!pin"):
|
|
r.Pin = true
|
|
|
|
// ##word escape → literal #word in body
|
|
case strings.HasPrefix(tok, "##") && len(tok) > 2:
|
|
parts = append(parts, "#"+tok[2:])
|
|
|
|
// @time inline time anchor (for todos: sets due_at)
|
|
// If not valid time format, keep as body text
|
|
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
|
timeStr := tok[1:]
|
|
if validateTime(timeStr) != nil {
|
|
parts = append(parts, tok)
|
|
} else if r.TimeAnchor != nil {
|
|
return "", fmt.Errorf("multiple time anchors")
|
|
} else {
|
|
r.TimeAnchor = &timeStr
|
|
}
|
|
|
|
// #tag extraction (lowercased, deduplicated)
|
|
case strings.HasPrefix(tok, "#") && len(tok) > 1:
|
|
tag := strings.ToLower(tok[1:])
|
|
if !seen[tag] {
|
|
r.Tags = append(r.Tags, tag)
|
|
seen[tag] = true
|
|
}
|
|
|
|
// ^card suffix (kept for now, spec says remove later)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|