~robertgzr/remind

03a2090eb67e2b0b59c05ac1196ff883fc98a2e5 — Robert Günzler 1 year, 5 months ago
initial commit
6 files changed, 485 insertions(+), 0 deletions(-)

A LICENSE
A go.mod
A go.sum
A parser.go
A remind.go
A remind_test.go
A  => LICENSE +19 -0
@@ 1,19 @@
Copyright (c) 2022 Robert Günzler <r@gnzler.io>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

A  => go.mod +9 -0
@@ 1,9 @@
module git.sr.ht/~robertgzr/remind

go 1.19

require (
	github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
	github.com/emersion/go-webdav v0.3.1
	github.com/teambition/rrule-go v1.7.2
)

A  => go.sum +8 -0
@@ 1,8 @@
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.3.1 h1:8ISu6AlBwu7DKg9RQE3iRpE3CPM8Bfpfz7L3bi/xlGI=
github.com/emersion/go-webdav v0.3.1/go.mod h1:uSM1VveeKtogBVWaYccTksToczooJ0rrVGNsgnDsr4Q=
github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=

A  => parser.go +111 -0
@@ 1,111 @@
package remind

import (
	"fmt"
	"strconv"
	"strings"
	"text/scanner"
	"time"
	"unicode"

	"github.com/teambition/rrule-go"
)

type p struct {
	tokens []string
}

func newP(input string) *p {
	s := new(scanner.Scanner)
	s = s.Init(strings.NewReader(input))
	// NOTE: allow digits and punctuation in identifiers,
	// scans time expressions like 13:00 as one token
	s.IsIdentRune = func(ch rune, _ int) bool {
		return unicode.IsPunct(ch) || unicode.IsLetter(ch) || unicode.IsDigit(ch)
	}

	var tokens []string
	var idx int = 0

	for {
		if s.Scan() == scanner.EOF {
			break
		}

		tok := s.TokenText()
		switch tok {
		case "REM", "DURATION", "MSG":
			tokens = append(tokens, tok, "")
			idx = len(tokens) - 1
			continue

		default:
			tokens[idx] = strings.TrimLeft(tokens[idx]+" "+tok, " ")
		}
	}

	return &p{tokens: tokens}
}

func (p *p) parse() (rem *Reminder, err error) {
	rem = new(Reminder)
	for pos, tok := range p.tokens {
		switch tok {
		case "REM":
			err = p.parseDateTime(rem, p.tokens[pos+1])

		case "DURATION":
			parts := strings.SplitN(p.tokens[pos+1], ":", 2)
			hours, err := strconv.Atoi(parts[0])
			if err != nil {
				return rem, err
			}
			rem.duration += time.Duration(hours) * time.Hour
			mins, err := strconv.Atoi(parts[1])
			if err != nil {
				return rem, err
			}
			rem.duration += time.Duration(mins) * time.Minute

		case "MSG":
			msg := p.tokens[pos+1]
			// trim leading bracket expression
			if msg[0] == '[' {
				end := strings.IndexRune(msg, ']')
				msg = strings.TrimSpace(msg[end+1:])
			}
			rem.msg = msg
		}
	}
	return rem, err
}

var timeFormats = map[string]bool{
	"2 Jan 2006@15:04":    true,
	"2 Jan 2006 AT 15:04": true,
	"2 Jan 2006":          false,
}

func (p *p) parseDateTime(rem *Reminder, datespec string) error {
	for fmt, hasClock := range timeFormats {
		if t, err := time.ParseInLocation(fmt, datespec, time.Local); err == nil {
			rem.due = t
			rem.hasClock = hasClock
			return nil
		}
	}

	// detect birthday reminders
	if t, err := time.Parse("2 Jan", datespec); err == nil {
		var err error
		rem.rule, err = rrule.NewRRule(rrule.ROption{
			Freq:       rrule.YEARLY,
			Bymonth:    []int{int(t.Month())},
			Bymonthday: []int{t.Day()},
		})
		return err
	}

	// TODO: parse more rrule expressions from datespec
	return fmt.Errorf("invalid datetime expression: %s", datespec)
}

A  => remind.go +151 -0
@@ 1,151 @@
package remind

import (
	"bytes"
	"crypto/md5"
	"encoding/gob"
	"fmt"
	"strings"
	"time"

	"github.com/emersion/go-ical"
	"github.com/teambition/rrule-go"
)

func ParseReminder(line string) (rem *Reminder, err error) {
	p := newP(line)
	return p.parse()
}

// ReminderFromComponent makes a Reminder type from a caldav component
func ReminderFromComponent(comp *ical.Component) (rem *Reminder, err error) {
	rem = new(Reminder)
	msgPrefix := ""

	switch comp.Name {
	case "VEVENT":
		// NOTE: ignore PropDue
		rem.due, err = comp.Props.DateTime(ical.PropDateTimeStart, time.Local)
		if err != nil {
			return nil, fmt.Errorf("failed to get start time: %v", err)
		}
		rem.hasClock = datetimePropHasClock(comp.Props.Get(ical.PropDateTimeStart))
		end, err := comp.Props.DateTime(ical.PropDateTimeEnd, time.Local)
		if err == nil && !end.IsZero() {
			rem.duration = end.Sub(rem.due)
		}
	case "VTODO":
		// NOTE: ignore PropDateTimeStart
		rem.due, err = comp.Props.DateTime(ical.PropDue, time.Local)
		if err != nil {
			return nil, fmt.Errorf("failed to get due time: %v", err)
		}
		rem.hasClock = datetimePropHasClock(comp.Props.Get(ical.PropDue))
		msgPrefix = "TODO: "
	default:
		return nil, fmt.Errorf("%s unsupported", comp.Name)
	}

	ruleset, err := comp.RecurrenceSet(time.Local)
	if err == nil && ruleset != nil {
		rem.rule = ruleset.GetRRule()
	}
	rem.msg, err = comp.Props.Text(ical.PropSummary)
	if err != nil {
		return nil, fmt.Errorf("failed to get summary")
	}
	rem.msg = msgPrefix + rem.msg

	if rem.due.IsZero() && rem.rule == nil {
		return nil, fmt.Errorf(`invalid component "%s:%s", missing start/due time or recurrence set`, comp.Name, rem.msg)
	}
	return rem, nil
}

func datetimePropHasClock(prop *ical.Prop) bool {
	if prop == nil {
		return false
	}
	return strings.Contains(prop.Value, "T")
}

type Reminder struct {
	due      time.Time
	rule     *rrule.RRule
	msg      string
	duration time.Duration
	hasClock bool
}

// String renders the `Reminder` as a remind(1) expression
func (rem *Reminder) String() string {
	var s strings.Builder
	s.WriteString("REM ")
	if rem.due.IsZero() && rem.rule != nil {
		switch rem.rule.Options.Freq {
		case rrule.YEARLY:
			if len(rem.rule.Options.Bymonth) == 1 &&
				len(rem.rule.Options.Bymonthday) == 1 {
				s.WriteString(fmt.Sprintf("%d %s",
					rem.rule.Options.Bymonthday[0],
					time.Month(rem.rule.Options.Bymonth[0]).String()[:3],
				))
			}
		default:
			panic(fmt.Sprintf("frequency %s not implemented", &rem.rule.Options.Freq))
		}
	} else {
		s.WriteString(rem.due.Format("2 Jan 2006"))
		if rem.hasClock {
			s.WriteString(" AT ")
			s.WriteString(rem.due.Format("15:04"))
		}
	}
	if rem.duration > 0 {
		s.WriteString(" DURATION ")
		rem.duration.Truncate(time.Hour)
		s.WriteString(fmt.Sprintf("%.0f:%.0f", rem.duration.Hours(), (rem.duration - rem.duration.Truncate(time.Hour)).Minutes()))
	}
	s.WriteString(" MSG ")
	s.WriteString(rem.msg)
	return s.String()
}

func structhash(s any) string {
	var b bytes.Buffer
	gob.NewEncoder(&b).Encode(s)
	return fmt.Sprintf("%x", md5.Sum(b.Bytes()))
}

// Component creates an `ical.Component` from a `Reminder`. Our convention is to
// create a *VTODO* if the message is prefixed with `TODO:`, otherwise a *VEVENT*
// will be created.
func (rem *Reminder) Component() (comp *ical.Component) {
	if idx := strings.Index(rem.msg, "TODO:"); idx != -1 {
		comp = ical.NewComponent(ical.CompToDo)
		comp.Props.SetText(ical.PropSummary, strings.TrimSpace(rem.msg[idx+5:]))
	} else {
		comp = ical.NewComponent(ical.CompEvent)
		comp.Props.SetText(ical.PropSummary, rem.msg)
	}

	// common props
	comp.Props.SetText(ical.PropUID, fmt.Sprintf("%s@~robertgzr/remind", structhash(rem)))
	comp.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())

	if !rem.due.IsZero() {
		if comp.Name == ical.CompToDo {
			comp.Props.SetDateTime(ical.PropDue, rem.due)
		} else {
			comp.Props.SetDateTime(ical.PropDateTimeStart, rem.due)
			if rem.duration > 0 {
				comp.Props.SetDateTime(ical.PropDateTimeEnd, rem.due.Add(rem.duration))
			}
		}
	}

	if rem.rule != nil {
		comp.Props.SetRecurrenceRule(&rem.rule.Options)
	}
	return comp
}

A  => remind_test.go +187 -0
@@ 1,187 @@
package remind

import (
	"reflect"
	"strings"
	"testing"
	"time"

	"github.com/emersion/go-ical"
	"github.com/teambition/rrule-go"
)

var cases = map[string]*Reminder{
	"REM 16 Sep 2022 MSG TODO: foo bar": {
		due:      time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local),
		rule:     nil,
		msg:      "TODO: foo bar",
		duration: 0,
		hasClock: false,
	},
	"REM 16 Sep 2022 AT 13:00 MSG foo bar": {
		due:      time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local),
		msg:      "foo bar",
		rule:     nil,
		duration: 0,
		hasClock: true,
	},
	"REM 16 Sep 2022@13:00 MSG foo bar": {
		due:      time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local),
		msg:      "foo bar",
		rule:     nil,
		duration: 0,
		hasClock: true,
	},
	"REM 16 Sep 2022 AT 13:00 DURATION 2:30 MSG foo bar": {
		due:      time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local),
		msg:      "foo bar",
		rule:     nil,
		duration: (2*time.Hour + 30*time.Minute),
		hasClock: true,
	},
	"REM 6 Jan MSG Every 6th of January": {
		due: time.Time{},
		msg: "Every 6th of January",
		rule: must(rrule.NewRRule(rrule.ROption{
			Freq:       rrule.YEARLY,
			Bymonth:    []int{1},
			Bymonthday: []int{6},
		})),
		duration: 0,
		hasClock: false,
	},
	"REM 6 Jan MSG [_yr_num(1994)] Omit functions": {
		due: time.Time{},
		msg: "Omit functions",
		rule: must(rrule.NewRRule(rrule.ROption{
			Freq:       rrule.YEARLY,
			Bymonth:    []int{1},
			Bymonthday: []int{6},
		})),
		duration: 0,
		hasClock: false,
	},
}

func must[T any](val T, err error) T {
	if err != nil {
		panic(err)
	}
	return val
}

func logf(t *testing.T, fmtstr string, values ...any) {
	if testing.Verbose() {
		t.Logf(fmtstr, values...)
	}
}

func TestFromComponent(t *testing.T) {
	input := make(map[string]*ical.Component)
	for line := range cases {
		switch line {
		case "REM 16 Sep 2022 MSG TODO: foo bar":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompToDo)
				c.Props.SetText(ical.PropSummary, "foo bar")
				c.Props.SetDate(ical.PropDue, time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local))
				return c
			}()
		case "REM 16 Sep 2022 AT 13:00 MSG foo bar":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompEvent)
				c.Props.SetText(ical.PropSummary, "foo bar")
				c.Props.SetDateTime(ical.PropDateTimeStart, time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local))
				return c
			}()
		case "REM 16 Sep 2022@13:00 MSG foo bar":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompEvent)
				c.Props.SetText(ical.PropSummary, "foo bar")
				c.Props.SetDateTime(ical.PropDateTimeStart, time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local))
				return c
			}()
		case "REM 16 Sep 2022 AT 13:00 DURATION 2:30 MSG foo bar":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompEvent)
				c.Props.SetText(ical.PropSummary, "foo bar")
				c.Props.SetDateTime(ical.PropDateTimeStart, time.Date(2022, time.September, 16, 13, 0, 0, 0, time.Local))
				c.Props.SetDateTime(ical.PropDateTimeEnd, time.Date(2022, time.September, 16, 15, 30, 0, 0, time.Local))
				return c
			}()
		case "REM 6 Jan MSG Every 6th of January":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompEvent)
				c.Props.SetText(ical.PropSummary, "Every 6th of January")
				c.Props.SetRecurrenceRule(&rrule.ROption{
					Freq:       rrule.YEARLY,
					Bymonth:    []int{1},
					Bymonthday: []int{6},
				})
				return c
			}()
		case "REM 6 Jan MSG [_yr_num(1994)] Omit functions":
			input[line] = func() (c *ical.Component) {
				c = ical.NewComponent(ical.CompEvent)
				c.Props.SetText(ical.PropSummary, "Omit functions")
				c.Props.SetRecurrenceRule(&rrule.ROption{
					Freq:       rrule.YEARLY,
					Bymonth:    []int{1},
					Bymonthday: []int{6},
				})
				return c
			}()
		default:
			input[line] = nil
		}
	}
	for line, expected := range cases {
		t.Run(line, func(t *testing.T) {
			if input[line] == nil {
				t.Skipf("missing test for: %s", line)
			}

			result, err := ReminderFromComponent(input[line])
			if err != nil {
				t.Fatal(err)
			}
			if !reflect.DeepEqual(result, expected) {
				logf(t, "%s\ngot:      %#+v\n!=\nexpected: %#+v", line, result, expected)
				t.Fail()
			}
		})
	}
}

func TestStringify(t *testing.T) {
	for line, rem := range cases {
		t.Run(line, func(t *testing.T) {
			if strings.Contains(line, "@") {
				t.Skip("we don't support rendering \"@\" datespec")
			}
			if strings.Contains(line, "_yr_num") {
				t.Skip("we don't support rendering function expressions")
			}
			result := rem.String()
			if result != line {
				logf(t, "\n%s\n!=\n%s", result, line)
				t.Fail()
			}
		})
	}
}

func TestParse(t *testing.T) {
	for line, expected := range cases {
		t.Run(line, func(t *testing.T) {
			rem, err := ParseReminder(line)
			if err != nil {
				t.Fatal(err)
			}
			if !reflect.DeepEqual(rem, expected) {
				logf(t, "%s\ngot:      %#+v\n!=\nexpected: %#+v", line, rem, expected)
				t.Fail()
			}
		})
	}
}