Implements Task.ToRemind(), which generates a Remind-formatted string including due date, metadata, projects, contexts, completion status, and priority.
300 lines
7.0 KiB
Go
300 lines
7.0 KiB
Go
package parser
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
}
|
|
step := 9999 / 25 // 399
|
|
val := 9999 - int(p-'A')*step
|
|
if p == 'Z' {
|
|
return 0
|
|
}
|
|
return val
|
|
}
|
|
|
|
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)
|
|
}
|