2025-03-15 22:16:41 -06:00

608 lines
11 KiB
Go

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()
}