package parser import ( "bufio" "encoding/json" "errors" "fmt" "io" "math" "regexp" "strings" "time" ) // Task represents a parsed todo item. type Task struct { Raw string `json:"raw"` Completed bool `json:"completed"` CompletionDate *time.Time `json:"completion_date,omitempty"` Priority *rune `json:"priority,omitempty"` // 'A'..'Z' CreationDate *time.Time `json:"creation_date,omitempty"` DueDate *time.Time `json:"due_date,omitempty"` // parsed from due:YYYY-MM-DD Description string `json:"description"` // description with projects/contexts/metadata removed Projects []string `json:"projects"` Contexts []string `json:"contexts"` Metadata map[string]string `json:"metadata"` } func (t Task) ToRemind() string { var sb strings.Builder sb.WriteString("REM TODO ") if t.DueDate == nil { return "" } sb.WriteString(t.DueDate.Format("2006-01-02")) sb.WriteString(" ++5 INFO \"Calendar: TODO.TX\" ") if len(t.Metadata) > 0 { for k, v := range t.Metadata { if k == "due" { continue } // k must have the first letter uppercase for Remind k = strings.ToUpper(k[:1]) + k[1:] sb.WriteString(fmt.Sprintf("INFO \"%s: %s\" ", k, v)) } } if len(t.Projects) > 0 { for _, p := range t.Projects { sb.WriteString(fmt.Sprintf("INFO \"List: %s\" ", p)) } } if len(t.Contexts) > 0 { for _, c := range t.Contexts { sb.WriteString(fmt.Sprintf("INFO \"Tag: %s\" ", c)) } } if t.Completed { sb.WriteString(fmt.Sprintf("COMPLETE-THROUGH %s ", t.DueDate.Format("2006-01-02"))) } if t.Priority != nil { sb.WriteString(fmt.Sprintf("PRIORITY %d ", t.PriorityAsRemind())) } sb.WriteString("MSG") if len(t.Projects) > 0 { sb.WriteString(" %:") } sb.WriteString(fmt.Sprintf(" %s", t.Description)) if t.Completed { sb.WriteString("%:") } else { sb.WriteString(" (%b)") } if len(t.Metadata) > 0 { for k := range t.Metadata { if k == "due" { continue } // uppercase first letter for Remind k = strings.ToUpper(k[:1]) + k[1:] sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", k, k)) } } return sb.String() } // PriorityAsRemind returns an integer representing the priority for sorting. // A = 9999 (highest), Z = 0 (lowest), absent = 5000. func (t Task) PriorityAsRemind() int { if t.Priority == nil { return 5000 } p := *t.Priority if p < 'A' || p > 'Z' { return 5000 } pInt := int(p) - 65 // 'A' = 65 value := 9999 - (float64(pInt) * (float64(9999) / float64(25))) value = math.Round(value) return int(value) } func (t Task) MarshalJSON() ([]byte, error) { type Alias Task aux := struct { Alias Priority *string `json:"priority,omitempty"` PriorityValue *int `json:"priority_value,omitempty"` }{ Alias: (Alias)(t), Priority: nil, PriorityValue: nil, } if t.Priority != nil { s := string(*t.Priority) aux.Priority = &s val := t.PriorityAsRemind() aux.PriorityValue = &val } return json.Marshal(aux) } var ( dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) spaceRe = regexp.MustCompile(`\s+`) dateLayout = "2006-01-02" // Protocols to exclude as metadata keys protocols = map[string]struct{}{ "http": {}, "https": {}, "ftp": {}, "mailto": {}, } ) // isProtocolKey checks if the key is a standard protocol. func isProtocolKey(k string) bool { _, found := protocols[strings.ToLower(k)] return found } // ParseReader reads all lines from r and returns parsed tasks. // Blank lines are skipped. func ParseReader(r io.Reader) ([]Task, []error) { var tasks []Task var errs []error sc := bufio.NewScanner(r) lineNo := 0 for sc.Scan() { lineNo++ line := sc.Text() trim := strings.TrimSpace(line) if trim == "" { continue } t, err := ParseLine(line) if err != nil { errs = append(errs, fmt.Errorf("line %d: %w", lineNo, err)) continue } tasks = append(tasks, t) } if err := sc.Err(); err != nil { errs = append(errs, err) } return tasks, errs } // ParseLine parses one line in the todo.txt format and returns a Task. func ParseLine(line string) (Task, error) { t := Task{ Raw: line, Metadata: make(map[string]string), } working := strings.TrimSpace(line) toks := splitTokens(working) i := 0 // 1) Completed? if i < len(toks) && toks[i] == "x" { t.Completed = true i++ // 2) Completion date if i < len(toks) && dateRe.MatchString(toks[i]) { if dt, err := time.Parse(dateLayout, toks[i]); err == nil { t.CompletionDate = &dt i++ } } // 3) Creation date if i < len(toks) && dateRe.MatchString(toks[i]) { if dt, err := time.Parse(dateLayout, toks[i]); err == nil { t.CreationDate = &dt i++ } } } // 4) Priority (can appear after x, dates) if i < len(toks) && len(toks[i]) == 3 && toks[i][0] == '(' && toks[i][2] == ')' && toks[i][1] >= 'A' && toks[i][1] <= 'Z' { r := rune(toks[i][1]) t.Priority = &r i++ } // 5) If not completed, check for priority at start if !t.Completed { if i < len(toks) && len(toks[i]) == 3 && toks[i][0] == '(' && toks[i][2] == ')' && toks[i][1] >= 'A' && toks[i][1] <= 'Z' { r := rune(toks[i][1]) t.Priority = &r i++ } // Creation date for incomplete tasks if i < len(toks) && dateRe.MatchString(toks[i]) { if dt, err := time.Parse(dateLayout, toks[i]); err == nil { t.CreationDate = &dt i++ } } } // 6) Parse remaining tokens descParts := make([]string, 0) for ; i < len(toks); i++ { tok := toks[i] if strings.HasPrefix(tok, "+") && len(tok) > 1 { t.Projects = append(t.Projects, tok[1:]) continue } if strings.HasPrefix(tok, "@") && len(tok) > 1 { if tok != "@todo" { // exclude @todo context t.Contexts = append(t.Contexts, tok[1:]) } continue } // Special case: location:"value with spaces" if strings.HasPrefix(tok, "location:\"") { val := tok[len("location:\""):] for { // If token ends with a quote, we've reached the end if strings.HasSuffix(val, "\"") { val = val[:len(val)-1] break } // Otherwise, append next token i++ if i >= len(toks) { // Unterminated quote, treat as is break } val += " " + toks[i] } t.Metadata["location"] = val continue } if !strings.ContainsAny(tok, " \t") && strings.Contains(tok, ":") { parts := strings.SplitN(tok, ":", 2) k, v := parts[0], parts[1] if k != "" && v != "" && !isProtocolKey(k) { t.Metadata[k] = v if k == "due" && dateRe.MatchString(v) { if dt, err := time.Parse(dateLayout, v); err == nil { t.DueDate = &dt } } continue } } descParts = append(descParts, tok) } t.Description = strings.TrimSpace(strings.Join(descParts, " ")) if t.Completed && t.CompletionDate == nil { return t, errors.New("completed task missing completion date (spec requires completion date directly after 'x')") } return t, nil } // splitTokens splits a line on whitespace preserving token order and ignoring extra spaces. func splitTokens(s string) []string { if s == "" { return nil } return spaceRe.Split(strings.TrimSpace(s), -1) }