hivedav

A curlable free/busy scheduler with CalDAV integration
git clone https://git.in0rdr.ch/hivedav.git
Log | Files | Refs | Pull requests | README | LICENSE

commit 52627a00f3ced37817693ce60bc07aa05ba313ac
parent 7c5c5cfa62db55d2088cf89558266d382421081a
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Wed, 13 Sep 2023 01:27:48 +0200

feat: serve weeks

Diffstat:
Mgo.mod | 3++-
Mgo.sum | 4++++
Mmain.go | 8+++++++-
Mserver.go | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
4 files changed, 202 insertions(+), 48 deletions(-)

diff --git a/go.mod b/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -23,7 +24,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - github.com/teambition/rrule-go v1.7.2 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect diff --git a/go.sum b/go.sum @@ -131,6 +131,8 @@ github.com/in0rdr/go-webdav v0.0.0-20230903141941-cde95745290e h1:iXhjrrNT+r6E6H github.com/in0rdr/go-webdav v0.0.0-20230903141941-cde95745290e/go.mod h1:u8ZQQE8aW8NvpP101ttUsXcbtRKiedaHAPtsKAFpPnY= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -185,6 +187,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= 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= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/main.go b/main.go @@ -6,6 +6,7 @@ import ( "net/http" "hivedav/config" "hivedav/caldav" + "github.com/julienschmidt/httprouter" ) func main() { @@ -61,5 +62,10 @@ func main() { } log.Printf("Ready, listening on %s:%d\n", conf.ListenAddress, conf.ListenPort) - http.ListenAndServe(fmt.Sprintf("%s:%d", conf.ListenAddress, conf.ListenPort), server) + + router := httprouter.New() + router.GET("/", server.Index) + router.GET("/week/:week", server.Week) + + log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", conf.ListenAddress, conf.ListenPort), router)) } diff --git a/server.go b/server.go @@ -2,8 +2,9 @@ package main import ( "time" + "strconv" + "github.com/teambition/rrule-go" "github.com/emersion/go-ical" - //"github.com/arran4/golang-ical" "hivedav/config" "hivedav/caldav" "fmt" @@ -14,6 +15,7 @@ import ( "io" "log" "net/http" + "github.com/julienschmidt/httprouter" "strings" ) @@ -34,6 +36,13 @@ var create_availability_1 = `CREATE TABLE IF NOT EXISTS availability_1 ( start DATETIME NOT NULL, end DATETIME NOT NULL, tz TEXT NOT NULL);` +var a = `SELECT * FROM availability WHERE + start BETWEEN '2023-01-30 15:00' AND '2023-01-30 16:00' OR + end BETWEEN '2023-01-30 15:00' AND '2023-01-30 16:00';` +var available = `SELECT * FROM availability WHERE + (start BETWEEN ? AND ?) OR + (end BETWEEN ? AND ?) OR + (start <= ? AND end >= ?);` func (s *Server) NewServer(c *config.Config) (*Server, error) { s = new(Server) @@ -59,15 +68,121 @@ func (s *Server) NewServer(c *config.Config) (*Server, error) { return s, nil } -func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { +// https://xferion.com/golang-reverse-isoweek-get-the-date-of-the-first-day-of-iso-week +func dayOfISOWeek(weekday int, week int, year int) time.Time { + timeNow := time.Now() + yearNow, weekNow:= timeNow.ISOWeek() + + // iterate back to the weekday + for timeNow.Weekday() != time.Weekday(weekday) { + // years, months, days + timeNow = timeNow.AddDate(0, 0, -1) + yearNow, weekNow = timeNow.ISOWeek() + } + + // if we iterated back into the old year, iterate forward in weekly + // steps (7) until we reach the weekday of the first week in yearNow + for yearNow < year { + timeNow = timeNow.AddDate(0, 0, 7) + yearNow, weekNow = timeNow.ISOWeek() + } + + // iterate forward to the first day of the given ISO week + for weekNow < week { + timeNow = timeNow.AddDate(0, 0, 7) + yearNow, weekNow = timeNow.ISOWeek() + } + + return timeNow +} + +func (s *Server) available(start string, end string) (bool, error) { + var ( + id int + rowStart time.Time + rowEnd time.Time + tz string + ) + + stmt, err := s.db.Prepare(available) + + if err != nil { + log.Println("Error during db statement Prepare()") + return false, err + } + + err = stmt.QueryRow(start, end, start, end, start, end).Scan(&id, &rowStart, &rowEnd, &tz) + if err != nil { + if err == sql.ErrNoRows { + log.Println("AVAILABLE") + return true, nil + } + log.Printf("Different error: %v\n", err) + return false, err + } + log.Println("NOT AVAILABLE") + return false, nil +} + +func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { userAgent := req.Header.Get("user-agent") + + timeNow := time.Now() + weekday := timeNow.Weekday() + year, week := timeNow.ISOWeek() + + // overwrite with requested week + week, err := strconv.Atoi(ps.ByName("week")) + if err != nil { + log.Printf("Cannot serve requested week '%s'") + w.WriteHeader(http.StatusBadRequest) + return + } + + log.Printf("Current weekday is '%d'\n", weekday) + log.Printf("Serving week '%v' of year '%v'\n", week, year) + + monday := dayOfISOWeek(1, week, year) + log.Printf("Monday is '%v'\n", monday) + sqlDateFmt := "2006-01-02 15:04" + if strings.Contains(userAgent, "curl") { w.Header().Set("Content-Type", "text/plain") io.WriteString(w, ansistyle.Bold.Open+"Welcome to the "+ansistyle.Bold.Close) io.WriteString(w, ansistyle.BgRed.Open+"hive!"+ansistyle.BgBlue.Close+"\n") + io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year)) pt := prettytable.New([]string{"Time ", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"}) - pt.AddRow("08:00 - 09:00", "", "", "busy", "", "") + + monTime:= dayOfISOWeek(1, week, year) + timeIt := time.Date(monTime.Year(), monTime.Month(), monTime.Day(), 0, 0, 0, 0, time.UTC) + + // Working hours - Eight to Five + for _, h := range[]int{8,9,10,11,12,13,14,15,16} { + var availability [5]string + // Working days - Monday through Friday + for _, d := range []int{1,2,3,4,5} { + start := timeIt.Add(time.Hour * time.Duration((d-1)*24 + h)).Format(sqlDateFmt) + end := timeIt.Add(time.Hour * time.Duration(h+1)).Format(sqlDateFmt) + avi, err := s.available(start, end) + if err != nil { + log.Printf("Error getting availability on day '%d' hour '%d'\n", d, h) + return + } + if avi { + availability[d-1] = "" + } else { + availability[d-1] = "X" + } + } + pt.AddRow(fmt.Sprintf("%02d:00 - %02d:00", h, h+1), + availability[0], + availability[1], + availability[2], + availability[3], + availability[4]) + } + io.WriteString(w, fmt.Sprint(pt)) } else { w.Header().Set("Content-Type", "text/html") @@ -76,24 +191,21 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } +func (s *Server) Index(w http.ResponseWriter, req *http.Request, _ httprouter.Params) { + timeNow := time.Now() + _, currentWeek:= timeNow.ISOWeek() + ps := httprouter.Params{ + httprouter.Param{"week", strconv.Itoa(currentWeek)}, + } + s.Week(w, req, ps) +} + func (s *Server) updateAvailability(calData []caldav.CalData) error { + // TODO: Make max for recurring events without end time configurable + rrLimit := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + // Walk through events and store start/end time in the database for _, event := range calData { - // arran4 library has issues with parsing the VEVENT DTEND timezones below - // cal, err := ics.ParseCalendar(strings.NewReader(event.Data)) - // if err != nil { - // log.Fatalf("Error parsing VEVENT ics: %v", err) - // } - - // for _, e := range cal.Events() { - // dtend, err := e.GetEndAt() - // if err != nil { - // log.Println("Error parsing VEVENT DTEND: ", err) - // } - // log.Printf("%d: %v\n", dtend) - // } - - // use emersion library dec := ical.NewDecoder(strings.NewReader(event.Data)) cal, err := dec.Decode() if err != nil { @@ -101,50 +213,75 @@ func (s *Server) updateAvailability(calData []caldav.CalData) error { } for _, e := range cal.Events() { + var tz string + // https://github.com/emersion/go-ical/blob/master/enums.go + if tzProp := e.Props.Get(ical.PropTimezoneID); tzProp != nil { + tz = tzProp.Value + } else { + // TODO: make fallback timezone configurable + tz = "Europe/Zurich" + } start := e.Props.Get(ical.PropDateTimeStart).Value end := e.Props.Get(ical.PropDateTimeEnd).Value - // try parsing all possible time formats - startTime, err := parseTime(start) + // Parse all possible time formats + // Don't use ical DateTime(), has problems with some timezones + startTime, err := parseTime(start, tz) if err != nil { return err } - endTime, err := parseTime(end) + endTime, err := parseTime(end, tz) if err != nil { return err } + duration := endTime.Sub(startTime) - var tz string - if tzProp := e.Props.Get(ical.PropTimezoneID); tzProp != nil { - tz = tzProp.Value - } else { - tz = "" + // Parse the recurrence rules + // https://github.com/emersion/go-ical/blob/master/ical.go + // https://github.com/emersion/go-ical/blob/master/components.go + roption, err := e.Props.RecurrenceRule() + if err != nil { + return err } + // set new availability in temporary table availability_1 + if _, err = s.db.Exec("INSERT INTO availability_1 VALUES(NULL, ?, ?, ?);", startTime, endTime, tz); err != nil { + return err + } - // //localTimezone, err := time.LoadLocation("Europe/Zurich") + // Save recurring events + if roption != nil { + r, err := rrule.NewRRule(*roption) + if err != nil { + return err + } - // // Uses UTC when location is nil - // // https://github.com/emersion/go-ical/blob/master/ical.go - // // https://github.com/emersion/go-ical/issues/10 - // //dtstart, err := event.DateTimeStart(localTimezone) - // dtstart, err := e.DateTimeStart(nil) - // if err != nil { - // log.Println("TZID not in tzdb form") - // // https://github.com/emersion/go-ical/blob/master/example_test.go - // e.Props.SetText(ical.PropTimezoneID, "Europe/Zurich") - // dtstart, err = e.DateTimeStart(nil) - // } + set := rrule.Set{} + set.RRule(r) + set.DTStart(startTime) - // dtend, err := e.DateTimeEnd(nil) + // add exception dates to recurrence set exclusion list + exceptionDates := e.Props.Get(ical.PropExceptionDates) + if exceptionDates != nil { + exDateTime, err := parseTime(exceptionDates.Value, tz) + if err != nil { + return err + } + set.ExDate(exDateTime) + } - // set new availability in temporary table availability_1 - if _, err = s.db.Exec("INSERT INTO availability_1 VALUES(NULL, ?, ?, ?);", startTime, endTime, tz); err != nil { - return err + // don't include the first event, handled outside the loop + rDates := set.Between(startTime, rrLimit, false) + for _, rrd := range rDates { + if _, err = s.db.Exec("INSERT INTO availability_1 VALUES(NULL, ?, ?, ?);", rrd, rrd.Add(duration), tz); err != nil { + return err + } + } } } } + // delete current availability if _, err := s.db.Exec("DROP TABLE IF EXISTS availability"); err != nil { return err @@ -157,16 +294,22 @@ func (s *Server) updateAvailability(calData []caldav.CalData) error { if _, err := s.db.Exec(create_availability_1); err != nil { return err } + return nil } -func parseTime(timeStr string) (time.Time, error){ - t, err := time.Parse("20060102T150405Z", timeStr) +func parseTime(timeStr string, tz string) (time.Time, error){ + local, err := time.LoadLocation(tz) + if err != nil { + return time.Time{}, err + } + + t, err := time.ParseInLocation("20060102T150405Z", timeStr, local) if err != nil { - t, err = time.Parse("20060102T150405", timeStr) + t, err = time.ParseInLocation("20060102T150405", timeStr, local) } if err != nil { - t, err = time.Parse("20060102", timeStr) + t, err = time.ParseInLocation("20060102", timeStr, local) } if err != nil { return time.Time{}, err