- Added support for Faster Whisper transcription via Jigasi and Skynet. - Updated .env.example to include new environment variables for transcription settings. - Enhanced Jitsi Docker Compose configuration to include Skynet and Jigasi services. - Introduced new API endpoints for managing organizational folders in the drive service. - Updated Nextcloud initialization script to enable external file mounting. - Improved error handling and response structures in the drive API. - Added new properties for organization settings related to transcription and agenda management.
829 lines
24 KiB
Go
829 lines
24 KiB
Go
package nextcloud
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Calendar struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
Color string `json:"color"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type Event struct {
|
|
UID string `json:"uid"`
|
|
Summary string `json:"summary"`
|
|
Description string `json:"description"`
|
|
Location string `json:"location"`
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
AllDay bool `json:"all_day"`
|
|
Path string `json:"path,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
Organizer string `json:"organizer,omitempty"`
|
|
Attendees []EventAttendee `json:"attendees,omitempty"`
|
|
MeetURL string `json:"meet_url,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Sequence int `json:"sequence,omitempty"`
|
|
RRule string `json:"rrule,omitempty"`
|
|
ExDates []string `json:"exdates,omitempty"`
|
|
RawICS string `json:"raw_ics,omitempty"`
|
|
}
|
|
|
|
type EventAttendee struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
}
|
|
|
|
var (
|
|
ErrETagMismatch = errors.New("nextcloud calendar etag mismatch")
|
|
ErrAttendeeNotFound = errors.New("nextcloud calendar attendee not found")
|
|
)
|
|
|
|
type FreeBusyRequest struct {
|
|
Start time.Time
|
|
End time.Time
|
|
Attendees []string
|
|
}
|
|
|
|
type FreeBusyPeriod struct {
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
Type string `json:"type,omitempty"`
|
|
}
|
|
|
|
type AttendeeFreeBusy struct {
|
|
Email string `json:"email"`
|
|
Periods []FreeBusyPeriod `json:"periods"`
|
|
}
|
|
|
|
type FreeBusyResponse struct {
|
|
Attendees []AttendeeFreeBusy `json:"attendees"`
|
|
}
|
|
|
|
func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar, error) {
|
|
path := fmt.Sprintf("/remote.php/dav/calendars/%s/", userID)
|
|
body := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:cs="urn:ietf:params:xml:ns:caldav" xmlns:apple="http://apple.com/ns/ical/">
|
|
<d:prop>
|
|
<d:displayname/>
|
|
<apple:calendar-color/>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
|
|
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
|
|
"Depth": "1",
|
|
"Content-Type": "application/xml",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return parseCalendarList(resp.Body, path)
|
|
}
|
|
|
|
// CreateCalendar provisions a new calendar collection via MKCALENDAR.
|
|
func (c *Client) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
|
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
|
var props strings.Builder
|
|
props.WriteString(fmt.Sprintf("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
|
|
if strings.TrimSpace(color) != "" {
|
|
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
|
|
}
|
|
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/">
|
|
<d:set><d:prop>%s</d:prop></d:set>
|
|
</c:mkcalendar>`, props.String())
|
|
|
|
resp, err := c.DoAsUser(ctx, "MKCALENDAR", path, strings.NewReader(body), userID, map[string]string{
|
|
"Content-Type": "application/xml",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return fmt.Errorf("create calendar failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateCalendar patches display name and/or color via PROPPATCH.
|
|
func (c *Client) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
|
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
|
var props strings.Builder
|
|
if strings.TrimSpace(displayName) != "" {
|
|
props.WriteString(fmt.Sprintf("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
|
|
}
|
|
if strings.TrimSpace(color) != "" {
|
|
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
|
|
}
|
|
if props.Len() == 0 {
|
|
return nil
|
|
}
|
|
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propertyupdate xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
|
|
<d:set><d:prop>%s</d:prop></d:set>
|
|
</d:propertyupdate>`, props.String())
|
|
|
|
resp, err := c.DoAsUser(ctx, "PROPPATCH", path, strings.NewReader(body), userID, map[string]string{
|
|
"Content-Type": "application/xml",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("update calendar failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteCalendar removes a calendar collection and all of its events.
|
|
func (c *Client) DeleteCalendar(ctx context.Context, userID, calID string) error {
|
|
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
|
resp, err := c.DoAsUser(ctx, "DELETE", path, nil, userID, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("delete calendar failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) {
|
|
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:prop>
|
|
<d:getetag/>
|
|
<c:calendar-data/>
|
|
</d:prop>
|
|
<c:filter>
|
|
<c:comp-filter name="VCALENDAR">
|
|
<c:comp-filter name="VEVENT">
|
|
<c:time-range start="%s" end="%s"/>
|
|
</c:comp-filter>
|
|
</c:comp-filter>
|
|
</c:filter>
|
|
</c:calendar-query>`, from.Format("20060102T150405Z"), to.Format("20060102T150405Z"))
|
|
|
|
resp, err := c.DoAsUser(ctx, "REPORT", calendarPath, strings.NewReader(body), userID, map[string]string{
|
|
"Depth": "1",
|
|
"Content-Type": "application/xml",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return parseEventList(resp.Body)
|
|
}
|
|
|
|
func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, event *Event) error {
|
|
ics := buildICS(event)
|
|
uid := event.UID
|
|
if uid == "" {
|
|
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
|
|
}
|
|
eventPath := fmt.Sprintf("%s%s.ics", calendarPath, uid)
|
|
|
|
resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, map[string]string{
|
|
"Content-Type": "text/calendar; charset=utf-8",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
|
return fmt.Errorf("create event failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
|
|
eventPath = normalizeDAVHref(eventPath)
|
|
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("get event failed: %d", resp.StatusCode)
|
|
}
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
event := parseICS(string(raw))
|
|
event.RawICS = string(raw)
|
|
event.Path = eventPath
|
|
event.ETag = strings.TrimSpace(resp.Header.Get("ETag"))
|
|
return &event, nil
|
|
}
|
|
|
|
func uidFromEventPath(eventPath string) string {
|
|
eventPath = strings.TrimSuffix(strings.TrimSpace(eventPath), "/")
|
|
if idx := strings.LastIndex(eventPath, "/"); idx >= 0 {
|
|
eventPath = eventPath[idx+1:]
|
|
}
|
|
return strings.TrimSuffix(eventPath, ".ics")
|
|
}
|
|
|
|
// MergeEvent overlays patch onto existing. Patch wins for editable fields; UID and
|
|
// exdates fall back to existing when absent so CalDAV PUT keeps a valid master.
|
|
func MergeEvent(existing, patch *Event) *Event {
|
|
if existing == nil {
|
|
return patch
|
|
}
|
|
if patch == nil {
|
|
out := *existing
|
|
return &out
|
|
}
|
|
|
|
merged := *existing
|
|
merged.Summary = patch.Summary
|
|
merged.Description = patch.Description
|
|
merged.Location = patch.Location
|
|
if strings.TrimSpace(patch.Start) != "" {
|
|
merged.Start = patch.Start
|
|
merged.AllDay = patch.AllDay
|
|
}
|
|
if strings.TrimSpace(patch.End) != "" {
|
|
merged.End = patch.End
|
|
}
|
|
if strings.TrimSpace(patch.UID) != "" {
|
|
merged.UID = patch.UID
|
|
}
|
|
if len(patch.Attendees) > 0 {
|
|
merged.Attendees = patch.Attendees
|
|
}
|
|
if strings.TrimSpace(patch.Organizer) != "" {
|
|
merged.Organizer = patch.Organizer
|
|
}
|
|
if strings.TrimSpace(patch.MeetURL) != "" {
|
|
merged.MeetURL = patch.MeetURL
|
|
}
|
|
if strings.TrimSpace(patch.Color) != "" {
|
|
merged.Color = patch.Color
|
|
}
|
|
if strings.TrimSpace(patch.RRule) != "" {
|
|
merged.RRule = patch.RRule
|
|
}
|
|
if patch.ExDates != nil {
|
|
merged.ExDates = patch.ExDates
|
|
}
|
|
if strings.TrimSpace(merged.UID) == "" {
|
|
merged.UID = uidFromEventPath(existing.Path)
|
|
}
|
|
return &merged
|
|
}
|
|
|
|
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
|
|
eventPath = normalizeDAVHref(eventPath)
|
|
if strings.TrimSpace(event.UID) == "" {
|
|
event.UID = uidFromEventPath(eventPath)
|
|
}
|
|
ics := buildICS(event)
|
|
headers := map[string]string{
|
|
"Content-Type": "text/calendar; charset=utf-8",
|
|
}
|
|
if strings.TrimSpace(ifMatch) != "" {
|
|
headers["If-Match"] = strings.TrimSpace(ifMatch)
|
|
}
|
|
|
|
resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, headers)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusPreconditionFailed {
|
|
return "", ErrETagMismatch
|
|
}
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
|
|
return "", fmt.Errorf("update event failed: %d", resp.StatusCode)
|
|
}
|
|
return strings.TrimSpace(resp.Header.Get("ETag")), nil
|
|
}
|
|
|
|
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
|
eventPath = normalizeDAVHref(eventPath)
|
|
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 204 {
|
|
return fmt.Errorf("delete event failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) FreeBusy(ctx context.Context, userID string, req *FreeBusyRequest) (*FreeBusyResponse, error) {
|
|
path := fmt.Sprintf("/remote.php/dav/calendars/%s/outbox/", userID)
|
|
body := buildFreeBusyRequestICS(userID, req)
|
|
|
|
resp, err := c.DoAsUser(ctx, "POST", path, strings.NewReader(body), userID, map[string]string{
|
|
"Content-Type": "text/calendar; charset=utf-8",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus {
|
|
return nil, fmt.Errorf("free/busy request failed: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
|
|
return parseFreeBusyResponse(raw)
|
|
}
|
|
|
|
func buildFreeBusyRequestICS(userID string, req *FreeBusyRequest) string {
|
|
var b strings.Builder
|
|
b.WriteString("BEGIN:VCALENDAR\r\n")
|
|
b.WriteString("VERSION:2.0\r\n")
|
|
b.WriteString("PRODID:-//Ulti Suite//EN\r\n")
|
|
b.WriteString("METHOD:REQUEST\r\n")
|
|
b.WriteString("BEGIN:VFREEBUSY\r\n")
|
|
b.WriteString(fmt.Sprintf("UID:fb-%d@ulti\r\n", time.Now().UnixNano()))
|
|
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", userID))
|
|
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
|
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", req.Start.UTC().Format("20060102T150405Z")))
|
|
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", req.End.UTC().Format("20060102T150405Z")))
|
|
for _, email := range req.Attendees {
|
|
addr := strings.TrimSpace(email)
|
|
if !strings.HasPrefix(strings.ToLower(addr), "mailto:") {
|
|
addr = "mailto:" + addr
|
|
}
|
|
b.WriteString(fmt.Sprintf("ATTENDEE:%s\r\n", addr))
|
|
}
|
|
b.WriteString("END:VFREEBUSY\r\n")
|
|
b.WriteString("END:VCALENDAR\r\n")
|
|
return b.String()
|
|
}
|
|
|
|
func parseFreeBusyResponse(raw []byte) (*FreeBusyResponse, error) {
|
|
text := strings.TrimSpace(string(raw))
|
|
if text == "" {
|
|
return &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}}, nil
|
|
}
|
|
if strings.HasPrefix(text, "<?xml") || strings.HasPrefix(text, "<") {
|
|
var ms calMultistatus
|
|
if err := xml.Unmarshal(raw, &ms); err == nil && len(ms.Responses) > 0 {
|
|
attendees := make([]AttendeeFreeBusy, 0, len(ms.Responses))
|
|
for _, r := range ms.Responses {
|
|
if r.Propstat.Prop.CalendarData != "" {
|
|
parsed := parseFreeBusyICS(r.Propstat.Prop.CalendarData)
|
|
attendees = append(attendees, parsed.Attendees...)
|
|
}
|
|
}
|
|
if len(attendees) > 0 {
|
|
return &FreeBusyResponse{Attendees: attendees}, nil
|
|
}
|
|
}
|
|
}
|
|
return parseFreeBusyICS(text), nil
|
|
}
|
|
|
|
func parseFreeBusyICS(ics string) *FreeBusyResponse {
|
|
resp := &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}}
|
|
inFreeBusy := false
|
|
curIdx := -1
|
|
|
|
for _, line := range strings.Split(ics, "\n") {
|
|
line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
|
|
switch {
|
|
case line == "BEGIN:VFREEBUSY":
|
|
inFreeBusy = true
|
|
curIdx = -1
|
|
case line == "END:VFREEBUSY":
|
|
inFreeBusy = false
|
|
curIdx = -1
|
|
case inFreeBusy && strings.HasPrefix(line, "ATTENDEE"):
|
|
resp.Attendees = append(resp.Attendees, AttendeeFreeBusy{
|
|
Email: extractMailto(extractValue(line)),
|
|
})
|
|
curIdx = len(resp.Attendees) - 1
|
|
case inFreeBusy && strings.HasPrefix(line, "FREEBUSY") && curIdx >= 0:
|
|
period := parseFreeBusyLine(line)
|
|
resp.Attendees[curIdx].Periods = append(resp.Attendees[curIdx].Periods, period)
|
|
}
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func parseFreeBusyLine(line string) FreeBusyPeriod {
|
|
period := FreeBusyPeriod{Type: "BUSY"}
|
|
value := extractValue(line)
|
|
if idx := strings.Index(value, "/"); idx >= 0 {
|
|
period.Start = value[:idx]
|
|
period.End = value[idx+1:]
|
|
}
|
|
if paramPart, _, ok := strings.Cut(line, ":"); ok {
|
|
for _, param := range strings.Split(paramPart, ";")[1:] {
|
|
if strings.HasPrefix(strings.ToUpper(param), "FBTYPE=") {
|
|
period.Type = strings.TrimPrefix(strings.ToUpper(param), "FBTYPE=")
|
|
}
|
|
}
|
|
}
|
|
return period
|
|
}
|
|
|
|
func extractMailto(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if idx := strings.Index(strings.ToLower(value), "mailto:"); idx >= 0 {
|
|
return value[idx+7:]
|
|
}
|
|
return value
|
|
}
|
|
|
|
// escapeICSText escapes TEXT property values per RFC 5545 §3.3.11.
|
|
func escapeICSText(value string) string {
|
|
value = strings.ReplaceAll(value, "\\", "\\\\")
|
|
value = strings.ReplaceAll(value, "\r\n", "\n")
|
|
value = strings.ReplaceAll(value, "\r", "\n")
|
|
value = strings.ReplaceAll(value, "\n", "\\n")
|
|
value = strings.ReplaceAll(value, ";", "\\;")
|
|
value = strings.ReplaceAll(value, ",", "\\,")
|
|
return value
|
|
}
|
|
|
|
func unescapeICSText(value string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(value))
|
|
for i := 0; i < len(value); i++ {
|
|
if value[i] == '\\' && i+1 < len(value) {
|
|
switch value[i+1] {
|
|
case 'n', 'N':
|
|
b.WriteByte('\n')
|
|
i++
|
|
continue
|
|
case '\\', ';', ',':
|
|
b.WriteByte(value[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
b.WriteByte(value[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isDateOnlyValue(value string) bool {
|
|
if len(value) != 8 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeDateProp(b *strings.Builder, name, value string, allDay bool) {
|
|
value = strings.TrimSpace(value)
|
|
if allDay || isDateOnlyValue(value) {
|
|
fmt.Fprintf(b, "%s;VALUE=DATE:%s\r\n", name, value)
|
|
return
|
|
}
|
|
fmt.Fprintf(b, "%s:%s\r\n", name, value)
|
|
}
|
|
|
|
func buildICS(event *Event) string {
|
|
var b strings.Builder
|
|
b.WriteString("BEGIN:VCALENDAR\r\n")
|
|
b.WriteString("VERSION:2.0\r\n")
|
|
b.WriteString("PRODID:-//Ulti Suite//EN\r\n")
|
|
b.WriteString("BEGIN:VEVENT\r\n")
|
|
if event.UID != "" {
|
|
b.WriteString(fmt.Sprintf("UID:%s\r\n", event.UID))
|
|
}
|
|
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICSText(event.Summary)))
|
|
if event.Description != "" {
|
|
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICSText(event.Description)))
|
|
}
|
|
if event.Location != "" {
|
|
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICSText(event.Location)))
|
|
}
|
|
if event.Organizer != "" {
|
|
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", event.Organizer))
|
|
}
|
|
for _, attendee := range event.Attendees {
|
|
email := strings.TrimSpace(attendee.Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
status := normalizeAttendeeStatus(attendee.Status)
|
|
role := strings.ToUpper(strings.TrimSpace(attendee.Role))
|
|
if role == "" {
|
|
role = "REQ-PARTICIPANT"
|
|
}
|
|
line := fmt.Sprintf("ATTENDEE;PARTSTAT=%s;ROLE=%s", status, role)
|
|
if strings.TrimSpace(attendee.Name) != "" {
|
|
line += fmt.Sprintf(";CN=\"%s\"", strings.TrimSpace(attendee.Name))
|
|
}
|
|
line += fmt.Sprintf(":mailto:%s\r\n", email)
|
|
b.WriteString(line)
|
|
}
|
|
if strings.TrimSpace(event.MeetURL) != "" {
|
|
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
|
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
|
}
|
|
if strings.TrimSpace(event.RRule) != "" {
|
|
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
|
|
}
|
|
for _, exDate := range event.ExDates {
|
|
exDate = strings.TrimSpace(exDate)
|
|
if exDate == "" {
|
|
continue
|
|
}
|
|
if isDateOnlyValue(exDate) {
|
|
b.WriteString(fmt.Sprintf("EXDATE;VALUE=DATE:%s\r\n", exDate))
|
|
} else {
|
|
b.WriteString(fmt.Sprintf("EXDATE:%s\r\n", exDate))
|
|
}
|
|
}
|
|
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
|
|
writeDateProp(&b, "DTEND", event.End, event.AllDay)
|
|
if event.Sequence > 0 {
|
|
b.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", event.Sequence))
|
|
}
|
|
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
|
b.WriteString("END:VEVENT\r\n")
|
|
b.WriteString("END:VCALENDAR\r\n")
|
|
return b.String()
|
|
}
|
|
|
|
func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
|
|
var ms multistatus
|
|
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
basePath = normalizeDAVHref(basePath)
|
|
calendars := make([]Calendar, 0)
|
|
for _, r := range ms.Responses {
|
|
href := normalizeDAVHref(r.Href)
|
|
if href == basePath {
|
|
continue
|
|
}
|
|
name := r.Propstat.Prop.DisplayName
|
|
if name == "" {
|
|
continue
|
|
}
|
|
calendars = append(calendars, Calendar{
|
|
ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
|
|
DisplayName: name,
|
|
Color: r.Propstat.Prop.CalendarColor,
|
|
Path: href,
|
|
})
|
|
}
|
|
return calendars, nil
|
|
}
|
|
|
|
func parseEventList(body io.Reader) ([]Event, error) {
|
|
var ms calMultistatus
|
|
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
events := make([]Event, 0, len(ms.Responses))
|
|
for _, r := range ms.Responses {
|
|
ics := r.Propstat.Prop.CalendarData
|
|
event := parseICS(ics)
|
|
event.RawICS = ics
|
|
event.Path = normalizeDAVHref(r.Href)
|
|
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
|
|
events = append(events, event)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// unfoldICSLines splits raw ICS into logical lines, joining folded
|
|
// continuation lines (RFC 5545 §3.1: lines starting with space or tab).
|
|
func unfoldICSLines(ics string) []string {
|
|
raw := strings.Split(strings.ReplaceAll(ics, "\r\n", "\n"), "\n")
|
|
lines := make([]string, 0, len(raw))
|
|
for _, l := range raw {
|
|
if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(lines) > 0 {
|
|
lines[len(lines)-1] += strings.TrimLeft(l, " \t")
|
|
continue
|
|
}
|
|
l = strings.TrimRight(l, "\r")
|
|
if l != "" {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// parsePropLine splits "NAME;PARAM=V;PARAM2=V2:value" into name, params, value.
|
|
func parsePropLine(line string) (name string, params map[string]string, value string) {
|
|
head, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
return strings.ToUpper(line), nil, ""
|
|
}
|
|
parts := strings.Split(head, ";")
|
|
name = strings.ToUpper(strings.TrimSpace(parts[0]))
|
|
if len(parts) > 1 {
|
|
params = make(map[string]string, len(parts)-1)
|
|
for _, p := range parts[1:] {
|
|
k, v, found := strings.Cut(p, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
params[strings.ToUpper(strings.TrimSpace(k))] = strings.Trim(strings.TrimSpace(v), "\"")
|
|
}
|
|
}
|
|
return name, params, val
|
|
}
|
|
|
|
// normalizeICSDate converts a DTSTART/DTEND value to either a date-only value
|
|
// (YYYYMMDD) or a UTC datetime (YYYYMMDDTHHMMSSZ) when a TZID is provided.
|
|
// Floating times without TZID are returned unchanged.
|
|
func normalizeICSDate(value string, params map[string]string) (normalized string, allDay bool) {
|
|
value = strings.TrimSpace(value)
|
|
if params["VALUE"] == "DATE" || isDateOnlyValue(value) {
|
|
return value, true
|
|
}
|
|
if strings.HasSuffix(value, "Z") {
|
|
return value, false
|
|
}
|
|
if tzid := params["TZID"]; tzid != "" {
|
|
if loc, err := time.LoadLocation(tzid); err == nil {
|
|
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
|
|
return t.UTC().Format("20060102T150405Z"), false
|
|
}
|
|
}
|
|
}
|
|
return value, false
|
|
}
|
|
|
|
// extractVEventBlock returns the logical lines of the primary VEVENT in an ICS
|
|
// payload: the master component (no RECURRENCE-ID) when present, else the first.
|
|
func extractVEventBlock(lines []string) []string {
|
|
var blocks [][]string
|
|
var current []string
|
|
depth := 0
|
|
for _, line := range lines {
|
|
upper := strings.ToUpper(line)
|
|
switch {
|
|
case upper == "BEGIN:VEVENT":
|
|
depth++
|
|
current = nil
|
|
case upper == "END:VEVENT":
|
|
if depth > 0 {
|
|
blocks = append(blocks, current)
|
|
depth = 0
|
|
}
|
|
case depth > 0:
|
|
current = append(current, line)
|
|
}
|
|
}
|
|
if len(blocks) == 0 {
|
|
return nil
|
|
}
|
|
for _, b := range blocks {
|
|
master := true
|
|
for _, line := range b {
|
|
if strings.HasPrefix(strings.ToUpper(line), "RECURRENCE-ID") {
|
|
master = false
|
|
break
|
|
}
|
|
}
|
|
if master {
|
|
return b
|
|
}
|
|
}
|
|
return blocks[0]
|
|
}
|
|
|
|
func parseICS(ics string) Event {
|
|
var e Event
|
|
block := extractVEventBlock(unfoldICSLines(ics))
|
|
for _, line := range block {
|
|
name, params, value := parsePropLine(line)
|
|
switch name {
|
|
case "UID":
|
|
e.UID = value
|
|
case "SUMMARY":
|
|
e.Summary = unescapeICSText(value)
|
|
case "DESCRIPTION":
|
|
e.Description = unescapeICSText(value)
|
|
case "LOCATION":
|
|
e.Location = unescapeICSText(value)
|
|
case "ORGANIZER":
|
|
e.Organizer = extractMailto(value)
|
|
case "ATTENDEE":
|
|
e.Attendees = append(e.Attendees, parseAttendeeLine(line))
|
|
case "X-ULTI-MEET-URL":
|
|
e.MeetURL = value
|
|
case "URL":
|
|
if e.MeetURL == "" {
|
|
e.MeetURL = value
|
|
}
|
|
case "COLOR":
|
|
e.Color = value
|
|
case "RRULE":
|
|
e.RRule = value
|
|
case "SEQUENCE":
|
|
if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
|
e.Sequence = seq
|
|
}
|
|
case "EXDATE":
|
|
for _, ex := range strings.Split(value, ",") {
|
|
normalized, _ := normalizeICSDate(ex, params)
|
|
if normalized != "" {
|
|
e.ExDates = append(e.ExDates, normalized)
|
|
}
|
|
}
|
|
case "DTSTART":
|
|
e.Start, e.AllDay = normalizeICSDate(value, params)
|
|
case "DTEND":
|
|
e.End, _ = normalizeICSDate(value, params)
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func extractValue(line string) string {
|
|
if idx := strings.LastIndex(line, ":"); idx >= 0 {
|
|
return line[idx+1:]
|
|
}
|
|
return line
|
|
}
|
|
|
|
func parseAttendeeLine(line string) EventAttendee {
|
|
attendee := EventAttendee{Status: "NEEDS-ACTION"}
|
|
paramPart, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
return attendee
|
|
}
|
|
attendee.Email = extractMailto(value)
|
|
parts := strings.Split(paramPart, ";")
|
|
for _, p := range parts[1:] {
|
|
key, val, found := strings.Cut(p, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch strings.ToUpper(strings.TrimSpace(key)) {
|
|
case "PARTSTAT":
|
|
attendee.Status = strings.ToUpper(strings.TrimSpace(val))
|
|
case "CN":
|
|
attendee.Name = strings.Trim(strings.TrimSpace(val), "\"")
|
|
case "ROLE":
|
|
attendee.Role = strings.ToUpper(strings.TrimSpace(val))
|
|
}
|
|
}
|
|
return attendee
|
|
}
|
|
|
|
func normalizeAttendeeStatus(status string) string {
|
|
status = strings.ToUpper(strings.TrimSpace(status))
|
|
switch status {
|
|
case "ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION":
|
|
return status
|
|
default:
|
|
return "NEEDS-ACTION"
|
|
}
|
|
}
|
|
|
|
type calMultistatus struct {
|
|
XMLName xml.Name `xml:"multistatus"`
|
|
Responses []calResponse `xml:"response"`
|
|
}
|
|
|
|
type calResponse struct {
|
|
Href string `xml:"href"`
|
|
Propstat calPropstat `xml:"propstat"`
|
|
}
|
|
|
|
type calPropstat struct {
|
|
Prop calProp `xml:"prop"`
|
|
}
|
|
|
|
type calProp struct {
|
|
ETag string `xml:"getetag"`
|
|
CalendarData string `xml:"calendar-data"`
|
|
}
|