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
}
}