diff --git a/.gitignore b/.gitignore index b0ac3ed..5568af8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .aider* +gcalsync diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0b9ad6b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +gcalsync is a Go CLI tool that synchronizes events across multiple Google Calendar accounts by creating "blocker" events (prefixed with "O_o") in other calendars to prevent double-booking. + +## Build & Run + +```bash +go build # produces ./gcalsync binary +./gcalsync add # add a calendar to sync +./gcalsync sync # synchronize calendars +./gcalsync desync # remove all blocker events +./gcalsync cleanup # clean blocker events from calendars +./gcalsync list # list synchronized calendars +``` + +No tests or linting are configured. The project uses Go 1.22.1 with CGO (sqlite3 dependency). + +## Architecture + +All source files are in the `main` package at the repository root — there are no sub-packages. + +| File | Purpose | +|------|---------| +| `main.go` | Command dispatcher — routes to add/sync/desync/cleanup/list | +| `common.go` | Config loading (TOML), OAuth2 flow, SQLite helpers, token management | +| `sync.go` | Core sync logic: fetches events, creates/updates blocker events across calendars | +| `add.go` | Registers a new calendar with sync mode (validates access via API, stores in DB) | +| `desync.go` | Deletes all blocker events from Google Calendar and DB | +| `cleanup.go` | Scans calendars for "O_o" events and removes them | +| `list.go` | Lists registered calendars with blocker event counts | +| `dbinit.go` | SQLite schema creation and versioned migrations (v0→v5) | + +### Sync Flow + +1. For each registered account, authenticate via OAuth2 (tokens stored as JSON in SQLite) +2. Fetch events from current + next month for each calendar +3. Filter out: existing blockers ("O_o" prefix), `workingLocation` events, optionally birthdays +4. For each real event in calendar X (mode=`read` or `both`), create/update a blocker in every other calendar with mode=`write` or `both` +5. Blocker events preserve timing, description, response status, visibility, and reminder settings +6. Clean up blockers whose original events were deleted/cancelled + +### Database (SQLite) + +Schema in `dbinit.go` with progressive migrations. Key tables: +- `tokens` — OAuth2 tokens per account +- `calendars` — registered (account_name, calendar_id, mode) tuples; mode is `read`, `write`, or `both` +- `blocker_events` — tracks created blockers with origin event IDs, calendar IDs, response status + +### Configuration + +Loaded from `.gcalsync.toml` in cwd or `~/.config/gcalsync/`. Sectioned format (`[general]` + `[google]`). Old flat format is auto-migrated. See `backup.gcalsync.toml` for template. + +Key settings: `disable_reminders`, `block_event_visibility`, `authorized_ports` (OAuth callback), `verbosity_level` (1-3), `ignore_birthdays`. Per-calendar sync mode (`read`/`write`/`both`) is stored in the database, not in the config file. + +## Key Dependencies + +- `github.com/BurntSushi/toml` — config parsing +- `github.com/mattn/go-sqlite3` — database (requires CGO) +- `golang.org/x/oauth2` + `google.golang.org/api` — Google Calendar API diff --git a/README.md b/README.md index 8282e31..35d57a6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Say goodbye to calendar conflicts and hello to seamless synchronization. 🎉 - 🔄 Sync events from multiple Google Calendars across different accounts - 🚫 Create "blocker" events in other calendars to prevent double bookings +- 🎯 Per-calendar sync modes: `read` (source only), `write` (destination only), or `both` (bidirectional) - 🗄️ Store access tokens and calendar data securely in a local SQLite database - 🔒 Authenticate with Google using the OAuth2 flow for desktop apps - 🧹 Easy way to cleanup calendars and remove all blocker events with a single command @@ -85,7 +86,17 @@ 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 the account name, calendar ID, and sync mode. The program will guide you through the OAuth2 authentication process and store the access token securely in the local database. + +The sync mode controls how the calendar participates in synchronization: + +| Mode | Events read from it? | Blocker events written to it? | +|------|---------------------|-------------------------------| +| `both` (default) | Yes | Yes | +| `read` | Yes | No | +| `write` | No | Yes | + +For example, if you set a work calendar to `read` mode, its events will create blockers in your other calendars, but no blocker events will be written back to it. Conversely, a `write`-only calendar will receive blocker events from other calendars but its own events won't be synced elsewhere. ### 🔄 Syncing Calendars @@ -97,7 +108,7 @@ To desync your calendars and remove all blocker events, run the `gcalsync desync ### 📋 Listing Calendars -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. +To list all calendars that have been added to the local database, run the `gcalsync list` command. The program will display the account name, calendar ID, sync mode, and blocker event count for each calendar. ### 🎗️ Disabling Reminders diff --git a/add.go b/add.go index 18c4c79..d9a6022 100644 --- a/add.go +++ b/add.go @@ -33,6 +33,16 @@ func addCalendar() { var calendarID string fmt.Scanln(&calendarID) + fmt.Print("🔄 Enter sync mode (read/write/both) [both]: ") + var mode string + fmt.Scanln(&mode) + if mode == "" { + mode = "both" + } + if mode != "read" && mode != "write" && mode != "both" { + log.Fatalf("❌ Invalid mode: %s. Must be read, write, or both.", mode) + } + ctx := context.Background() client := getClient(ctx, oauthConfig, db, accountName, config) @@ -46,10 +56,10 @@ func addCalendar() { if err != nil { log.Fatalf("Error retrieving calendar: %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, mode) VALUES (?, ?, ?)`, accountName, calendarID, mode) 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("✅ Calendar %s added successfully for account %s (mode: %s)\n", calendarID, accountName, mode) } diff --git a/cleanup.go b/cleanup.go index fed3c22..5dfb83b 100644 --- a/cleanup.go +++ b/cleanup.go @@ -26,17 +26,17 @@ func cleanupCalendars() { ctx := context.Background() - for accountName, calendarIDs := range calendars { + for accountName, entries := 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) } - 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 _, entry := range entries { + fmt.Printf("🧹 Cleaning up calendar: %s\n", entry.ID) + cleanupCalendar(calendarService, entry.ID) + db.Exec("DELETE FROM blocker_events WHERE calendar_id = ?", entry.ID) } } diff --git a/dbinit.go b/dbinit.go index 2cb0e87..ff84d9c 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 mode TEXT DEFAULT 'both'`) + if err != nil { + log.Fatalf("Error adding mode 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/list.go b/list.go index ac077c0..8791435 100644 --- a/list.go +++ b/list.go @@ -14,18 +14,21 @@ 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;") + rows, err := db.Query(`SELECT c.account_name, c.calendar_id, c.mode, COALESCE(b.num_events, 0) as num_events + FROM calendars c + LEFT JOIN (SELECT account_name, calendar_id, count(1) as num_events FROM blocker_events GROUP BY 1,2) b + ON c.account_name = b.account_name AND c.calendar_id = b.calendar_id`) 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 string + var accountName, calendarID, mode string var numEvents int - if err := rows.Scan(&accountName, &calendarID, &numEvents); err != nil { + if err := rows.Scan(&accountName, &calendarID, &mode, &numEvents); err != nil { log.Fatalf("❌ Unable to read calendar record or no calendars defined: %v", err) } - fmt.Printf(" 👤 %s (📅 %s) - %d\n", accountName, calendarID, numEvents) + fmt.Printf(" 👤 %s (📅 %s) [mode: %s] - %d blocker events\n", accountName, calendarID, mode, numEvents) } } diff --git a/sync.go b/sync.go index 29e98b1..ef1dcbb 100644 --- a/sync.go +++ b/sync.go @@ -13,6 +13,11 @@ import ( "google.golang.org/api/option" ) +type CalendarEntry struct { + ID string + Mode string +} + func syncCalendars() { config, err := readConfig(".gcalsync.toml") if err != nil { @@ -32,7 +37,7 @@ func syncCalendars() { ctx := context.Background() fmt.Println("🚀 Starting calendar synchronization...") - for accountName, calendarIDs := range calendars { + for accountName, entries := 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)) @@ -40,9 +45,13 @@ func syncCalendars() { log.Fatalf("Error creating calendar client: %v", err) } - for _, calendarID := range calendarIDs { - fmt.Printf(" ↪️ Syncing calendar: %s\n", calendarID) - syncCalendar(db, calendarService, calendarID, calendars, accountName, useReminders, eventVisibility, ignoreBirthdays) + for _, entry := range entries { + if entry.Mode == "write" { + fmt.Printf(" ⏭️ Skipping read for write-only calendar: %s\n", entry.ID) + continue + } + fmt.Printf(" ↪️ Syncing calendar: %s (mode: %s)\n", entry.ID, entry.Mode) + syncCalendar(db, calendarService, entry.ID, calendars, accountName, useReminders, eventVisibility, ignoreBirthdays) } fmt.Println("✅ Calendar synchronization completed successfully!") } @@ -50,21 +59,21 @@ func syncCalendars() { fmt.Println("Calendars synced successfully") } -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][]CalendarEntry { + calendars := make(map[string][]CalendarEntry) + rows, _ := db.Query("SELECT account_name, calendar_id, mode FROM calendars") defer rows.Close() for rows.Next() { - var accountName, calendarID string - if err := rows.Scan(&accountName, &calendarID); err != nil { + var accountName, calendarID, mode string + if err := rows.Scan(&accountName, &calendarID, &mode); err != nil { log.Fatalf("Error scanning calendar row: %v", err) } - calendars[accountName] = append(calendars[accountName], calendarID) + calendars[accountName] = append(calendars[accountName], CalendarEntry{ID: calendarID, Mode: mode}) } return calendars } -func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID string, calendars map[string][]string, accountName string, useReminders bool, eventVisibility string, ignoreBirthdays bool) { +func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID string, calendars map[string][]CalendarEntry, accountName string, useReminders bool, eventVisibility string, ignoreBirthdays bool) { config, err := readConfig(".gcalsync.toml") if err != nil { log.Fatalf("Error reading config file: %v", err) @@ -110,9 +119,10 @@ func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID stri 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 { + for otherAccountName, entries := range calendars { + for _, otherEntry := range entries { + otherCalendarID := otherEntry.ID + if otherCalendarID != calendarID && otherEntry.Mode != "read" { var existingBlockerEventID string var last_updated string var originCalendarID string @@ -210,9 +220,10 @@ func syncCalendar(db *sql.DB, calendarService *calendar.Service, calendarID stri // Delete blocker events that not exists 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 { + for otherAccountName, entries := range calendars { + for _, otherEntry := range entries { + otherCalendarID := otherEntry.ID + if otherCalendarID != calendarID && otherEntry.Mode != "read" { 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)