Files
todotxt2remind/internal/parser/parser.go
Paolo Donadeo 096216579d fix(parser): warn instead of silently dropping tasks with invalid due dates
A malformed due: value (e.g. due:2024-13-99) left DueDate nil, causing
ToRemind to silently emit nothing for that task. Now ParseLine reports
an error so the problem surfaces on stderr instead of vanishing.

Also fixes staticcheck QF1012 (WriteString(Sprintf(...)) -> Fprintf)
flagged by the IDE in ToRemind.
2026-07-09 23:55:18 +02:00

323 lines
7.7 KiB
Go

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(indent int) 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, values := range t.Metadata {
if k == "due" {
continue
}
// k must have the first letter uppercase for Remind
kUpper := strings.ToUpper(k[:1]) + k[1:]
if len(values) == 1 {
fmt.Fprintf(&sb, "INFO \"%s: %s\" ", kUpper, values[0])
} else {
for i, v := range values {
fmt.Fprintf(&sb, "INFO \"%s%d: %s\" ", kUpper, i+1, v)
}
}
}
}
if len(t.Projects) > 0 {
for _, p := range t.Projects {
fmt.Fprintf(&sb, "INFO \"List: %s\" ", p)
}
}
if len(t.Contexts) > 0 {
for _, c := range t.Contexts {
fmt.Fprintf(&sb, "INFO \"Tag: %s\" ", c)
}
}
if t.Completed {
fmt.Fprintf(&sb, "COMPLETE-THROUGH %s ", t.DueDate.Format("2006-01-02"))
}
if t.Priority != nil {
fmt.Fprintf(&sb, "PRIORITY %d ", t.PriorityAsRemind())
}
sb.WriteString("MSG")
if len(t.Projects) > 0 {
sb.WriteString(" %<List>:")
}
fmt.Fprintf(&sb, " %s", t.Description)
if t.Completed {
sb.WriteString("%:")
} else {
sb.WriteString(" (%b)")
}
headingSpaces := strings.Repeat(" ", indent)
if len(t.Metadata) > 0 {
for k, values := range t.Metadata {
if k == "due" {
continue
}
// uppercase first letter for Remind
kUpper := strings.ToUpper(k[:1]) + k[1:]
if len(values) == 1 {
fmt.Fprintf(&sb, "%%_%s%s: %%<%s>", headingSpaces, kUpper, kUpper)
} else {
for i := range values {
kNumbered := fmt.Sprintf("%s%d", kUpper, i+1)
fmt.Fprintf(&sb, "%%_%s%s: %%<%s>", headingSpaces, kNumbered, kNumbered)
}
}
}
}
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
var invalidDue string
// 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"] = append(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] = append(t.Metadata[k], v)
if k == "due" {
dt, err := time.Parse(dateLayout, v)
if dateRe.MatchString(v) && err == nil {
t.DueDate = &dt
} else {
invalidDue = v
}
}
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')")
}
if invalidDue != "" {
return t, fmt.Errorf("invalid due date %q: expected format YYYY-MM-DD (task will be dropped, no Remind entry generated)", invalidDue)
}
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)
}