@@ 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.
@@ 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)
+}
@@ 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
+}
@@ 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()
+ }
+ })
+ }
+}