diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..137e678 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +env.sh diff --git a/CONRIBUTORS b/CONRIBUTORS new file mode 100644 index 0000000..69defb4 --- /dev/null +++ b/CONRIBUTORS @@ -0,0 +1 @@ +https://github.com/Truenya diff --git a/README.md b/README.md new file mode 100644 index 0000000..55bebca --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Caldav notification daemon +### prepare +``` +pip install -r requirements.txt +sudo ln -s $PWD/caldav-fetch.py /usr/bin/caldav-fetch.py +go mod tidy +``` + +#### run +``` +go run main.go & disown +``` + +#### or build and run +``` +go build -o caldav_daemon . +./caldav_daemon & disown +``` + +#### to stop +``` +killall caldav_daemon +``` + +###### or +``` +killall go +``` + +## environment variables to configure +### must be set +- CALDAV_USERNAME +- CALDAV_PASSWORD +- CALDAV_URL + +### optional +- CALDAV_NOTIFY_ICON +- CALDAV_SERVER_OFFSET_HOURS default - same as server +- CALDAV_REFRESH_PERIOD_MINUTES default 10 +- CALDAV_NOTIFY_BEFORE_MINUTES default 5 + +## for better experience +it is useful to create env file with environment variables and source it diff --git a/caldav-fetch.py b/caldav-fetch.py new file mode 100755 index 0000000..032ee7c --- /dev/null +++ b/caldav-fetch.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from icalendar import Calendar +import caldav +import json +from os import environ as env + +username = env["CALDAV_USERNAME"] +password = env["CALDAV_PASSWORD"] +url = env["CALDAV_URL"] +caldav_url = f"https://{url}/{username}/" +headers = {} + + +def fetch_and_print(): + with caldav.DAVClient( + url=caldav_url, + username=username, + password=password, + headers=headers, # Optional parameter to set HTTP headers on each request if needed + ) as client: + print_calendar_events_json(client.principal().calendars()) + + +def print_calendar_events_json(calendars): + if not calendars: + return + events = [] + for calendar in calendars: + for eventraw in calendar.events(): + for component in Calendar.from_ical(eventraw._data).walk(): + if component.name != "VEVENT": + continue + cur = {} + cur['calendar'] = f'{calendar}' + cur['summary'] = component.get('summary') + cur['description'] = component.get('description') + cur['start'] = component.get('dtstart').dt.strftime('%m/%d/%Y %H:%M') + endDate = component.get('dtend') + if endDate and endDate.dt: + cur['end'] = endDate.dt.strftime('%m/%d/%Y %H:%M') + cur['datestamp'] = component.get('dtstamp').dt.strftime('%m/%d/%Y %H:%M') + events.append(cur) + print(json.dumps(events, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + fetch_and_print() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2851829 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module caldav_daemon + +go 1.22.5 + +require github.com/martinlindhe/notify v0.0.0-20181008203735-20632c9a275a + +require ( + github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..83c357a --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw= +github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y= +github.com/martinlindhe/notify v0.0.0-20181008203735-20632c9a275a h1:nQcAxLK581HrmqF0TVy2GC3iFjB8X+aWGtxQ/t2uyGE= +github.com/martinlindhe/notify v0.0.0-20181008203735-20632c9a275a/go.mod h1:zL1p4SieQ27ZZ4V4KdVYdEcSkVl1OwNoi8xI1r5hJkc= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo= +gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a011cae --- /dev/null +++ b/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/martinlindhe/notify" +) + +const ( + defaultTimeBeforeNotify = 5 * time.Minute + defaultRefreshPeriod = 10 * time.Minute + timeFormat = "1/02/2006 15:04" + tzFile = "/etc/timezone" + messageAboutOffsets = "if event is in future, probably your timezone is not same as on your server, than please set offset in the CALDAV_SERVER_OFFSET_HOURS env var\n" + notifyEnvIcon = "CALDAV_NOTIFY_ICON" + notifyEnvOffset = "CALDAV_SERVER_OFFSET_HOURS" + notifyEnvRefreshPeriod = "CALDAV_REFRESH_PERIOD_MINUTES" + notifyEnvTimeBefore = "CALDAV_NOTIFY_BEFORE_MINUTES" +) + +func main() { + notify.Notify("", "https://github.com/Truenya", "notification daemon started", "") + events := make(chan event) + go planEvents(events) + + icon := os.Getenv(notifyEnvIcon) + for e := range events { + notify.Notify("", e.Summary, e.Description, icon) + } +} + +func planEvents(ch chan event) { + planned := map[string]struct{}{} + offset := getOffsetByServer() + period := getRefreshPeriod() + timeBefore := getTimeBeforeNotify() + for { + events := getEvents(offset) + checkAndRememberEvents(ch, events, planned, timeBefore) + time.Sleep(period) + } +} + +func checkAndRememberEvents(ch chan event, events []event, planned map[string]struct{}, timeBefore time.Duration) { + for _, e := range events { + key := e.Summary + e.Start.String() + if _, ok := planned[key]; ok || e.isPast() { + if e.isToday() { + fmt.Printf("skipping event from past: %s at: %s\n %s", e.Summary, e.Start.String(), messageAboutOffsets) + } + continue + } + + go e.notify(ch, timeBefore) + planned[key] = struct{}{} + + fmt.Println("planned event:", e.Summary, "at:", e.Start.String()) + } +} + +func (e event) notify(ch chan event, timeBefore time.Duration) { + dur := time.Until(e.Start) - timeBefore + if dur > 0 { + fmt.Println("sleeping for:", dur, "for event:", e.Summary) + time.Sleep(dur) + } + ch <- e + fmt.Printf("notified for event: %+v", e) +} + +func getEvents(offset time.Duration) []event { + out, err := exec.Command("caldav-fetch.py").Output() + if err != nil { + processExitError(err) + } + + data := []eventRaw{} + if err := json.Unmarshal(out, &data); err != nil { + panic(err) + } + + result := make([]event, len(data)) + for i, e := range data { + e, err := eventFromEventRaw(e, offset) + if err != nil { + fmt.Printf("failed to parse event: %+v", e) + continue + } + result[i] = e + } + + return result +} + +type eventRaw struct { + Name string + Start string + End string + Datestamp string + Summary string + Description string +} + +type event struct { + Name string + Summary string + Description string + Start time.Time + End time.Time + CreatedAt time.Time +} + +func (e event) isPast() bool { + return e.Start.Before(time.Now()) && e.End.Before(time.Now()) +} + +func (e event) isToday() bool { + return e.Start.Day() == time.Now().Day() +} + +func eventFromEventRaw(raw eventRaw, offset time.Duration) (event, error) { + start, err := time.Parse(timeFormat, raw.Start) + if err != nil { + return event{}, err + } + end, _ := time.Parse(timeFormat, raw.End) + datestamp, _ := time.Parse(timeFormat, raw.Datestamp) + + if offset == 0 { + // The time comes according to the server time zone, but is read as UTC, so we subtract offset + // Let's assume that the server is in the same zone as the client + // Otherwise, offset must be set in the corresponding environment variable + _, offset := time.Now().Zone() + + // change time by timezone of current location + start = start.Add(-time.Duration(offset) * time.Second) + end = end.Add(-time.Duration(offset) * time.Second) + datestamp = datestamp.Add(-time.Duration(offset) * time.Second) + } + + return event{ + Name: raw.Name, + Summary: raw.Summary, + Description: raw.Description, + Start: start, + End: end, + CreatedAt: datestamp, + }, nil +} + +func getRefreshPeriod() time.Duration { + period := os.Getenv(notifyEnvRefreshPeriod) + if period == "" { + return defaultRefreshPeriod + } + d, err := strconv.Atoi(period) + if err != nil { + fmt.Println("error parsing refresh period:", err) + return defaultRefreshPeriod + } + return time.Duration(d) * time.Minute +} + +func getTimeBeforeNotify() time.Duration { + period := os.Getenv(notifyEnvTimeBefore) + if period == "" { + return defaultTimeBeforeNotify + } + d, err := strconv.Atoi(period) + if err != nil { + fmt.Println("error parsing time before notify:", err) + return defaultTimeBeforeNotify + } + return time.Duration(d) * time.Minute +} + +func getOffsetByServer() time.Duration { + offsetHours := os.Getenv(notifyEnvOffset) + if offsetHours == "" { + return 0 + } + + intOffset, err := strconv.Atoi(offsetHours) + if err != nil { + return 0 + } + + return time.Duration(intOffset) * time.Hour +} + +func processExitError(err error) { + ee := &exec.ExitError{} + if !errors.As(err, &ee) || !strings.Contains(string(ee.Stderr), "Unauthorized") { + fmt.Printf("failed to run caldav-fetch.py: \n%s", err) + os.Exit(1) + } + + fmt.Println("Unauthorized. Please check your credentials in python script.") + os.Exit(1) +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..529ffbd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +icalendar +caldav +json