package main import ( "fmt" "iter" "maps" "slices" "sort" "strconv" "strings" "time" "unicode" ) // 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 // 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:1 // 2024-01-01 12:01 [x] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:1 completed:2024-01-01 // 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:2 // # Complete the entire series. // 2024-01-01 12:01 [x] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 completed:2024-12-31 // # multi line // 2024-01-01 // [ ] (A) Do this thing @high // [ ] (B) Do that thing @medium // 12:01-13:01 read emails @low // ## Time formats ## // HH - start hour // HH:MM - start hour and minute // HH-HH - start and end hour // HH:MM-HH:MM - start and end hour and minute // ## To Do ## // [ ] not done // [!] at risk ? // [x] done // ## Todo Priority ## // (A) High // (B) Medium // (C) Low // None // ## Repeat frequencies ## // @yearly // @monthly // @weekly // @daily // ## Repeat modifiers ## // interval:N // count:N // until:YYYY-MM-DD type Entry struct { Title string Note string Projects Set[string] Contexts Set[string] Attributes Attributes Slate Task Event } func (e *Entry) Parse(text string) (int, error) { i, err := e.Slate.Parse(text) if err != nil { i += trimSpace(text[i:]) // return 0, err } if strings.HasPrefix(text[i:], "- ") { i++ i += trimSpace(text[i:]) e.Note = text[i:] i += len(text[i:]) return i, nil } di, err := e.Task.Parse(text[i:]) if err == nil { i += di } di, err = e.Event.Parse(text[i:]) if err == nil { i += di } text = text[i:] if len(text) > 0 { i += len(text) e.Title = text } // parse attributes e.Projects = make(Set[string]) e.Contexts = make(Set[string]) e.Attributes = make(Attributes) for _, field := range strings.Fields(e.Title) { if strings.HasPrefix(field, "@") { e.Contexts.Add(field[1:]) continue } if strings.HasPrefix(field, "+") { e.Projects.Add(field[1:]) continue } if key, value, ok := strings.Cut(field, ":"); ok { e.Attributes[key] = append(e.Attributes[key], value) } } switch { case e.Contexts.Has("yearly"): e.Slate.Frequency = Yearly case e.Contexts.Has("monthly"): e.Slate.Frequency = Monthly case e.Contexts.Has("weekly"): e.Slate.Frequency = Weekly case e.Contexts.Has("daily"): e.Slate.Frequency = Daily } if v, ok := e.Attributes.GetN("until", 0); ok { e.Slate.Until, _ = time.Parse("2006-01-02", v) } if v, ok := e.Attributes.GetN("interval", 0); ok { e.Slate.Interval, _ = strconv.Atoi(v) } if v, ok := e.Attributes.GetN("count", 0); ok { e.Slate.Count, _ = strconv.Atoi(v) } if v, ok := e.Attributes.GetN("rel", 0); ok { e.Task.RelatedTo, _ = time.Parse("2006-01-02", v) } if v, ok := e.Attributes.GetN("completed", 0); ok { e.Task.CompletedOn, _ = time.Parse("2006-01-02", v) } return i, nil } func (e Entry) String() string { var b strings.Builder b.WriteString(e.Slate.String()) b.WriteString(e.DetailString()) return b.String() } func (e Entry) DetailString() string { var b strings.Builder if !e.Task.IsZero(){ b.WriteString(" " + e.Task.String()) } if !e.Event.IsZero() { b.WriteString(" " + e.Event.String()) } if len(e.Title) > 0 { b.WriteString(" " + e.Title) } return b.String() } func (e Entry) IsZero() bool { return e.Slate.IsZero() && e.Task.IsZero() && e.Event.IsZero() && len(e.Attributes) == 0 && len(e.Contexts) == 0 && len(e.Projects) == 0 && len(e.Title) == 0 } func (e Entry) IsMulti() bool { return !e.Slate.IsZero() && e.Task.IsZero() && e.Event.IsZero() && len(e.Attributes) == 0 && len(e.Contexts) == 0 && len(e.Projects) == 0 && len(e.Title) == 0 } func (e Entry) Project(start time.Time, until time.Duration) iter.Seq[time.Time] { begin := start end := start.Add(until) return func(yield func(time.Time) bool) { doProject := func(start time.Time) bool { for ts := range e.Date.Project(start) { if ts.Before(begin) || ts.After(end) { continue } if !yield(ts) { return false } } return true } switch e.Frequency { case Yearly: start = beginningOfYear(start) for start.Before(end) { if !doProject(start) { break } start = start.AddDate(1, 0, 0) start = beginningOfYear(start) } case Monthly: start = beginningOfMonth(start) for start.Before(end) { if !doProject(start) { break } start = start.AddDate(0, 1, 0) } case Weekly: start = beginningOfWeek(start) for start.Before(end) { if !doProject(start) { break } start = start.AddDate(0, 0, 7) } case Daily: for start.Before(end) { if !doProject(start) { break } start = start.AddDate(0, 0, 1) } default: start = time.Date(e.Date.Year, 1,1,0,0, 0, 0, time.UTC) doProject(start) } } } func (e Entry) Less(c Entry) bool { return false } type Set[T comparable] map[T]struct{} func NewSet[T comparable](vals ...T) Set[T] { s := make(Set[T]) s.Add(vals...) return s } func (s *Set[T]) Add(keys ...T) { for _, key := range keys { (*s)[key] = struct{}{} } } func (s Set[T]) Has(key T) bool { _, ok := s[key] return ok } type Attributes map[string][]string func (a Attributes) GetN(key string, n int) (string, bool) { if v, ok := a[key]; ok { if len(v) > n { return v[n], true } } return "", false } func (a *Attributes) Add(key string, val ...string) { (*a)[key] = append((*a)[key], val...) } func (a *Attributes) Set(key string, val ...string) { (*a)[key] = val } type Task struct { Done bool Priority Priority RelatedTo time.Time CompletedOn time.Time } func (t *Task) Parse(text string) (int, error) { if len(text) < 3 { return 0, fmt.Errorf("invalid task") } i := 0 switch { case strings.HasPrefix(text, "[ ]"): i += 3 case strings.HasPrefix(text, "[x]"): i += 3 t.Done = true default: return 0, fmt.Errorf("invalid task") } t.Priority = None i += trimSpace(text[i:]) di, err := t.Priority.Parse(text[i:]) if err == nil { i += di } i += trimSpace(text[i:]) return i, nil } // String func (t Task) String() string { var b strings.Builder b.WriteRune('[') if t.Done { b.WriteRune('x') } else { b.WriteRune(' ') } b.WriteRune(']') b.WriteString(t.Priority.String()) return b.String() } func (t *Task) IsZero() bool { if t == nil { return true } return t.Priority == 0 && t.CompletedOn.IsZero() && t.RelatedTo.IsZero() } type Event struct { StartsAt Time EndsAt Time } func (e *Event) Parse(text string) (int, error) { if len(text) == 0 { return 0, fmt.Errorf("invalid event") } i := 0 di, v, err := parseDigits(text) if err == nil { e.StartsAt.Valid = true e.StartsAt.Hour = v i += di } else { return 0, fmt.Errorf("invalid event: %w", err) } if len(text[i:]) > 0 && text[i] == ':' { i++ di, v, err = parseDigits(text[i:]) if err == nil { e.StartsAt.Minute = v i += di } } if len(text[i:]) > 0 && text[i] == '-' { i++ di, v, err = parseDigits(text[i:]) if err == nil { e.EndsAt.Valid = true e.EndsAt.Hour = v i += di } if len(text[i:]) > 0 && text[i] == ':' { i++ di, v, err = parseDigits(text[i:]) if err == nil { e.EndsAt.Minute = v i += di } } } i += trimSpace(text[i:]) return i, nil } // String func (e Event) String() string { var b strings.Builder if !e.StartsAt.Valid { return "" } b.WriteString(e.StartsAt.String()) if e.EndsAt.Valid { b.WriteRune('-') b.WriteString(e.EndsAt.String()) } return b.String() } func (e *Event) IsZero() bool { if e == nil { return true } return !e.StartsAt.Valid && !e.EndsAt.Valid } type Priority rune const ( High Priority = 'A' Medium Priority = 'B' Low Priority = 'C' None Priority = ' ' ) func (p *Priority) Parse(text string) (int, error) { if len(text) == 0 { return 0, nil } i := 0 switch { case strings.HasPrefix(text, "(A)"): i += 3 *p = High case strings.HasPrefix(text, "(B)"): i += 3 *p = Medium case strings.HasPrefix(text, "(C)"): i += 3 *p = Low default: return 0, fmt.Errorf("invalid priority") } return i, nil } func (p Priority) String() string { var b strings.Builder switch p { case High, Medium, Low: b.WriteRune(' ') b.WriteRune('(') b.WriteRune(rune(p)) b.WriteRune(')') } return b.String() } func trimSpace(text string) int { i := 0 for _, c := range text { if unicode.IsSpace(c) { i++ continue } break } return i } func parseDigits(text string) (int, int, error) { i := 0 for _, c := range text { if unicode.IsDigit(c) { i++ continue } break } v, err := strconv.Atoi(text[:i]) return i, v, err } type Time struct { Hour int Minute int Valid bool } func (t *Time) Duration() time.Duration { return time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute } func (t *Time) String() string { var b strings.Builder b.WriteString(strconv.Itoa(t.Hour)) if t.Minute > 0 { b.WriteRune(':') b.WriteString(strconv.Itoa(t.Minute)) } return b.String() } type Entries map[string][]Entry func NewEntries(entries ...Entry) Entries { var e Entries e.add(entries...) return e } func (e *Entries) add(entries ...Entry) { if *e == nil { *e = make(map[string][]Entry, len(entries)) } for _, entry := range entries { (*e)[entry.Slate.Date.String()] = append((*e)[entry.Slate.Date.String()], entry) } } func (e Entries) Iter() iter.Seq[Entry] { keys := slices.Collect(maps.Keys(e)) sort.Strings(keys) return func(yield func(Entry) bool) { for _, k := range keys { for _, i := range (e)[k] { if !yield(i) { return } } } } } func (e *Entries) Parse(text string) (int, error) { if len(text) == 0 { return 0, nil } var lastDate Date var lis []Entry i := 0 for _, line := range strings.Split(text, "\n") { i++ var e Entry di, err := e.Parse(line) i += di if err != nil { continue } if e.IsZero() { continue } if e.Date.IsZero() { e.Date = lastDate } if e.IsMulti() { lastDate = e.Date continue } lis = append(lis, e) } i-- e.add(lis...) return i, nil } func (e Entries) String() string { keys := slices.Collect(maps.Keys(e)) sort.Strings(keys) var b strings.Builder for _, key := range keys { lis := e[key] if len(lis) > 1 { b.WriteString(key) b.WriteString("\n") for _, e := range lis { b.WriteString("\t") b.WriteString(e.DetailString()) b.WriteString("\n") } } else { for _, e := range lis { b.WriteString(e.String()) b.WriteString("\n") } } } return b.String() }