feat: initial parser for todo.txt format with spec and test cases
- Add parser for todo.txt tasks supporting priority, dates, projects, contexts, and key:value metadata - Include full todo.txt format specification (SPECS.md) and visual description (description.svg) - Add sample test.todo.txt file with valid, borderline, and malformed cases - Initialize Go module and main entrypoint for parsing and JSON output - Add .gitignore for binary artifacts
This commit is contained in:
201
internal/parser/parser.go
Normal file
201
internal/parser/parser.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
// A=9999, Z=0, linear scale
|
||||
return 9999 - int(p-'A')*400
|
||||
}
|
||||
|
||||
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 da escludere come chiave dei metadati
|
||||
protocols = map[string]struct{}{
|
||||
"http": {},
|
||||
"https": {},
|
||||
"ftp": {},
|
||||
"mailto": {},
|
||||
}
|
||||
)
|
||||
|
||||
// isProtocolKey controlla se la chiave è un protocollo standard.
|
||||
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
|
||||
}
|
||||
// Nuova logica per metadati: nessuno spazio, almeno un ':', chiave non protocollo
|
||||
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
|
||||
// Se il campo è due:YYYY-MM-DD, parsalo anche come 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)
|
||||
}
|
||||
Reference in New Issue
Block a user