608 lines
11 KiB
Go
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()
|
|
}
|