├── .gitignore ├── README.md ├── add.go ├── backup.gcalsync.toml ├── cleanup.go ├── common.go ├── dbinit.go ├── desync.go ├── go.mod ├── go.sum ├── list.go ├── main.go └── sync.go /.gitignore: -------------------------------------------------------------------------------- 1 | .aider* 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📆 gcalsync: Sync Your Google Calendars Like a Boss! 🚀 2 | 3 | [![Go Version](https://img.shields.io/badge/go-1.16+-00ADD8?style=flat-square&logo=go)](https://golang.org/) 4 | [![License](https://img.shields.io/badge/license-MIT-0969da?style=flat-square&logo=opensource)](https://opensource.org/licenses/MIT) 5 | 6 | Welcome to **gcalsync**, the ultimate tool for syncing your Google Calendars across multiple accounts! 7 | Say goodbye to calendar conflicts and hello to seamless synchronization. 🎉 8 | 9 | ## 🌟 Features 10 | 11 | - 🔄 Sync events from multiple Google Calendars across different accounts 12 | - 🚫 Create "blocker" events in other calendars to prevent double bookings 13 | - 🗄️ Store access tokens and calendar data securely in a local SQLite database 14 | - 🔒 Authenticate with Google using the OAuth2 flow for desktop apps 15 | - 🧹 Easy way to cleanup calendars and remove all blocker events with a single command 16 | 17 | ## 📋 Prerequisites 18 | 19 | - Go 1.16 or higher 20 | - A Google Cloud Platform project with the Google Calendar API enabled 21 | - OAuth2 credentials (client ID and client secret) for the desktop app flow 22 | 23 | ## 🚀 Getting Started 24 | 25 | 1. Clone the repository: 26 | 27 | ``` 28 | git clone https://github.com/bobuk/gcalsync.git 29 | ``` 30 | 31 | 2. Navigate to the project directory: 32 | 33 | ``` 34 | cd gcalsync 35 | ``` 36 | 37 | 3. Install the dependencies: 38 | 39 | ``` 40 | go mod download 41 | ``` 42 | 43 | 4. Create a `.gcalsync.toml` file in the project directory with your OAuth2 credentials: 44 | 45 | ```toml 46 | [general] 47 | disable_reminders = false # Disable reminders for blocker events 48 | block_event_visibility = "private" # Visibility of blocker events (private, public, or default) 49 | authorized_ports = [8080, 8081, 8082] # Ports that can be used for OAuth callback 50 | 51 | [google] 52 | client_id = "your-client-id" # Your OAuth2 client ID 53 | client_secret = "your-client-secret" # Your OAuth2 client secret 54 | ``` 55 | 56 | 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. 57 | 58 | You can move the file to `~/.config/gcalsync/.gcalsync.toml` to avoid storing sensitive data in the project directory. In this case your database file will be created in `~/.config/gcalsync/` as well. 59 | 60 | 5. Build the executable: 61 | 62 | ``` 63 | go build 64 | ``` 65 | 66 | 6. Run the `gcalsync` command with the desired action: 67 | - To add a new calendar: 68 | ``` 69 | ./gcalsync add 70 | ``` 71 | - To sync calendars: 72 | ``` 73 | ./gcalsync sync 74 | ``` 75 | - To desync calendars: 76 | ``` 77 | ./gcalsync desync 78 | ``` 79 | - To list all calendars: 80 | ``` 81 | ./gcalsync list 82 | ``` 83 | 84 | ## 📚 Documentation 85 | 86 | ### 🆕 Adding a Calendar 87 | 88 | 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. 89 | 90 | ### 🔄 Syncing Calendars 91 | 92 | To sync your calendars, run the `gcalsync sync` command. The program will retrieve events from the specified calendars within the current and next month time window. It will create "blocker" events in other calendars to prevent double bookings and store the blocker event details in the local database. 93 | 94 | ### 🧹 Desyncing Calendars 95 | 96 | To desync your calendars and remove all blocker events, run the `gcalsync desync` command. The program will retrieve the blocker event details from the local database and remove the corresponding events from the respective calendars. 97 | 98 | ### 📋 Listing Calendars 99 | 100 | To list all calendars that have been added to the local database, run the `gcalsync list` command. The program will display the account name and calendar ID for each calendar. 101 | 102 | ### 🎗️ Disabling Reminders 103 | 104 | By default blocker events will inherit your default Google Calendar reminder/alert settings (typically – 10 minutes before the event). If you *do not want* to receive reminders for the blocker events, you can disable them by setting the `disable_reminders` field to `true` in the `.gcalsync.toml` configuration file. 105 | 106 | ### 🕶️ Setting Block Event Visibility 107 | 108 | By default blocker events will be created with the visibility set to "private". If you want to change the visibility of blocker events, you can set the `block_event_visibility` field to "public" or "default" in the `.gcalsync.toml` configuration file. 109 | 110 | ### Configuration File 111 | 112 | The `.gcalsync.toml` configuration file is used to store OAuth2 credentials and general settings for the program. You can customize the settings to suit your preferences and needs. The file should be located in the project directory or `~/.config/gcalsync/` directory. 113 | 114 | At a minimum, the configuration file should contain the following fields: 115 | 116 | ```toml 117 | [google] 118 | client_id = "your-client-id" 119 | client_secret = "your-client-secret" 120 | ``` 121 | Additional sections and fields can be added to configure the program behavior: 122 | 123 | ```toml 124 | [general] 125 | block_event_visibility = "private" # Keep O_o event public or private 126 | disable_reminders = true # Set reminders on O_o events or not 127 | verbosity_level = 1 # How much chatter to spill out when running sync 128 | authorized_ports = [3000, 3001, 3002] # Casllback ports to listen to for OAuth token response 129 | ``` 130 | 131 | #### 🔌 Configuration Parameters 132 | 133 | - `[google]` section 134 | - `client_id`: Your Google app client ID 135 | - `client_secret` Your Google app configuration secret 136 | - `[general]` section 137 | - `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. 138 | - `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. 139 | - `disable_reminders`: Whether your blocker events should stay quite and **not** alert you. Possible values are `true` or `false`. default is `false`. 140 | - `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. 141 | 142 | ## 🤝 Contributing 143 | 144 | 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! 💪 145 | 146 | ## 📄 License 147 | 148 | This project is licensed under the [MIT License](https://opensource.org/licenses/MIT). Feel free to use, modify, and distribute the code as you see fit. We hope you find it useful! 🌟 149 | 150 | ## 🙏 Acknowledgements 151 | 152 | - The terrible [Go](https://golang.org/) programming language 153 | - The [Google Calendar API](https://developers.google.com/calendar) for making this project almost impossible to implement 154 | - The [OAuth2](https://oauth.net/2/) protocol for very missleading but secure authentication 155 | - The [SQLite](https://www.sqlite.org/) database for lightweight and efficient storage, the only one that added no pain. 156 | -------------------------------------------------------------------------------- /add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "google.golang.org/api/calendar/v3" 9 | "google.golang.org/api/option" 10 | ) 11 | 12 | func addCalendar() { 13 | config, err := readConfig(".gcalsync.toml") 14 | if err != nil { 15 | log.Fatalf("Error reading config file: %v", err) 16 | } 17 | 18 | // Initialize the global oauthConfig 19 | initOAuthConfig(config) 20 | 21 | db, err := openDB(".gcalsync.db") 22 | if err != nil { 23 | log.Fatalf("Error opening database: %v", err) 24 | } 25 | defer db.Close() 26 | 27 | fmt.Println("🚀 Starting calendar addition...") 28 | fmt.Print("👤 Enter account name: ") 29 | var accountName string 30 | fmt.Scanln(&accountName) 31 | 32 | fmt.Print("📅 Enter calendar ID: ") 33 | var calendarID string 34 | fmt.Scanln(&calendarID) 35 | 36 | ctx := context.Background() 37 | 38 | client := getClient(ctx, oauthConfig, db, accountName, config) 39 | 40 | calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 41 | if err != nil { 42 | log.Fatalf("Error creating calendar client: %v", err) 43 | } 44 | 45 | _, err = calendarService.CalendarList.Get(calendarID).Do() 46 | if err != nil { 47 | log.Fatalf("Error retrieving calendar: %v", err) 48 | } 49 | _, err = db.Exec(`INSERT INTO calendars (account_name, calendar_id) VALUES (?, ?)`, accountName, calendarID) 50 | if err != nil { 51 | log.Fatalf("Error saving calendar ID: %v", err) 52 | } 53 | 54 | fmt.Printf("✅ Calendar %s added successfully for account %s\n", calendarID, accountName) 55 | } 56 | -------------------------------------------------------------------------------- /backup.gcalsync.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | disable_reminders = false # Set reminders on O_o events or not 3 | verbosity_level = 1 # How much chatter to spill out when running sync 1 = errors only, 2 = info, 3 = debug 4 | block_event_visibility = "private" # Keep O_o event public or private 5 | authorized_ports = [8080, 8081, 8082] # Ports to listen on for OAuth token callback (the same you configured your app with in Google console!) 6 | 7 | [google] 8 | client_id = "" # Get these from the Google Developer Console 9 | client_secret = "" # Get these from the Google Developer -------------------------------------------------------------------------------- /cleanup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "google.golang.org/api/calendar/v3" 10 | "google.golang.org/api/option" 11 | ) 12 | 13 | func cleanupCalendars() { 14 | config, err := readConfig(".gcalsync.toml") 15 | if err != nil { 16 | log.Fatalf("Error reading config file: %v", err) 17 | } 18 | 19 | db, err := openDB(".gcalsync.db") 20 | if err != nil { 21 | log.Fatalf("Error opening database: %v", err) 22 | } 23 | defer db.Close() 24 | 25 | calendars := getCalendarsFromDB(db) 26 | 27 | ctx := context.Background() 28 | 29 | for accountName, calendarIDs := range calendars { 30 | client := getClient(ctx, oauthConfig, db, accountName, config) 31 | calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 32 | if err != nil { 33 | log.Fatalf("Error creating calendar client: %v", err) 34 | } 35 | 36 | for _, calendarID := range calendarIDs { 37 | fmt.Printf("🧹 Cleaning up calendar: %s\n", calendarID) 38 | cleanupCalendar(calendarService, calendarID) 39 | db.Exec("DELETE FROM blocker_events WHERE calendar_id = ?", calendarID) 40 | } 41 | } 42 | 43 | fmt.Println("Calendars desynced successfully") 44 | } 45 | 46 | func cleanupCalendar(calendarService *calendar.Service, calendarID string) { 47 | // ctx := context.Background() 48 | pageToken := "" 49 | 50 | for { 51 | events, err := calendarService.Events.List(calendarID). 52 | PageToken(pageToken). 53 | SingleEvents(true). 54 | OrderBy("startTime"). 55 | Do() 56 | if err != nil { 57 | log.Fatalf("Error retrieving events: %v", err) 58 | } 59 | 60 | for _, event := range events.Items { 61 | if strings.Contains(event.Summary, "O_o") { 62 | err := calendarService.Events.Delete(calendarID, event.Id).Do() 63 | fmt.Printf("Deleted event %s from calendar %s\n", event.Summary, calendarID) 64 | if err != nil { 65 | log.Fatalf("Error deleting blocker event: %v", err) 66 | } 67 | } 68 | } 69 | 70 | pageToken = events.NextPageToken 71 | if pageToken == "" { 72 | break 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "runtime" 14 | "strings" 15 | "time" 16 | 17 | "github.com/BurntSushi/toml" 18 | _ "github.com/mattn/go-sqlite3" 19 | "golang.org/x/oauth2" 20 | "golang.org/x/oauth2/google" 21 | "google.golang.org/api/calendar/v3" 22 | "google.golang.org/api/option" 23 | ) 24 | 25 | type GoogleConfig struct { 26 | ClientID string `toml:"client_id"` 27 | ClientSecret string `toml:"client_secret"` 28 | } 29 | 30 | type GeneralConfig struct { 31 | DisableReminders bool `toml:"disable_reminders"` 32 | EventVisibility string `toml:"block_event_visibility"` 33 | AuthorizedPorts []int `toml:"authorized_ports"` 34 | Verbosity int `toml:"verbosity"` 35 | } 36 | 37 | type Config struct { 38 | General GeneralConfig `toml:"general"` 39 | Google GoogleConfig `toml:"google"` 40 | } 41 | 42 | var oauthConfig *oauth2.Config 43 | var configDir string 44 | 45 | func initOAuthConfig(config *Config) { 46 | oauthConfig = &oauth2.Config{ 47 | ClientID: config.Google.ClientID, 48 | ClientSecret: config.Google.ClientSecret, 49 | Endpoint: google.Endpoint, 50 | Scopes: []string{calendar.CalendarScope}, 51 | // RedirectURL will be set dynamically in getTokenFromWeb 52 | } 53 | } 54 | 55 | func readConfig(filename string) (*Config, error) { 56 | // Try first current dir, then `$HOME/.config/gcalsync/` 57 | data, err := os.ReadFile(filename) 58 | if err != nil { 59 | data, err = os.ReadFile(os.Getenv("HOME") + "/.config/gcalsync/" + filename) 60 | if err != nil { 61 | return nil, err 62 | } 63 | configDir = os.Getenv("HOME") + "/.config/gcalsync/" 64 | } 65 | 66 | // Check the config file format an update it to new, if it is old 67 | err = upadteConfigFormatIfNeeded(data, configDir, filename) 68 | if err != nil { 69 | return nil, err 70 | } 71 | var config Config 72 | if err := toml.Unmarshal(data, &config); err != nil { 73 | return nil, err 74 | } 75 | 76 | return &config, nil 77 | } 78 | 79 | func upadteConfigFormatIfNeeded(data []byte, configDir, filename string) error { 80 | type oldConfig struct { 81 | DisableReminders bool `toml:"disable_reminders"` 82 | EventVisibility string `toml:"block_event_visibility"` 83 | AuthorizedPorts []int `toml:"authorized_ports"` 84 | ClientID string `toml:"client_id"` 85 | ClientSecret string `toml:"client_secret"` 86 | Verbosity int `toml:"verbosity_level"` 87 | } 88 | var old oldConfig 89 | if err := toml.Unmarshal(data, &old); err != nil { 90 | return err 91 | } 92 | if old.ClientID == "" || old.ClientSecret == "" { 93 | var cfg Config 94 | if err := toml.Unmarshal(data, &cfg); err != nil { 95 | return err 96 | } 97 | // The config is already in the new format or it is empty 98 | return nil 99 | } 100 | fmt.Printf("⚠️ Old config file format detected. Updating to new format...\n") 101 | 102 | // Convert old config to new format 103 | newConfig := Config{ 104 | General: GeneralConfig{ 105 | DisableReminders: old.DisableReminders, 106 | EventVisibility: old.EventVisibility, 107 | AuthorizedPorts: old.AuthorizedPorts, 108 | Verbosity: old.Verbosity, 109 | }, 110 | Google: GoogleConfig{ 111 | ClientID: old.ClientID, 112 | ClientSecret: old.ClientSecret, 113 | }, 114 | } 115 | data, err := toml.Marshal(newConfig) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | // Move the old config file to a backup 121 | if _, err := os.Stat(configDir + filename); err == nil { 122 | timStamp := time.Now().Format("2006-01-02-150405") 123 | backupFilename := fmt.Sprintf("%s%s.bak-%s", configDir, filename, timStamp) 124 | err = os.Rename(configDir+filename, backupFilename) 125 | if err != nil { 126 | return err 127 | } 128 | fmt.Printf(" ℹ️ Old config file moved to %s\n", backupFilename) 129 | } 130 | err = os.WriteFile(configDir+filename, data, 0644) 131 | if err != nil { 132 | return err 133 | } 134 | fmt.Printf("✅ Config file updated to new format and saved to %s\n", configDir+filename) 135 | return nil 136 | } 137 | 138 | func openDB(filename string) (*sql.DB, error) { 139 | // Try first the same dir, where the config file was found 140 | db, err := sql.Open("sqlite3", configDir+filename) 141 | if err != nil { 142 | // Try the current dir 143 | db, err = sql.Open("sqlite3", filename) 144 | if err != nil { 145 | return nil, err 146 | } 147 | } 148 | return db, nil 149 | } 150 | 151 | func getTokenFromWeb(config *oauth2.Config, cfg *Config) *oauth2.Token { 152 | // Start local server 153 | listener, err := findAvailablePort(cfg.General.AuthorizedPorts) 154 | if err != nil { 155 | log.Fatalf("Unable to start listener: %v", err) 156 | } 157 | defer listener.Close() 158 | 159 | port := listener.Addr().(*net.TCPAddr).Port 160 | config.RedirectURL = fmt.Sprintf("http://localhost:%d", port) 161 | 162 | codeChan := make(chan string) 163 | 164 | var server *http.Server 165 | server = &http.Server{ 166 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 | code := r.URL.Query().Get("code") 168 | codeChan <- code 169 | fmt.Fprintf(w, "Authorization successful! You can close this window.") 170 | go func() { 171 | time.Sleep(time.Second) 172 | server.Shutdown(context.Background()) 173 | }() 174 | }), 175 | } 176 | 177 | go server.Serve(listener) 178 | 179 | authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) 180 | fmt.Printf("Please visit this URL to authorize the application: \n%v\n", authURL) 181 | 182 | // Open browser automatically 183 | // err = openBrowser(authURL) 184 | // if err != nil { 185 | // fmt.Printf("Failed to open browser automatically: %v\n", err) 186 | // fmt.Println("Please open the URL manually in your browser.") 187 | // } 188 | 189 | // Copy URL to clipboard 190 | err = copyUrlToClipboard(authURL) 191 | if err != nil { 192 | fmt.Printf("Failed to copy URL to clipboard: %v\n", err) 193 | fmt.Println("Please copy the URL manually and open it in your browser.") 194 | } 195 | 196 | code := <-codeChan 197 | 198 | tok, err := config.Exchange(context.TODO(), code) 199 | if err != nil { 200 | log.Fatalf("Unable to retrieve token: %v", err) 201 | } 202 | return tok 203 | } 204 | 205 | func saveToken(db *sql.DB, accountName string, token *oauth2.Token) error { 206 | tokenJSON, err := json.Marshal(token) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | _, err = db.Exec("INSERT OR REPLACE INTO tokens (account_name, token) VALUES (?, ?)", accountName, tokenJSON) 212 | return err 213 | } 214 | 215 | func getClient(ctx context.Context, config *oauth2.Config, db *sql.DB, accountName string, cfg *Config) *http.Client { 216 | var tokenJSON []byte 217 | err := db.QueryRow("SELECT token FROM tokens WHERE account_name = ?", accountName).Scan(&tokenJSON) 218 | if err != nil { 219 | if err == sql.ErrNoRows { 220 | fmt.Printf(" ❗️ No token found for account %s. Obtaining a new token.\n", accountName) 221 | token := getTokenFromWeb(config, cfg) 222 | saveToken(db, accountName, token) 223 | return config.Client(ctx, token) 224 | } 225 | log.Fatalf("Error retrieving token from database: %v", err) 226 | } 227 | 228 | var token oauth2.Token 229 | err = json.Unmarshal(tokenJSON, &token) 230 | if err != nil { 231 | log.Fatalf("Error unmarshaling token: %v", err) 232 | } 233 | 234 | tokenSource := config.TokenSource(ctx, &token) 235 | newToken, err := tokenSource.Token() 236 | if err != nil { 237 | if strings.Contains(err.Error(), "token expired") || 238 | strings.Contains(err.Error(), "Token has been expired or revoked") || 239 | strings.Contains(err.Error(), "invalid_grant") || 240 | strings.Contains(err.Error(), "oauth2: token expired and refresh token is not set") { 241 | fmt.Printf(" ❗️ Token expired or revoked for account %s. Obtaining a new token.\n", accountName) 242 | // Delete the existing invalid token 243 | _, err := db.Exec("DELETE FROM tokens WHERE account_name = ?", accountName) 244 | if err != nil { 245 | log.Printf("Warning: Failed to delete invalid token: %v", err) 246 | } 247 | // Get a new token from the web 248 | newToken = getTokenFromWeb(config, cfg) 249 | saveToken(db, accountName, newToken) 250 | return config.Client(ctx, newToken) 251 | } 252 | log.Fatalf("Error retrieving token from token source: %v", err) 253 | } 254 | 255 | if newToken.AccessToken != token.AccessToken { 256 | fmt.Printf("Token refreshed for account %s.\n", accountName) 257 | saveToken(db, accountName, newToken) 258 | } 259 | 260 | // Check if the token is expired and refresh it if necessary 261 | if token.Expiry.Before(time.Now()) { 262 | fmt.Printf(" ❗️ Token expired for account %s. Refreshing token.\n", accountName) 263 | newToken, err := config.TokenSource(ctx, &token).Token() 264 | if err != nil { 265 | log.Fatalf("Error refreshing token: %v", err) 266 | } 267 | saveToken(db, accountName, newToken) 268 | return config.Client(ctx, newToken) 269 | } 270 | 271 | return config.Client(ctx, &token) 272 | } 273 | 274 | // Check if the token has expired and refresh if necessary, return updated calendarService 275 | func tokenExpired(db *sql.DB, accountName string, calendarService *calendar.Service, ctx context.Context) *calendar.Service { 276 | var tokenJSON []byte 277 | err := db.QueryRow("SELECT token FROM tokens WHERE account_name = ?", accountName).Scan(&tokenJSON) 278 | if err != nil { 279 | log.Fatalf("Error retrieving token from database: %v", err) 280 | } 281 | 282 | var token oauth2.Token 283 | err = json.Unmarshal(tokenJSON, &token) 284 | if err != nil { 285 | log.Fatalf("Error unmarshaling token: %v", err) 286 | } 287 | 288 | if token.Expiry.Before(time.Now()) { 289 | fmt.Printf(" ❗️ Token expired for account %s. Refreshing token.\n", accountName) 290 | newToken, err := oauthConfig.TokenSource(ctx, &token).Token() 291 | if err != nil { 292 | log.Fatalf("Error refreshing token: %v", err) 293 | } 294 | saveToken(db, accountName, newToken) 295 | 296 | // Create new calendar service with updated token 297 | calendarService, err = calendar.NewService(ctx, option.WithHTTPClient(oauthConfig.Client(ctx, newToken))) 298 | if err != nil { 299 | log.Fatalf("Unable to create new calendar service: %v", err) 300 | } 301 | } 302 | 303 | return calendarService 304 | } 305 | 306 | // Helper function to find an available port in a range 307 | func findAvailablePort(authorizedPorts []int) (net.Listener, error) { 308 | for _, port := range authorizedPorts { 309 | listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) 310 | if err == nil { 311 | return listener, nil 312 | } 313 | } 314 | return nil, fmt.Errorf("no available ports in range %v", authorizedPorts) 315 | } 316 | 317 | // Open a URL in the default browser 318 | func openBrowser(url string) error { 319 | var cmd string 320 | var args []string 321 | 322 | switch runtime.GOOS { 323 | case "windows": 324 | cmd = "cmd" 325 | args = []string{"/c", "start"} 326 | case "darwin": 327 | cmd = "open" 328 | default: // "linux", "freebsd", "openbsd", "netbsd" 329 | cmd = "xdg-open" 330 | } 331 | args = append(args, url) 332 | return exec.Command(cmd, args...).Start() 333 | } 334 | 335 | // Copy a URL into a clipboard automatically 336 | func copyUrlToClipboard(url string) error { 337 | var cmd string 338 | var args []string 339 | 340 | switch runtime.GOOS { 341 | case "windows": 342 | cmd = "cmd" 343 | args = []string{"/c", "echo", url, "|", "clip"} 344 | case "darwin": 345 | cmd = "pbcopy" 346 | default: // "linux", "freebsd", "openbsd", "netbsd" 347 | cmd = "xclip" 348 | args = []string{"-selection", "clipboard"} 349 | } 350 | 351 | command := exec.Command(cmd, args...) 352 | command.Stdin = strings.NewReader(url) 353 | return command.Run() 354 | } 355 | -------------------------------------------------------------------------------- /dbinit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "log" 4 | 5 | func dbInit() { 6 | db, err := openDB(".gcalsync.db") 7 | if err != nil { 8 | log.Fatalf("Error opening database: %v", err) 9 | } 10 | defer db.Close() 11 | 12 | var dbVersion int 13 | err = db.QueryRow("SELECT version FROM db_version WHERE name='gcalsync'").Scan(&dbVersion) 14 | if err != nil { 15 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS db_version ( 16 | name TEXT PRIMARY KEY, 17 | version INTEGER 18 | )`) 19 | if err != nil { 20 | log.Fatalf("Error creating db_version table: %v", err) 21 | } 22 | _, err = db.Exec(`INSERT INTO db_version (name, version) VALUES ('gcalsync', 0)`) 23 | if err != nil { 24 | log.Fatalf("Error initializing db_version table: %v", err) 25 | } 26 | dbVersion = 0 27 | } 28 | 29 | if dbVersion == 0 { 30 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS tokens ( 31 | account_name TEXT PRIMARY KEY, 32 | token TEXT)`) 33 | if err != nil { 34 | log.Fatalf("Error creating tokens table: %v", err) 35 | } 36 | 37 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS calendars ( 38 | account_name TEXT, 39 | calendar_id TEXT, 40 | PRIMARY KEY (account_name, calendar_id))`) 41 | 42 | if err != nil { 43 | log.Fatalf("Error creating calendars table: %v", err) 44 | } 45 | 46 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS blocker_events ( 47 | event_id TEXT, 48 | calendar_id TEXT, 49 | account_name TEXT, 50 | origin_event_id TEXT, 51 | PRIMARY KEY (calendar_id, origin_event_id) 52 | )`) 53 | 54 | if err != nil { 55 | log.Fatalf("Error creating blocker_events table: %v", err) 56 | } 57 | 58 | dbVersion = 1 59 | _, err = db.Exec(`UPDATE db_version SET version = 1 WHERE name = 'gcalsync'`) 60 | if err != nil { 61 | log.Fatalf("Error updating db_version table: %v", err) 62 | } 63 | } 64 | 65 | if dbVersion == 1 { 66 | _, err = db.Exec(`ALTER TABLE blocker_events ADD COLUMN last_updated TEXT`) 67 | if err != nil { 68 | log.Fatalf("Error adding last_updated column to blocker_events table: %v", err) 69 | } 70 | 71 | dbVersion = 2 72 | _, err = db.Exec(`UPDATE db_version SET version = 2 WHERE name = 'gcalsync'`) 73 | if err != nil { 74 | log.Fatalf("Error updating db_version table: %v", err) 75 | } 76 | 77 | } 78 | 79 | if dbVersion == 2 { 80 | _, err = db.Exec(`ALTER TABLE blocker_events ADD COLUMN origin_calendar_id TEXT`) 81 | if err != nil { 82 | log.Fatalf("Error adding origin_calendar_id column to blocker_events table: %v", err) 83 | } 84 | 85 | dbVersion = 3 86 | _, err = db.Exec(`UPDATE db_version SET version = 3 WHERE name = 'gcalsync'`) 87 | if err != nil { 88 | log.Fatalf("Error updating db_version table: %v", err) 89 | } 90 | } 91 | 92 | if dbVersion == 3 { 93 | _, err = db.Exec(`ALTER TABLE blocker_events ADD COLUMN response_status TEXT DEFAULT 'tentative'`) 94 | if err != nil { 95 | log.Fatalf("Error adding response_status column to blocker_events table: %v", err) 96 | } 97 | 98 | dbVersion = 4 99 | _, err = db.Exec(`UPDATE db_version SET version = 4 WHERE name = 'gcalsync'`) 100 | if err != nil { 101 | log.Fatalf("Error updating db_version table: %v", err) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /desync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | 9 | "google.golang.org/api/calendar/v3" 10 | "google.golang.org/api/googleapi" 11 | "google.golang.org/api/option" 12 | ) 13 | 14 | func desyncCalendars() { 15 | config, err := readConfig(".gcalsync.toml") 16 | if err != nil { 17 | log.Fatalf("Error reading config file: %v", err) 18 | } 19 | 20 | ctx := context.Background() 21 | db, err := openDB(".gcalsync.db") 22 | if err != nil { 23 | log.Fatalf("Error opening database: %v", err) 24 | } 25 | defer db.Close() 26 | 27 | fmt.Println("🚀 Starting calendar desynchronization...") 28 | 29 | rows, err := db.Query("SELECT event_id, calendar_id, account_name FROM blocker_events") 30 | if err != nil { 31 | log.Fatalf("❌ Error retrieving blocker events from database: %v", err) 32 | } 33 | defer rows.Close() 34 | 35 | var eventIDCalendarIDPairs []struct { 36 | EventID string 37 | CalendarID string 38 | } 39 | 40 | for rows.Next() { 41 | var eventID, calendarID, accountName string 42 | if err := rows.Scan(&eventID, &calendarID, &accountName); err != nil { 43 | log.Fatalf("❌ Error scanning blocker event row: %v", err) 44 | } 45 | 46 | eventIDCalendarIDPairs = append(eventIDCalendarIDPairs, struct { 47 | EventID string 48 | CalendarID string 49 | }{EventID: eventID, CalendarID: calendarID}) 50 | 51 | client := getClient(ctx, oauthConfig, db, accountName, config) 52 | calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 53 | if err != nil { 54 | log.Fatalf("❌ Error creating calendar client: %v", err) 55 | } 56 | 57 | err = calendarService.Events.Delete(calendarID, eventID).Do() 58 | if err != nil { 59 | if googleErr, ok := err.(*googleapi.Error); ok && googleErr.Code == 404 { 60 | fmt.Printf(" ⚠️ Blocker event not found in calendar: %s\n", eventID) 61 | } else { 62 | log.Fatalf("❌ Error deleting blocker event: %v", err) 63 | } 64 | } else { 65 | fmt.Printf(" ✅ Blocker event deleted: %s\n", eventID) 66 | } 67 | } 68 | 69 | // Delete blocker events from the database after the iteration 70 | for _, pair := range eventIDCalendarIDPairs { 71 | _, err := db.Exec("DELETE FROM blocker_events WHERE event_id = ? AND calendar_id = ?", pair.EventID, pair.CalendarID) 72 | if err != nil { 73 | log.Fatalf("❌ Error deleting blocker event from database: %v", err) 74 | } else { 75 | fmt.Printf(" 📥 Blocker event deleted from database: %s\n", pair.EventID) 76 | } 77 | } 78 | 79 | fmt.Println("Calendars desynced successfully") 80 | } 81 | 82 | func getAccountNameByCalendarID(db *sql.DB, calendarID string) string { 83 | var accountName string 84 | err := db.QueryRow("SELECT account_name FROM calendars WHERE calendar_id = ?", calendarID).Scan(&accountName) 85 | if err != nil { 86 | log.Fatalf("Error retrieving account name for calendar ID %s: %v", calendarID, err) 87 | } 88 | return accountName 89 | } 90 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bobuk/gcalsync 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/mattn/go-sqlite3 v1.14.22 8 | golang.org/x/oauth2 v0.20.0 9 | google.golang.org/api v0.182.0 10 | ) 11 | 12 | require ( 13 | cloud.google.com/go/auth v0.5.0 // indirect 14 | cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect 15 | cloud.google.com/go/compute v1.27.0 // indirect 16 | cloud.google.com/go/compute/metadata v0.3.0 // indirect 17 | github.com/felixge/httpsnoop v1.0.4 // indirect 18 | github.com/go-logr/logr v1.4.2 // indirect 19 | github.com/go-logr/stdr v1.2.2 // indirect 20 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 21 | github.com/golang/protobuf v1.5.4 // indirect 22 | github.com/google/s2a-go v0.1.7 // indirect 23 | github.com/google/uuid v1.6.0 // indirect 24 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 25 | github.com/googleapis/gax-go/v2 v2.12.4 // indirect 26 | go.opencensus.io v0.24.0 // indirect 27 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect 28 | go.opentelemetry.io/otel v1.27.0 // indirect 29 | go.opentelemetry.io/otel/metric v1.27.0 // indirect 30 | go.opentelemetry.io/otel/trace v1.27.0 // indirect 31 | golang.org/x/crypto v0.23.0 // indirect 32 | golang.org/x/net v0.25.0 // indirect 33 | golang.org/x/sys v0.20.0 // indirect 34 | golang.org/x/text v0.15.0 // indirect 35 | google.golang.org/appengine v1.6.8 // indirect 36 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect 37 | google.golang.org/grpc v1.64.0 // indirect 38 | google.golang.org/protobuf v1.34.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.114.0 h1:OIPFAdfrFDFO2ve2U7r/H5SwSbBzEdrBdE7xkgwc+kY= 3 | cloud.google.com/go v0.114.0/go.mod h1:ZV9La5YYxctro1HTPug5lXH/GefROyW8PPD4T8n9J8E= 4 | cloud.google.com/go/auth v0.5.0 h1:GtSZfKJkPrZi/s3AkiHnUYVI4dTP/kg8+I3unm0omag= 5 | cloud.google.com/go/auth v0.5.0/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc= 6 | cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= 7 | cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= 8 | cloud.google.com/go/compute v1.23.4 h1:EBT9Nw4q3zyE7G45Wvv3MzolIrCJEuHys5muLY0wvAw= 9 | cloud.google.com/go/compute v1.23.4/go.mod h1:/EJMj55asU6kAFnuZET8zqgwgJ9FvXWXOkkfQZa4ioI= 10 | cloud.google.com/go/compute v1.27.0 h1:EGawh2RUnfHT5g8f/FX3Ds6KZuIBC77hZoDrBvEZw94= 11 | cloud.google.com/go/compute v1.27.0/go.mod h1:LG5HwRmWFKM2C5XxHRiNzkLLXW48WwvyVC0mfWsYPOM= 12 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 13 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 14 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 15 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 16 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 17 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 18 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 19 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 20 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 21 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 22 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 23 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 28 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 29 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 30 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 31 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 32 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 33 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 34 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 35 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 36 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 37 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 38 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 39 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 40 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 41 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 42 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 43 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 44 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 45 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 46 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 47 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 48 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 49 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 50 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 51 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 52 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 53 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 54 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 55 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 56 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 57 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 58 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 59 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 60 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 61 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 62 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 63 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 65 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 66 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 67 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 68 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 69 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 70 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 71 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 72 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 73 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 74 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 75 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 76 | github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA= 77 | github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= 78 | github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= 79 | github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= 80 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 81 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 82 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 83 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 84 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 86 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 87 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 88 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 89 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 90 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 91 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 92 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 93 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 94 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 95 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 96 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 97 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 98 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 99 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= 100 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= 101 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 102 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 103 | go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= 104 | go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= 105 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 106 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 107 | go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= 108 | go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= 109 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 110 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 111 | go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= 112 | go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= 113 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 114 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 115 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 116 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 117 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 118 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 119 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 120 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 121 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 122 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 123 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 124 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 125 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 126 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 127 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 128 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 129 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 130 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 131 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 132 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 133 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 134 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 135 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 136 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 137 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 138 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 139 | golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= 140 | golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= 141 | golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= 142 | golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 143 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 144 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 145 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 146 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 148 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 149 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 150 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 151 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 152 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 156 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 159 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 160 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 161 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 162 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 163 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 164 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 165 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 166 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 167 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 168 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 169 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 170 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 171 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 172 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 173 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 174 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 175 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 176 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 177 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 178 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 179 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 180 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 181 | google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48= 182 | google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8= 183 | google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE= 184 | google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM= 185 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 186 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 187 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 188 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 189 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 190 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 191 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 192 | google.golang.org/genproto v0.0.0-20240205150955-31a09d347014 h1:g/4bk7P6TPMkAUbUhquq98xey1slwvuVJPosdBqYJlU= 193 | google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M= 194 | google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= 195 | google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 h1:x9PwdEgd11LgK+orcck69WVRo7DezSO4VUMPI4xpc8A= 196 | google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I= 197 | google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No= 198 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0= 199 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= 200 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= 201 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= 202 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 203 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 204 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 205 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 206 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 207 | google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= 208 | google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= 209 | google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= 210 | google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= 211 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 212 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 213 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 214 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 215 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 216 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 217 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 218 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 219 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 220 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 221 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 222 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 223 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 224 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 225 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 226 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 227 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 228 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 229 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 230 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 231 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 232 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | func listCalendars() { 9 | db, err := openDB(".gcalsync.db") 10 | if err != nil { 11 | log.Fatalf("Error opening database: %v", err) 12 | } 13 | defer db.Close() 14 | 15 | fmt.Println("📋 Here's the list of calendars you are syncing:") 16 | 17 | rows, err := db.Query("SELECT account_name, calendar_id, count(1) as num_events FROM blocker_events GROUP BY 1,2;") 18 | if err != nil { 19 | log.Fatalf("❌ Error retrieving blocker events from database: %v", err) 20 | } 21 | defer rows.Close() 22 | 23 | for rows.Next() { 24 | var accountName, calendarID string 25 | var numEvents int 26 | if err := rows.Scan(&accountName, &calendarID, &numEvents); err != nil { 27 | log.Fatalf("❌ Unable to read calendar record or no calendars defined: %v", err) 28 | } 29 | fmt.Printf(" 👤 %s (📅 %s) - %d\n", accountName, calendarID, numEvents) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | if len(os.Args) < 2 { 11 | fmt.Println("Usage: gcalsync (add|sync|desync|list)") 12 | os.Exit(1) 13 | } 14 | config, err := readConfig(".gcalsync.toml") 15 | if err != nil { 16 | log.Fatalf("Error reading config file: %v", err) 17 | } 18 | initOAuthConfig(config) 19 | dbInit() 20 | command := os.Args[1] 21 | switch command { 22 | case "add": 23 | addCalendar() 24 | case "sync": 25 | syncCalendars() 26 | case "desync": 27 | desyncCalendars() 28 | case "cleanup": 29 | cleanupCalendars() 30 | case "list": 31 | listCalendars() 32 | default: 33 | fmt.Printf("Unknown command: %s\n", command) 34 | os.Exit(1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sync.go: -------------------------------------------------------------------------------- 1 | // sync.go 2 | package main 3 | 4 | import ( 5 | "context" 6 | "database/sql" 7 | "fmt" 8 | "log" 9 | "strings" 10 | "time" 11 | 12 | "google.golang.org/api/calendar/v3" 13 | "google.golang.org/api/option" 14 | ) 15 | 16 | func syncCalendars() { 17 | config, err := readConfig(".gcalsync.toml") 18 | if err != nil { 19 | log.Fatalf("Error reading config file: %v", err) 20 | } 21 | useReminders := config.General.DisableReminders 22 | eventVisibility := config.General.EventVisibility 23 | 24 | db, err := openDB(".gcalsync.db") 25 | if err != nil { 26 | log.Fatalf("Error opening database: %v", err) 27 | } 28 | defer db.Close() 29 | 30 | calendars := getCalendarsFromDB(db) 31 | 32 | ctx := context.Background() 33 | fmt.Println("🚀 Starting calendar synchronization...") 34 | for accountName, calendarIDs := range calendars { 35 | fmt.Printf("📅 Syncing calendars for account: %s\n", accountName) 36 | client := getClient(ctx, oauthConfig, db, accountName, config) 37 | calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 38 | if err != nil { 39 | log.Fatalf("Error creating calendar client: %v", err) 40 | } 41 | 42 | for _, calendarID := range calendarIDs { 43 | fmt.Printf(" ↪️ Syncing calendar: %s\n", calendarID) 44 | syncCalendar(db, calendarService, calendarID, calendars, accountName, useReminders, eventVisibility) 45 | } 46 | fmt.Println("✅ Calendar synchronization completed successfully!") 47 | } 48 | 49 | fmt.Println("Calendars synced successfully") 50 | } 51 | 52 | func getCalendarsFromDB(db *sql.DB) map[string][]string { 53 | calendars := make(map[string][]string) 54 | rows, _ := db.Query("SELECT account_name, calendar_id FROM calendars") 55 | defer rows.Close() 56 | for rows.Next() { 57 | var accountName, calendarID string 58 | if err := rows.Scan(&accountName, &calendarID); err != nil { 59 | log.Fatalf("Error scanning calendar row: %v", err) 60 | } 61 | calendars[accountName] = append(calendars[accountName], calendarID) 62 | } 63 | return calendars 64 | } 65 | 66 | func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID string, calendars map[string][]string, accountName string, useReminders bool, eventVisibility string) { 67 | config, err := readConfig(".gcalsync.toml") 68 | if err != nil { 69 | log.Fatalf("Error reading config file: %v", err) 70 | } 71 | 72 | ctx := context.Background() 73 | calendarService = tokenExpired(db, accountName, calendarService, ctx) 74 | pageToken := "" 75 | 76 | now := time.Now() 77 | startOfCurrentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) 78 | endOfNextMonth := startOfCurrentMonth.AddDate(0, 2, -1) 79 | timeMin := startOfCurrentMonth.Format(time.RFC3339) 80 | timeMax := endOfNextMonth.Format(time.RFC3339) 81 | 82 | var allEventsId = map[string]bool{} 83 | 84 | for { 85 | fmt.Printf(" 📥 Retrieving events for calendar: %s\n", calendarID) 86 | events, err := calendarService.Events.List(calendarID). 87 | PageToken(pageToken). 88 | SingleEvents(true). 89 | TimeMin(timeMin). 90 | TimeMax(timeMax). 91 | OrderBy("startTime"). 92 | Do() 93 | if err != nil { 94 | log.Fatalf("Error retrieving events: %v", err) 95 | } 96 | 97 | for _, event := range events.Items { 98 | allEventsId[event.Id] = true 99 | // Google marks "working locations" as events, but we don't want to sync them 100 | if event.EventType == "workingLocation" { 101 | continue 102 | } 103 | if !strings.Contains(event.Summary, "O_o") { 104 | fmt.Printf(" ✨ Syncing event: %s\n", event.Summary) 105 | for otherAccountName, calendarIDs := range calendars { 106 | for _, otherCalendarID := range calendarIDs { 107 | if otherCalendarID != calendarID { 108 | var existingBlockerEventID string 109 | var last_updated string 110 | var originCalendarID string 111 | var responseStatus string 112 | 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) 113 | 114 | // Get original event's response status for the calendar owner 115 | originalResponseStatus := "accepted" // default 116 | if event.Attendees != nil { 117 | for _, attendee := range event.Attendees { 118 | if attendee.Email == calendarID { 119 | originalResponseStatus = attendee.ResponseStatus 120 | break 121 | } 122 | } 123 | } 124 | 125 | // Only skip if event exists, is up to date, and response status hasn't changed 126 | if err == nil && last_updated == event.Updated && originCalendarID == calendarID && responseStatus == originalResponseStatus { 127 | fmt.Printf(" ⚠️ Blocker event already exists for origin event ID %s in calendar %s and up to date\n", event.Id, otherCalendarID) 128 | continue 129 | } 130 | 131 | client := getClient(ctx, oauthConfig, db, otherAccountName, config) 132 | otherCalendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 133 | if err != nil { 134 | log.Fatalf("Error creating calendar client: %v", err) 135 | } 136 | 137 | blockerSummary := fmt.Sprintf("O_o %s", event.Summary) 138 | blockerDescription := event.Description 139 | 140 | if event.End == nil { 141 | startTime, _ := time.Parse(time.RFC3339, event.Start.DateTime) 142 | duration := time.Hour 143 | endTime := startTime.Add(duration) 144 | event.End = &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)} 145 | } 146 | 147 | blockerEvent := &calendar.Event{ 148 | Summary: blockerSummary, 149 | Description: blockerDescription, 150 | Start: event.Start, 151 | End: event.End, 152 | Attendees: []*calendar.EventAttendee{ 153 | { 154 | Email: otherCalendarID, 155 | ResponseStatus: originalResponseStatus, 156 | }, 157 | }, 158 | } 159 | if !useReminders { 160 | blockerEvent.Reminders = nil 161 | } 162 | 163 | if eventVisibility != "" { 164 | blockerEvent.Visibility = eventVisibility 165 | } 166 | 167 | var res *calendar.Event 168 | 169 | if existingBlockerEventID != "" { 170 | res, err = otherCalendarService.Events.Update(otherCalendarID, existingBlockerEventID, blockerEvent).Do() 171 | } else { 172 | res, err = otherCalendarService.Events.Insert(otherCalendarID, blockerEvent).Do() 173 | } 174 | if err == nil { 175 | fmt.Printf(" ➕ Blocker event created or updated: %s (Response: %s)\n", blockerEvent.Summary, originalResponseStatus) 176 | fmt.Printf(" 📅 Destination calendar: %s\n", otherCalendarID) 177 | result, err := db.Exec(`INSERT OR REPLACE INTO blocker_events 178 | (event_id, origin_calendar_id, calendar_id, account_name, origin_event_id, last_updated, response_status) 179 | VALUES (?, ?, ?, ?, ?, ?, ?)`, 180 | res.Id, calendarID, otherCalendarID, otherAccountName, event.Id, event.Updated, originalResponseStatus) 181 | if err != nil { 182 | log.Printf("Error inserting blocker event into database: %v\n", err) 183 | } else { 184 | rowsAffected, _ := result.RowsAffected() 185 | fmt.Printf(" 📥 Blocker event inserted into database. Rows affected: %d\n", rowsAffected) 186 | } 187 | } 188 | 189 | if err != nil { 190 | log.Fatalf("Error creating blocker event: %v", err) 191 | } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | pageToken = events.NextPageToken 198 | if pageToken == "" { 199 | break 200 | } 201 | } 202 | 203 | // Delete blocker events that not exists from this calendar in other calendars 204 | fmt.Printf(" 🗑 Deleting blocker events that no longer exist in calendar %s from other calendars…\n", calendarID) 205 | for otherAccountName, calendarIDs := range calendars { 206 | for _, otherCalendarID := range calendarIDs { 207 | if otherCalendarID != calendarID { 208 | client := getClient(ctx, oauthConfig, db, otherAccountName, config) 209 | otherCalendarService, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 210 | rows, err := db.Query("SELECT event_id, origin_event_id FROM blocker_events WHERE calendar_id = ? AND origin_calendar_id = ?", otherCalendarID, calendarID) 211 | if err != nil { 212 | log.Fatalf("Error retrieving blocker events: %v", err) 213 | } 214 | eventsToDelete := make([]string, 0) 215 | 216 | defer rows.Close() 217 | for rows.Next() { 218 | var eventID string 219 | var originEventID string 220 | if err := rows.Scan(&eventID, &originEventID); err != nil { 221 | log.Fatalf("Error scanning blocker event row: %v", err) 222 | } 223 | 224 | if val := allEventsId[originEventID]; !val { 225 | 226 | res, err := calendarService.Events.Get(calendarID, originEventID).Do() 227 | if err != nil || res == nil || res.Status == "cancelled" { 228 | fmt.Printf(" 🚩 Event marked for deletion: %s\n", eventID) 229 | eventsToDelete = append(eventsToDelete, eventID) 230 | } 231 | } 232 | } 233 | 234 | for _, eventID := range eventsToDelete { 235 | fmt.Printf(" 🗑 Deleting blocker event: %s\n", eventID) 236 | res, err := otherCalendarService.Events.Get(otherCalendarID, eventID).Do() 237 | 238 | alreadyDeleted := false 239 | 240 | if err != nil { 241 | alreadyDeleted = strings.Contains(err.Error(), "410") 242 | if !alreadyDeleted { 243 | log.Fatalf("Error retrieving blocker event: %v", err) 244 | } 245 | } 246 | 247 | if !alreadyDeleted { 248 | err = otherCalendarService.Events.Delete(otherCalendarID, eventID).Do() 249 | if err != nil { 250 | if res.Status != "cancelled" { 251 | log.Fatalf("Error deleting blocker event: %v", err) 252 | } else { 253 | fmt.Printf(" ❗️ Event already deleted in the other calendar: %s\n", eventID) 254 | } 255 | } 256 | } 257 | _, err = db.Exec("DELETE FROM blocker_events WHERE event_id = ?", eventID) 258 | if err != nil { 259 | log.Fatalf("Error deleting blocker event from database: %v", err) 260 | } 261 | 262 | fmt.Printf(" ✅ Blocker event deleted: %s\n", res.Summary) 263 | } 264 | } 265 | } 266 | } 267 | } 268 | --------------------------------------------------------------------------------