CalAnonSync/src/calanonsync/caldav.go

380 lines
8.9 KiB
Go
Raw Normal View History

package main
import (
"bufio"
2018-04-01 20:12:36 +02:00
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
2018-04-02 11:51:57 +02:00
"regexp"
"strings"
2018-04-01 11:27:53 +02:00
"text/template"
"time"
)
const ICAL_TIME = "20060102T150405"
const ICAL_DATE = "20060102"
type CalDAV struct {
ServerSettings
httpClient *http.Client
}
type ICal struct {
data []string
eventData []string // sub slice
}
2018-04-02 10:36:45 +02:00
// Retrieve the summary (aka title) of the event.
func (i ICal) Summary() string {
for _, s := range i.eventData {
if strings.HasPrefix(s, "SUMMARY:") {
return s[len("SUMMARY:"):]
}
}
return ""
}
2018-04-02 10:36:45 +02:00
// Retrieve the UID of the event.
func (i ICal) UID() string {
for _, s := range i.eventData {
if strings.HasPrefix(s, "UID:") {
return s[len("UID:"):]
}
}
return ""
}
2018-04-02 10:36:45 +02:00
// Internal helper function to retrieve a time from the given field within
// the event data of the ICal structure. Timezone information are ignored.
// The time is therefore either a local time or a UTC time, depending on
// the notation.
func (i ICal) getTimeField(f string) (t time.Time) {
for _, s := range i.eventData {
if strings.HasPrefix(s, f) {
st := s[strings.LastIndex(s, ":")+1:]
// BEWARE, we don't parse possible ICal timezones (yet). If it is not
// a UTC time, we simply assume local time.
zone := time.Local
if st[len(st)-1:] == "Z" {
st = st[:len(st)-1]
zone = time.UTC
}
var err error
if strings.Contains(s, ";VALUE=DATE") {
t, err = time.ParseInLocation(ICAL_DATE, st, zone)
} else {
t, err = time.ParseInLocation(ICAL_TIME, st, zone)
}
if err != nil {
log.Printf("Cannot decode %s '%s': %s", f, st, err)
}
return
}
}
return
}
2018-04-02 11:51:57 +02:00
var fieldMatch = regexp.MustCompile(`^(\w+)[;:]`)
// Update the ical structure with a new start/end time (and the
// timestamp of the last modification).
2018-04-02 12:56:55 +02:00
// If wholeDay is requested, the start and end times are tried
// first. If they are precisely 0:00 to 0:00, fine. Otherwise
// they are converted to local timezone and tried again. If that
// also fails the event is NOT considered whole day!
func (ical *ICal) Update(newStart, newEnd time.Time, wholeDay bool) {
2018-04-02 13:03:41 +02:00
if wholeDay && (newStart.Hour() != 0 || newStart.Minute() != 0 || newStart.Second() != 0 ||
newEnd.Hour() != 0 || newEnd.Minute() != 0 || newEnd.Second() != 0) {
2018-04-02 12:56:55 +02:00
newStart = newStart.In(time.Local)
newEnd = newEnd.In(time.Local)
2018-04-02 13:03:41 +02:00
if newStart.Hour() != 0 || newStart.Minute() != 0 || newStart.Second() != 0 ||
newEnd.Hour() != 0 || newEnd.Minute() != 0 || newEnd.Second() != 0 {
2018-04-02 12:56:55 +02:00
log.Printf("Cannot update event %s as whole day.\n", ical.UID())
wholeDay = false
}
}
formatTime := func(t time.Time) string {
if wholeDay {
return ";VALUE=DATE:" + t.Format(ICAL_DATE)
} else {
return ":" + t.UTC().Format(ICAL_TIME) + "Z"
}
}
2018-04-02 11:51:57 +02:00
for i, line := range ical.eventData {
match := fieldMatch.FindStringSubmatch(line)
if match == nil {
continue
}
// For last modification
now := time.Now().UTC()
switch match[1] {
case "DTSTART":
2018-04-02 12:56:55 +02:00
ical.eventData[i] = match[1] + formatTime(newStart)
2018-04-02 11:51:57 +02:00
case "DTEND":
2018-04-02 12:56:55 +02:00
ical.eventData[i] = match[1] + formatTime(newEnd)
2018-04-02 11:51:57 +02:00
case "DTSTAMP", "LAST-MODIFIED":
ical.eventData[i] = match[1] + ":" + now.Format(ICAL_TIME) + "Z"
}
}
}
2018-04-02 10:36:45 +02:00
// Retrieve the start time of the event.
func (i ICal) Start() time.Time {
return i.getTimeField("DTSTART")
}
2018-04-02 10:36:45 +02:00
// Retrieve the end time of the event.
func (i ICal) End() time.Time {
return i.getTimeField("DTEND")
}
func (i *ICal) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
s := ""
err := d.DecodeElement(&s, &start)
if err != nil {
return err
}
ical, err := ParseICal(strings.NewReader(s))
if err != nil {
return err
}
*i = *ical
return nil
}
2018-04-02 10:36:45 +02:00
// Builds a string representation of the ICal (as ICS).
2018-04-01 11:22:55 +02:00
func (i ICal) String() string {
sb := &strings.Builder{}
for n, line := range i.data {
if n > 0 {
sb.WriteRune('\n')
}
sb.WriteString(line)
}
return sb.String()
}
2018-04-02 10:36:45 +02:00
// Parses the given ICal into structure. It identifies the first (and only
// the first) event within the given calendar.
func ParseICal(r io.Reader) (*ICal, error) {
var data []string
scanner := bufio.NewScanner(r)
eventStart := -1
eventEnd := -1
i := 0
for scanner.Scan() {
line := scanner.Text()
data = append(data, line)
if eventStart == -1 && line == "BEGIN:VEVENT" {
eventStart = i
}
if eventEnd == -1 && line == "END:VEVENT" {
eventEnd = i
}
i++
}
err := scanner.Err()
if err != nil {
return nil, err
}
// If we didn't find a start or an end, abort.
if eventStart < 0 || eventEnd <= eventStart {
return nil, errors.New("calendar item without event")
}
return &ICal{
data: data,
eventData: data[eventStart+1 : eventEnd],
}, err
}
2018-04-02 10:36:45 +02:00
// Create a new empty ICal structure with the given parameters.
// Currently only supports time based events; date-only is not
// supported.
2018-04-02 13:20:48 +02:00
func CreateICal(uid, summary string, start, end time.Time, wholeDay bool) *ICal {
2018-04-01 20:12:36 +02:00
model := struct {
UID string
Summary string
Start string
End string
Now string
}{
UID: uid,
Summary: summary,
Start: start.UTC().Format(ICAL_TIME),
End: end.UTC().Format(ICAL_TIME),
Now: time.Now().UTC().Format(ICAL_TIME),
}
b := &bytes.Buffer{}
err := icalTemplate.Execute(b, model)
if err != nil {
// Internal stuff, so this really should not fail.
panic(err)
}
ical, err := ParseICal(b)
if err != nil {
// Internal stuff, so this really should not fail.
panic(err)
}
2018-04-02 13:20:48 +02:00
if wholeDay {
// To not duplicate the wholeDay logic here (or in the template),
// simply postprocess the generated ical.
ical.Update(start, end, wholeDay)
}
2018-04-01 20:12:36 +02:00
return ical
}
func NewCalDAV(settings ServerSettings) *CalDAV {
return &CalDAV{
httpClient: http.DefaultClient,
ServerSettings: settings,
}
}
type CalDAVItem struct {
HRef string
*ICal
}
2018-04-02 10:36:45 +02:00
// Retrieve all events from the configured CalDAV server.
// Each item will contain the original URL in relative format
// as well as the ICal data for the given event.
//
// It is expected that each event is a ICal calendar with only
// a single event.
func (c *CalDAV) GetEvents() ([]CalDAVItem, error) {
req, err := http.NewRequest("PROPFIND", c.URL, strings.NewReader(`<propfind xmlns='DAV:'><prop><calendar-data xmlns='urn:ietf:params:xml:ns:caldav'/></prop></propfind>`))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "text/xml")
req.SetBasicAuth(c.Username, c.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("Unexpected status code: %d", resp.StatusCode)
}
defer resp.Body.Close()
ms := &MultiStatus{}
err = xml.NewDecoder(resp.Body).Decode(ms)
if err != nil {
return nil, err
}
result := make([]CalDAVItem, 0, len(ms.Response))
for _, p := range ms.Response {
2018-04-01 20:25:44 +02:00
ical := p.PropStat.Prop.CalendarData
item := CalDAVItem{p.HRef, &ical}
result = append(result, item)
}
return result, nil
}
2018-04-02 10:36:45 +02:00
// Put the given item on the configured CalDAV server, either updating
// an existing item or creating a new one.
// The target URL is calculated by the URL in the settings together with
// the HRef of the CalDAV item.
func (c CalDAV) UploadItem(item CalDAVItem) error {
itemURL, _ := url.Parse(item.HRef)
base, _ := url.Parse(c.URL)
// Build the absolute URL for the item
itemURL = base.ResolveReference(itemURL)
req, err := http.NewRequest("PUT", itemURL.String(), strings.NewReader(item.ICal.String()))
if err != nil {
return err
}
req.SetBasicAuth(c.Username, c.Password)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode >= 300 {
return fmt.Errorf("Unexpected result while uploading %s: %d", itemURL.String(), resp.StatusCode)
}
return nil
}
2018-04-02 10:36:45 +02:00
// Removes the given item from the configured CalDAV server using the
// calculated URL from the settings together with the HRef of the given
// CalDAV item.
func (c CalDAV) DeleteItem(item CalDAVItem) error {
itemURL, _ := url.Parse(item.HRef)
base, _ := url.Parse(c.URL)
// Build the absolute URL for the item
itemURL = base.ResolveReference(itemURL)
req, err := http.NewRequest("DELETE", itemURL.String(), nil)
if err != nil {
return err
}
req.SetBasicAuth(c.Username, c.Password)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode >= 300 {
return fmt.Errorf("Unexpected result while deleting %s: %d", itemURL.String(), resp.StatusCode)
}
return nil
}
type MultiStatus struct {
Response []PropFindResponse `xml:"response"`
}
type PropFindResponse struct {
HRef string `xml:"href"`
PropStat struct {
Prop struct {
CalendarData ICal `xml:"calendar-data"`
} `xml:"prop"`
} `xml:"propstat"`
}
2018-04-01 11:27:53 +02:00
var icalTemplate = template.Must(template.New("icalTemplate").Parse(`BEGIN:VCALENDAR
2018-04-01 20:12:36 +02:00
PRODID:-//aksdb//calanonsync//EN
2018-04-01 11:27:53 +02:00
VERSION:2.0
BEGIN:VEVENT
UID:{{ .UID }}
SUMMARY:{{ .Summary }}
CLASS:PUBLIC
DTSTART:{{ .Start }}Z
DTEND:{{ .End }}Z
CREATED:{{ .Now }}Z
DTSTAMP:{{ .Now }}Z
LAST-MODIFIED:{{ .Now }}Z
END:VEVENT
END:VCALENDAR`))