Files
todotxt2remind/internal/parser/parser.go
Paolo Donadeo ec9df87d68 fix(parser): exclude "url" metadata from Remind output (since version 06.02.02 of remind)
Previously, only "due" metadata was excluded when generating Remind
output. This change also skips "url" metadata, preventing it from
appearing in the Remind string.

Version 06.02.02 of remind supports "url" metadata.
2026-01-11 21:54:39 +01:00

313 lines
7.5 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() 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 {
sb.WriteString(fmt.Sprintf("INFO \"%s: %s\" ", kUpper, values[0]))
} else {
for i, v := range values {
sb.WriteString(fmt.Sprintf("INFO \"%s%d: %s\" ", kUpper, i+1, 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(" %<List>:")
}
sb.WriteString(fmt.Sprintf(" %s", t.Description))
if t.Completed {
sb.WriteString("%:")
} else {
sb.WriteString(" (%b)")
}
if len(t.Metadata) > 0 {
for k, values := range t.Metadata {
if k == "due" || k == "url" {
continue
}
// uppercase first letter for Remind
kUpper := strings.ToUpper(k[:1]) + k[1:]
if len(values) == 1 {
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", kUpper, kUpper))
} else {
for i := range values {
kNumbered := fmt.Sprintf("%s%d", kUpper, i+1)
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", 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
// 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" && 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)
}