Files
todotxt2remind/internal/parser/parser.go
Paolo Donadeo 57ac41ff72 fix(parser): correct priority calculation for 'Z' to return 0
Previously, the priority calculation did not handle 'Z' correctly,
resulting in a non-zero value. Now, 'Z' returns 0 as intended, and the
step size is calculated for a linear scale.
2025-11-25 00:40:16 +01:00

205 lines
5.2 KiB
Go

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
}
step := 9999 / 25 // 399
val := 9999 - int(p-'A')*step
if p == 'Z' {
return 0
}
return val
}
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 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)
// 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
}
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 the field is due:YYYY-MM-DD, also parse it as 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)
}