diff --git a/README.md b/README.md index dccc64e..2b149b3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ documentation [here](./docs/adapters.md). - Google - Outlook - [ZEP](https://www.zep.de/en/) +- Apple ## Sink @@ -128,6 +129,7 @@ documentation [here](./docs/adapters.md). - Google - Outlook +- Apple ## Transformers @@ -228,7 +230,7 @@ events which weren't synced with CalendarSync alone! :) ) # Trademarks GOOGLE is a trademark of GOOGLE INC. OUTLOOK is a trademark of Microsoft -Corporation +Corporation. APPLE is a trademark of Apple Inc. # Relevant RFCs and Links diff --git a/docs/adapters.md b/docs/adapters.md index a36d05e..0cb72b6 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -88,3 +88,53 @@ sink: ``` If you want to use the created OAuth Application also with accounts outside of your Google Workspace, make sure to set the Usertype to `external` in the `OAuth Consent Screen` Menu. + +## Apple CalDAV Adapter Setup + +The Apple CalDAV adapter allows synchronization with iCloud calendars using the industry-standard CalDAV protocol. This adapter works with any Apple ID that has iCloud calendar access enabled. + +### Prerequisites + +Before setting up the adapter, you need: +- An Apple ID with iCloud enabled +- Access to Apple ID account settings (appleid.apple.com) +- The calendar identifier from your Calendar.app + +### Setting up App-Specific Password + +Since Apple requires two-factor authentication for iCloud access, you cannot use your regular Apple ID password. Instead, you must generate an app-specific password: + +1. Sign in to [appleid.apple.com](https://appleid.apple.com) +2. Navigate to "Sign-In and Security" +3. Select "App-Specific Passwords" +4. Click "Generate an app-specific password" +5. Enter a label like "CalendarSync" +6. Copy the generated password (format: `xxxx-xxxx-xxxx-xxxx`) + +**Important**: Save this password immediately — Apple will only show it once. + +### Finding Your Calendar ID + +The calendar ID is needed to specify which iCloud calendar to sync: + +1. Open Calendar.app on macOS +2. Right-click on the desired calendar in the sidebar +3. Select "Get Info" +4. The calendar ID is typically the calendar name in lowercase with special characters removed +5. Common examples: `home`, `work`, `personal` + +Alternatively, you can use CalDAV discovery tools or inspect the CalDAV URLs in Calendar.app's account settings. + +### Configuration + +```yaml +source: + adapter: + type: "apple" + calendar: "personal" # Your calendar identifier + config: {} + oAuth: + clientId: "your-apple-id@icloud.com" # Your Apple ID email + clientKey: "xxxx-xxxx-xxxx-xxxx" # App-specific password + tenantId: "" # Leave empty for Apple CalDAV +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 05cb0bb..e9d97cd 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/aquilax/truncate v1.0.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/log v0.4.2 - github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 + github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 github.com/emersion/go-webdav v0.6.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 diff --git a/go.sum b/go.sum index ff9ed34..5bdfd60 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w= github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 6c1698f..73798b4 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -10,6 +10,7 @@ const ( GoogleCalendarType Type = "google" ZepCalendarType Type = "zep" OutlookHttpCalendarType Type = "outlook_http" + AppleCalendarType Type = "apple" ) // ConfigReader provides an interface for adapters to load their own configuration map. diff --git a/internal/adapter/apple/adapter.go b/internal/adapter/apple/adapter.go new file mode 100644 index 0000000..eaad173 --- /dev/null +++ b/internal/adapter/apple/adapter.go @@ -0,0 +1,180 @@ +package apple + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/log" + + "github.com/inovex/CalendarSync/internal/adapter/port" + "github.com/inovex/CalendarSync/internal/auth" + "github.com/inovex/CalendarSync/internal/models" +) + +const ( + baseUrl = "https://caldav.icloud.com" + timeFormat = "20060102T150405Z" +) + +type AppleCalendarClient interface { + ListEvents(ctx context.Context, starttime time.Time, endtime time.Time) ([]models.Event, error) + CreateEvent(ctx context.Context, event models.Event) error + UpdateEvent(ctx context.Context, event models.Event) error + DeleteEvent(ctx context.Context, event models.Event) error + ResolveCalendarID(ctx context.Context, calendarID string) (string, string, error) + DiscoverCalendars(ctx context.Context) ([]string, error) + GetCalendarHash() string +} + +type CalendarAPI struct { + appleClient AppleCalendarClient + calendarID string + + username string + appPassword string + authenticated bool + + logger *log.Logger + + storage auth.Storage +} + +// Assert that the expected interfaces are implemented +var _ port.Configurable = &CalendarAPI{} +var _ port.LogSetter = &CalendarAPI{} + +func (c *CalendarAPI) SetupAuth(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error { + // Reuse OAuth fields for basic auth: + // clientId = Apple ID + // clientSecret = App-specific password + // tenantId = unused (empty) + + switch { + case credentials.Client.Id == "": + return fmt.Errorf("%s adapter 'clientId' (Apple ID) cannot be empty", c.Name()) + case credentials.Client.Secret == "": + return fmt.Errorf("%s adapter 'clientSecret' (app-specific password) cannot be empty", c.Name()) + case credentials.CalendarId == "": + return fmt.Errorf("%s adapter 'calendar' cannot be empty", c.Name()) + } + + c.calendarID = credentials.CalendarId + c.username = credentials.Client.Id + c.appPassword = credentials.Client.Secret + c.storage = storage + c.authenticated = true + return nil +} + +// Stub for interface compliance +func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error { + return c.SetupAuth(ctx, credentials, storage, bindPort) +} + +func (c *CalendarAPI) SetupBasicAuth(ctx context.Context, credentials auth.Credentials, storage auth.Storage) error { + switch { + case credentials.Client.Id == "": // Using Client.Id as username for Apple ID + return fmt.Errorf("%s adapter 'username' (Apple ID) cannot be empty", c.Name()) + case credentials.Client.Secret == "": // Using Client.Secret as app-specific password + return fmt.Errorf("%s adapter 'appPassword' cannot be empty", c.Name()) + case credentials.CalendarId == "": + return fmt.Errorf("%s adapter 'calendar' cannot be empty", c.Name()) + } + + c.calendarID = credentials.CalendarId + c.username = credentials.Client.Id + c.appPassword = credentials.Client.Secret + c.storage = storage + + // For Apple CalDAV, we don't need OAuth2, just basic auth with app-specific password + c.authenticated = true + c.logger.Debug("Apple adapter configured with basic auth") + + return nil +} + +func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error { + if !c.authenticated { + return fmt.Errorf("Apple adapter not properly authenticated") + } + + c.appleClient = &ACalClient{ + Username: c.username, + AppPassword: c.appPassword, + CalendarID: c.calendarID, + } + + // Verify calendar resolution and discovery + calendars, err := c.appleClient.DiscoverCalendars(ctx) + if err != nil { + log.Errorf("Calendar discovery failed: %v", err) + } else { + log.Infof("Discovered calendars: %v", calendars) + + principalID, resolvedID, err := c.appleClient.(*ACalClient).getResolvedCalendarInfo(ctx) + if err != nil { + return fmt.Errorf("failed to resolve calendar '%s': %w", c.calendarID, err) + } + log.Infof("Successfully resolved calendar '%s' to %s/%s", c.calendarID, principalID, resolvedID) + } + + c.logger.Debug("Apple CalDAV client initialized") + return nil +} + +func (c *CalendarAPI) EventsInTimeframe(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) { + events, err := c.appleClient.ListEvents(ctx, start, end) + if err != nil { + return nil, err + } + + c.logger.Infof("loaded %d events between %s and %s.", len(events), start.Format(time.DateOnly), end.Format(time.DateOnly)) + + return events, nil +} + +func (c *CalendarAPI) CreateEvent(ctx context.Context, e models.Event) error { + err := c.appleClient.CreateEvent(ctx, e) + if err != nil { + return err + } + + c.logger.Info("Event created", "title", e.ShortTitle(), "time", e.StartTime.String()) + + return nil +} + +func (c *CalendarAPI) UpdateEvent(ctx context.Context, e models.Event) error { + err := c.appleClient.UpdateEvent(ctx, e) + if err != nil { + return err + } + + c.logger.Info("Event updated", "title", e.ShortTitle(), "time", e.StartTime.String()) + + return nil +} + +func (c *CalendarAPI) DeleteEvent(ctx context.Context, e models.Event) error { + err := c.appleClient.DeleteEvent(ctx, e) + if err != nil { + return err + } + + c.logger.Info("Event deleted", "title", e.ShortTitle(), "time", e.StartTime.String()) + + return nil +} + +func (c *CalendarAPI) GetCalendarHash() string { + return c.appleClient.GetCalendarHash() +} + +func (c *CalendarAPI) Name() string { + return "Apple CalDAV" +} + +func (c *CalendarAPI) SetLogger(logger *log.Logger) { + c.logger = logger +} diff --git a/internal/adapter/apple/client.go b/internal/adapter/apple/client.go new file mode 100644 index 0000000..17bdec0 --- /dev/null +++ b/internal/adapter/apple/client.go @@ -0,0 +1,638 @@ +package apple + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/charmbracelet/log" + "github.com/emersion/go-ical" + + "github.com/inovex/CalendarSync/internal/models" +) + +// ACalClient implements the AppleCalendarClient interface +type ACalClient struct { + Username string + AppPassword string + CalendarID string + httpClient *http.Client + + resolveOnce sync.Once + cachedPrincipalID string + cachedCalendarID string + resolutionError error +} + +type CalDAVPropfind struct { + XMLName xml.Name `xml:"DAV: propfind"` + Prop CalDAVProp `xml:"DAV: prop"` +} + +type CalDAVProp struct { + GetETag xml.Name `xml:"DAV: getetag"` + CalendarData xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-data"` +} + +type CalDAVMultistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []CalDAVResponse `xml:"DAV: response"` +} + +type CalDAVResponse struct { + Href string `xml:"DAV: href"` + Propstat CalDAVPropstat `xml:"DAV: propstat"` +} + +type CalDAVPropstat struct { + Prop CalDAVPropData `xml:"DAV: prop"` + Status string `xml:"DAV: status"` +} + +type CalDAVPropData struct { + ETag string `xml:"DAV: getetag"` + CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"` +} + +type CalDAVCompFilter struct { + Name string `xml:"name,attr"` + TimeRange *CalDAVTimeRange `xml:"urn:ietf:params:xml:ns:caldav time-range,omitempty"` + CompFilter *CalDAVCompFilter `xml:"urn:ietf:params:xml:ns:caldav comp-filter,omitempty"` +} + +type CalDAVTimeRange struct { + Start string `xml:"start,attr"` + End string `xml:"end,attr"` +} + +type CalDAVResourceType struct { + Collection xml.Name `xml:"DAV: collection"` + Calendar xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar"` +} + +type CalDAVComponentSet struct { + Components []CalDAVComponent `xml:"urn:ietf:params:xml:ns:caldav comp"` +} + +type CalDAVComponent struct { + Name string `xml:"name,attr"` +} + +type CalDAVCalendarList struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []CalDAVCalendarResponse `xml:"DAV: response"` +} + +type CalDAVCalendarResponse struct { + Href string `xml:"DAV: href"` + Propstat CalDAVCalendarPropstat `xml:"DAV: propstat"` +} + +type CalDAVCalendarPropstat struct { + Prop CalDAVCalendarProp `xml:"DAV: prop"` + Status string `xml:"DAV: status"` +} + +type CalDAVCalendarProp struct { + DisplayName string `xml:"DAV: displayname"` + ResourceType CalDAVResourceType `xml:"DAV: resourcetype"` + ComponentSet CalDAVComponentSet `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"` +} + +type CalDAVCalendarQuery struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-query"` + Prop CalDAVProp `xml:"DAV: prop"` + Filter CalDAVCalendarQueryFilter `xml:"urn:ietf:params:xml:ns:caldav filter"` +} + +type CalDAVCalendarQueryFilter struct { + CompFilter CalDAVCompFilter `xml:"urn:ietf:params:xml:ns:caldav comp-filter"` +} + +func (c *ACalClient) initHttpClient() { + if c.httpClient == nil { + c.httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + } +} + +func (c *ACalClient) makeRequest(ctx context.Context, method, path string, body []byte, headers map[string]string) (*http.Response, error) { + c.initHttpClient() + + url := fmt.Sprintf("%s%s", baseUrl, path) + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + + // Set basic auth + req.SetBasicAuth(c.Username, c.AppPassword) + + // Set default headers + req.Header.Set("User-Agent", "CalendarSync/1.0") + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + + // Set additional headers + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + switch resp.StatusCode { + case 401: + return nil, fmt.Errorf("authentication failed - check Apple ID and app-specific password") + case 404: + return nil, fmt.Errorf("calendar not found - check calendar ID '%s'", c.CalendarID) + case 403: + return nil, fmt.Errorf("access denied - check calendar permissions") + default: + return nil, fmt.Errorf("CalDAV request failed with status %d: %s", resp.StatusCode, string(body)) + } + } + + return resp, nil +} + +func (c *ACalClient) DiscoverPrincipal(ctx context.Context) (string, error) { + propfindXML := ` + + + + +` + + resp, err := c.makeRequest(ctx, "PROPFIND", "/.well-known/caldav", []byte(propfindXML), map[string]string{ + "Depth": "0", + }) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Parse to extract principal ID from href like "/ID/principal/" + var multistatus CalDAVMultistatus + if err := xml.Unmarshal(responseBody, &multistatus); err != nil { + return "", fmt.Errorf("failed to parse principal response: %w", err) + } + + if len(multistatus.Responses) > 0 { + href := multistatus.Responses[0].Href + // Extract principal ID from "/ID/principal/" + parts := strings.Split(strings.Trim(href, "/"), "/") + if len(parts) >= 1 { + return parts[0], nil + } + } + + return "", fmt.Errorf("could not discover principal ID") +} + +func (c *ACalClient) ResolveCalendarID(ctx context.Context, friendlyName string) (string, string, error) { + principalID, err := c.DiscoverPrincipal(ctx) + if err != nil { + return "", "", fmt.Errorf("failed to discover principal: %w", err) + } + + calendars, err := c.discoverCalendarsWithPrincipal(ctx, principalID) + if err != nil { + return "", "", fmt.Errorf("failed to discover calendars: %w", err) + } + + if len(calendars) == 0 { + return "", "", fmt.Errorf("no calendars found") + } + + // Check if it's already a UUID + if len(friendlyName) > 30 && strings.Contains(friendlyName, "-") { + for _, calendarID := range calendars { + if calendarID == friendlyName { + return principalID, calendarID, nil + } + } + } + + // Look up by friendly name (case-insensitive exact match) + calendarID, exists := calendars[strings.ToLower(friendlyName)] + if exists { + return principalID, calendarID, nil + } + + // If exact match fails, try partial matching + var matches []string + var matchedCalendarID string + + for name, id := range calendars { + if strings.Contains(strings.ToLower(name), strings.ToLower(friendlyName)) { + matches = append(matches, name) + matchedCalendarID = id + } + } + + // If exactly one partial match, use it + if len(matches) == 1 { + log.Printf("Resolved '%s' to calendar: '%s' -> %s (partial match)", friendlyName, matches[0], matchedCalendarID) + return principalID, matchedCalendarID, nil + } + + // If multiple matches, that's ambiguous + if len(matches) > 1 { + return "", "", fmt.Errorf("calendar name '%s' is ambiguous. Multiple matches found: %v", friendlyName, matches) + } + + // No matches found + availableCalendars := make([]string, 0, len(calendars)) + for name := range calendars { + availableCalendars = append(availableCalendars, name) + } + + return "", "", fmt.Errorf("calendar '%s' not found. Available calendars: %v", friendlyName, availableCalendars) +} + +func (c *ACalClient) DiscoverCalendars(ctx context.Context) ([]string, error) { + principalID, err := c.DiscoverPrincipal(ctx) + if err != nil { + return nil, err + } + + calendars, err := c.discoverCalendarsWithPrincipal(ctx, principalID) + if err != nil { + return nil, err + } + + var calendarNames []string + for name := range calendars { + calendarNames = append(calendarNames, name) + } + + return calendarNames, nil +} + +func (c *ACalClient) ListEvents(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) { + principalID, resolvedCalendarID, err := c.ResolveCalendarID(ctx, c.CalendarID) + if err != nil { + return nil, fmt.Errorf("failed to resolve calendar '%s': %w", c.CalendarID, err) + } + + // Build CalDAV query + query := CalDAVCalendarQuery{ + Prop: CalDAVProp{ + GetETag: xml.Name{}, + CalendarData: xml.Name{}, + }, + Filter: CalDAVCalendarQueryFilter{ + CompFilter: CalDAVCompFilter{ + Name: "VCALENDAR", + CompFilter: &CalDAVCompFilter{ + Name: "VEVENT", + TimeRange: &CalDAVTimeRange{ + Start: start.UTC().Format(timeFormat), + End: end.UTC().Format(timeFormat), + }, + }, + }, + }, + } + + calendarPath := fmt.Sprintf("/%s/calendars/%s/", principalID, resolvedCalendarID) + + queryXML, err := xml.Marshal(query) + if err != nil { + return nil, fmt.Errorf("failed to marshal CalDAV query: %w", err) + } + + resp, err := c.makeRequest(ctx, "REPORT", calendarPath, queryXML, map[string]string{ + "Depth": "1", + }) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var multistatus CalDAVMultistatus + if err := xml.Unmarshal(responseBody, &multistatus); err != nil { + return nil, fmt.Errorf("failed to parse CalDAV response: %w", err) + } + + var events []models.Event + for _, response := range multistatus.Responses { + if response.Propstat.Prop.CalendarData != "" { + event, err := c.parseICalendarData(response.Propstat.Prop.CalendarData, response.Href) + if err != nil { + log.Errorf("failed to parse calendar data: %v", err) + continue + } + if event != nil { + events = append(events, *event) + } + } + } + + return events, nil +} + +func (c *ACalClient) parseICalendarData(icalData, href string) (*models.Event, error) { + decoder := ical.NewDecoder(strings.NewReader(icalData)) + cal, err := decoder.Decode() + if err != nil { + return nil, fmt.Errorf("failed to parse iCalendar data: %w", err) + } + + for _, child := range cal.Children { + if child.Name == ical.CompEvent { + return c.convertVEventToEvent(child, href), nil + } + } + + return nil, fmt.Errorf("no VEVENT found in calendar data") +} + +func (c *ACalClient) convertVEventToEvent(vevent *ical.Component, href string) *models.Event { + event := &models.Event{ + Metadata: models.NewEventMetadata(href, href, c.GetCalendarHash()), + } + + // Extract basic properties + if summary, _ := vevent.Props.Text(ical.PropSummary); summary != "" { + event.Title = summary + } + + if description, _ := vevent.Props.Text(ical.PropDescription); description != "" { + event.Description = description + } + + if location, _ := vevent.Props.Text(ical.PropLocation); location != "" { + event.Location = location + } + + if uid, _ := vevent.Props.Text(ical.PropUID); uid != "" { + event.ICalUID = uid + event.ID = uid + event.Metadata.SyncID = models.NewEventID(uid) + } + + // Extract datetime properties + if startTime, err := vevent.Props.DateTime(ical.PropDateTimeStart, time.Local); err == nil && !startTime.IsZero() { + event.StartTime = startTime + } + + if endTime, err := vevent.Props.DateTime(ical.PropDateTimeEnd, time.Local); err == nil && !endTime.IsZero() { + event.EndTime = endTime + } + + // Check if it's an all-day event + if startProp := vevent.Props.Get(ical.PropDateTimeStart); startProp != nil { + event.AllDay = startProp.ValueType() == ical.ValueDate + } + + event.Accepted = true + return event +} + +func (c *ACalClient) createOrUpdateEvent(ctx context.Context, event models.Event, isUpdate bool) error { + principalID, resolvedCalendarID, err := c.getResolvedCalendarInfo(ctx) + if err != nil { + return fmt.Errorf("failed to resolve calendar '%s': %w", c.CalendarID, err) + } + + icalData := c.eventToICalendar(event) + + uid := strings.ReplaceAll(event.ICalUID, "@", "-") + filename := fmt.Sprintf("%s.ics", uid) + eventPath := fmt.Sprintf("/%s/calendars/%s/%s", principalID, resolvedCalendarID, filename) + + // Set headers based on operation type + headers := map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + } + + // Only add If-None-Match for new resource creation + if !isUpdate { + headers["If-None-Match"] = "*" + } + + _, err = c.makeRequest(ctx, "PUT", eventPath, []byte(icalData), headers) + return err +} + +func (c *ACalClient) CreateEvent(ctx context.Context, event models.Event) error { + err := c.createOrUpdateEvent(ctx, event, false) + if err != nil { + if strings.Contains(err.Error(), "status 412") { + log.Debugf("Event already exists, updating instead: %s", event.ShortTitle()) + return c.createOrUpdateEvent(ctx, event, true) + } + return err + } + return nil +} + +func (c *ACalClient) UpdateEvent(ctx context.Context, event models.Event) error { + return c.createOrUpdateEvent(ctx, event, true) +} + +func (c *ACalClient) DeleteEvent(ctx context.Context, event models.Event) error { + principalID, resolvedCalendarID, err := c.getResolvedCalendarInfo(ctx) + if err != nil { + return fmt.Errorf("failed to resolve calendar '%s': %w", c.CalendarID, err) + } + + uid := strings.ReplaceAll(event.ICalUID, "@", "-") + filename := fmt.Sprintf("%s.ics", uid) + eventPath := fmt.Sprintf("/%s/calendars/%s/%s", principalID, resolvedCalendarID, filename) + + _, err = c.makeRequest(ctx, "DELETE", eventPath, nil, nil) + return err +} + +func (c *ACalClient) discoverCalendarsWithPrincipal(ctx context.Context, principalID string) (map[string]string, error) { + propfindXML := ` + + + + + + +` + + calendarsPath := fmt.Sprintf("/%s/calendars/", principalID) + resp, err := c.makeRequest(ctx, "PROPFIND", calendarsPath, []byte(propfindXML), map[string]string{ + "Depth": "1", + }) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var calendarList CalDAVCalendarList + if err := xml.Unmarshal(responseBody, &calendarList); err != nil { + return nil, fmt.Errorf("failed to parse calendar list: %w", err) + } + + calendars := make(map[string]string) + + for _, response := range calendarList.Responses { + // Check if this is actually a calendar (has calendar resource type) + isCalendar := false + supportsEvents := false + + // Check resource type + if response.Propstat.Prop.ResourceType.Calendar.Local != "" { + isCalendar = true + } + + // Check if it supports VEVENT + for _, comp := range response.Propstat.Prop.ComponentSet.Components { + if comp.Name == "VEVENT" { + supportsEvents = true + break + } + } + + if isCalendar && supportsEvents && response.Propstat.Prop.DisplayName != "" { + // Extract calendar ID from href like "/ID/calendars/UUID/" + parts := strings.Split(strings.Trim(response.Href, "/"), "/") + if len(parts) >= 3 { + calendarID := parts[2] + displayName := response.Propstat.Prop.DisplayName + calendars[strings.ToLower(displayName)] = calendarID + } + } + } + + return calendars, nil +} + +func (c *ACalClient) eventToICalendar(event models.Event) string { + // Create calendar + cal := ical.NewCalendar() + cal.Props.SetText(ical.PropVersion, "2.0") + cal.Props.SetText(ical.PropProductID, "-//CalendarSync//CalendarSync 1.0//EN") + + // Create event + vevent := ical.NewComponent(ical.CompEvent) + vevent.Props.SetText(ical.PropUID, event.ICalUID) + vevent.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) + + // Handle all-day vs timed events + if event.AllDay { + vevent.Props.SetDate(ical.PropDateTimeStart, event.StartTime) + vevent.Props.SetDate(ical.PropDateTimeEnd, event.EndTime) + } else { + vevent.Props.SetDateTime(ical.PropDateTimeStart, event.StartTime.UTC()) + vevent.Props.SetDateTime(ical.PropDateTimeEnd, event.EndTime.UTC()) + } + + if event.Title != "" { + vevent.Props.SetText(ical.PropSummary, event.Title) + } + if event.Description != "" { + vevent.Props.SetText(ical.PropDescription, event.Description) + } + if event.Location != "" { + vevent.Props.SetText(ical.PropLocation, event.Location) + } + + cal.Children = append(cal.Children, vevent) + + // Encode to string + var buf strings.Builder + encoder := ical.NewEncoder(&buf) + if err := encoder.Encode(cal); err != nil { + // Fallback to manual format if encoding fails + return c.eventToICalendarManual(event) + } + + return buf.String() +} + +func (c *ACalClient) eventToICalendarManual(event models.Event) string { + return fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CalendarSync//CalendarSync 1.0//EN +BEGIN:VEVENT +UID:%s +DTSTAMP:%s +DTSTART:%s +DTEND:%s +SUMMARY:%s +DESCRIPTION:%s +LOCATION:%s +END:VEVENT +END:VCALENDAR`, + event.ICalUID, + time.Now().UTC().Format(timeFormat), + event.StartTime.UTC().Format(timeFormat), + event.EndTime.UTC().Format(timeFormat), + event.Title, + event.Description, + event.Location, + ) +} + +func (c *ACalClient) GetCalendarHash() string { + var id []byte + components := []string{c.Username, c.CalendarID} + + sum := sha1.Sum([]byte(strings.Join(components, ""))) + id = append(id, sum[:]...) + return base64.URLEncoding.EncodeToString(id) +} + +func (c *ACalClient) getResolvedCalendarInfo(ctx context.Context) (string, string, error) { + c.resolveOnce.Do(func() { + c.cachedPrincipalID, c.cachedCalendarID, c.resolutionError = c.ResolveCalendarID(ctx, c.CalendarID) + }) + + return c.cachedPrincipalID, c.cachedCalendarID, c.resolutionError +} + +func (c *ACalClient) resetCache() { + c.resolveOnce = sync.Once{} + c.cachedPrincipalID = "" + c.cachedCalendarID = "" + c.resolutionError = nil +} diff --git a/internal/adapter/apple/models.go b/internal/adapter/apple/models.go new file mode 100644 index 0000000..d25111c --- /dev/null +++ b/internal/adapter/apple/models.go @@ -0,0 +1,26 @@ +package apple + +import ( + "github.com/inovex/CalendarSync/internal/models" +) + +// CalDAVEvent represents an event as stored in CalDAV format +type CalDAVEvent struct { + ID string + ICalUID string + ETag string + Href string + Summary string + Description string + Location string + StartTime string + EndTime string + AllDay bool +} + +// CalDAVExtensions extensions for CalendarSync metadata in CalDAV +type CalDAVExtensions struct { + ExtensionName string `xml:"extensionName"` + // Embed CalendarSync metadata + models.Metadata +} diff --git a/internal/adapter/port/interface.go b/internal/adapter/port/interface.go index 18bb9c6..0bc9a29 100644 --- a/internal/adapter/port/interface.go +++ b/internal/adapter/port/interface.go @@ -19,6 +19,10 @@ type Configurable interface { Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error } +type AuthAdapter interface { + SetupAuth(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error +} + type OAuth2Adapter interface { SetupOauth2(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error } diff --git a/internal/adapter/sink_adapter.go b/internal/adapter/sink_adapter.go index 4cf078a..5aec370 100644 --- a/internal/adapter/sink_adapter.go +++ b/internal/adapter/sink_adapter.go @@ -10,8 +10,10 @@ import ( "github.com/charmbracelet/log" + "github.com/inovex/CalendarSync/internal/adapter/apple" "github.com/inovex/CalendarSync/internal/adapter/google" outlook "github.com/inovex/CalendarSync/internal/adapter/outlook_http" + "github.com/inovex/CalendarSync/internal/adapter/port" "github.com/inovex/CalendarSync/internal/sync" ) @@ -30,6 +32,8 @@ func SinkClientFactory(typ Type) (sync.Sink, error) { return new(google.CalendarAPI), nil case OutlookHttpCalendarType: return new(outlook.CalendarAPI), nil + case AppleCalendarType: + return new(apple.CalendarAPI), nil default: return nil, fmt.Errorf("unknown sink adapter client type %s", typ) } @@ -45,21 +49,23 @@ func NewSinkAdapterFromConfig(ctx context.Context, bindPort uint, openBrowser bo c.SetLogger(logger) } - if c, ok := client.(port.OAuth2Adapter); ok { - if err := c.SetupOauth2(ctx, - auth.Credentials{ - Client: auth.Client{ - Id: config.Adapter().OAuth.ClientID, - Secret: config.Adapter().OAuth.ClientKey, - }, - Tenant: auth.Tenant{ - Id: config.Adapter().OAuth.TenantID, - }, - CalendarId: config.Adapter().Calendar, - }, - storage, - bindPort, - ); err != nil { + credentials := auth.Credentials{ + Client: auth.Client{ + Id: config.Adapter().OAuth.ClientID, + Secret: config.Adapter().OAuth.ClientKey, + }, + Tenant: auth.Tenant{ + Id: config.Adapter().OAuth.TenantID, + }, + CalendarId: config.Adapter().Calendar, + } + + if c, ok := client.(port.AuthAdapter); ok { + if err := c.SetupAuth(ctx, credentials, storage, bindPort); err != nil { + return nil, err + } + } else if c, ok := client.(port.OAuth2Adapter); ok { + if err := c.SetupOauth2(ctx, credentials, storage, bindPort); err != nil { return nil, err } } diff --git a/internal/adapter/source_adapter.go b/internal/adapter/source_adapter.go index 7184ea3..4a71ba4 100644 --- a/internal/adapter/source_adapter.go +++ b/internal/adapter/source_adapter.go @@ -12,6 +12,7 @@ import ( outlook "github.com/inovex/CalendarSync/internal/adapter/outlook_http" "github.com/inovex/CalendarSync/internal/adapter/port" + "github.com/inovex/CalendarSync/internal/adapter/apple" "github.com/inovex/CalendarSync/internal/adapter/google" "github.com/inovex/CalendarSync/internal/adapter/zep" "github.com/inovex/CalendarSync/internal/sync" @@ -26,6 +27,8 @@ func SourceClientFactory(typ Type) (sync.Source, error) { return new(zep.CalendarAPI), nil case OutlookHttpCalendarType: return new(outlook.CalendarAPI), nil + case AppleCalendarType: + return new(apple.CalendarAPI), nil default: return nil, fmt.Errorf("unknown source adapter client type %s", typ) } @@ -49,21 +52,23 @@ func NewSourceAdapterFromConfig(ctx context.Context, bindPort uint, openBrowser c.SetLogger(logger) } - if c, ok := client.(port.OAuth2Adapter); ok { - if err := c.SetupOauth2(ctx, - auth.Credentials{ - Client: auth.Client{ - Id: config.Adapter().OAuth.ClientID, - Secret: config.Adapter().OAuth.ClientKey, - }, - Tenant: auth.Tenant{ - Id: config.Adapter().OAuth.TenantID, - }, - CalendarId: config.Adapter().Calendar, - }, - storage, - bindPort, - ); err != nil { + credentials := auth.Credentials{ + Client: auth.Client{ + Id: config.Adapter().OAuth.ClientID, + Secret: config.Adapter().OAuth.ClientKey, + }, + Tenant: auth.Tenant{ + Id: config.Adapter().OAuth.TenantID, + }, + CalendarId: config.Adapter().Calendar, + } + + if c, ok := client.(port.AuthAdapter); ok { + if err := c.SetupAuth(ctx, credentials, storage, bindPort); err != nil { + return nil, err + } + } else if c, ok := client.(port.OAuth2Adapter); ok { + if err := c.SetupOauth2(ctx, credentials, storage, bindPort); err != nil { return nil, err } }