From 630fc8d899380349c385ce45133256c3b4ca885a Mon Sep 17 00:00:00 2001 From: Grigory Bakunov Date: Sun, 27 Apr 2025 02:31:51 +0300 Subject: [PATCH 1/2] Add CalDAV calendar support for multiple providers --- README.md | 59 ++++-- add.go | 101 +++++++++-- backup.gcalsync.toml | 20 ++- caldav_calendar.go | 247 +++++++++++++++++++++++++ calendar_provider.go | 22 +++ cleanup.go | 107 +++++++++-- common.go | 12 +- dbinit.go | 13 ++ desync.go | 110 ++++++++++-- go.mod | 3 + go.sum | 7 + google_calendar.go | 113 ++++++++++++ list.go | 33 +++- sync.go | 415 +++++++++++++++++++++++++++---------------- 14 files changed, 1043 insertions(+), 219 deletions(-) create mode 100644 caldav_calendar.go create mode 100644 calendar_provider.go create mode 100644 google_calendar.go diff --git a/README.md b/README.md index 8282e31..d7fa1b3 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ Say goodbye to calendar conflicts and hello to seamless synchronization. πŸŽ‰ ## 🌟 Features -- πŸ”„ Sync events from multiple Google Calendars across different accounts +- πŸ”„ Sync events from multiple Google Calendars and CalDAV calendars (iCal, Nextcloud, etc.) - 🚫 Create "blocker" events in other calendars to prevent double bookings - πŸ—„οΈ Store access tokens and calendar data securely in a local SQLite database - πŸ”’ Authenticate with Google using the OAuth2 flow for desktop apps +- 🌐 Support for CalDAV/iCal calendars through standard HTTP authentication - 🧹 Easy way to cleanup calendars and remove all blocker events with a single command ## πŸ“‹ Prerequisites @@ -40,7 +41,7 @@ Say goodbye to calendar conflicts and hello to seamless synchronization. πŸŽ‰ go mod download ``` -4. Create a `.gcalsync.toml` file in the project directory with your OAuth2 credentials: +4. Create a `.gcalsync.toml` file in the project directory with your credentials: ```toml [general] @@ -51,6 +52,13 @@ Say goodbye to calendar conflicts and hello to seamless synchronization. πŸŽ‰ [google] client_id = "your-client-id" # Your OAuth2 client ID client_secret = "your-client-secret" # Your OAuth2 client secret + + # Optional: For CalDAV/iCal calendar support + [caldav_servers.work] + server_url = "https://caldav.example.com/dav/" # CalDAV server URL + username = "your-username" # CalDAV username + password = "your-password" # CalDAV password + name = "Work Calendar" # Optional friendly name ``` Don't forget to choose the appropriate OAuth2 consent screen settings and [add the necessary scopes](https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid) for the Google Calendar API, also double check that you are select "Desktop app" as application type. @@ -85,7 +93,15 @@ Say goodbye to calendar conflicts and hello to seamless synchronization. πŸŽ‰ ### πŸ†• Adding a Calendar -To add a new calendar to sync, run the `gcalsync add` command. You will be prompted to enter the account name and calendar ID. The program will guide you through the OAuth2 authentication process and store the access token securely in the local database. +To add a new calendar to sync, run the `gcalsync add` command. You will be prompted to enter: + +1. The account name (a label to identify this calendar) +2. The provider type (google or caldav) +3. The calendar ID or URL: + - For Google calendars: typically your email address or a specific calendar ID + - For CalDAV calendars: the full URL to the calendar (e.g., https://caldav.example.com/dav/calendars/user/calendar-name/) + +For Google calendars, the program will guide you through the OAuth2 authentication process and store the access token securely in the local database. For CalDAV calendars, authentication is handled using the credentials specified in your config file. ### πŸ”„ Syncing Calendars @@ -113,32 +129,41 @@ The `.gcalsync.toml` configuration file is used to store OAuth2 credentials and At a minimum, the configuration file should contain the following fields: -```toml -[google] -client_id = "your-client-id" -client_secret = "your-client-secret" -``` -Additional sections and fields can be added to configure the program behavior: - ```toml [general] block_event_visibility = "private" # Keep O_o event public or private disable_reminders = true # Set reminders on O_o events or not verbosity_level = 1 # How much chatter to spill out when running sync -authorized_ports = [3000, 3001, 3002] # Casllback ports to listen to for OAuth token response +authorized_ports = [3000, 3001, 3002] # Callback ports to listen to for OAuth token response + +[google] +client_id = "your-client-id" # Your Google app client ID +client_secret = "your-client-secret" # Your Google app configuration secret + +[caldav] +server_url = "https://caldav.example.com/dav/" # CalDAV server URL +username = "your-username" # CalDAV username +password = "your-password" # CalDAV password ``` #### πŸ”Œ Configuration Parameters -- `[google]` section - - `client_id`: Your Google app client ID - - `client_secret` Your Google app configuration secret - `[general]` section - `authorized_ports`: The application needs to start a temporary local server to receive the OAuth callback from Google. By default, it will try ports 8080, 8081, and 8082. You can customize these ports by setting the `authorized_ports` array in your configuration file. The application will try each port in order until it finds an available one. Make sure these ports are allowed by your firewall and not in use by other applications. - - `block_event_visibility`: Defines whether you want to keep blocker events ("O_o") publicly visible or not. Posible values are `private` or `public`. If ommitted -- `public` is used. + - `block_event_visibility`: Defines whether you want to keep blocker events ("O_o") publicly visible or not. Possible values are `private` or `public`. If omitted -- `public` is used. - `disable_reminders`: Whether your blocker events should stay quite and **not** alert you. Possible values are `true` or `false`. default is `false`. - `verbosity_level`: How "chatty" you want the app to be 1..3 with 1 being mostly quite and 3 giving you full details of what it is doing. +- `[google]` section + - `client_id`: Your Google app client ID + - `client_secret`: Your Google app configuration secret + +- `[caldav_servers.]` section + - `server_url`: Base URL of your CalDAV server + - `username`: Username for CalDAV authentication + - `password`: Password for CalDAV authentication + - `name`: Optional friendly name for the CalDAV server + ## 🀝 Contributing Contributions are welcome! If you encounter any issues or have suggestions for improvement, please open an issue or submit a pull request. Let's make gcalsync even better together! πŸ’ͺ @@ -152,4 +177,6 @@ This project is licensed under the [MIT License](https://opensource.org/licenses - The terrible [Go](https://golang.org/) programming language - The [Google Calendar API](https://developers.google.com/calendar) for making this project almost impossible to implement - The [OAuth2](https://oauth.net/2/) protocol for very missleading but secure authentication -- The [SQLite](https://www.sqlite.org/) database for lightweight and efficient storage, the only one that added no pain. +- The [SQLite](https://www.sqlite.org/) database for lightweight and efficient storage, the only one that added no pain +- The [go-webdav](https://github.com/emersion/go-webdav) library for excellent WebDAV/CalDAV support +- The [go-ical](https://github.com/emersion/go-ical) library for parsing and generating iCalendar data diff --git a/add.go b/add.go index 18c4c79..39d6fee 100644 --- a/add.go +++ b/add.go @@ -1,9 +1,12 @@ package main import ( + "bufio" "context" "fmt" "log" + "os" + "strings" "google.golang.org/api/calendar/v3" "google.golang.org/api/option" @@ -29,27 +32,99 @@ func addCalendar() { var accountName string fmt.Scanln(&accountName) - fmt.Print("πŸ“… Enter calendar ID: ") - var calendarID string - fmt.Scanln(&calendarID) + fmt.Print("πŸ”„ Enter provider type (google or caldav): ") + var providerType string + fmt.Scanln(&providerType) + providerType = strings.ToLower(providerType) + + fmt.Print("πŸ“… Enter calendar ID or URL: ") + reader := bufio.NewReader(os.Stdin) + calendarID, _ := reader.ReadString('\n') + calendarID = strings.TrimSpace(calendarID) ctx := context.Background() + var providerConfig string - client := getClient(ctx, oauthConfig, db, accountName, config) + // Validate calendar access based on provider type + if providerType == "google" { + client := getClient(ctx, oauthConfig, db, accountName, config) - calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - log.Fatalf("Error creating calendar client: %v", err) + calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Error creating calendar client: %v", err) + } + + _, err = calendarService.CalendarList.Get(calendarID).Do() + if err != nil { + log.Fatalf("Error retrieving Google calendar: %v", err) + } + } else if providerType == "caldav" { + // Check if we have any CalDAV servers configured + if len(config.CalDAVs) == 0 { + log.Fatalf("Error: No CalDAV server configurations found in .gcalsync.toml") + } + + // Use the server configuration + var serverName string + var serverConfig CalDAVConfig + + // List available servers for selection + fmt.Println("Available CalDAV servers:") + servers := make([]string, 0, len(config.CalDAVs)) + + // List all configured servers + i := 0 + for name, server := range config.CalDAVs { + displayName := name + if server.Name != "" { + displayName = server.Name + } + fmt.Printf(" %d: %s (%s)\n", i, displayName, server.ServerURL) + servers = append(servers, name) + i++ + } + + fmt.Print("Enter server number: ") + var serverIndex int + fmt.Scanln(&serverIndex) + + if serverIndex < 0 || serverIndex >= len(servers) { + log.Fatalf("Error: Invalid server selection") + } + + serverName = servers[serverIndex] + serverConfig = config.CalDAVs[serverName] + + fmt.Printf("Using CalDAV server: %s\n", serverConfig.ServerURL) + + caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + if err != nil { + log.Fatalf("Error connecting to CalDAV server: %v", err) + } + + err = caldavProvider.GetCalendar(calendarID) + if err != nil { + log.Fatalf("Error retrieving CalDAV calendar: %v", err) + } + + // Store the server name in the provider_config field + providerConfig = serverName + } else { + log.Fatalf("Error: Unsupported provider type: %s (must be 'google' or 'caldav')", providerType) } - _, err = calendarService.CalendarList.Get(calendarID).Do() - if err != nil { - log.Fatalf("Error retrieving calendar: %v", err) + // Update schema to include provider_config field if not exists + _, err = db.Exec(`ALTER TABLE calendars ADD COLUMN provider_config TEXT DEFAULT ''`) + if err != nil && !strings.Contains(err.Error(), "duplicate column name") { + log.Printf("Warning: Failed to add provider_config column: %v", err) } - _, err = db.Exec(`INSERT INTO calendars (account_name, calendar_id) VALUES (?, ?)`, accountName, calendarID) + + _, err = db.Exec(`INSERT INTO calendars (account_name, calendar_id, provider_type, provider_config) VALUES (?, ?, ?, ?)`, + accountName, calendarID, providerType, providerConfig) if err != nil { log.Fatalf("Error saving calendar ID: %v", err) } - fmt.Printf("βœ… Calendar %s added successfully for account %s\n", calendarID, accountName) -} + fmt.Printf("βœ… %s Calendar %s added successfully for account %s\n", + strings.ToUpper(providerType), calendarID, accountName) +} \ No newline at end of file diff --git a/backup.gcalsync.toml b/backup.gcalsync.toml index 1b5be4d..6bb9353 100644 --- a/backup.gcalsync.toml +++ b/backup.gcalsync.toml @@ -6,4 +6,22 @@ authorized_ports = [8080, 8081, 8082] # Ports to listen on for OAuth token callb [google] client_id = "" # Get these from the Google Developer Console -client_secret = "" # Get these from the Google Developer \ No newline at end of file +client_secret = "" # Get these from the Google Developer Console + +# CalDAV servers configuration +# Each server is defined in its own section under [caldav_servers.NAME] +[caldav_servers] + +# Example work server +[caldav_servers.work] +server_url = "" # CalDAV server URL (e.g., https://work.example.com/dav/) +username = "" # CalDAV server username +password = "" # CalDAV server password +name = "Work Calendar" # Optional friendly name + +# Example personal server +[caldav_servers.personal] +server_url = "" # CalDAV server URL (e.g., https://personal.example.com/dav/) +username = "" # CalDAV server username +password = "" # CalDAV server password +name = "Personal Calendar" # Optional friendly name \ No newline at end of file diff --git a/caldav_calendar.go b/caldav_calendar.go new file mode 100644 index 0000000..fff9330 --- /dev/null +++ b/caldav_calendar.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" +) + +type CalDAVProvider struct { + client *caldav.Client + ctx context.Context + serverURL string +} + +func NewCalDAVProvider(ctx context.Context, serverURL, username, password string) (*CalDAVProvider, error) { + baseURL, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid CalDAV server URL: %w", err) + } + + // Create HTTP client with authentication if needed + var httpClient webdav.HTTPClient = http.DefaultClient + if username != "" && password != "" { + httpClient = webdav.HTTPClientWithBasicAuth(httpClient, username, password) + } + + c, err := caldav.NewClient(httpClient, baseURL.String()) + if err != nil { + return nil, fmt.Errorf("failed to create CalDAV client: %w", err) + } + + // Test connection + _, err = c.FindCalendars(ctx, "") // Empty path means server root + if err != nil { + return nil, fmt.Errorf("failed to connect to CalDAV server: %w", err) + } + + return &CalDAVProvider{ + client: c, + ctx: ctx, + serverURL: serverURL, + }, nil +} + +func (c *CalDAVProvider) GetCalendar(calendarID string) error { + calURL, err := url.Parse(calendarID) + if err != nil { + return fmt.Errorf("invalid calendar URL: %w", err) + } + + // Extract the calendar home set from the URL (usually the parent path) + homeSetPath := "/" + if calURL.Path != "" { + parts := strings.Split(strings.TrimRight(calURL.Path, "/"), "/") + if len(parts) > 1 { + homeSetPath = "/" + strings.Join(parts[:len(parts)-1], "/") + } + } + + // Find all calendars in the home set + calendars, err := c.client.FindCalendars(c.ctx, homeSetPath) + if err != nil { + return fmt.Errorf("failed to find calendars: %w", err) + } + + // Check if our target calendar is among them + for _, cal := range calendars { + if cal.Path == calURL.Path { + return nil // Found it! + } + } + + return fmt.Errorf("calendar not found at path: %s", calURL.Path) +} + +func (c *CalDAVProvider) AddEvent(calendarID string, event *Event) (string, error) { + calURL, err := url.Parse(calendarID) + if err != nil { + return "", fmt.Errorf("invalid calendar URL: %w", err) + } + + // Create unique ID for the event + eventUID := "gcalsync-" + time.Now().Format("20060102T150405Z") + + // Create iCal event + icalEvent := ical.NewEvent() + icalEvent.Component.Props.SetText("UID", eventUID) + icalEvent.Component.Props.SetText("SUMMARY", event.Summary) + icalEvent.Component.Props.SetText("DESCRIPTION", event.Description) + icalEvent.Component.Props.SetDateTime("DTSTART", event.Start) + icalEvent.Component.Props.SetDateTime("DTEND", event.End) + icalEvent.Component.Props.SetText("STATUS", "CONFIRMED") + + // Create iCal calendar + calendar := ical.NewCalendar() + calendar.Component.Children = append(calendar.Component.Children, icalEvent.Component) + + // Create path for the new event + path := calURL.Path + "/" + eventUID + ".ics" + + // Use PutCalendarObject to create the event + _, err = c.client.PutCalendarObject(c.ctx, path, calendar) + if err != nil { + return "", fmt.Errorf("failed to create event: %w", err) + } + + return eventUID, nil +} + +func (c *CalDAVProvider) UpdateEvent(calendarID string, eventID string, event *Event) error { + calURL, err := url.Parse(calendarID) + if err != nil { + return fmt.Errorf("invalid calendar URL: %w", err) + } + + // Create updated iCal event + icalEvent := ical.NewEvent() + icalEvent.Component.Props.SetText("UID", eventID) + icalEvent.Component.Props.SetText("SUMMARY", event.Summary) + icalEvent.Component.Props.SetText("DESCRIPTION", event.Description) + icalEvent.Component.Props.SetDateTime("DTSTART", event.Start) + icalEvent.Component.Props.SetDateTime("DTEND", event.End) + if event.Status != "" { + icalEvent.Component.Props.SetText("STATUS", strings.ToUpper(event.Status)) + } else { + icalEvent.Component.Props.SetText("STATUS", "CONFIRMED") + } + + // Create iCal calendar + calendar := ical.NewCalendar() + calendar.Component.Children = append(calendar.Component.Children, icalEvent.Component) + + // Update CalDAV event using the same PutCalendarObject method + // The eventID + .ics is the typical filename format for CalDAV events + path := calURL.Path + "/" + eventID + ".ics" + + // Use PutCalendarObject to update the event (create or replace) + _, err = c.client.PutCalendarObject(c.ctx, path, calendar) + if err != nil { + return fmt.Errorf("failed to update event: %w", err) + } + + return nil +} + +func (c *CalDAVProvider) DeleteEvent(calendarID string, eventID string) error { + calURL, err := url.Parse(calendarID) + if err != nil { + return fmt.Errorf("invalid calendar URL: %w", err) + } + + // Construct the path to the event file + path := calURL.Path + "/" + eventID + ".ics" + + // Delete the event using RemoveAll method from webdav + // Note: This uses the underlying webdav.Client's RemoveAll method + // which is inherited by caldav.Client for deleting resources + err = c.client.Client.RemoveAll(c.ctx, path) + if err != nil { + return fmt.Errorf("failed to delete event: %w", err) + } + + return nil +} + +func (c *CalDAVProvider) ListEvents(calendarID string, timeMin, timeMax time.Time) ([]*Event, error) { + calURL, err := url.Parse(calendarID) + if err != nil { + return nil, fmt.Errorf("invalid calendar URL: %w", err) + } + + // Setup a CalendarQuery to filter events by time range + query := &caldav.CalendarQuery{ + CompFilter: caldav.CompFilter{ + Name: "VCALENDAR", + Comps: []caldav.CompFilter{{ + Name: "VEVENT", + Start: timeMin, + End: timeMax, + }}, + }, + } + + // Execute the query to get calendar objects in the time range + objects, err := c.client.QueryCalendar(c.ctx, calURL.Path, query) + if err != nil { + return nil, fmt.Errorf("failed to list events: %w", err) + } + + // Process the results into our Event structure + var result []*Event + for _, obj := range objects { + // The Data field in CalendarObject is already a *ical.Calendar, no need to parse + calendar := obj.Data + + // Extract VEVENT components + for _, comp := range calendar.Component.Children { + if comp.Name != "VEVENT" { + continue + } + + // Extract event properties + uid := getTextProp(comp.Props, "UID") + summary := getTextProp(comp.Props, "SUMMARY") + description := getTextProp(comp.Props, "DESCRIPTION") + status := getTextProp(comp.Props, "STATUS") + if status == "" { + status = "confirmed" // Default status if not specified + } else { + // Convert from iCalendar status format (e.g., "CONFIRMED") to lowercase + status = strings.ToLower(status) + } + + // Parse dates + start, _ := comp.Props.DateTime("DTSTART", time.UTC) + end, _ := comp.Props.DateTime("DTEND", time.UTC) + + // Create Event object + result = append(result, &Event{ + ID: uid, + Summary: summary, + Description: description, + Start: start, + End: end, + Status: status, + }) + } + } + + return result, nil +} + +// Helper function to get text property safely +func getTextProp(props ical.Props, name string) string { + prop := props.Get(name) + if prop == nil { + return "" + } + return prop.Value +} \ No newline at end of file diff --git a/calendar_provider.go b/calendar_provider.go new file mode 100644 index 0000000..0495f55 --- /dev/null +++ b/calendar_provider.go @@ -0,0 +1,22 @@ +package main + +import ( + "time" +) + +type CalendarProvider interface { + GetCalendar(calendarID string) error + AddEvent(calendarID string, event *Event) (string, error) + UpdateEvent(calendarID string, eventID string, event *Event) error + DeleteEvent(calendarID string, eventID string) error + ListEvents(calendarID string, timeMin, timeMax time.Time) ([]*Event, error) +} + +type Event struct { + ID string + Summary string + Description string + Start time.Time + End time.Time + Status string +} diff --git a/cleanup.go b/cleanup.go index fed3c22..0b5ff44 100644 --- a/cleanup.go +++ b/cleanup.go @@ -5,9 +5,9 @@ import ( "fmt" "log" "strings" + "time" "google.golang.org/api/calendar/v3" - "google.golang.org/api/option" ) func cleanupCalendars() { @@ -25,24 +25,87 @@ func cleanupCalendars() { calendars := getCalendarsFromDB(db) ctx := context.Background() - - for accountName, calendarIDs := range calendars { - client := getClient(ctx, oauthConfig, db, accountName, config) - calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - log.Fatalf("Error creating calendar client: %v", err) + + // Create provider instances for each account + providers = make(map[string]map[string]CalendarProvider) + + for accountName, calendarInfos := range calendars { + fmt.Printf("πŸ“… Setting up account: %s\n", accountName) + providers[accountName] = make(map[string]CalendarProvider) + + // Initialize providers for each type needed by this account + for i, calInfo := range calendarInfos { + switch calInfo.ProviderType { + case "google": + if _, exists := providers[accountName]["google"]; !exists { + client := getClient(ctx, oauthConfig, db, accountName, config) + googleProvider, err := NewGoogleCalendarProvider(ctx, client) + if err != nil { + log.Fatalf("Error creating Google calendar provider: %v", err) + } + providers[accountName]["google"] = googleProvider + } + + case "caldav": + // Get the server configuration from provider_config + var serverConfig CalDAVConfig + serverName := calInfo.ProviderConfig + + // If there's no server name, we need the user to reconfigure + if serverName == "" || serverName == "default" { + log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") + } + + // Use the server from CalDAV servers config + if server, ok := config.CalDAVs[serverName]; ok { + serverConfig = server + } else { + log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) + } + + // Create a provider key that includes the server name + providerKey := "caldav-" + serverName + + // Only create the provider if we don't already have one for this server + if _, exists := providers[accountName][providerKey]; !exists { + caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + if err != nil { + log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) + } + providers[accountName][providerKey] = caldavProvider + } + + // Update the calendar info to use the correct provider key + calendarInfos[i].ProviderKey = providerKey + + default: + log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) + } } - for _, calendarID := range calendarIDs { - fmt.Printf("🧹 Cleaning up calendar: %s\n", calendarID) - cleanupCalendar(calendarService, calendarID) - db.Exec("DELETE FROM blocker_events WHERE calendar_id = ?", calendarID) + for _, calInfo := range calendarInfos { + fmt.Printf("🧹 Cleaning up calendar: %s\n", calInfo.ID) + + // Determine which provider to use + providerKey := calInfo.ProviderType + if calInfo.ProviderKey != "" { + providerKey = calInfo.ProviderKey + } + + provider := providers[accountName][providerKey] + if provider == nil { + log.Fatalf("Error: Provider not found for key: %s", providerKey) + } + + cleanupCalendarWithProvider(provider, calInfo.ID) + db.Exec("DELETE FROM blocker_events WHERE calendar_id = ?", calInfo.ID) } } fmt.Println("Calendars desynced successfully") } +// Legacy function for backward compatibility func cleanupCalendar(calendarService *calendar.Service, calendarID string) { // ctx := context.Background() pageToken := "" @@ -73,3 +136,25 @@ func cleanupCalendar(calendarService *calendar.Service, calendarID string) { } } } + +// New function that works with any CalendarProvider implementation +func cleanupCalendarWithProvider(provider CalendarProvider, calendarID string) { + // Get all events for the next year (to ensure we catch all blockers) + now := time.Now() + oneYearFromNow := now.AddDate(1, 0, 0) + + events, err := provider.ListEvents(calendarID, now, oneYearFromNow) + if err != nil { + log.Fatalf("Error retrieving events: %v", err) + } + + for _, event := range events { + if strings.Contains(event.Summary, "O_o") { + err := provider.DeleteEvent(calendarID, event.ID) + fmt.Printf("Deleted event %s from calendar %s\n", event.Summary, calendarID) + if err != nil { + log.Fatalf("Error deleting blocker event: %v", err) + } + } + } +} \ No newline at end of file diff --git a/common.go b/common.go index 51e751d..c3953b9 100644 --- a/common.go +++ b/common.go @@ -27,6 +27,13 @@ type GoogleConfig struct { ClientSecret string `toml:"client_secret"` } +type CalDAVConfig struct { + ServerURL string `toml:"server_url"` + Username string `toml:"username"` + Password string `toml:"password"` + Name string `toml:"name"` +} + type GeneralConfig struct { DisableReminders bool `toml:"disable_reminders"` EventVisibility string `toml:"block_event_visibility"` @@ -35,8 +42,9 @@ type GeneralConfig struct { } type Config struct { - General GeneralConfig `toml:"general"` - Google GoogleConfig `toml:"google"` + General GeneralConfig `toml:"general"` + Google GoogleConfig `toml:"google"` + CalDAVs map[string]CalDAVConfig `toml:"caldav_servers"` // CalDAV servers } var oauthConfig *oauth2.Config diff --git a/dbinit.go b/dbinit.go index 2cb0e87..8d35284 100644 --- a/dbinit.go +++ b/dbinit.go @@ -101,4 +101,17 @@ func dbInit() { log.Fatalf("Error updating db_version table: %v", err) } } + + if dbVersion == 4 { + _, err = db.Exec(`ALTER TABLE calendars ADD COLUMN provider_type TEXT DEFAULT 'google'`) + if err != nil { + log.Fatalf("Error adding provider_type column to calendars table: %v", err) + } + + dbVersion = 5 + _, err = db.Exec(`UPDATE db_version SET version = 5 WHERE name = 'gcalsync'`) + if err != nil { + log.Fatalf("Error updating db_version table: %v", err) + } + } } diff --git a/desync.go b/desync.go index 2e09b8b..f37473a 100644 --- a/desync.go +++ b/desync.go @@ -2,13 +2,10 @@ package main import ( "context" - "database/sql" + "database/sql" "fmt" "log" - - "google.golang.org/api/calendar/v3" - "google.golang.org/api/googleapi" - "google.golang.org/api/option" + "strings" ) func desyncCalendars() { @@ -26,7 +23,70 @@ func desyncCalendars() { fmt.Println("πŸš€ Starting calendar desynchronization...") - rows, err := db.Query("SELECT event_id, calendar_id, account_name FROM blocker_events") + // Create provider instances for each account + providers = make(map[string]map[string]CalendarProvider) + + // First, get all calendars to set up providers + calendars := getCalendarsFromDB(db) + + // Initialize providers + for accountName, calendarInfos := range calendars { + fmt.Printf("πŸ“… Setting up account: %s\n", accountName) + providers[accountName] = make(map[string]CalendarProvider) + + // Initialize providers for each type needed by this account + for i, calInfo := range calendarInfos { + switch calInfo.ProviderType { + case "google": + if _, exists := providers[accountName]["google"]; !exists { + client := getClient(ctx, oauthConfig, db, accountName, config) + googleProvider, err := NewGoogleCalendarProvider(ctx, client) + if err != nil { + log.Fatalf("Error creating Google calendar provider: %v", err) + } + providers[accountName]["google"] = googleProvider + } + + case "caldav": + // Get the server configuration from provider_config + var serverConfig CalDAVConfig + serverName := calInfo.ProviderConfig + + // If there's no server name, we need the user to reconfigure + if serverName == "" || serverName == "default" { + log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") + } + + // Use the server from CalDAV servers config + if server, ok := config.CalDAVs[serverName]; ok { + serverConfig = server + } else { + log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) + } + + // Create a provider key that includes the server name + providerKey := "caldav-" + serverName + + // Only create the provider if we don't already have one for this server + if _, exists := providers[accountName][providerKey]; !exists { + caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + if err != nil { + log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) + } + providers[accountName][providerKey] = caldavProvider + } + + // Update the calendar info to use the correct provider key + calendarInfos[i].ProviderKey = providerKey + + default: + log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) + } + } + } + + // Get all blocker events + rows, err := db.Query("SELECT be.event_id, be.calendar_id, be.account_name, c.provider_type, c.provider_config FROM blocker_events be LEFT JOIN calendars c ON be.calendar_id = c.calendar_id") if err != nil { log.Fatalf("❌ Error retrieving blocker events from database: %v", err) } @@ -38,8 +98,8 @@ func desyncCalendars() { } for rows.Next() { - var eventID, calendarID, accountName string - if err := rows.Scan(&eventID, &calendarID, &accountName); err != nil { + var eventID, calendarID, accountName, providerType, providerConfig string + if err := rows.Scan(&eventID, &calendarID, &accountName, &providerType, &providerConfig); err != nil { log.Fatalf("❌ Error scanning blocker event row: %v", err) } @@ -48,18 +108,36 @@ func desyncCalendars() { CalendarID string }{EventID: eventID, CalendarID: calendarID}) - client := getClient(ctx, oauthConfig, db, accountName, config) - calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - log.Fatalf("❌ Error creating calendar client: %v", err) + // If provider type is empty, assume "google" for backward compatibility + if providerType == "" { + providerType = "google" } - err = calendarService.Events.Delete(calendarID, eventID).Do() + // For CalDAV, construct the provider key + var providerKey string + if providerType == "caldav" { + if providerConfig == "" || providerConfig == "default" { + log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") + } + providerKey = "caldav-" + providerConfig + } else { + providerKey = providerType + } + + // Get the appropriate provider + provider, ok := providers[accountName][providerKey] + if !ok { + // If provider isn't initialized for some reason, fail with error + log.Fatalf("Error: Provider not found for account %s, key %s. Please run sync or add command to set up the providers.", accountName, providerKey) + } + + // Delete the event using the provider + err = provider.DeleteEvent(calendarID, eventID) if err != nil { - if googleErr, ok := err.(*googleapi.Error); ok && googleErr.Code == 404 { + if strings.Contains(err.Error(), "not found") { fmt.Printf(" ⚠️ Blocker event not found in calendar: %s\n", eventID) } else { - log.Fatalf("❌ Error deleting blocker event: %v", err) + log.Printf("❌ Error deleting blocker event: %v", err) } } else { fmt.Printf(" βœ… Blocker event deleted: %s\n", eventID) @@ -86,4 +164,4 @@ func getAccountNameByCalendarID(db *sql.DB, calendarID string) string { log.Fatalf("Error retrieving account name for calendar ID %s: %v", calendarID, err) } return accountName -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 938889a..c789638 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/compute v1.27.0 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect + github.com/emersion/go-webdav v0.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -23,6 +25,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.4 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect go.opentelemetry.io/otel v1.27.0 // indirect diff --git a/go.sum b/go.sum index 7fe1475..36e9ae6 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,11 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-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= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -91,6 +96,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= diff --git a/google_calendar.go b/google_calendar.go new file mode 100644 index 0000000..27ffec3 --- /dev/null +++ b/google_calendar.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" +) + +type GoogleCalendarProvider struct { + service *calendar.Service + ctx context.Context +} + +func NewGoogleCalendarProvider(ctx context.Context, client *http.Client) (*GoogleCalendarProvider, error) { + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("failed to create calendar service: %w", err) + } + return &GoogleCalendarProvider{ + service: service, + ctx: ctx, + }, nil +} + +func (g *GoogleCalendarProvider) GetCalendar(calendarID string) error { + _, err := g.service.CalendarList.Get(calendarID).Do() + if err != nil { + return fmt.Errorf("failed to get calendar: %w", err) + } + return nil +} + +func (g *GoogleCalendarProvider) AddEvent(calendarID string, event *Event) (string, error) { + googleEvent := &calendar.Event{ + Summary: event.Summary, + Description: event.Description, + Start: &calendar.EventDateTime{ + DateTime: event.Start.Format(time.RFC3339), + }, + End: &calendar.EventDateTime{ + DateTime: event.End.Format(time.RFC3339), + }, + } + + createdEvent, err := g.service.Events.Insert(calendarID, googleEvent).Do() + if err != nil { + return "", fmt.Errorf("failed to create event: %w", err) + } + + return createdEvent.Id, nil +} + +func (g *GoogleCalendarProvider) UpdateEvent(calendarID string, eventID string, event *Event) error { + googleEvent := &calendar.Event{ + Summary: event.Summary, + Description: event.Description, + Start: &calendar.EventDateTime{ + DateTime: event.Start.Format(time.RFC3339), + }, + End: &calendar.EventDateTime{ + DateTime: event.End.Format(time.RFC3339), + }, + } + + _, err := g.service.Events.Update(calendarID, eventID, googleEvent).Do() + if err != nil { + return fmt.Errorf("failed to update event: %w", err) + } + + return nil +} + +func (g *GoogleCalendarProvider) DeleteEvent(calendarID string, eventID string) error { + err := g.service.Events.Delete(calendarID, eventID).Do() + if err != nil { + return fmt.Errorf("failed to delete event: %w", err) + } + return nil +} + +func (g *GoogleCalendarProvider) ListEvents(calendarID string, timeMin, timeMax time.Time) ([]*Event, error) { + events, err := g.service.Events.List(calendarID). + TimeMin(timeMin.Format(time.RFC3339)). + TimeMax(timeMax.Format(time.RFC3339)). + SingleEvents(true). + OrderBy("startTime"). + Do() + + if err != nil { + return nil, fmt.Errorf("failed to list events: %w", err) + } + + var result []*Event + for _, item := range events.Items { + start, _ := time.Parse(time.RFC3339, item.Start.DateTime) + end, _ := time.Parse(time.RFC3339, item.End.DateTime) + + result = append(result, &Event{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + Status: item.Status, + }) + } + + return result, nil +} diff --git a/list.go b/list.go index ac077c0..a32d392 100644 --- a/list.go +++ b/list.go @@ -14,18 +14,41 @@ func listCalendars() { fmt.Println("πŸ“‹ Here's the list of calendars you are syncing:") - rows, err := db.Query("SELECT account_name, calendar_id, count(1) as num_events FROM blocker_events GROUP BY 1,2;") + // First list all calendars + fmt.Println("\nπŸ“… Calendars:") + rows, err := db.Query("SELECT account_name, calendar_id, provider_type FROM calendars;") if err != nil { - log.Fatalf("❌ Error retrieving blocker events from database: %v", err) + log.Fatalf("❌ Error retrieving calendars from database: %v", err) } defer rows.Close() for rows.Next() { + var accountName, calendarID, providerType string + if err := rows.Scan(&accountName, &calendarID, &providerType); err != nil { + log.Fatalf("❌ Unable to read calendar record: %v", err) + } + + if providerType == "" { + providerType = "google" // Default for backward compatibility + } + + fmt.Printf(" πŸ‘€ %s (πŸ“… %s) [%s]\n", accountName, calendarID, providerType) + } + + // Then list blocker events + fmt.Println("\n🚫 Blocker Events:") + blockerRows, err := db.Query("SELECT account_name, calendar_id, count(1) as num_events FROM blocker_events GROUP BY 1,2;") + if err != nil { + log.Fatalf("❌ Error retrieving blocker events from database: %v", err) + } + defer blockerRows.Close() + + for blockerRows.Next() { var accountName, calendarID string var numEvents int - if err := rows.Scan(&accountName, &calendarID, &numEvents); err != nil { - log.Fatalf("❌ Unable to read calendar record or no calendars defined: %v", err) + if err := blockerRows.Scan(&accountName, &calendarID, &numEvents); err != nil { + log.Fatalf("❌ Unable to read blocker event record: %v", err) } - fmt.Printf(" πŸ‘€ %s (πŸ“… %s) - %d\n", accountName, calendarID, numEvents) + fmt.Printf(" πŸ‘€ %s (πŸ“… %s) - %d events\n", accountName, calendarID, numEvents) } } diff --git a/sync.go b/sync.go index 95ccd5b..6e18ed6 100644 --- a/sync.go +++ b/sync.go @@ -8,9 +8,6 @@ import ( "log" "strings" "time" - - "google.golang.org/api/calendar/v3" - "google.golang.org/api/option" ) func syncCalendars() { @@ -27,190 +24,270 @@ func syncCalendars() { } defer db.Close() + // Ensure provider_config column exists + _, err = db.Exec(`ALTER TABLE calendars ADD COLUMN provider_config TEXT DEFAULT ''`) + if err != nil && !strings.Contains(err.Error(), "duplicate column name") { + log.Printf("Warning: Failed to add provider_config column: %v", err) + } + calendars := getCalendarsFromDB(db) ctx := context.Background() fmt.Println("πŸš€ Starting calendar synchronization...") - for accountName, calendarIDs := range calendars { - fmt.Printf("πŸ“… Syncing calendars for account: %s\n", accountName) - client := getClient(ctx, oauthConfig, db, accountName, config) - calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - log.Fatalf("Error creating calendar client: %v", err) + + // Create provider instances for each account + providers = make(map[string]map[string]CalendarProvider) + + for accountName, calendarInfos := range calendars { + fmt.Printf("πŸ“… Setting up account: %s\n", accountName) + providers[accountName] = make(map[string]CalendarProvider) + + // Initialize providers for each type needed by this account + for i, calInfo := range calendarInfos { + switch calInfo.ProviderType { + case "google": + if _, exists := providers[accountName]["google"]; !exists { + client := getClient(ctx, oauthConfig, db, accountName, config) + googleProvider, err := NewGoogleCalendarProvider(ctx, client) + if err != nil { + log.Fatalf("Error creating Google calendar provider: %v", err) + } + providers[accountName]["google"] = googleProvider + } + + case "caldav": + // Get the server configuration based on provider_config + var serverConfig CalDAVConfig + serverName := calInfo.ProviderConfig + + // If there's no server name stored, we need to ask the user to reconfigure this calendar + if serverName == "" || serverName == "default" { + log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") + } + + // Use the server from the CalDAV servers config + if server, ok := config.CalDAVs[serverName]; ok { + serverConfig = server + } else { + log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) + } + + // Create a provider key that includes the server name to allow multiple servers + providerKey := "caldav-" + serverName + + // Only create the provider if we don't already have one for this server + if _, exists := providers[accountName][providerKey]; !exists { + caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + if err != nil { + log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) + } + providers[accountName][providerKey] = caldavProvider + } + + // Update the calendar info to use the correct provider key + calendarInfos[i].ProviderKey = providerKey + + default: + log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) + } } - - for _, calendarID := range calendarIDs { - fmt.Printf(" β†ͺ️ Syncing calendar: %s\n", calendarID) - syncCalendar(db, calendarService, calendarID, calendars, accountName, useReminders, eventVisibility) + + // Sync each calendar using the appropriate provider + for _, calInfo := range calendarInfos { + fmt.Printf(" β†ͺ️ Syncing %s calendar: %s\n", calInfo.ProviderType, calInfo.ID) + + // Determine which provider to use + providerKey := calInfo.ProviderType + if calInfo.ProviderKey != "" { + providerKey = calInfo.ProviderKey + } + + provider := providers[accountName][providerKey] + if provider == nil { + log.Fatalf("Error: Provider not found for key: %s", providerKey) + } + + syncCalendarWithProvider(db, provider, calInfo.ID, calendars, accountName, useReminders, eventVisibility, calInfo.ProviderType) } - fmt.Println("βœ… Calendar synchronization completed successfully!") } - fmt.Println("Calendars synced successfully") + fmt.Println("βœ… Calendar synchronization completed successfully!") +} + +type CalendarInfo struct { + ID string + ProviderType string + ProviderConfig string // Stores server name for CalDAV + ProviderKey string // Used to lookup the right provider } -func getCalendarsFromDB(db *sql.DB) map[string][]string { - calendars := make(map[string][]string) - rows, _ := db.Query("SELECT account_name, calendar_id FROM calendars") +func getCalendarsFromDB(db *sql.DB) map[string][]CalendarInfo { + calendars := make(map[string][]CalendarInfo) + + // Updated query to include provider_config + rows, err := db.Query("SELECT account_name, calendar_id, provider_type, provider_config FROM calendars") + if err != nil { + // Handle case where provider_config column doesn't exist yet + if strings.Contains(err.Error(), "no such column") { + rows, err = db.Query("SELECT account_name, calendar_id, provider_type, '' AS provider_config FROM calendars") + if err != nil { + log.Fatalf("Error querying calendars: %v", err) + } + } else { + log.Fatalf("Error querying calendars: %v", err) + } + } + defer rows.Close() for rows.Next() { - var accountName, calendarID string - if err := rows.Scan(&accountName, &calendarID); err != nil { + var accountName, calendarID, providerType, providerConfig string + if err := rows.Scan(&accountName, &calendarID, &providerType, &providerConfig); err != nil { log.Fatalf("Error scanning calendar row: %v", err) } - calendars[accountName] = append(calendars[accountName], calendarID) + // Default to "google" for backwards compatibility with existing data + if providerType == "" { + providerType = "google" + } + calendars[accountName] = append(calendars[accountName], CalendarInfo{ + ID: calendarID, + ProviderType: providerType, + ProviderConfig: providerConfig, + }) } return calendars } -func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID string, calendars map[string][]string, accountName string, useReminders bool, eventVisibility string) { - config, err := readConfig(".gcalsync.toml") - if err != nil { - log.Fatalf("Error reading config file: %v", err) - } - - ctx := context.Background() - calendarService = tokenExpired(db, accountName, calendarService, ctx) - pageToken := "" +// Map of account names to map of provider types to providers +var providers map[string]map[string]CalendarProvider +func syncCalendarWithProvider(db *sql.DB, provider CalendarProvider, calendarID string, calendars map[string][]CalendarInfo, accountName string, useReminders bool, eventVisibility string, providerType string) { + now := time.Now() startOfCurrentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) endOfNextMonth := startOfCurrentMonth.AddDate(0, 2, -1) - timeMin := startOfCurrentMonth.Format(time.RFC3339) - timeMax := endOfNextMonth.Format(time.RFC3339) - + var allEventsId = map[string]bool{} - for { - fmt.Printf(" πŸ“₯ Retrieving events for calendar: %s\n", calendarID) - events, err := calendarService.Events.List(calendarID). - PageToken(pageToken). - SingleEvents(true). - TimeMin(timeMin). - TimeMax(timeMax). - OrderBy("startTime"). - Do() - if err != nil { - log.Fatalf("Error retrieving events: %v", err) - } - - for _, event := range events.Items { - allEventsId[event.Id] = true - // Google marks "working locations" as events, but we don't want to sync them - if event.EventType == "workingLocation" { - continue - } - if !strings.Contains(event.Summary, "O_o") { - fmt.Printf(" ✨ Syncing event: %s\n", event.Summary) - for otherAccountName, calendarIDs := range calendars { - for _, otherCalendarID := range calendarIDs { - if otherCalendarID != calendarID { - var existingBlockerEventID string - var last_updated string - var originCalendarID string - var responseStatus string - err := db.QueryRow("SELECT event_id, last_updated, origin_calendar_id, response_status FROM blocker_events WHERE calendar_id = ? AND origin_event_id = ?", otherCalendarID, event.Id).Scan(&existingBlockerEventID, &last_updated, &originCalendarID, &responseStatus) - - // Get original event's response status for the calendar owner - originalResponseStatus := "accepted" // default - if event.Attendees != nil { - for _, attendee := range event.Attendees { - if attendee.Email == calendarID { - originalResponseStatus = attendee.ResponseStatus - break - } - } - } + fmt.Printf(" πŸ“₯ Retrieving events for calendar: %s\n", calendarID) + events, err := provider.ListEvents(calendarID, startOfCurrentMonth, endOfNextMonth) + if err != nil { + log.Fatalf("Error retrieving events: %v", err) + } - // Only skip if event exists, is up to date, and response status hasn't changed - if err == nil && last_updated == event.Updated && originCalendarID == calendarID && responseStatus == originalResponseStatus { - fmt.Printf(" ⚠️ Blocker event already exists for origin event ID %s in calendar %s and up to date\n", event.Id, otherCalendarID) - continue - } + for _, event := range events { + allEventsId[event.ID] = true + + // Skip "working location" events (only for Google provider as it's Google-specific) + if providerType == "google" && strings.Contains(event.Summary, "working location") { + continue + } + + if !strings.Contains(event.Summary, "O_o") { + fmt.Printf(" ✨ Syncing event: %s\n", event.Summary) + for otherAccountName, calendarInfos := range calendars { + for _, otherCalendarInfo := range calendarInfos { + if otherCalendarInfo.ID != calendarID { + var existingBlockerEventID string + var last_updated string + var originCalendarID string + var responseStatus string + err := db.QueryRow("SELECT event_id, last_updated, origin_calendar_id, response_status FROM blocker_events WHERE calendar_id = ? AND origin_event_id = ?", + otherCalendarInfo.ID, event.ID).Scan(&existingBlockerEventID, &last_updated, &originCalendarID, &responseStatus) + + // We'll use current time as update time if not available + updatedTime := time.Now().Format(time.RFC3339) + + // Get original event's response status for the calendar owner + originalResponseStatus := "accepted" // default + + // Only skip if event exists, is up to date, and response status hasn't changed + if err == nil && last_updated == updatedTime && originCalendarID == calendarID && responseStatus == originalResponseStatus { + fmt.Printf(" ⚠️ Blocker event already exists for origin event ID %s in calendar %s and up to date\n", event.ID, otherCalendarInfo.ID) + continue + } - client := getClient(ctx, oauthConfig, db, otherAccountName, config) - otherCalendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - log.Fatalf("Error creating calendar client: %v", err) - } + // Get provider for target calendar + providerKey := otherCalendarInfo.ProviderType + if otherCalendarInfo.ProviderKey != "" { + providerKey = otherCalendarInfo.ProviderKey + } + + otherProvider := providers[otherAccountName][providerKey] + if otherProvider == nil { + log.Fatalf("Error: Provider not found for account %s, key %s", otherAccountName, providerKey) + } - blockerSummary := fmt.Sprintf("O_o %s", event.Summary) - blockerDescription := event.Description + blockerSummary := fmt.Sprintf("O_o %s", event.Summary) + blockerDescription := event.Description - if event.End == nil { - startTime, _ := time.Parse(time.RFC3339, event.Start.DateTime) - duration := time.Hour - endTime := startTime.Add(duration) - event.End = &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)} - } + // Ensure event has end time + endTime := event.End + if endTime.IsZero() { + endTime = event.Start.Add(time.Hour) + } - blockerEvent := &calendar.Event{ - Summary: blockerSummary, - Description: blockerDescription, - Start: event.Start, - End: event.End, - Attendees: []*calendar.EventAttendee{ - { - Email: otherCalendarID, - ResponseStatus: originalResponseStatus, - }, - }, - } - if !useReminders { - blockerEvent.Reminders = nil - } + // Create blocker event + blockerEvent := &Event{ + Summary: blockerSummary, + Description: blockerDescription, + Start: event.Start, + End: endTime, + Status: "confirmed", + } - if eventVisibility != "" { - blockerEvent.Visibility = eventVisibility - } + var newEventID string - var res *calendar.Event + if existingBlockerEventID != "" { + // Update existing blocker event + err = otherProvider.UpdateEvent(otherCalendarInfo.ID, existingBlockerEventID, blockerEvent) + newEventID = existingBlockerEventID + } else { + // Create new blocker event + newEventID, err = otherProvider.AddEvent(otherCalendarInfo.ID, blockerEvent) + } - if existingBlockerEventID != "" { - res, err = otherCalendarService.Events.Update(otherCalendarID, existingBlockerEventID, blockerEvent).Do() + if err == nil { + fmt.Printf(" βž• Blocker event created or updated: %s (Response: %s)\n", blockerEvent.Summary, originalResponseStatus) + fmt.Printf(" πŸ“… Destination calendar: %s\n", otherCalendarInfo.ID) + result, err := db.Exec(`INSERT OR REPLACE INTO blocker_events + (event_id, origin_calendar_id, calendar_id, account_name, origin_event_id, last_updated, response_status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + newEventID, calendarID, otherCalendarInfo.ID, otherAccountName, event.ID, updatedTime, originalResponseStatus) + if err != nil { + log.Printf("Error inserting blocker event into database: %v\n", err) } else { - res, err = otherCalendarService.Events.Insert(otherCalendarID, blockerEvent).Do() - } - if err == nil { - fmt.Printf(" βž• Blocker event created or updated: %s (Response: %s)\n", blockerEvent.Summary, originalResponseStatus) - fmt.Printf(" πŸ“… Destination calendar: %s\n", otherCalendarID) - result, err := db.Exec(`INSERT OR REPLACE INTO blocker_events - (event_id, origin_calendar_id, calendar_id, account_name, origin_event_id, last_updated, response_status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - res.Id, calendarID, otherCalendarID, otherAccountName, event.Id, event.Updated, originalResponseStatus) - if err != nil { - log.Printf("Error inserting blocker event into database: %v\n", err) - } else { - rowsAffected, _ := result.RowsAffected() - fmt.Printf(" πŸ“₯ Blocker event inserted into database. Rows affected: %d\n", rowsAffected) - } + rowsAffected, _ := result.RowsAffected() + fmt.Printf(" πŸ“₯ Blocker event inserted into database. Rows affected: %d\n", rowsAffected) } + } - if err != nil { - log.Fatalf("Error creating blocker event: %v", err) - } + if err != nil { + log.Fatalf("Error creating blocker event: %v", err) } } } } } - pageToken = events.NextPageToken - if pageToken == "" { - break - } } - // Delete blocker events that not exists from this calendar in other calendars + // Delete blocker events that no longer exist from this calendar in other calendars fmt.Printf(" πŸ—‘ Deleting blocker events that no longer exist in calendar %s from other calendars…\n", calendarID) - for otherAccountName, calendarIDs := range calendars { - for _, otherCalendarID := range calendarIDs { - if otherCalendarID != calendarID { - client := getClient(ctx, oauthConfig, db, otherAccountName, config) - otherCalendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) - rows, err := db.Query("SELECT event_id, origin_event_id FROM blocker_events WHERE calendar_id = ? AND origin_calendar_id = ?", otherCalendarID, calendarID) + for otherAccountName, calendarInfos := range calendars { + for _, otherCalendarInfo := range calendarInfos { + if otherCalendarInfo.ID != calendarID { + // Get provider for target calendar + providerKey := otherCalendarInfo.ProviderType + if otherCalendarInfo.ProviderKey != "" { + providerKey = otherCalendarInfo.ProviderKey + } + + otherProvider := providers[otherAccountName][providerKey] + rows, err := db.Query("SELECT event_id, origin_event_id FROM blocker_events WHERE calendar_id = ? AND origin_calendar_id = ?", + otherCalendarInfo.ID, calendarID) if err != nil { log.Fatalf("Error retrieving blocker events: %v", err) } + eventsToDelete := make([]string, 0) defer rows.Close() @@ -221,10 +298,14 @@ func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID stri log.Fatalf("Error scanning blocker event row: %v", err) } + // Check if original event still exists if val := allEventsId[originEventID]; !val { - - res, err := calendarService.Events.Get(calendarID, originEventID).Do() - if err != nil || res == nil || res.Status == "cancelled" { + // Try to get the event from the original calendar to verify it's truly gone + var sourceEvent *Event + // We'll catch the error later if the event doesn't exist + sourceEvent, _ = getEventFromProvider(provider, calendarID, originEventID) + + if sourceEvent == nil || sourceEvent.Status == "cancelled" { fmt.Printf(" 🚩 Event marked for deletion: %s\n", eventID) eventsToDelete = append(eventsToDelete, eventID) } @@ -233,35 +314,59 @@ func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID stri for _, eventID := range eventsToDelete { fmt.Printf(" πŸ—‘ Deleting blocker event: %s\n", eventID) - res, err := otherCalendarService.Events.Get(otherCalendarID, eventID).Do() - + + // Check if the event still exists in the target calendar + var targetEvent *Event + targetEvent, err = getEventFromProvider(otherProvider, otherCalendarInfo.ID, eventID) + alreadyDeleted := false - - if err != nil { - alreadyDeleted = strings.Contains(err.Error(), "410") - if !alreadyDeleted { - log.Fatalf("Error retrieving blocker event: %v", err) - } + if err != nil || targetEvent == nil { + alreadyDeleted = true } if !alreadyDeleted { - err = otherCalendarService.Events.Delete(otherCalendarID, eventID).Do() + err = otherProvider.DeleteEvent(otherCalendarInfo.ID, eventID) if err != nil { - if res.Status != "cancelled" { + if targetEvent != nil && targetEvent.Status != "cancelled" { log.Fatalf("Error deleting blocker event: %v", err) } else { fmt.Printf(" ❗️ Event already deleted in the other calendar: %s\n", eventID) } } } + _, err = db.Exec("DELETE FROM blocker_events WHERE event_id = ?", eventID) if err != nil { log.Fatalf("Error deleting blocker event from database: %v", err) } - fmt.Printf(" βœ… Blocker event deleted: %s\n", res.Summary) + fmt.Printf(" βœ… Blocker event deleted: %s\n", eventID) } } } } } + +// Helper function to get a single event from a provider +func getEventFromProvider(provider CalendarProvider, calendarID, eventID string) (*Event, error) { + // This implementation is a temporary solution since the interface doesn't have a GetEvent method + // Each provider should implement GetEvent in future versions + // For now, we'll query over a large time range to try to find the event + + // Query over a 10-year range to try to find the event + startTime := time.Now().AddDate(-5, 0, 0) // 5 years ago + endTime := time.Now().AddDate(5, 0, 0) // 5 years in future + + events, err := provider.ListEvents(calendarID, startTime, endTime) + if err != nil { + return nil, err + } + + for _, event := range events { + if event.ID == eventID { + return event, nil + } + } + + return nil, fmt.Errorf("event not found") +} \ No newline at end of file From 969f5707b7079948bd307159279919d90b0d1dd7 Mon Sep 17 00:00:00 2001 From: Grigory Bakunov Date: Mon, 28 Apr 2025 19:23:01 +0300 Subject: [PATCH 2/2] Implement CalendarFactory pattern and add GetEvent method --- add.go | 31 +++++------- caldav_calendar.go | 57 +++++++++++++++++++++ calendar_factory.go | 118 +++++++++++++++++++++++++++++++++++++++++++ calendar_provider.go | 3 +- cleanup.go | 65 +++--------------------- desync.go | 65 ++---------------------- google_calendar.go | 19 +++++++ sync.go | 108 ++++++++------------------------------- 8 files changed, 243 insertions(+), 223 deletions(-) create mode 100644 calendar_factory.go diff --git a/add.go b/add.go index 39d6fee..5e139ef 100644 --- a/add.go +++ b/add.go @@ -7,9 +7,6 @@ import ( "log" "os" "strings" - - "google.golang.org/api/calendar/v3" - "google.golang.org/api/option" ) func addCalendar() { @@ -44,17 +41,18 @@ func addCalendar() { ctx := context.Background() var providerConfig string + + // Use CalendarFactory to create and validate provider + calendarFactory := NewCalendarFactory(ctx, config, db) // Validate calendar access based on provider type if providerType == "google" { - client := getClient(ctx, oauthConfig, db, accountName, config) - - calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + provider, err := calendarFactory.CreateCalendarProvider(providerType, accountName, "") if err != nil { - log.Fatalf("Error creating calendar client: %v", err) + log.Fatalf("Error creating Google calendar provider: %v", err) } - - _, err = calendarService.CalendarList.Get(calendarID).Do() + + err = calendarFactory.ValidateCalendarAccess(provider, calendarID) if err != nil { log.Fatalf("Error retrieving Google calendar: %v", err) } @@ -64,10 +62,6 @@ func addCalendar() { log.Fatalf("Error: No CalDAV server configurations found in .gcalsync.toml") } - // Use the server configuration - var serverName string - var serverConfig CalDAVConfig - // List available servers for selection fmt.Println("Available CalDAV servers:") servers := make([]string, 0, len(config.CalDAVs)) @@ -92,17 +86,18 @@ func addCalendar() { log.Fatalf("Error: Invalid server selection") } - serverName = servers[serverIndex] - serverConfig = config.CalDAVs[serverName] + serverName := servers[serverIndex] + serverConfig := config.CalDAVs[serverName] fmt.Printf("Using CalDAV server: %s\n", serverConfig.ServerURL) - caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + // Create and validate provider using the factory + provider, err := calendarFactory.CreateCalendarProvider(providerType, accountName, serverName) if err != nil { - log.Fatalf("Error connecting to CalDAV server: %v", err) + log.Fatalf("Error creating CalDAV provider: %v", err) } - err = caldavProvider.GetCalendar(calendarID) + err = calendarFactory.ValidateCalendarAccess(provider, calendarID) if err != nil { log.Fatalf("Error retrieving CalDAV calendar: %v", err) } diff --git a/caldav_calendar.go b/caldav_calendar.go index fff9330..fd4115c 100644 --- a/caldav_calendar.go +++ b/caldav_calendar.go @@ -237,6 +237,63 @@ func (c *CalDAVProvider) ListEvents(calendarID string, timeMin, timeMax time.Tim return result, nil } +func (c *CalDAVProvider) GetEvent(calendarID string, eventID string) (*Event, error) { + calURL, err := url.Parse(calendarID) + if err != nil { + return nil, fmt.Errorf("invalid calendar URL: %w", err) + } + + // Construct the path to the event file + path := calURL.Path + "/" + eventID + ".ics" + + // Get the calendar object + object, err := c.client.GetCalendarObject(c.ctx, path) + if err != nil { + return nil, fmt.Errorf("failed to get event: %w", err) + } + + calendar := object.Data + + // Find the VEVENT component + var eventComp *ical.Component + for _, comp := range calendar.Component.Children { + if comp.Name == "VEVENT" { + eventComp = comp + break + } + } + + if eventComp == nil { + return nil, fmt.Errorf("no VEVENT component found in calendar object") + } + + // Extract event properties + uid := getTextProp(eventComp.Props, "UID") + summary := getTextProp(eventComp.Props, "SUMMARY") + description := getTextProp(eventComp.Props, "DESCRIPTION") + status := getTextProp(eventComp.Props, "STATUS") + if status == "" { + status = "confirmed" // Default status if not specified + } else { + // Convert from iCalendar status format (e.g., "CONFIRMED") to lowercase + status = strings.ToLower(status) + } + + // Parse dates + start, _ := eventComp.Props.DateTime("DTSTART", time.UTC) + end, _ := eventComp.Props.DateTime("DTEND", time.UTC) + + // Create Event object + return &Event{ + ID: uid, + Summary: summary, + Description: description, + Start: start, + End: end, + Status: status, + }, nil +} + // Helper function to get text property safely func getTextProp(props ical.Props, name string) string { prop := props.Get(name) diff --git a/calendar_factory.go b/calendar_factory.go new file mode 100644 index 0000000..81a2086 --- /dev/null +++ b/calendar_factory.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "database/sql" + "fmt" +) + +// CalendarFactory handles creation and management of calendar providers +type CalendarFactory struct { + config *Config + db *sql.DB + ctx context.Context +} + +// NewCalendarFactory creates a new calendar factory instance +func NewCalendarFactory(ctx context.Context, config *Config, db *sql.DB) *CalendarFactory { + return &CalendarFactory{ + config: config, + db: db, + ctx: ctx, + } +} + +// GetAllCalendars returns all calendar providers for all accounts +func (cf *CalendarFactory) GetAllCalendars() (map[string]map[string]CalendarProvider, map[string][]CalendarInfo, error) { + calendars := getCalendarsFromDB(cf.db) + providers := make(map[string]map[string]CalendarProvider) + + for accountName, calendarInfos := range calendars { + providers[accountName] = make(map[string]CalendarProvider) + + // Initialize providers for each type needed by this account + for i, calInfo := range calendarInfos { + switch calInfo.ProviderType { + case "google": + if _, exists := providers[accountName]["google"]; !exists { + client := getClient(cf.ctx, oauthConfig, cf.db, accountName, cf.config) + googleProvider, err := NewGoogleCalendarProvider(cf.ctx, client) + if err != nil { + return nil, nil, fmt.Errorf("error creating Google calendar provider: %w", err) + } + providers[accountName]["google"] = googleProvider + } + + case "caldav": + // Get the server configuration based on provider_config + var serverConfig CalDAVConfig + serverName := calInfo.ProviderConfig + + // If there's no server name stored, we need to ask the user to reconfigure this calendar + if serverName == "" || serverName == "default" { + return nil, nil, fmt.Errorf("calendar references removed legacy CalDAV configuration; please remove and re-add this calendar") + } + + // Use the server from the CalDAV servers config + if server, ok := cf.config.CalDAVs[serverName]; ok { + serverConfig = server + } else { + return nil, nil, fmt.Errorf("CalDAV server '%s' not found in configuration", serverName) + } + + // Create a provider key that includes the server name to allow multiple servers + providerKey := "caldav-" + serverName + + // Only create the provider if we don't already have one for this server + if _, exists := providers[accountName][providerKey]; !exists { + caldavProvider, err := NewCalDAVProvider(cf.ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + if err != nil { + return nil, nil, fmt.Errorf("error connecting to CalDAV server %s: %w", serverName, err) + } + providers[accountName][providerKey] = caldavProvider + } + + // Update the calendar info to use the correct provider key + calendarInfos[i].ProviderKey = providerKey + + default: + return nil, nil, fmt.Errorf("unsupported provider type: %s", calInfo.ProviderType) + } + } + } + + return providers, calendars, nil +} + +// CreateCalendarProvider creates a specific calendar provider +func (cf *CalendarFactory) CreateCalendarProvider(providerType string, accountName string, serverName string) (CalendarProvider, error) { + switch providerType { + case "google": + client := getClient(cf.ctx, oauthConfig, cf.db, accountName, cf.config) + return NewGoogleCalendarProvider(cf.ctx, client) + + case "caldav": + // Get the server configuration + if serverName == "" || serverName == "default" { + return nil, fmt.Errorf("no server name provided for CalDAV provider") + } + + // Use the server from the CalDAV servers config + var serverConfig CalDAVConfig + if server, ok := cf.config.CalDAVs[serverName]; ok { + serverConfig = server + } else { + return nil, fmt.Errorf("CalDAV server '%s' not found in configuration", serverName) + } + + return NewCalDAVProvider(cf.ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) + + default: + return nil, fmt.Errorf("unsupported provider type: %s", providerType) + } +} + +// ValidateCalendarAccess checks if the provided calendar ID is accessible +func (cf *CalendarFactory) ValidateCalendarAccess(provider CalendarProvider, calendarID string) error { + return provider.GetCalendar(calendarID) +} \ No newline at end of file diff --git a/calendar_provider.go b/calendar_provider.go index 0495f55..e9cb183 100644 --- a/calendar_provider.go +++ b/calendar_provider.go @@ -10,6 +10,7 @@ type CalendarProvider interface { UpdateEvent(calendarID string, eventID string, event *Event) error DeleteEvent(calendarID string, eventID string) error ListEvents(calendarID string, timeMin, timeMax time.Time) ([]*Event, error) + GetEvent(calendarID string, eventID string) (*Event, error) } type Event struct { @@ -19,4 +20,4 @@ type Event struct { Start time.Time End time.Time Status string -} +} \ No newline at end of file diff --git a/cleanup.go b/cleanup.go index 0b5ff44..a0c5665 100644 --- a/cleanup.go +++ b/cleanup.go @@ -22,66 +22,17 @@ func cleanupCalendars() { } defer db.Close() - calendars := getCalendarsFromDB(db) - ctx := context.Background() - // Create provider instances for each account - providers = make(map[string]map[string]CalendarProvider) - + // Use the calendar factory to get all providers + calendarFactory := NewCalendarFactory(ctx, config, db) + providers, calendars, err := calendarFactory.GetAllCalendars() + if err != nil { + log.Fatalf("Error initializing calendar providers: %v", err) + } + for accountName, calendarInfos := range calendars { fmt.Printf("πŸ“… Setting up account: %s\n", accountName) - providers[accountName] = make(map[string]CalendarProvider) - - // Initialize providers for each type needed by this account - for i, calInfo := range calendarInfos { - switch calInfo.ProviderType { - case "google": - if _, exists := providers[accountName]["google"]; !exists { - client := getClient(ctx, oauthConfig, db, accountName, config) - googleProvider, err := NewGoogleCalendarProvider(ctx, client) - if err != nil { - log.Fatalf("Error creating Google calendar provider: %v", err) - } - providers[accountName]["google"] = googleProvider - } - - case "caldav": - // Get the server configuration from provider_config - var serverConfig CalDAVConfig - serverName := calInfo.ProviderConfig - - // If there's no server name, we need the user to reconfigure - if serverName == "" || serverName == "default" { - log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") - } - - // Use the server from CalDAV servers config - if server, ok := config.CalDAVs[serverName]; ok { - serverConfig = server - } else { - log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) - } - - // Create a provider key that includes the server name - providerKey := "caldav-" + serverName - - // Only create the provider if we don't already have one for this server - if _, exists := providers[accountName][providerKey]; !exists { - caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) - if err != nil { - log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) - } - providers[accountName][providerKey] = caldavProvider - } - - // Update the calendar info to use the correct provider key - calendarInfos[i].ProviderKey = providerKey - - default: - log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) - } - } for _, calInfo := range calendarInfos { fmt.Printf("🧹 Cleaning up calendar: %s\n", calInfo.ID) @@ -102,7 +53,7 @@ func cleanupCalendars() { } } - fmt.Println("Calendars desynced successfully") + fmt.Println("Calendars cleaned up successfully") } // Legacy function for backward compatibility diff --git a/desync.go b/desync.go index f37473a..d0318c6 100644 --- a/desync.go +++ b/desync.go @@ -23,66 +23,11 @@ func desyncCalendars() { fmt.Println("πŸš€ Starting calendar desynchronization...") - // Create provider instances for each account - providers = make(map[string]map[string]CalendarProvider) - - // First, get all calendars to set up providers - calendars := getCalendarsFromDB(db) - - // Initialize providers - for accountName, calendarInfos := range calendars { - fmt.Printf("πŸ“… Setting up account: %s\n", accountName) - providers[accountName] = make(map[string]CalendarProvider) - - // Initialize providers for each type needed by this account - for i, calInfo := range calendarInfos { - switch calInfo.ProviderType { - case "google": - if _, exists := providers[accountName]["google"]; !exists { - client := getClient(ctx, oauthConfig, db, accountName, config) - googleProvider, err := NewGoogleCalendarProvider(ctx, client) - if err != nil { - log.Fatalf("Error creating Google calendar provider: %v", err) - } - providers[accountName]["google"] = googleProvider - } - - case "caldav": - // Get the server configuration from provider_config - var serverConfig CalDAVConfig - serverName := calInfo.ProviderConfig - - // If there's no server name, we need the user to reconfigure - if serverName == "" || serverName == "default" { - log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") - } - - // Use the server from CalDAV servers config - if server, ok := config.CalDAVs[serverName]; ok { - serverConfig = server - } else { - log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) - } - - // Create a provider key that includes the server name - providerKey := "caldav-" + serverName - - // Only create the provider if we don't already have one for this server - if _, exists := providers[accountName][providerKey]; !exists { - caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) - if err != nil { - log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) - } - providers[accountName][providerKey] = caldavProvider - } - - // Update the calendar info to use the correct provider key - calendarInfos[i].ProviderKey = providerKey - - default: - log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) - } - } + // Use the calendar factory to get all providers + calendarFactory := NewCalendarFactory(ctx, config, db) + providers, _, err := calendarFactory.GetAllCalendars() + if err != nil { + log.Fatalf("Error initializing calendar providers: %v", err) } // Get all blocker events diff --git a/google_calendar.go b/google_calendar.go index 27ffec3..694af69 100644 --- a/google_calendar.go +++ b/google_calendar.go @@ -111,3 +111,22 @@ func (g *GoogleCalendarProvider) ListEvents(calendarID string, timeMin, timeMax return result, nil } + +func (g *GoogleCalendarProvider) GetEvent(calendarID string, eventID string) (*Event, error) { + item, err := g.service.Events.Get(calendarID, eventID).Do() + if err != nil { + return nil, fmt.Errorf("failed to get event: %w", err) + } + + start, _ := time.Parse(time.RFC3339, item.Start.DateTime) + end, _ := time.Parse(time.RFC3339, item.End.DateTime) + + return &Event{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + Status: item.Status, + }, nil +} \ No newline at end of file diff --git a/sync.go b/sync.go index 6e18ed6..772c13f 100644 --- a/sync.go +++ b/sync.go @@ -30,67 +30,18 @@ func syncCalendars() { log.Printf("Warning: Failed to add provider_config column: %v", err) } - calendars := getCalendarsFromDB(db) - ctx := context.Background() fmt.Println("πŸš€ Starting calendar synchronization...") - // Create provider instances for each account - providers = make(map[string]map[string]CalendarProvider) - + // Use the calendar factory to get all providers + calendarFactory := NewCalendarFactory(ctx, config, db) + providers, calendars, err := calendarFactory.GetAllCalendars() + if err != nil { + log.Fatalf("Error initializing calendar providers: %v", err) + } + for accountName, calendarInfos := range calendars { fmt.Printf("πŸ“… Setting up account: %s\n", accountName) - providers[accountName] = make(map[string]CalendarProvider) - - // Initialize providers for each type needed by this account - for i, calInfo := range calendarInfos { - switch calInfo.ProviderType { - case "google": - if _, exists := providers[accountName]["google"]; !exists { - client := getClient(ctx, oauthConfig, db, accountName, config) - googleProvider, err := NewGoogleCalendarProvider(ctx, client) - if err != nil { - log.Fatalf("Error creating Google calendar provider: %v", err) - } - providers[accountName]["google"] = googleProvider - } - - case "caldav": - // Get the server configuration based on provider_config - var serverConfig CalDAVConfig - serverName := calInfo.ProviderConfig - - // If there's no server name stored, we need to ask the user to reconfigure this calendar - if serverName == "" || serverName == "default" { - log.Fatalf("Error: Calendar references removed legacy CalDAV configuration. Please remove and re-add this calendar using: ./gcalsync add") - } - - // Use the server from the CalDAV servers config - if server, ok := config.CalDAVs[serverName]; ok { - serverConfig = server - } else { - log.Fatalf("Error: CalDAV server '%s' not found in configuration", serverName) - } - - // Create a provider key that includes the server name to allow multiple servers - providerKey := "caldav-" + serverName - - // Only create the provider if we don't already have one for this server - if _, exists := providers[accountName][providerKey]; !exists { - caldavProvider, err := NewCalDAVProvider(ctx, serverConfig.ServerURL, serverConfig.Username, serverConfig.Password) - if err != nil { - log.Fatalf("Error connecting to CalDAV server %s: %v", serverName, err) - } - providers[accountName][providerKey] = caldavProvider - } - - // Update the calendar info to use the correct provider key - calendarInfos[i].ProviderKey = providerKey - - default: - log.Fatalf("Error: Unsupported provider type: %s", calInfo.ProviderType) - } - } // Sync each calendar using the appropriate provider for _, calInfo := range calendarInfos { @@ -107,7 +58,7 @@ func syncCalendars() { log.Fatalf("Error: Provider not found for key: %s", providerKey) } - syncCalendarWithProvider(db, provider, calInfo.ID, calendars, accountName, useReminders, eventVisibility, calInfo.ProviderType) + syncCalendarWithProvider(db, provider, calInfo.ID, calendars, accountName, useReminders, eventVisibility, calInfo.ProviderType, providers) } } @@ -157,10 +108,17 @@ func getCalendarsFromDB(db *sql.DB) map[string][]CalendarInfo { return calendars } -// Map of account names to map of provider types to providers -var providers map[string]map[string]CalendarProvider - -func syncCalendarWithProvider(db *sql.DB, provider CalendarProvider, calendarID string, calendars map[string][]CalendarInfo, accountName string, useReminders bool, eventVisibility string, providerType string) { +func syncCalendarWithProvider( + db *sql.DB, + provider CalendarProvider, + calendarID string, + calendars map[string][]CalendarInfo, + accountName string, + useReminders bool, + eventVisibility string, + providerType string, + providers map[string]map[string]CalendarProvider, +) { now := time.Now() startOfCurrentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) @@ -303,7 +261,7 @@ func syncCalendarWithProvider(db *sql.DB, provider CalendarProvider, calendarID // Try to get the event from the original calendar to verify it's truly gone var sourceEvent *Event // We'll catch the error later if the event doesn't exist - sourceEvent, _ = getEventFromProvider(provider, calendarID, originEventID) + sourceEvent, _ = provider.GetEvent(calendarID, originEventID) if sourceEvent == nil || sourceEvent.Status == "cancelled" { fmt.Printf(" 🚩 Event marked for deletion: %s\n", eventID) @@ -317,7 +275,7 @@ func syncCalendarWithProvider(db *sql.DB, provider CalendarProvider, calendarID // Check if the event still exists in the target calendar var targetEvent *Event - targetEvent, err = getEventFromProvider(otherProvider, otherCalendarInfo.ID, eventID) + targetEvent, err = otherProvider.GetEvent(otherCalendarInfo.ID, eventID) alreadyDeleted := false if err != nil || targetEvent == nil { @@ -345,28 +303,4 @@ func syncCalendarWithProvider(db *sql.DB, provider CalendarProvider, calendarID } } } -} - -// Helper function to get a single event from a provider -func getEventFromProvider(provider CalendarProvider, calendarID, eventID string) (*Event, error) { - // This implementation is a temporary solution since the interface doesn't have a GetEvent method - // Each provider should implement GetEvent in future versions - // For now, we'll query over a large time range to try to find the event - - // Query over a 10-year range to try to find the event - startTime := time.Now().AddDate(-5, 0, 0) // 5 years ago - endTime := time.Now().AddDate(5, 0, 0) // 5 years in future - - events, err := provider.ListEvents(calendarID, startTime, endTime) - if err != nil { - return nil, err - } - - for _, event := range events { - if event.ID == eventID { - return event, nil - } - } - - return nil, fmt.Errorf("event not found") } \ No newline at end of file