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 }