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 7c5c5cfa62db55d2088cf89558266d382421081a
parent 770da5fd591500032a931250e57690a4cae81146
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Tue, 12 Sep 2023 10:29:28 +0200

feat: parse time

Diffstat:
M.gitignore | 1+
MREADME.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
Mmain.go | 61+++----------------------------------------------------------
Mserver.go | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 172 insertions(+), 70 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,3 +2,4 @@ *.env coverage.out hivedav +app.db diff --git a/README.md b/README.md @@ -48,16 +48,63 @@ HIVEDAV_CALDAV_PASSWORD= There is an example config file provided in `./app.env.example`. -## Storage +The `HIVEDAV_CALENDAR` is a number that points to the index of the calendar +collection in the ["calendar home +set"](https://www.rfc-editor.org/rfc/rfc4791.html#section-6.2.1) which should +be used to read availability data. + +TODO: specify calendar using the "Name" propery +## Storage The application stores the event start/end times in an sqlite database in the directory where the process is running. +The schema of the `availability` table is very simple and straight forward: + +```bash +sqlite3 app.db ".schema availability" +CREATE TABLE availability ( + id INTEGER NOT NULL PRIMARY KEY, + start DATETIME NOT NULL, + end DATETIME NOT NULL, + tz TEXT NOT NULL); +``` + +To show the current availability time slots: ```bash sqlite3 app.db "select * from availability;" | less +sqlite3 app.db "select * from availability where start between '2023-01-01' and '2023-02-01';" | less ``` -TODO: Add the schema +There exists another table `availability_1` which is used to store the new +availability time slots during an availability update ("CalDAV request"). +During this CalDAV request, the table `availability_1` is used to temporarily +store the new availability. After the update is done, the temporary table is +set as the "current" availability. The application only reads availability from +the `availability` table, not from the temporary `availability_1` table. + +## CalDAV Requests +The CalDAV requests in the `caldav` module are used to query: +* the user principal +* the calendar home set +* the calendar from the home set (`HIVEDAV_CALENDAR`) used as source of + availability data + +The CalDAV requests are mad according to the examples in +https://sabre.io/dav/building-a-caldav-client. + +The requests are kept very simple intentionally with libraries included in Go: +* The requests only use the `net/http` library to do the HTTP requests. +* The response is parsed using the `encoding/xml` library + +No external library was used perform the requests, because these libraries +typically assume specific behavior for the remote CalDAV server. Unfortunately, +the servers vary in their behavior quite a lot. For instance, certain servers +don't support the [`calendar-query +REPORT`](https://www.rfc-editor.org/rfc/rfc4791.html#section-7.8). To make this +application work with as many remote servers as possible, all events from the +remote calendar are fetched during an update and the availability data is +stored in a local database for further processing. ## Development & Contributions * All source code is available in this repository. Contributions are always diff --git a/main.go b/main.go @@ -3,13 +3,9 @@ package main import ( "fmt" "log" - //"time" - "strings" "net/http" "hivedav/config" "hivedav/caldav" - "github.com/emersion/go-ical" - //"github.com/arran4/golang-ical" ) func main() { @@ -59,60 +55,9 @@ func main() { log.Fatal("Error reading calendar URI: ", err) } - // 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 { - log.Fatalf("Error decoding VEVENT ics: %v", err) - } - - for _, e := range cal.Events() { - - - start := e.Props.Get(ical.PropDateTimeStart).Value - end := e.Props.Get(ical.PropDateTimeEnd).Value - var tz string - if tzProp := e.Props.Get(ical.PropTimezoneID); tzProp != nil { - tz = tzProp.Value - } else { - tz = "" - } - - // //localTimezone, err := time.LoadLocation("Europe/Zurich") - - // // 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) - // } - - // dtend, err := e.DateTimeEnd(nil) - if _, err = server.db.Exec("INSERT INTO availability VALUES(NULL, ?, ?, ?);", start, end, tz); err != nil { - log.Fatal("Error saving events to database", err) - } - } + err = server.updateAvailability(calData) + if err != nil { + log.Fatalf("Error fetching availability: %v", err) } log.Printf("Ready, listening on %s:%d\n", conf.ListenAddress, conf.ListenPort) diff --git a/server.go b/server.go @@ -1,7 +1,11 @@ package main import ( + "time" + "github.com/emersion/go-ical" + //"github.com/arran4/golang-ical" "hivedav/config" + "hivedav/caldav" "fmt" "github.com/pquerna/termchalk/ansistyle" "github.com/pquerna/termchalk/prettytable" @@ -18,6 +22,19 @@ type Server struct { db *sql.DB } +var drop = `DROP TABLE IF EXISTS availability; + DROP TABLE IF EXISTS availability_1;` +var create_availability = `CREATE TABLE IF NOT EXISTS availability ( + id INTEGER NOT NULL PRIMARY KEY, + start DATETIME NOT NULL, + end DATETIME NOT NULL, + tz TEXT NOT NULL);` +var create_availability_1 = `CREATE TABLE IF NOT EXISTS availability_1 ( + id INTEGER NOT NULL PRIMARY KEY, + start DATETIME NOT NULL, + end DATETIME NOT NULL, + tz TEXT NOT NULL);` + func (s *Server) NewServer(c *config.Config) (*Server, error) { s = new(Server) s.config = c @@ -29,19 +46,13 @@ func (s *Server) NewServer(c *config.Config) (*Server, error) { } s.db = db - drop := "DROP TABLE IF EXISTS availability;" - create := ` - CREATE TABLE availability ( - id INTEGER NOT NULL PRIMARY KEY, - start DATETIME NOT NULL, - end DATETIME NOT NULL, - tz TEXT NOT NULL - );` - if _, err := db.Exec(drop); err != nil { return nil, err } - if _, err := db.Exec(create); err != nil { + if _, err := db.Exec(create_availability); err != nil { + return nil, err + } + if _, err := db.Exec(create_availability_1); err != nil { return nil, err } @@ -65,6 +76,104 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } +func (s *Server) updateAvailability(calData []caldav.CalData) error { + // 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 { + return err + } + + for _, e := range cal.Events() { + + start := e.Props.Get(ical.PropDateTimeStart).Value + end := e.Props.Get(ical.PropDateTimeEnd).Value + + // try parsing all possible time formats + startTime, err := parseTime(start) + if err != nil { + return err + } + endTime, err := parseTime(end) + if err != nil { + return err + } + + var tz string + if tzProp := e.Props.Get(ical.PropTimezoneID); tzProp != nil { + tz = tzProp.Value + } else { + tz = "" + } + + + // //localTimezone, err := time.LoadLocation("Europe/Zurich") + + // // 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) + // } + + // dtend, err := e.DateTimeEnd(nil) + + // 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 + } + } + } + // delete current availability + if _, err := s.db.Exec("DROP TABLE IF EXISTS availability"); err != nil { + return err + } + // set new availability as current availability + if _, err := s.db.Exec("ALTER TABLE availability_1 RENAME TO availability"); err != nil { + return err + } + // prepare new temporary table + 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) + if err != nil { + t, err = time.Parse("20060102T150405", timeStr) + } + if err != nil { + t, err = time.Parse("20060102", timeStr) + } + if err != nil { + return time.Time{}, err + } + return t, nil +} + func (s *Server) showCubicles() { }