Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ documentation [here](./docs/adapters.md).
- Google
- Outlook
- [ZEP](https://www.zep.de/en/)
- Apple

## Sink

Expand All @@ -128,6 +129,7 @@ documentation [here](./docs/adapters.md).

- Google
- Outlook
- Apple

## Transformers

Expand Down Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions internal/adapter/apple/adapter.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading