package parse import ( "fmt" "strconv" "strings" "time" ) type Result struct { Body string Glyph string Title *string Description *string TimeAnchor *string Tags []string FilterTags []string CardSuffix *string Pin bool Query bool QueryDateFrom *string QueryDateTo *string QueryCardType *string } var validCardTypes = map[string]string{ "card": "snippet", "c": "snippet", "snippet": "snippet", "template": "template", "checklist": "checklist", "decision": "decision", "link": "link", "note": "note", "n": "note", } 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 now := time.Now() for _, tok := range tokens { switch { case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"): tag := strings.ToLower(tok[1:]) r.FilterTags = append(r.FilterTags, tag) case tok == "@today": d := now.Format("2006-01-02") r.QueryDateFrom = &d r.QueryDateTo = &d case tok == "@yesterday": d := now.AddDate(0, 0, -1).Format("2006-01-02") r.QueryDateFrom = &d r.QueryDateTo = &d case tok == "@week": d := now.AddDate(0, 0, -7).Format("2006-01-02") r.QueryDateFrom = &d case tok == "@month": d := now.AddDate(0, -1, 0).Format("2006-01-02") r.QueryDateFrom = &d case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"): if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 { d := now.AddDate(0, 0, -n).Format("2006-01-02") r.QueryDateTo = &d } else { bodyParts = append(bodyParts, tok) } case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"): if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 { d := now.AddDate(0, 0, -n).Format("2006-01-02") r.QueryDateFrom = &d } else { bodyParts = append(bodyParts, tok) } case strings.HasPrefix(tok, "^") && len(tok) > 1: suffix := tok[1:] if ct, ok := validCardTypes[suffix]; ok { r.QueryCardType = &ct } else { bodyParts = append(bodyParts, tok) } default: 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) { seen := map[string]bool{} for _, t := range r.Tags { seen[strings.ToLower(t)] = true } var outLines []string for _, line := range strings.Split(text, "\n") { tokens := strings.Fields(line) var lineParts []string for _, tok := range tokens { switch { case handleFlags && strings.EqualFold(tok, "!pin"): r.Pin = true case strings.HasPrefix(tok, "##") && len(tok) > 2: lineParts = append(lineParts, "#"+tok[2:]) case strings.HasPrefix(tok, "@") && len(tok) > 1: timeStr := tok[1:] if validateTime(timeStr) != nil { lineParts = append(lineParts, tok) } else if r.TimeAnchor != nil { return "", fmt.Errorf("multiple time anchors") } else { r.TimeAnchor = &timeStr } case strings.HasPrefix(tok, "#") && len(tok) > 1: tag := strings.ToLower(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: lineParts = append(lineParts, tok) } } outLines = append(outLines, strings.Join(lineParts, " ")) } return strings.Join(outLines, "\n"), 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 }