BREAKING CHANGE: The Task struct's Metadata field now maps to slices of strings (map[string][]string) instead of single strings. This allows tasks to have multiple values for the same metadata key. All code interacting with Metadata must be updated to handle slices. Version bumped to v3.0.0.
313 lines
7.5 KiB
Go
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" {
|
|
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)
|
|
}
|