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.
323 lines
7.7 KiB
Go
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)
|
|
}
|