package parser import ( "bufio" "encoding/json" "errors" "fmt" "io" "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"` } // 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 } // A=9999, Z=0, linear scale return 9999 - int(p-'A')*400 } 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}$`) priorityHead = regexp.MustCompile(`^\([A-Z]\)\s+`) completedHead = regexp.MustCompile(`^x\s+`) spaceRe = regexp.MustCompile(`\s+`) dateLayout = "2006-01-02" // Protocols da escludere come chiave dei metadati protocols = map[string]struct{}{ "http": {}, "https": {}, "ftp": {}, "mailto": {}, } ) // isProtocolKey controlla se la chiave è un protocollo standard. 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) // 1) Completed? if completedHead.MatchString(working) { t.Completed = true working = completedHead.ReplaceAllString(working, "") toks := splitTokens(working) consumed := 0 if len(toks) > 0 && dateRe.MatchString(toks[0]) { if dt, err := time.Parse(dateLayout, toks[0]); err == nil { t.CompletionDate = &dt consumed = 1 } } if consumed < len(toks) && dateRe.MatchString(toks[consumed]) { if dt, err := time.Parse(dateLayout, toks[consumed]); err == nil { t.CreationDate = &dt consumed++ } } if consumed > 0 { working = strings.TrimSpace(strings.Join(toks[consumed:], " ")) } } else { if priorityHead.MatchString(working) { if len(working) >= 3 { r := rune(working[1]) t.Priority = &r } working = priorityHead.ReplaceAllString(working, "") } toks := splitTokens(working) if len(toks) > 0 && dateRe.MatchString(toks[0]) { if dt, err := time.Parse(dateLayout, toks[0]); err == nil { t.CreationDate = &dt working = strings.TrimSpace(strings.Join(toks[1:], " ")) } } } tokens := splitTokens(working) descParts := make([]string, 0, len(tokens)) for _, tok := range tokens { if strings.HasPrefix(tok, "+") && len(tok) > 1 { t.Projects = append(t.Projects, tok[1:]) continue } if strings.HasPrefix(tok, "@") && len(tok) > 1 { t.Contexts = append(t.Contexts, tok[1:]) continue } // Nuova logica per metadati: nessuno spazio, almeno un ':', chiave non protocollo 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 // Se il campo è due:YYYY-MM-DD, parsalo anche come DueDate if k == "due" && dateRe.MatchString(v) { if dt, err := time.Parse(dateLayout, v); err == nil { t.DueDate = &dt } } continue } } // else part of description 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) }