package main import ( "bufio" "bytes" "encoding/xml" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "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 } func (i ICal) Valid() bool { return len(i.eventData) > 0 } // 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 "" } // 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 "" } // 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 } var fieldMatch = regexp.MustCompile(`^(\w+)[;:]`) // Update the ical structure with a new start/end time (and the // timestamp of the last modification). // 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) { if wholeDay && (newStart.Hour() != 0 || newStart.Minute() != 0 || newStart.Second() != 0 || newEnd.Hour() != 0 || newEnd.Minute() != 0 || newEnd.Second() != 0) { newStart = newStart.In(time.Local) newEnd = newEnd.In(time.Local) if newStart.Hour() != 0 || newStart.Minute() != 0 || newStart.Second() != 0 || newEnd.Hour() != 0 || newEnd.Minute() != 0 || newEnd.Second() != 0 { 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" } } 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": ical.eventData[i] = match[1] + formatTime(newStart) case "DTEND": ical.eventData[i] = match[1] + formatTime(newEnd) case "DTSTAMP", "LAST-MODIFIED": ical.eventData[i] = match[1] + ":" + now.Format(ICAL_TIME) + "Z" } } } // Retrieve the start time of the event. func (i ICal) Start() time.Time { return i.getTimeField("DTSTART") } // 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 } // Builds a string representation of the ICal (as ICS). 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() } // 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 { log.Println("Encountered calendar item without event") return &ICal{ data: data, eventData: make([]string, 0), }, err } return &ICal{ data: data, eventData: data[eventStart+1 : eventEnd], }, err } // Create a new empty ICal structure with the given parameters. // Currently only supports time based events; date-only is not // supported. func CreateICal(uid, summary string, start, end time.Time, wholeDay bool) *ICal { 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) } if wholeDay { // To not duplicate the wholeDay logic here (or in the template), // simply postprocess the generated ical. ical.Update(start, end, wholeDay) } return ical } func NewCalDAV(settings ServerSettings) *CalDAV { return &CalDAV{ httpClient: http.DefaultClient, ServerSettings: settings, } } type CalDAVItem struct { HRef string *ICal } // 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(``)) 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 { ical := p.PropStat.Prop.CalendarData if ical.Valid() { item := CalDAVItem{p.HRef, &ical} result = append(result, item) } } return result, nil } // 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 } // 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"` } var icalTemplate = template.Must(template.New("icalTemplate").Parse(`BEGIN:VCALENDAR PRODID:-//aksdb//calanonsync//EN 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`))