Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Крылов Александр committed Jul 12, 2024
0 parents commit 163f170
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
env.sh
1 change: 1 addition & 0 deletions CONRIBUTORS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
https://github.com/Truenya
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions caldav-fetch.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
207 changes: 207 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
icalendar
caldav
json

0 comments on commit 163f170

Please sign in to comment.