Files
Paolo Donadeo adbc2cf364 fix(parser): improve priority calculation accuracy using math.Round
Refactored PriorityAsRemind to use floating point division and rounding
for more precise priority mapping.
2025-11-26 16:24:28 +01:00

299 lines
7.1 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, 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(" %<List>:")
}
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)
}