Previously, the priority calculation did not handle 'Z' correctly, resulting in a non-zero value. Now, 'Z' returns 0 as intended, and the step size is calculated for a linear scale.
205 lines
5.2 KiB
Go
205 lines
5.2 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"`
|
|
}
|
|
|
|
// 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}$`)
|
|
priorityHead = regexp.MustCompile(`^\([A-Z]\)\s+`)
|
|
completedHead = regexp.MustCompile(`^x\s+`)
|
|
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)
|
|
|
|
// 1) Completed?
|
|
if completedHead.MatchString(working) {
|
|
t.Completed = true
|
|
working = completedHead.ReplaceAllString(working, "")
|
|
toks := splitTokens(working)
|
|
consumed := 0
|
|
if len(toks) > 0 && dateRe.MatchString(toks[0]) {
|
|
if dt, err := time.Parse(dateLayout, toks[0]); err == nil {
|
|
t.CompletionDate = &dt
|
|
consumed = 1
|
|
}
|
|
}
|
|
if consumed < len(toks) && dateRe.MatchString(toks[consumed]) {
|
|
if dt, err := time.Parse(dateLayout, toks[consumed]); err == nil {
|
|
t.CreationDate = &dt
|
|
consumed++
|
|
}
|
|
}
|
|
if consumed > 0 {
|
|
working = strings.TrimSpace(strings.Join(toks[consumed:], " "))
|
|
}
|
|
} else {
|
|
if priorityHead.MatchString(working) {
|
|
if len(working) >= 3 {
|
|
r := rune(working[1])
|
|
t.Priority = &r
|
|
}
|
|
working = priorityHead.ReplaceAllString(working, "")
|
|
}
|
|
toks := splitTokens(working)
|
|
if len(toks) > 0 && dateRe.MatchString(toks[0]) {
|
|
if dt, err := time.Parse(dateLayout, toks[0]); err == nil {
|
|
t.CreationDate = &dt
|
|
working = strings.TrimSpace(strings.Join(toks[1:], " "))
|
|
}
|
|
}
|
|
}
|
|
|
|
tokens := splitTokens(working)
|
|
descParts := make([]string, 0, len(tokens))
|
|
for _, tok := range tokens {
|
|
if strings.HasPrefix(tok, "+") && len(tok) > 1 {
|
|
t.Projects = append(t.Projects, tok[1:])
|
|
continue
|
|
}
|
|
if strings.HasPrefix(tok, "@") && len(tok) > 1 {
|
|
t.Contexts = append(t.Contexts, tok[1:])
|
|
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 the field is due:YYYY-MM-DD, also parse it as DueDate
|
|
if k == "due" && dateRe.MatchString(v) {
|
|
if dt, err := time.Parse(dateLayout, v); err == nil {
|
|
t.DueDate = &dt
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
// else part of description
|
|
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)
|
|
}
|