feat(parser): support multiple values per metadata key in Task

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.
This commit is contained in:
2025-12-17 00:16:16 +01:00
parent bfb1bb3433
commit daffdf4441
2 changed files with 34 additions and 20 deletions

View File

@@ -14,16 +14,16 @@ import (
// Task represents a parsed todo item. // Task represents a parsed todo item.
type Task struct { type Task struct {
Raw string `json:"raw"` Raw string `json:"raw"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
CompletionDate *time.Time `json:"completion_date,omitempty"` CompletionDate *time.Time `json:"completion_date,omitempty"`
Priority *rune `json:"priority,omitempty"` // 'A'..'Z' Priority *rune `json:"priority,omitempty"` // 'A'..'Z'
CreationDate *time.Time `json:"creation_date,omitempty"` CreationDate *time.Time `json:"creation_date,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"` // parsed from due:YYYY-MM-DD DueDate *time.Time `json:"due_date,omitempty"` // parsed from due:YYYY-MM-DD
Description string `json:"description"` // description with projects/contexts/metadata removed Description string `json:"description"` // description with projects/contexts/metadata removed
Projects []string `json:"projects"` Projects []string `json:"projects"`
Contexts []string `json:"contexts"` Contexts []string `json:"contexts"`
Metadata map[string]string `json:"metadata"` Metadata map[string][]string `json:"metadata"`
} }
func (t Task) ToRemind() string { func (t Task) ToRemind() string {
@@ -37,13 +37,19 @@ func (t Task) ToRemind() string {
sb.WriteString(" ++5 INFO \"Calendar: TODO.TX\" ") sb.WriteString(" ++5 INFO \"Calendar: TODO.TX\" ")
if len(t.Metadata) > 0 { if len(t.Metadata) > 0 {
for k, v := range t.Metadata { for k, values := range t.Metadata {
if k == "due" { if k == "due" {
continue continue
} }
// k must have the first letter uppercase for Remind // k must have the first letter uppercase for Remind
k = strings.ToUpper(k[:1]) + k[1:] kUpper := strings.ToUpper(k[:1]) + k[1:]
sb.WriteString(fmt.Sprintf("INFO \"%s: %s\" ", k, v)) 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))
}
}
} }
} }
@@ -82,13 +88,20 @@ func (t Task) ToRemind() string {
} }
if len(t.Metadata) > 0 { if len(t.Metadata) > 0 {
for k := range t.Metadata { for k, values := range t.Metadata {
if k == "due" { if k == "due" {
continue continue
} }
// uppercase first letter for Remind // uppercase first letter for Remind
k = strings.ToUpper(k[:1]) + k[1:] kUpper := strings.ToUpper(k[:1]) + k[1:]
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", k, k)) 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))
}
}
} }
} }
@@ -181,7 +194,7 @@ func ParseReader(r io.Reader) ([]Task, []error) {
func ParseLine(line string) (Task, error) { func ParseLine(line string) (Task, error) {
t := Task{ t := Task{
Raw: line, Raw: line,
Metadata: make(map[string]string), Metadata: make(map[string][]string),
} }
working := strings.TrimSpace(line) working := strings.TrimSpace(line)
@@ -262,14 +275,14 @@ func ParseLine(line string) (Task, error) {
} }
val += " " + toks[i] val += " " + toks[i]
} }
t.Metadata["location"] = val t.Metadata["location"] = append(t.Metadata["location"], val)
continue continue
} }
if !strings.ContainsAny(tok, " \t") && strings.Contains(tok, ":") { if !strings.ContainsAny(tok, " \t") && strings.Contains(tok, ":") {
parts := strings.SplitN(tok, ":", 2) parts := strings.SplitN(tok, ":", 2)
k, v := parts[0], parts[1] k, v := parts[0], parts[1]
if k != "" && v != "" && !isProtocolKey(k) { if k != "" && v != "" && !isProtocolKey(k) {
t.Metadata[k] = v t.Metadata[k] = append(t.Metadata[k], v)
if k == "due" && dateRe.MatchString(v) { if k == "due" && dateRe.MatchString(v) {
if dt, err := time.Parse(dateLayout, v); err == nil { if dt, err := time.Parse(dateLayout, v); err == nil {
t.DueDate = &dt t.DueDate = &dt
@@ -277,6 +290,7 @@ func ParseLine(line string) (Task, error) {
} }
continue continue
} }
} }
descParts = append(descParts, tok) descParts = append(descParts, tok)
} }

View File

@@ -14,7 +14,7 @@ var (
inputFile string inputFile string
outputFile string outputFile string
debug bool debug bool
version = "v2.0.0" version = "v3.0.0"
) )
func main() { func main() {