From 1a74fb00e94b5f23a9e583e6d84df88b0bab7885 Mon Sep 17 00:00:00 2001 From: Khang Nguyen Date: Tue, 10 Feb 2026 20:30:00 -0500 Subject: [PATCH 1/2] feat: add Google Calendar support with full CRUD operations Add calendar commands (list, get, create, update, delete, respond, today, week, calendars) with OAuth2 PKCE authentication. Includes flexible date/time parsing, recurring event support, attendee management, and user-friendly error handling. Updates auth to share client logic between Gmail and Calendar services. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 33 +- README.md | 35 +- cmd/calendar.go | 654 +++++++++++++++++++ cmd/calendar_time.go | 137 ++++ cmd/calendar_time_fuzz_test.go | 43 ++ cmd/calendar_time_test.go | 424 ++++++++++++ cmd/calendar_write.go | 391 +++++++++++ cmd/calendar_write_test.go | 145 ++++ cmd/root.go | 9 +- go.mod | 8 +- go.sum | 10 + internal/auth/auth.go | 82 ++- internal/auth/auth_test.go | 127 ++++ internal/auth/oauth2.go | 20 +- skills/gsuite-manager/SKILL.md | 142 +++- skills/gsuite-manager/references/commands.md | 168 +++++ 16 files changed, 2380 insertions(+), 48 deletions(-) create mode 100644 cmd/calendar.go create mode 100644 cmd/calendar_time.go create mode 100644 cmd/calendar_time_fuzz_test.go create mode 100644 cmd/calendar_time_test.go create mode 100644 cmd/calendar_write.go create mode 100644 cmd/calendar_write_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 3070d73..cb61dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,50 +7,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash go build -o gsuite . # Build binary ./gsuite --help # Run locally +go test ./... -race # Run all tests ``` Version is injected at build time via ldflags targeting `cmd.Version`. GoReleaser handles this for releases; for local dev builds, version will show "dev". -No tests exist yet. No linter is configured. - ## Architecture -This is a Go CLI tool using Cobra for Gmail operations via Google service account with domain-wide delegation. +This is a Go CLI tool using Cobra for Google Workspace operations (Gmail and Calendar) via OAuth2 PKCE authentication. **Entry point:** `main.go` → `cmd.Execute()` **Three packages:** - `main` — just calls `cmd.Execute()` - `cmd` — all Cobra commands and CLI logic -- `internal/auth` — service account authentication and Gmail service creation +- `internal/auth` — OAuth2 PKCE authentication, Gmail and Calendar service creation ### Command Structure -Root command defines persistent flags (`--credentials-file`, `--user`, `--format`, `--verbose`) and exposes getter functions (`GetCredentialsFile()`, `GetUserEmail()`, `GetOutputFormat()`, `GetVerbose()`) used by all subcommands. +Root command defines persistent flags (`--account`, `--format`, `--verbose`) and exposes getter functions (`GetAccountEmail()`, `GetOutputFormat()`, `GetVerbose()`) used by all subcommands. + +Subcommands: `messages`, `threads`, `labels`, `drafts`, `send`, `search`, `calendar`, `whoami`, `version`, `login`, `logout`, `accounts`. Parent commands like `messages` and `calendar` have nested subcommands. -Subcommands: `messages`, `threads`, `labels`, `drafts`, `send`, `search`, `whoami`, `version`. Parent commands like `messages` have nested subcommands (`list`, `get`, `modify`, `get-attachment`). +Calendar commands: `list`, `get`, `create`, `update`, `delete`, `respond`, `today`, `week`, `calendars`. ### Command Pattern Every command function follows this flow: 1. Get global flags via getter functions from `root.go` -2. Build `auth.Config` struct -3. Call `auth.NewGmailService()` to get authenticated client -4. Execute Gmail API calls -5. Output as text (default) or JSON based on `--format` flag +2. Call `auth.NewGmailService()` or `auth.NewCalendarService()` to get authenticated client +3. Execute API calls +4. Output as text (default) or JSON based on `--format` flag ### Authentication Flow `internal/auth` handles credential loading with this priority: -1. `--credentials-file` flag -2. `GOOGLE_CREDENTIALS` env var (raw JSON) -3. `GOOGLE_APPLICATION_CREDENTIALS` env var (file path) +1. `GOOGLE_CREDENTIALS` env var (raw JSON) +2. `GOOGLE_APPLICATION_CREDENTIALS` env var (file path) -Uses JWT config with `gmail.GmailModifyScope`, sets `Subject` to the `--user` email for domain-wide delegation. All API calls use "me" which resolves to the impersonated user. +Uses OAuth2 PKCE flow with scopes: `gmail.GmailModifyScope`, `calendar.CalendarEventsScope`, `calendar.CalendarReadonlyScope`. Shared auth logic is in `newAuthenticatedClient()` which both `NewGmailService()` and `NewCalendarService()` use. ### Output Formatting -All commands support `--format text` (default) and `--format json`. JSON output uses inline struct types with `json` tags defined within each command's run function. The shared `outputJSON()` helper in `root.go` handles marshaling. +All commands support `--format text` (default) and `--format json`. JSON output uses inline struct types with `json` tags defined within each command's run function (never `omitempty`). The shared `outputJSON()` helper in `root.go` handles marshaling. + +### Calendar Date/Time Parsing + +`cmd/calendar_time.go` provides flexible date/time parsing: RFC3339, date-only, date+time, time-only, relative (today/tomorrow/+Nd/weekday names). All functions accept `now time.Time` and `loc *time.Location` for testability — never call `time.Now()` directly. ### Email Encoding diff --git a/README.md b/README.md index 667e2fd..114d7eb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# gsuite - Gmail CLI Tool +# gsuite - Google Workspace CLI Tool -A command-line interface for Gmail mailbox management. Authenticate with your Gmail account via OAuth2 and manage messages, threads, labels, and drafts from the terminal. +A command-line interface for Google Workspace management. Authenticate with your account via OAuth2 and manage Gmail messages, threads, labels, drafts, and Google Calendar events from the terminal. ## Installation @@ -22,7 +22,7 @@ go build -o gsuite . ## Prerequisites -1. **Google Cloud Project** with Gmail API enabled +1. **Google Cloud Project** with Gmail API and Calendar API enabled 2. **OAuth2 Client Credentials** (Desktop or Web application type) 3. Set credentials via environment variable: - `GOOGLE_CREDENTIALS` — raw JSON content @@ -45,6 +45,12 @@ gsuite send --to "user@example.com" --subject "Hello" --body "Message content" # Search messages gsuite search "from:user@example.com is:unread" + +# View today's calendar events +gsuite calendar today + +# Create a meeting +gsuite calendar create --summary "Team Standup" --start "2026-03-15 09:00" --duration 30m ``` ## Multi-Account Support @@ -104,6 +110,15 @@ The `--account` flag (or `GSUITE_ACCOUNT` env var) can be passed to any command | `drafts delete ` | Delete a draft | | `send` | Send an email (supports markdown, attachments) | | `search ` | Search messages using Gmail query syntax | +| `calendar list` | List upcoming calendar events | +| `calendar get ` | Get event details including attendees | +| `calendar create` | Create a calendar event | +| `calendar update ` | Update an existing event | +| `calendar delete ` | Delete a calendar event | +| `calendar respond ` | RSVP to an event invitation | +| `calendar today` | Show today's events | +| `calendar week` | Show this week's events (Mon-Sun) | +| `calendar calendars` | List available calendars | | `version` | Show version information | | `install-skill` | Install the Claude Code skill for Gmail management | @@ -150,6 +165,20 @@ gsuite labels create -n "My Label" # JSON output for scripting gsuite messages list -f json gsuite search "is:unread" -f json + +# Calendar: list upcoming events +gsuite calendar list --after today --before +7d + +# Calendar: create a recurring meeting with attendees +gsuite calendar create --summary "Weekly 1:1" --start "2026-03-15 10:00" \ + --duration 30m --rrule "FREQ=WEEKLY;BYDAY=MO" \ + --attendees "alice@example.com" --send-updates all + +# Calendar: RSVP to an event +gsuite calendar respond abc123def456 --status accepted + +# Calendar: delete a recurring event (all instances) +gsuite calendar delete abc123def456 --recurring-scope all --yes ``` ## License diff --git a/cmd/calendar.go b/cmd/calendar.go new file mode 100644 index 0000000..df6fb7b --- /dev/null +++ b/cmd/calendar.go @@ -0,0 +1,654 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/khang/google-suite-cli/internal/auth" + "github.com/spf13/cobra" + "google.golang.org/api/calendar/v3" +) + +var ( + calendarID string + calendarMaxResults int64 + calendarAfter string + calendarBefore string + calendarQuery string + calendarSingleEvents bool + calendarOrderBy string + calendarTimezone string + calendarShowDeleted bool + + // Write command flags (used by calendar_write.go) + calendarSummary string + calendarStart string + calendarEnd string + calendarDuration string + calendarDescription string + calendarLocation string + calendarAttendees string + calendarAllDay bool + calendarRrule string + calendarSendUpdates string + calendarAddAttendees string + calendarRemoveAttendees string + calendarRecurringScope string + calendarYes bool + calendarStatus string + calendarComment string +) + +var errDone = fmt.Errorf("done") + +var calendarCmd = &cobra.Command{ + Use: "calendar", + Short: "Manage Google Calendar events", + Long: `Commands for listing, creating, updating, and managing Google Calendar events. + +Use the subcommands to interact with calendars and events for the authenticated user.`, +} + +var calendarListCmd = &cobra.Command{ + Use: "list", + Short: "List upcoming calendar events", + RunE: runCalendarList, +} + +var calendarGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of a calendar event", + Args: cobra.ExactArgs(1), + RunE: runCalendarGet, +} + +var calendarTodayCmd = &cobra.Command{ + Use: "today", + Short: "Show today's events", + RunE: runCalendarToday, +} + +var calendarWeekCmd = &cobra.Command{ + Use: "week", + Short: "Show this week's events", + RunE: runCalendarWeek, +} + +var calendarCalendarsCmd = &cobra.Command{ + Use: "calendars", + Short: "List available calendars", + RunE: runCalendarCalendars, +} + +var calendarCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a calendar event", + Long: `Create a new event in the specified calendar. + +Required flags: + --summary: Event title + --start: Start time (supports various formats) + +The --end flag or --duration flag specifies when the event ends. +If neither is provided, a 1-hour duration is assumed. +Use --all-day for all-day events (only date portion of --start is used).`, + Example: ` # Create a 1-hour meeting + gsuite calendar create --summary "Team Meeting" --start "2026-03-15 09:00" + + # Create a meeting with explicit duration + gsuite calendar create --summary "Standup" --start "2026-03-15 09:00" --duration 30m + + # Create an all-day event + gsuite calendar create --summary "Company Holiday" --start 2026-12-25 --all-day + + # Create a recurring weekly meeting + gsuite calendar create --summary "1:1" --start "2026-03-15 10:00" --duration 30m --rrule "FREQ=WEEKLY;BYDAY=MO" + + # Create an event with attendees + gsuite calendar create --summary "Review" --start "2026-03-15 14:00" --duration 1h --attendees "alice@example.com,bob@example.com" --send-updates all`, +} + +var calendarUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a calendar event", + Long: `Update an existing calendar event's properties. + +Only the flags you provide will be changed; other fields remain unchanged. +Use --add-attendees and --remove-attendees to modify the attendee list.`, + Example: ` # Change event title + gsuite calendar update abc123 --summary "New Title" + + # Reschedule an event + gsuite calendar update abc123 --start "2026-03-20 10:00" --end "2026-03-20 11:00" + + # Add attendees and notify them + gsuite calendar update abc123 --add-attendees "carol@example.com" --send-updates all`, + Args: cobra.ExactArgs(1), +} + +var calendarDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a calendar event", + Long: `Delete a calendar event by its ID. + +Use --yes to skip the confirmation prompt. +For recurring events, --recurring-scope controls whether to delete +just this instance ("this") or all instances ("all").`, + Example: ` # Delete an event (will prompt for confirmation) + gsuite calendar delete abc123 + + # Delete without confirmation + gsuite calendar delete abc123 --yes + + # Delete all instances of a recurring event + gsuite calendar delete abc123 --recurring-scope all --yes`, + Args: cobra.ExactArgs(1), +} + +var calendarRespondCmd = &cobra.Command{ + Use: "respond ", + Short: "Respond to a calendar event invitation", + Long: `Set your RSVP status for a calendar event. + +Required flags: + --status: One of "accepted", "declined", or "tentative"`, + Example: ` # Accept an invitation + gsuite calendar respond abc123 --status accepted + + # Decline with a comment + gsuite calendar respond abc123 --status declined --comment "Out of office" + + # Tentatively accept + gsuite calendar respond abc123 --status tentative`, + Args: cobra.ExactArgs(1), +} + +func init() { + rootCmd.AddCommand(calendarCmd) + calendarCmd.AddCommand(calendarListCmd) + calendarCmd.AddCommand(calendarGetCmd) + calendarCmd.AddCommand(calendarTodayCmd) + calendarCmd.AddCommand(calendarWeekCmd) + calendarCmd.AddCommand(calendarCalendarsCmd) + calendarCmd.AddCommand(calendarCreateCmd) + calendarCmd.AddCommand(calendarUpdateCmd) + calendarCmd.AddCommand(calendarDeleteCmd) + calendarCmd.AddCommand(calendarRespondCmd) + + // List flags + calendarListCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarListCmd.Flags().Int64VarP(&calendarMaxResults, "max-results", "n", 25, "Maximum number of events") + calendarListCmd.Flags().StringVar(&calendarAfter, "after", "", "Show events after this time") + calendarListCmd.Flags().StringVar(&calendarBefore, "before", "", "Show events before this time") + calendarListCmd.Flags().StringVarP(&calendarQuery, "query", "q", "", "Search query") + calendarListCmd.Flags().BoolVar(&calendarSingleEvents, "single-events", true, "Expand recurring events") + calendarListCmd.Flags().StringVar(&calendarOrderBy, "order-by", "startTime", "Order by: startTime or updated") + calendarListCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone") + calendarListCmd.Flags().BoolVar(&calendarShowDeleted, "show-deleted", false, "Show deleted events") + + // Get flags + calendarGetCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarGetCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone") + + // Today flags + calendarTodayCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarTodayCmd.Flags().Int64VarP(&calendarMaxResults, "max-results", "n", 25, "Maximum number of events") + calendarTodayCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone") + + // Week flags + calendarWeekCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarWeekCmd.Flags().Int64VarP(&calendarMaxResults, "max-results", "n", 25, "Maximum number of events") + calendarWeekCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone") + + // Calendars flags + calendarCalendarsCmd.Flags().Int64VarP(&calendarMaxResults, "max-results", "n", 100, "Maximum number of calendars") + + // Create flags + calendarCreateCmd.Flags().StringVar(&calendarSummary, "summary", "", "Event title (required)") + calendarCreateCmd.Flags().StringVar(&calendarStart, "start", "", "Start time (required)") + calendarCreateCmd.Flags().StringVar(&calendarEnd, "end", "", "End time") + calendarCreateCmd.Flags().StringVarP(&calendarDuration, "duration", "d", "", "Duration (e.g., 1h, 30m)") + calendarCreateCmd.Flags().StringVar(&calendarDescription, "description", "", "Event description") + calendarCreateCmd.Flags().StringVarP(&calendarLocation, "location", "l", "", "Event location") + calendarCreateCmd.Flags().StringVar(&calendarAttendees, "attendees", "", "Comma-separated attendee emails") + calendarCreateCmd.Flags().BoolVar(&calendarAllDay, "all-day", false, "Create all-day event") + calendarCreateCmd.Flags().StringVar(&calendarRrule, "rrule", "", "Recurrence rule (e.g., FREQ=WEEKLY;BYDAY=MO,WE,FR)") + calendarCreateCmd.Flags().StringVar(&calendarSendUpdates, "send-updates", "none", "Send notifications: all, externalOnly, none") + calendarCreateCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone for the event") + calendarCreateCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarCreateCmd.MarkFlagRequired("summary") + calendarCreateCmd.MarkFlagRequired("start") + + // Update flags + calendarUpdateCmd.Flags().StringVar(&calendarSummary, "summary", "", "New event title") + calendarUpdateCmd.Flags().StringVar(&calendarStart, "start", "", "New start time") + calendarUpdateCmd.Flags().StringVar(&calendarEnd, "end", "", "New end time") + calendarUpdateCmd.Flags().StringVar(&calendarDescription, "description", "", "New description") + calendarUpdateCmd.Flags().StringVarP(&calendarLocation, "location", "l", "", "New location") + calendarUpdateCmd.Flags().StringVar(&calendarAddAttendees, "add-attendees", "", "Comma-separated emails to add") + calendarUpdateCmd.Flags().StringVar(&calendarRemoveAttendees, "remove-attendees", "", "Comma-separated emails to remove") + calendarUpdateCmd.Flags().StringVar(&calendarSendUpdates, "send-updates", "none", "Send notifications: all, externalOnly, none") + calendarUpdateCmd.Flags().StringVar(&calendarTimezone, "timezone", "", "IANA timezone") + calendarUpdateCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarUpdateCmd.Flags().StringVar(&calendarRecurringScope, "recurring-scope", "this", "Recurring event scope: this, all") + + // Delete flags + calendarDeleteCmd.Flags().StringVar(&calendarSendUpdates, "send-updates", "none", "Send notifications: all, externalOnly, none") + calendarDeleteCmd.Flags().StringVar(&calendarRecurringScope, "recurring-scope", "this", "Recurring event scope: this, all") + calendarDeleteCmd.Flags().BoolVar(&calendarYes, "yes", false, "Confirm destructive operations") + calendarDeleteCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + + // Respond flags + calendarRespondCmd.Flags().StringVar(&calendarStatus, "status", "", "Response status: accepted, declined, tentative (required)") + calendarRespondCmd.Flags().StringVar(&calendarComment, "comment", "", "RSVP comment") + calendarRespondCmd.Flags().StringVar(&calendarSendUpdates, "send-updates", "none", "Send notifications: all, externalOnly, none") + calendarRespondCmd.Flags().StringVar(&calendarID, "calendar-id", "primary", "Calendar ID") + calendarRespondCmd.MarkFlagRequired("status") +} + +func resolveTimezone() (*time.Location, error) { + if calendarTimezone != "" { + loc, err := time.LoadLocation(calendarTimezone) + if err != nil { + return nil, fmt.Errorf("invalid timezone %q: %w", calendarTimezone, err) + } + return loc, nil + } + return time.Now().Location(), nil +} + +func listCalendarEvents(cmd *cobra.Command, calID string, timeMin, timeMax time.Time, maxResults int64, query string, singleEvents bool, orderBy string, tz *time.Location, showDeleted bool) error { + ctx := context.Background() + + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + call := service.Events.List(calID). + TimeMin(timeMin.Format(time.RFC3339)). + TimeMax(timeMax.Format(time.RFC3339)). + SingleEvents(singleEvents). + ShowDeleted(showDeleted). + Fields("items(id,summary,start,end,location,status,recurringEventId),nextPageToken") + + if orderBy != "" { + call = call.OrderBy(orderBy) + } + if query != "" { + call = call.Q(query) + } + if calendarTimezone != "" { + call = call.TimeZone(calendarTimezone) + } + + call.MaxResults(min(maxResults, 250)) + + var allEvents []*calendar.Event + err = call.Pages(ctx, func(page *calendar.Events) error { + allEvents = append(allEvents, page.Items...) + if int64(len(allEvents)) >= maxResults { + return errDone + } + return nil + }) + if err != nil && err != errDone { + return auth.HandleCalendarError(err, "failed to list events") + } + if int64(len(allEvents)) > maxResults { + allEvents = allEvents[:maxResults] + } + + if GetOutputFormat() == "json" { + type eventListItem struct { + ID string `json:"id"` + Summary string `json:"summary"` + Start string `json:"start"` + End string `json:"end"` + Location string `json:"location"` + Status string `json:"status"` + AllDay bool `json:"all_day"` + Recurring bool `json:"recurring"` + RecurringEventID string `json:"recurring_event_id"` + } + + if len(allEvents) == 0 { + return outputJSON([]struct{}{}) + } + + items := make([]eventListItem, len(allEvents)) + for i, ev := range allEvents { + isAllDay := ev.Start != nil && ev.Start.Date != "" + items[i] = eventListItem{ + ID: ev.Id, + Summary: ev.Summary, + Start: formatEventTime(ev.Start, tz), + End: formatEventTime(ev.End, tz), + Location: ev.Location, + Status: ev.Status, + AllDay: isAllDay, + Recurring: ev.RecurringEventId != "", + RecurringEventID: ev.RecurringEventId, + } + } + return outputJSON(items) + } + + if len(allEvents) == 0 { + fmt.Println("No events found.") + return nil + } + + printEventTable(allEvents, tz) + return nil +} + +func printEventTable(events []*calendar.Event, tz *time.Location) { + fmt.Printf("%-12s %-20s %s\n", "DATE", "TIME", "SUMMARY") + fmt.Printf("%-12s %-20s %s\n", "----", "----", "-------") + + for _, ev := range events { + date, timeRange := formatEventTableRow(ev, tz) + summary := ev.Summary + if ev.RecurringEventId != "" { + summary += " (recurring)" + } + fmt.Printf("%-12s %-20s %s\n", date, timeRange, summary) + } + + fmt.Printf("\n[%d event(s)]\n", len(events)) +} + +func formatEventTableRow(ev *calendar.Event, tz *time.Location) (string, string) { + if ev.Start == nil { + return "", "" + } + + if ev.Start.Date != "" { + t, err := time.Parse("2006-01-02", ev.Start.Date) + if err != nil { + return ev.Start.Date, "all day" + } + return t.Format("2006-01-02"), "all day" + } + + if ev.Start.DateTime != "" { + startT, err := time.Parse(time.RFC3339, ev.Start.DateTime) + if err != nil { + return "", ev.Start.DateTime + } + startT = startT.In(tz) + date := startT.Format("2006-01-02") + startStr := startT.Format("03:04 PM") + + endStr := "" + if ev.End != nil && ev.End.DateTime != "" { + endT, err := time.Parse(time.RFC3339, ev.End.DateTime) + if err == nil { + endStr = endT.In(tz).Format("03:04 PM") + } + } + + if endStr != "" { + return date, startStr + " - " + endStr + } + return date, startStr + } + + return "", "" +} + +func runCalendarList(cmd *cobra.Command, args []string) error { + tz, err := resolveTimezone() + if err != nil { + return err + } + + now := time.Now().In(tz) + + timeMin := now + if calendarAfter != "" { + t, err := parseDateTime(calendarAfter, tz, now) + if err != nil { + return fmt.Errorf("invalid --after value: %w", err) + } + timeMin = t + } + + timeMax := now.AddDate(0, 0, 30) + if calendarBefore != "" { + t, err := parseDateTime(calendarBefore, tz, now) + if err != nil { + return fmt.Errorf("invalid --before value: %w", err) + } + timeMax = t + } + + return listCalendarEvents(cmd, calendarID, timeMin, timeMax, calendarMaxResults, calendarQuery, calendarSingleEvents, calendarOrderBy, tz, calendarShowDeleted) +} + +func runCalendarGet(cmd *cobra.Command, args []string) error { + eventID := args[0] + + tz, err := resolveTimezone() + if err != nil { + return err + } + + ctx := context.Background() + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + ev, err := service.Events.Get(calendarID, eventID).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to get event") + } + + if GetOutputFormat() == "json" { + type attendeeItem struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + ResponseStatus string `json:"response_status"` + Organizer bool `json:"organizer"` + Self bool `json:"self"` + } + type eventDetail struct { + ID string `json:"id"` + Summary string `json:"summary"` + Start string `json:"start"` + End string `json:"end"` + Status string `json:"status"` + Location string `json:"location"` + Description string `json:"description"` + Recurrence []string `json:"recurrence"` + RecurringEventID string `json:"recurring_event_id"` + Attendees []attendeeItem `json:"attendees"` + HtmlLink string `json:"html_link"` + Creator string `json:"creator"` + Organizer string `json:"organizer"` + } + + detail := eventDetail{ + ID: ev.Id, + Summary: ev.Summary, + Start: formatEventTime(ev.Start, tz), + End: formatEventTime(ev.End, tz), + Status: ev.Status, + Location: ev.Location, + Description: ev.Description, + Recurrence: ev.Recurrence, + RecurringEventID: ev.RecurringEventId, + HtmlLink: ev.HtmlLink, + } + + if ev.Creator != nil { + detail.Creator = ev.Creator.Email + } + if ev.Organizer != nil { + detail.Organizer = ev.Organizer.Email + } + + if len(ev.Attendees) > 0 { + detail.Attendees = make([]attendeeItem, len(ev.Attendees)) + for i, a := range ev.Attendees { + detail.Attendees[i] = attendeeItem{ + Email: a.Email, + DisplayName: a.DisplayName, + ResponseStatus: a.ResponseStatus, + Organizer: a.Organizer, + Self: a.Self, + } + } + } else { + detail.Attendees = []attendeeItem{} + } + + if detail.Recurrence == nil { + detail.Recurrence = []string{} + } + + return outputJSON(detail) + } + + // Text output + fmt.Printf("Event: %s\n", ev.Summary) + fmt.Printf("ID: %s\n", ev.Id) + fmt.Printf("Start: %s\n", formatEventTime(ev.Start, tz)) + fmt.Printf("End: %s\n", formatEventTime(ev.End, tz)) + fmt.Printf("Status: %s\n", ev.Status) + + if ev.Location != "" { + fmt.Printf("Location: %s\n", ev.Location) + } + if ev.Description != "" { + fmt.Printf("Description: %s\n", ev.Description) + } + if ev.Creator != nil { + fmt.Printf("Creator: %s\n", ev.Creator.Email) + } + if ev.Organizer != nil { + fmt.Printf("Organizer: %s\n", ev.Organizer.Email) + } + if len(ev.Recurrence) > 0 { + fmt.Printf("Recurrence: %s\n", strings.Join(ev.Recurrence, ", ")) + } + if ev.RecurringEventId != "" { + fmt.Printf("Recurring Event ID: %s\n", ev.RecurringEventId) + } + if ev.HtmlLink != "" { + fmt.Printf("Link: %s\n", ev.HtmlLink) + } + + if len(ev.Attendees) > 0 { + fmt.Printf("\nAttendees:\n") + for _, a := range ev.Attendees { + name := a.Email + if a.DisplayName != "" { + name = fmt.Sprintf("%s <%s>", a.DisplayName, a.Email) + } + status := a.ResponseStatus + if a.Organizer { + status += " (organizer)" + } + if a.Self { + status += " (you)" + } + fmt.Printf(" - %s [%s]\n", name, status) + } + } + + return nil +} + +func runCalendarToday(cmd *cobra.Command, args []string) error { + tz, err := resolveTimezone() + if err != nil { + return err + } + + now := time.Now().In(tz) + dayStart := startOfDay(now, tz) + dayEnd := dayStart.AddDate(0, 0, 1) + + return listCalendarEvents(cmd, calendarID, dayStart, dayEnd, calendarMaxResults, "", true, "startTime", tz, false) +} + +func runCalendarWeek(cmd *cobra.Command, args []string) error { + tz, err := resolveTimezone() + if err != nil { + return err + } + + now := time.Now().In(tz) + // Find Monday of the current week + daysFromMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7 + weekStart := startOfDay(now.AddDate(0, 0, -daysFromMonday), tz) + weekEnd := weekStart.AddDate(0, 0, 7) + + return listCalendarEvents(cmd, calendarID, weekStart, weekEnd, calendarMaxResults, "", true, "startTime", tz, false) +} + +func runCalendarCalendars(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + resp, err := service.CalendarList.List(). + MaxResults(calendarMaxResults). + Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to list calendars") + } + + if GetOutputFormat() == "json" { + type calendarItem struct { + ID string `json:"id"` + Summary string `json:"summary"` + AccessRole string `json:"access_role"` + Primary bool `json:"primary"` + Timezone string `json:"timezone"` + } + + if len(resp.Items) == 0 { + return outputJSON([]struct{}{}) + } + + items := make([]calendarItem, len(resp.Items)) + for i, cal := range resp.Items { + items[i] = calendarItem{ + ID: cal.Id, + Summary: cal.Summary, + AccessRole: cal.AccessRole, + Primary: cal.Primary, + Timezone: cal.TimeZone, + } + } + return outputJSON(items) + } + + if len(resp.Items) == 0 { + fmt.Println("No calendars found.") + return nil + } + + fmt.Printf("%-40s %-30s %-15s %s\n", "ID", "NAME", "ROLE", "TIMEZONE") + fmt.Printf("%-40s %-30s %-15s %s\n", "--", "----", "----", "--------") + + for _, cal := range resp.Items { + name := cal.Summary + if cal.Primary { + name += " (primary)" + } + fmt.Printf("%-40s %-30s %-15s %s\n", cal.Id, name, cal.AccessRole, cal.TimeZone) + } + + fmt.Printf("\n[%d calendar(s)]\n", len(resp.Items)) + return nil +} diff --git a/cmd/calendar_time.go b/cmd/calendar_time.go new file mode 100644 index 0000000..8b68705 --- /dev/null +++ b/cmd/calendar_time.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "fmt" + "regexp" + "strings" + "time" + + "google.golang.org/api/calendar/v3" +) + +var relDaysRegexp = regexp.MustCompile(`^\+(\d+)d$`) + +var dayNames = map[string]time.Weekday{ + "monday": time.Monday, + "tuesday": time.Tuesday, + "wednesday": time.Wednesday, + "thursday": time.Thursday, + "friday": time.Friday, + "saturday": time.Saturday, + "sunday": time.Sunday, +} + +func parseDateTime(input string, loc *time.Location, now time.Time) (time.Time, error) { + input = strings.TrimSpace(input) + if input == "" { + return time.Time{}, fmt.Errorf("empty datetime input; accepted formats: RFC3339, 2006-01-02, 2006-01-02 15:04, 2006-01-02T15:04:05, 15:04, today, tomorrow, monday-sunday, +Nd") + } + + if t, ok := parseRelative(input, loc, now); ok { + return t, nil + } + + if t, err := time.Parse(time.RFC3339, input); err == nil { + return t, nil + } + + formats := []string{ + "2006-01-02", + "2006-01-02 15:04", + "2006-01-02T15:04:05", + } + for _, f := range formats { + if t, err := time.ParseInLocation(f, input, loc); err == nil { + return t, nil + } + } + + // Time-only: anchor to today + if t, err := time.ParseInLocation("15:04", input, loc); err == nil { + return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, loc), nil + } + + return time.Time{}, fmt.Errorf("cannot parse %q; accepted formats: RFC3339, 2006-01-02, 2006-01-02 15:04, 2006-01-02T15:04:05, 15:04, today, tomorrow, monday-sunday, +Nd", input) +} + +func parseRelative(input string, loc *time.Location, now time.Time) (time.Time, bool) { + lower := strings.ToLower(strings.TrimSpace(input)) + + switch lower { + case "today": + return startOfDay(now, loc), true + case "tomorrow": + return startOfDay(now.AddDate(0, 0, 1), loc), true + } + + if wd, ok := dayNames[lower]; ok { + return nextWeekday(now.In(loc), wd), true + } + + if m := relDaysRegexp.FindStringSubmatch(lower); m != nil { + n := 0 + for _, c := range m[1] { + n = n*10 + int(c-'0') + } + return startOfDay(now.AddDate(0, 0, n), loc), true + } + + return time.Time{}, false +} + +func parseDuration(input string) (time.Duration, error) { + d, err := time.ParseDuration(input) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", input, err) + } + if d <= 0 { + return 0, fmt.Errorf("duration must be positive, got %s", d) + } + return d, nil +} + +func buildEventDateTime(t time.Time, allDay bool, tz string) *calendar.EventDateTime { + edt := &calendar.EventDateTime{} + if allDay { + edt.Date = t.Format("2006-01-02") + } else { + edt.DateTime = t.Format(time.RFC3339) + } + if tz != "" { + edt.TimeZone = tz + } + return edt +} + +func formatEventTime(edt *calendar.EventDateTime, displayTz *time.Location) string { + if edt == nil { + return "" + } + if edt.Date != "" { + t, err := time.Parse("2006-01-02", edt.Date) + if err != nil { + return edt.Date + } + return t.Format("Mon Jan 02, 2006 (all day)") + } + if edt.DateTime != "" { + t, err := time.Parse(time.RFC3339, edt.DateTime) + if err != nil { + return edt.DateTime + } + return t.In(displayTz).Format("Mon Jan 02, 2006 03:04 PM MST") + } + return "" +} + +func startOfDay(t time.Time, loc *time.Location) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} + +func nextWeekday(from time.Time, target time.Weekday) time.Time { + days := int(target) - int(from.Weekday()) + if days <= 0 { + days += 7 + } + return startOfDay(from.AddDate(0, 0, days), from.Location()) +} diff --git a/cmd/calendar_time_fuzz_test.go b/cmd/calendar_time_fuzz_test.go new file mode 100644 index 0000000..3f22377 --- /dev/null +++ b/cmd/calendar_time_fuzz_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "testing" + "time" +) + +func FuzzParseDateTime(f *testing.F) { + f.Add("2026-03-15T09:00:00-07:00") + f.Add("2026-03-15") + f.Add("2026-03-15 09:00") + f.Add("2026-03-15T09:00:00") + f.Add("09:00") + f.Add("today") + f.Add("tomorrow") + f.Add("+3d") + f.Add("monday") + f.Add("friday") + f.Add("") + f.Add("not-a-date") + f.Add("2024-02-29") + + f.Fuzz(func(t *testing.T, input string) { + now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + // Should never panic + parseDateTime(input, time.UTC, now) + }) +} + +func FuzzParseDuration(f *testing.F) { + f.Add("1h") + f.Add("30m") + f.Add("1h30m") + f.Add("0s") + f.Add("-1h") + f.Add("abc") + f.Add("") + + f.Fuzz(func(t *testing.T, input string) { + // Should never panic + parseDuration(input) + }) +} diff --git a/cmd/calendar_time_test.go b/cmd/calendar_time_test.go new file mode 100644 index 0000000..0820c0d --- /dev/null +++ b/cmd/calendar_time_test.go @@ -0,0 +1,424 @@ +package cmd + +import ( + "testing" + "time" + + "google.golang.org/api/calendar/v3" +) + +// Fixed reference time: Sunday, March 15, 2026, 12:00 UTC +var refNow = time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + +func TestParseDateTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Time + wantErr bool + }{ + { + name: "RFC3339", + input: "2026-03-15T09:00:00-07:00", + want: time.Date(2026, 3, 15, 9, 0, 0, 0, time.FixedZone("", -7*3600)), + }, + { + name: "date only", + input: "2026-03-15", + want: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + }, + { + name: "date and time", + input: "2026-03-15 09:00", + want: time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC), + }, + { + name: "ISO without timezone", + input: "2026-03-15T09:00:00", + want: time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC), + }, + { + name: "time only anchors to today", + input: "09:00", + want: time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC), + }, + { + name: "today keyword", + input: "today", + want: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + }, + { + name: "tomorrow keyword", + input: "tomorrow", + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + }, + { + name: "relative +3d", + input: "+3d", + want: time.Date(2026, 3, 18, 0, 0, 0, 0, time.UTC), + }, + { + name: "monday from Sunday", + input: "monday", + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + }, + { + name: "friday from Sunday", + input: "friday", + want: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + }, + { + name: "leap year date", + input: "2024-02-29", + want: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC), + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "garbage input", + input: "not-a-date", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseDateTime(tt.input, time.UTC, refNow) + if tt.wantErr { + if err == nil { + t.Fatalf("parseDateTime(%q) expected error, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("parseDateTime(%q) unexpected error: %v", tt.input, err) + } + if !got.Equal(tt.want) { + t.Fatalf("parseDateTime(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseRelative(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Time + ok bool + }{ + { + name: "today", + input: "today", + want: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "Tomorrow mixed case", + input: "Tomorrow", + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "+1d", + input: "+1d", + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "+7d", + input: "+7d", + want: time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "monday", + input: "monday", + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "FRIDAY uppercase", + input: "FRIDAY", + want: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + ok: true, + }, + { + name: "not a day", + input: "notaday", + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := parseRelative(tt.input, time.UTC, refNow) + if ok != tt.ok { + t.Fatalf("parseRelative(%q) ok = %v, want %v", tt.input, ok, tt.ok) + } + if tt.ok && !got.Equal(tt.want) { + t.Fatalf("parseRelative(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Duration + wantErr bool + }{ + {name: "1 hour", input: "1h", want: time.Hour}, + {name: "30 minutes", input: "30m", want: 30 * time.Minute}, + {name: "1h30m", input: "1h30m", want: 90 * time.Minute}, + {name: "zero", input: "0s", wantErr: true}, + {name: "negative", input: "-1h", wantErr: true}, + {name: "invalid", input: "abc", wantErr: true}, + {name: "empty", input: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseDuration(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("parseDuration(%q) expected error, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("parseDuration(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("parseDuration(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestBuildEventDateTime(t *testing.T) { + t.Parallel() + + t.Run("timed event", func(t *testing.T) { + t.Parallel() + ts := time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC) + edt := buildEventDateTime(ts, false, "America/Los_Angeles") + if edt.DateTime == "" { + t.Fatalf("expected DateTime to be set") + } + if edt.Date != "" { + t.Fatalf("expected Date to be empty for timed event") + } + if edt.TimeZone != "America/Los_Angeles" { + t.Fatalf("TimeZone = %q, want %q", edt.TimeZone, "America/Los_Angeles") + } + }) + + t.Run("all-day event", func(t *testing.T) { + t.Parallel() + ts := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) + edt := buildEventDateTime(ts, true, "") + if edt.Date != "2026-03-15" { + t.Fatalf("Date = %q, want %q", edt.Date, "2026-03-15") + } + if edt.DateTime != "" { + t.Fatalf("expected DateTime to be empty for all-day event") + } + if edt.TimeZone != "" { + t.Fatalf("expected empty TimeZone when not provided") + } + }) + + t.Run("with timezone", func(t *testing.T) { + t.Parallel() + ts := time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC) + edt := buildEventDateTime(ts, false, "Europe/London") + if edt.TimeZone != "Europe/London" { + t.Fatalf("TimeZone = %q, want %q", edt.TimeZone, "Europe/London") + } + }) +} + +func TestFormatEventTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + edt *calendar.EventDateTime + want string + }{ + { + name: "DateTime field", + edt: &calendar.EventDateTime{DateTime: "2026-03-15T09:00:00Z"}, + want: "Sun Mar 15, 2026 09:00 AM UTC", + }, + { + name: "Date field", + edt: &calendar.EventDateTime{Date: "2026-03-15"}, + want: "Sun Mar 15, 2026 (all day)", + }, + { + name: "nil input", + edt: nil, + want: "", + }, + { + name: "empty EventDateTime", + edt: &calendar.EventDateTime{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := formatEventTime(tt.edt, time.UTC) + if got != tt.want { + t.Fatalf("formatEventTime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStartOfDay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + t time.Time + loc *time.Location + want time.Time + }{ + { + name: "midday UTC", + t: time.Date(2026, 3, 15, 14, 30, 45, 123, time.UTC), + loc: time.UTC, + want: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + }, + { + name: "already midnight", + t: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + loc: time.UTC, + want: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := startOfDay(tt.t, tt.loc) + if !got.Equal(tt.want) { + t.Fatalf("startOfDay() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNextWeekday(t *testing.T) { + t.Parallel() + + // refNow is Sunday March 15, 2026 + tests := []struct { + name string + from time.Time + target time.Weekday + want time.Time + }{ + { + name: "Sunday to Monday", + from: refNow, + target: time.Monday, + want: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + }, + { + name: "Sunday to Friday", + from: refNow, + target: time.Friday, + want: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + }, + { + name: "Sunday to Sunday wraps to next week", + from: refNow, + target: time.Sunday, + want: time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC), + }, + { + name: "Monday to Wednesday", + from: time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), + target: time.Wednesday, + want: time.Date(2026, 3, 18, 0, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := nextWeekday(tt.from, tt.target) + if !got.Equal(tt.want) { + t.Fatalf("nextWeekday() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseDateTimeDST(t *testing.T) { + t.Parallel() + + la, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Fatalf("failed to load timezone: %v", err) + } + + // March 8, 2026 is DST spring-forward in LA (2:00 AM -> 3:00 AM) + dstNow := time.Date(2026, 3, 8, 12, 0, 0, 0, la) + + t.Run("time-only during DST transition day", func(t *testing.T) { + t.Parallel() + got, err := parseDateTime("09:00", la, dstNow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := time.Date(2026, 3, 8, 9, 0, 0, 0, la) + if !got.Equal(want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + + t.Run("tomorrow across DST boundary", func(t *testing.T) { + t.Parallel() + got, err := parseDateTime("tomorrow", la, dstNow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := time.Date(2026, 3, 9, 0, 0, 0, 0, la) + if !got.Equal(want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + + t.Run("date parsed in LA timezone", func(t *testing.T) { + t.Parallel() + got, err := parseDateTime("2026-03-08", la, dstNow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := time.Date(2026, 3, 8, 0, 0, 0, 0, la) + if !got.Equal(want) { + t.Fatalf("got %v, want %v", got, want) + } + }) +} diff --git a/cmd/calendar_write.go b/cmd/calendar_write.go new file mode 100644 index 0000000..84f768b --- /dev/null +++ b/cmd/calendar_write.go @@ -0,0 +1,391 @@ +package cmd + +import ( + "context" + "fmt" + "net/mail" + "strings" + "time" + + "github.com/khang/google-suite-cli/internal/auth" + "github.com/spf13/cobra" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" +) + +func init() { + calendarCreateCmd.RunE = runCalendarCreate + calendarUpdateCmd.RunE = runCalendarUpdate + calendarDeleteCmd.RunE = runCalendarDelete + calendarRespondCmd.RunE = runCalendarRespond +} + +func runCalendarCreate(cmd *cobra.Command, args []string) error { + if err := validateCalendarCreateFlags(calendarStart, calendarEnd, calendarDuration, calendarAllDay); err != nil { + return err + } + + tz, err := resolveTimezone() + if err != nil { + return err + } + + now := time.Now().In(tz) + startTime, err := parseDateTime(calendarStart, tz, now) + if err != nil { + return fmt.Errorf("invalid --start value: %w", err) + } + + var endTime time.Time + switch { + case calendarEnd != "": + endTime, err = parseDateTime(calendarEnd, tz, now) + if err != nil { + return fmt.Errorf("invalid --end value: %w", err) + } + case calendarDuration != "": + d, err := parseDuration(calendarDuration) + if err != nil { + return err + } + endTime = startTime.Add(d) + case calendarAllDay: + endTime = startTime.AddDate(0, 0, 1) + default: + endTime = startTime.Add(time.Hour) + } + + event := &calendar.Event{ + Summary: calendarSummary, + Description: calendarDescription, + Location: calendarLocation, + Start: buildEventDateTime(startTime, calendarAllDay, calendarTimezone), + End: buildEventDateTime(endTime, calendarAllDay, calendarTimezone), + } + + if calendarRrule != "" { + event.Recurrence = []string{"RRULE:" + calendarRrule} + if event.Start.TimeZone == "" { + event.Start.TimeZone = tz.String() + } + if event.End.TimeZone == "" { + event.End.TimeZone = tz.String() + } + } + + if calendarAttendees != "" { + emails, err := validateAttendeeEmails(calendarAttendees) + if err != nil { + return err + } + attendees := make([]*calendar.EventAttendee, len(emails)) + for i, email := range emails { + attendees[i] = &calendar.EventAttendee{Email: email} + } + event.Attendees = attendees + } + + ctx := context.Background() + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + result, err := service.Events.Insert(calendarID, event).SendUpdates(calendarSendUpdates).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to create event") + } + + if GetOutputFormat() == "json" { + type createResult struct { + ID string `json:"id"` + Summary string `json:"summary"` + HtmlLink string `json:"html_link"` + Start string `json:"start"` + End string `json:"end"` + } + return outputJSON(createResult{ + ID: result.Id, + Summary: result.Summary, + HtmlLink: result.HtmlLink, + Start: formatEventTime(result.Start, tz), + End: formatEventTime(result.End, tz), + }) + } + + fmt.Printf("Event created: %s\n", result.Id) + fmt.Printf("Link: %s\n", result.HtmlLink) + return nil +} + +func runCalendarUpdate(cmd *cobra.Command, args []string) error { + eventID := args[0] + + tz, err := resolveTimezone() + if err != nil { + return err + } + + ctx := context.Background() + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + event, err := service.Events.Get(calendarID, eventID).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to get event") + } + + // For recurring events with --recurring-scope all, operate on the parent + if calendarRecurringScope == "all" && event.RecurringEventId != "" { + eventID = event.RecurringEventId + event, err = service.Events.Get(calendarID, eventID).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to get recurring event") + } + } + + now := time.Now().In(tz) + + if cmd.Flags().Changed("summary") { + event.Summary = calendarSummary + } + + if cmd.Flags().Changed("start") { + startTime, err := parseDateTime(calendarStart, tz, now) + if err != nil { + return fmt.Errorf("invalid --start value: %w", err) + } + isAllDay := event.Start != nil && event.Start.Date != "" + event.Start = buildEventDateTime(startTime, isAllDay, calendarTimezone) + } + + if cmd.Flags().Changed("end") { + endTime, err := parseDateTime(calendarEnd, tz, now) + if err != nil { + return fmt.Errorf("invalid --end value: %w", err) + } + isAllDay := event.End != nil && event.End.Date != "" + event.End = buildEventDateTime(endTime, isAllDay, calendarTimezone) + } + + if cmd.Flags().Changed("description") { + if calendarDescription == "" { + event.NullFields = append(event.NullFields, "Description") + } else { + event.Description = calendarDescription + } + } + + if cmd.Flags().Changed("location") { + if calendarLocation == "" { + event.NullFields = append(event.NullFields, "Location") + } else { + event.Location = calendarLocation + } + } + + if cmd.Flags().Changed("timezone") { + if event.Start != nil { + event.Start.TimeZone = calendarTimezone + } + if event.End != nil { + event.End.TimeZone = calendarTimezone + } + } + + if calendarAddAttendees != "" { + emails, err := validateAttendeeEmails(calendarAddAttendees) + if err != nil { + return err + } + for _, email := range emails { + event.Attendees = append(event.Attendees, &calendar.EventAttendee{Email: email}) + } + } + + if calendarRemoveAttendees != "" { + emails, err := validateAttendeeEmails(calendarRemoveAttendees) + if err != nil { + return err + } + removeSet := make(map[string]bool, len(emails)) + for _, email := range emails { + removeSet[strings.ToLower(email)] = true + } + filtered := make([]*calendar.EventAttendee, 0, len(event.Attendees)) + for _, a := range event.Attendees { + if !removeSet[strings.ToLower(a.Email)] { + filtered = append(filtered, a) + } + } + event.Attendees = filtered + } + + event.ServerResponse = googleapi.ServerResponse{} + + result, err := service.Events.Update(calendarID, eventID, event).SendUpdates(calendarSendUpdates).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to update event") + } + + if GetOutputFormat() == "json" { + type updateResult struct { + ID string `json:"id"` + Summary string `json:"summary"` + HtmlLink string `json:"html_link"` + } + return outputJSON(updateResult{ + ID: result.Id, + Summary: result.Summary, + HtmlLink: result.HtmlLink, + }) + } + + fmt.Printf("Event updated: %s\n", result.Id) + return nil +} + +func runCalendarDelete(cmd *cobra.Command, args []string) error { + eventID := args[0] + + if calendarRecurringScope == "all" && !calendarYes { + return fmt.Errorf("this will delete ALL instances of this recurring event. Use --yes to confirm, or --recurring-scope this to delete only this instance") + } + + ctx := context.Background() + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + // For recurring events with --recurring-scope all, operate on the parent + if calendarRecurringScope == "all" { + event, err := service.Events.Get(calendarID, eventID).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to get event") + } + if event.RecurringEventId != "" { + eventID = event.RecurringEventId + } + } + + err = service.Events.Delete(calendarID, eventID).SendUpdates(calendarSendUpdates).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to delete event") + } + + if GetOutputFormat() == "json" { + type deleteResult struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + } + return outputJSON(deleteResult{ + ID: eventID, + Deleted: true, + }) + } + + fmt.Printf("Event deleted: %s\n", eventID) + return nil +} + +func runCalendarRespond(cmd *cobra.Command, args []string) error { + eventID := args[0] + + validStatuses := map[string]bool{ + "accepted": true, + "declined": true, + "tentative": true, + } + if !validStatuses[calendarStatus] { + return fmt.Errorf("invalid --status %q: must be one of accepted, declined, tentative", calendarStatus) + } + + ctx := context.Background() + service, err := auth.NewCalendarService(ctx, GetAccountEmail()) + if err != nil { + return auth.HandleCalendarError(err, "authentication failed") + } + + event, err := service.Events.Get(calendarID, eventID).Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to get event") + } + + var selfAttendee *calendar.EventAttendee + for _, a := range event.Attendees { + if a.Self { + selfAttendee = a + break + } + } + if selfAttendee == nil { + return fmt.Errorf("you are not listed as an attendee of this event") + } + + selfAttendee.ResponseStatus = calendarStatus + if calendarComment != "" { + selfAttendee.Comment = calendarComment + } + + patchEvent := &calendar.Event{ + Attendees: event.Attendees, + } + + result, err := service.Events.Patch(calendarID, eventID, patchEvent). + SendUpdates(calendarSendUpdates). + Do() + if err != nil { + return auth.HandleCalendarError(err, "failed to update RSVP") + } + + if GetOutputFormat() == "json" { + type respondResult struct { + ID string `json:"id"` + Status string `json:"status"` + } + return outputJSON(respondResult{ + ID: result.Id, + Status: calendarStatus, + }) + } + + fmt.Printf("RSVP updated: %s for %s\n", calendarStatus, result.Id) + return nil +} + +func validateCalendarCreateFlags(start, end, duration string, allDay bool) error { + if end != "" && duration != "" { + return fmt.Errorf("--end and --duration are mutually exclusive") + } + if allDay && duration != "" { + return fmt.Errorf("--all-day and --duration cannot be combined") + } + return nil +} + +func validateAttendeeEmails(csv string) ([]string, error) { + parts := strings.Split(csv, ",") + emails := make([]string, 0, len(parts)) + + for _, part := range parts { + email := strings.TrimSpace(part) + if email == "" { + continue + } + + addr, err := mail.ParseAddress(email) + if err != nil { + // Try wrapping with angle brackets for bare emails + addr, err = mail.ParseAddress("<" + email + ">") + if err != nil { + return nil, fmt.Errorf("invalid email address %q: %w", email, err) + } + } + emails = append(emails, addr.Address) + } + + return emails, nil +} diff --git a/cmd/calendar_write_test.go b/cmd/calendar_write_test.go new file mode 100644 index 0000000..039472a --- /dev/null +++ b/cmd/calendar_write_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "testing" +) + +func TestValidateCalendarCreateFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start string + end string + duration string + allDay bool + wantErr bool + errMsg string + }{ + { + name: "valid: start only", + start: "2026-03-15 09:00", + }, + { + name: "valid: start and end", + start: "2026-03-15 09:00", + end: "2026-03-15 10:00", + }, + { + name: "valid: start and duration", + start: "2026-03-15 09:00", + duration: "1h", + }, + { + name: "valid: all-day", + start: "2026-03-15", + allDay: true, + }, + { + name: "invalid: end and duration", + start: "2026-03-15 09:00", + end: "2026-03-15 10:00", + duration: "1h", + wantErr: true, + errMsg: "--end and --duration are mutually exclusive", + }, + { + name: "invalid: all-day and duration", + start: "2026-03-15", + allDay: true, + duration: "1h", + wantErr: true, + errMsg: "--all-day and --duration cannot be combined", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateCalendarCreateFlags(tt.start, tt.end, tt.duration, tt.allDay) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if err.Error() != tt.errMsg { + t.Fatalf("error = %q, want %q", err.Error(), tt.errMsg) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateAttendeeEmails(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "single email", + input: "alice@example.com", + want: []string{"alice@example.com"}, + }, + { + name: "multiple emails", + input: "alice@example.com,bob@example.com", + want: []string{"alice@example.com", "bob@example.com"}, + }, + { + name: "emails with whitespace", + input: " alice@example.com , bob@example.com ", + want: []string{"alice@example.com", "bob@example.com"}, + }, + { + name: "email with display name", + input: "Alice ", + want: []string{"alice@example.com"}, + }, + { + name: "skip empty parts", + input: "alice@example.com,,bob@example.com", + want: []string{"alice@example.com", "bob@example.com"}, + }, + { + name: "invalid email", + input: "not-an-email", + wantErr: true, + }, + { + name: "one valid one invalid", + input: "alice@example.com,bad-email", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := validateAttendeeEmails(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %d emails, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("email[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 477c831..be69997 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,13 +17,14 @@ var ( // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "gsuite", - Short: "Gmail CLI tool", - Long: `gsuite is a command-line interface for Gmail mailbox management. + Short: "Google Workspace CLI tool", + Long: `gsuite is a command-line interface for Google Workspace management. Authenticate with 'gsuite login' to get started. -Provides full access to Gmail operations including reading, sending, searching, -and managing messages, threads, labels, and drafts. +Provides access to Gmail operations including reading, sending, searching, +and managing messages, threads, labels, and drafts. Also supports Google Calendar +for listing, creating, updating, and responding to events. Designed for automation workflows and scripting with support for both human-readable and JSON output formats.`, diff --git a/go.mod b/go.mod index 5161deb..70dce29 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.24.13 require ( github.com/spf13/cobra v1.10.2 - golang.org/x/oauth2 v0.34.0 - google.golang.org/api v0.265.0 + golang.org/x/oauth2 v0.35.0 + google.golang.org/api v0.266.0 ) require ( @@ -21,7 +21,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/yuin/goldmark v1.7.16 // indirect @@ -34,7 +34,7 @@ require ( golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 03a47f1..a00fdd5 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -62,6 +64,8 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= @@ -72,12 +76,18 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 3ffbd39..fc74c54 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,4 +1,4 @@ -// Package auth provides OAuth2 PKCE authentication for Google Gmail API. +// Package auth provides OAuth2 PKCE authentication for Google Gmail and Calendar APIs. package auth import ( @@ -8,7 +8,10 @@ import ( "fmt" "os" + "golang.org/x/oauth2" + calendar "google.golang.org/api/calendar/v3" "google.golang.org/api/gmail/v1" + "google.golang.org/api/googleapi" ) // LoadCredentials loads OAuth2 client credentials JSON from environment variables. @@ -104,44 +107,99 @@ func Login(ctx context.Context, credJSON []byte) (string, error) { return email, nil } -// NewGmailService creates an authenticated Gmail service for the given account. -// If account is empty, the active account from AccountStore is used. -// Runs EnsureMigrated to transparently upgrade legacy single-token setups. -func NewGmailService(ctx context.Context, account string) (*gmail.Service, error) { +// newAuthenticatedClient loads credentials, resolves the account, and returns +// a configured OAuth2Config and token ready to create service clients. +func newAuthenticatedClient(ctx context.Context, account string) (*OAuth2Config, *oauth2.Token, error) { credJSON, err := LoadCredentials() if err != nil { - return nil, fmt.Errorf("failed to load credentials: %w", err) + return nil, nil, fmt.Errorf("failed to load credentials: %w", err) } clientID, clientSecret, err := extractOAuth2ClientCreds(credJSON) if err != nil { - return nil, err + return nil, nil, err } if err := EnsureMigrated(ctx); err != nil { - return nil, fmt.Errorf("failed to run migration: %w", err) + return nil, nil, fmt.Errorf("failed to run migration: %w", err) } resolvedEmail := account if resolvedEmail == "" { store, err := LoadAccountStore() if err != nil { - return nil, fmt.Errorf("failed to load account store: %w", err) + return nil, nil, fmt.Errorf("failed to load account store: %w", err) } resolvedEmail, err = store.GetActive() if err != nil { - return nil, fmt.Errorf("no authenticated accounts. Run 'gsuite login' first") + return nil, nil, fmt.Errorf("no authenticated accounts. Run 'gsuite login' first") } } token, err := LoadTokenFor(resolvedEmail) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("no token for account %s. Run 'gsuite login' to authenticate", resolvedEmail) + return nil, nil, fmt.Errorf("no token for account %s. Run 'gsuite login' to authenticate", resolvedEmail) } - return nil, fmt.Errorf("failed to load token for %s: %w", resolvedEmail, err) + return nil, nil, fmt.Errorf("failed to load token for %s: %w", resolvedEmail, err) } oauthCfg := NewOAuth2Config(clientID, clientSecret) + return oauthCfg, token, nil +} + +// NewGmailService creates an authenticated Gmail service for the given account. +// If account is empty, the active account from AccountStore is used. +// Runs EnsureMigrated to transparently upgrade legacy single-token setups. +func NewGmailService(ctx context.Context, account string) (*gmail.Service, error) { + oauthCfg, token, err := newAuthenticatedClient(ctx, account) + if err != nil { + return nil, err + } return oauthCfg.NewGmailService(ctx, token) } + +// NewCalendarService creates an authenticated Calendar service for the given account. +// If account is empty, the active account from AccountStore is used. +func NewCalendarService(ctx context.Context, account string) (*calendar.Service, error) { + oauthCfg, token, err := newAuthenticatedClient(ctx, account) + if err != nil { + return nil, err + } + return oauthCfg.NewCalendarService(ctx, token) +} + +// isInsufficientScopeError checks if an API error is a 403 with insufficientPermissions reason. +func isInsufficientScopeError(err error) bool { + var gErr *googleapi.Error + if errors.As(err, &gErr) && gErr.Code == 403 { + for _, item := range gErr.Errors { + if item.Reason == "insufficientPermissions" { + return true + } + } + } + return false +} + +// HandleCalendarError translates common Google API errors into user-friendly messages. +func HandleCalendarError(err error, context string) error { + if err == nil { + return nil + } + var gErr *googleapi.Error + if errors.As(err, &gErr) { + switch gErr.Code { + case 401: + return fmt.Errorf("%s: authentication expired. Run 'gsuite login' to re-authenticate", context) + case 403: + if isInsufficientScopeError(err) { + return fmt.Errorf("%s: calendar permission not granted. Run 'gsuite login' to re-authenticate with calendar access", context) + } + return fmt.Errorf("%s: access denied: %w", context, err) + case 404: + return fmt.Errorf("%s: not found", context) + } + } + return fmt.Errorf("%s: %w", context, err) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 9122ea9..3f9342e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,8 +1,11 @@ package auth import ( + "fmt" "strings" "testing" + + "google.golang.org/api/googleapi" ) func TestExtractOAuth2ClientCreds(t *testing.T) { @@ -82,3 +85,127 @@ func TestExtractOAuth2ClientCreds(t *testing.T) { }) } } + +func TestIsInsufficientScopeError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "should return true for 403 with insufficientPermissions", + err: &googleapi.Error{ + Code: 403, + Errors: []googleapi.ErrorItem{{Reason: "insufficientPermissions"}}, + }, + want: true, + }, + { + name: "should return false for 403 with different reason", + err: &googleapi.Error{ + Code: 403, + Errors: []googleapi.ErrorItem{{Reason: "forbidden"}}, + }, + want: false, + }, + { + name: "should return false for 401 error", + err: &googleapi.Error{ + Code: 401, + }, + want: false, + }, + { + name: "should return false for 404 error", + err: &googleapi.Error{ + Code: 404, + }, + want: false, + }, + { + name: "should return false for nil error", + err: nil, + want: false, + }, + { + name: "should return false for non-googleapi error", + err: fmt.Errorf("some other error"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := isInsufficientScopeError(tt.err) + if got != tt.want { + t.Errorf("isInsufficientScopeError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleCalendarError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + context string + wantNil bool + wantErrContain string + }{ + { + name: "should return nil for nil error", + err: nil, + context: "test", + wantNil: true, + }, + { + name: "should suggest login for 401", + err: &googleapi.Error{Code: 401}, + context: "list events", + wantErrContain: "gsuite login", + }, + { + name: "should mention calendar permission for 403 insufficient scope", + err: &googleapi.Error{ + Code: 403, + Errors: []googleapi.ErrorItem{{Reason: "insufficientPermissions"}}, + }, + context: "list events", + wantErrContain: "calendar permission", + }, + { + name: "should say not found for 404", + err: &googleapi.Error{Code: 404}, + context: "get event", + wantErrContain: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := HandleCalendarError(tt.err, tt.context) + + if tt.wantNil { + if got != nil { + t.Fatalf("expected nil, got %v", got) + } + return + } + + if got == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(got.Error(), tt.wantErrContain) { + t.Errorf("error %q does not contain %q", got.Error(), tt.wantErrContain) + } + }) + } +} diff --git a/internal/auth/oauth2.go b/internal/auth/oauth2.go index a0d3d75..748ab6e 100644 --- a/internal/auth/oauth2.go +++ b/internal/auth/oauth2.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "google.golang.org/api/calendar/v3" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" ) @@ -43,7 +44,11 @@ func NewOAuth2Config(clientID, clientSecret string) *OAuth2Config { ClientSecret: clientSecret, Endpoint: google.Endpoint, RedirectURL: redirectURL, - Scopes: []string{gmail.GmailModifyScope}, + Scopes: []string{ + gmail.GmailModifyScope, + calendar.CalendarEventsScope, + calendar.CalendarReadonlyScope, + }, }, } } @@ -164,6 +169,19 @@ func (c *OAuth2Config) NewGmailService(ctx context.Context, token *oauth2.Token) return service, nil } +// NewCalendarService creates an authenticated Calendar service from an existing OAuth2 token. +func (c *OAuth2Config) NewCalendarService(ctx context.Context, token *oauth2.Token) (*calendar.Service, error) { + tokenSource := c.config.TokenSource(ctx, token) + client := oauth2.NewClient(ctx, tokenSource) + + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("failed to create Calendar service: %w", err) + } + + return service, nil +} + // generateCodeVerifier generates a PKCE code verifier from 32 random bytes, // encoded as base64url without padding. func generateCodeVerifier() (string, error) { diff --git a/skills/gsuite-manager/SKILL.md b/skills/gsuite-manager/SKILL.md index 49fd848..72f5e5b 100644 --- a/skills/gsuite-manager/SKILL.md +++ b/skills/gsuite-manager/SKILL.md @@ -1,18 +1,20 @@ --- name: gsuite-manager description: >- - This skill should be used when managing Gmail through the gsuite CLI tool. - It applies when the user asks to read, send, search, label, archive, or - otherwise manage their Gmail — including messages, threads, labels, drafts, - and attachments. Trigger keywords include email, Gmail, inbox, send, draft, - label, search mail, unread, archive, attachment. + This skill should be used when managing Gmail or Google Calendar through the + gsuite CLI tool. It applies when the user asks to read, send, search, label, + archive, or otherwise manage their Gmail — including messages, threads, labels, + drafts, and attachments — or when managing calendar events including listing, + creating, updating, deleting, and responding to invitations. Trigger keywords + include email, Gmail, inbox, send, draft, label, search mail, unread, archive, + attachment, calendar, events, meeting, schedule, RSVP, invite. --- -# Gmail Manager via gsuite CLI +# Google Workspace Manager via gsuite CLI -Manage Gmail accounts through the `gsuite` command-line tool. This skill covers -authentication, reading mail, sending emails, organizing with labels, managing -drafts, searching, and inbox cleanup. +Manage Gmail and Google Calendar through the `gsuite` command-line tool. This +skill covers authentication, reading mail, sending emails, organizing with +labels, managing drafts, searching, inbox cleanup, and calendar event management. ## Prerequisites @@ -109,12 +111,17 @@ Destructive actions that MUST be confirmed: - `gsuite drafts delete` — permanently deletes a draft - `gsuite labels delete` — permanently deletes a label - `gsuite messages modify` with `--remove-labels` — removing labels from messages +- `gsuite calendar delete` — deletes a calendar event +- `gsuite calendar delete --recurring-scope all` — deletes ALL instances of a recurring event (requires `--yes`) +- `gsuite calendar create --send-updates all` — sends real email invitations to attendees +- `gsuite calendar update --send-updates all` — sends update notifications to attendees Safe read-only actions that do NOT need confirmation: - `whoami`, `messages list`, `messages get`, `threads list`, `threads get` - `search`, `labels list`, `drafts list`, `drafts get` - `messages get-attachment` (downloads a file, low risk) - `accounts list`, `accounts switch` (just changes active account) +- `calendar list`, `calendar get`, `calendar today`, `calendar week`, `calendar calendars` Medium-risk actions — confirm if the scope is large: - `gsuite labels create` — creating labels @@ -123,6 +130,9 @@ Medium-risk actions — confirm if the scope is large: - `gsuite messages modify` with `--add-labels` only — adding labels - `gsuite accounts remove` — removes an account and its token - `gsuite logout` — removes the active account's token +- `gsuite calendar create` (without `--send-updates all`) — creates event without notifying +- `gsuite calendar update` (without `--send-updates all`) — modifies event without notifying +- `gsuite calendar respond` — changes your RSVP status ## Output Format @@ -267,6 +277,114 @@ Then download: gsuite messages get-attachment --output ./downloads/file.pdf ``` +## Calendar Workflows + +### Check Today's Schedule + +```bash +gsuite calendar today +``` + +### Check This Week's Events + +```bash +gsuite calendar week +``` + +### List Upcoming Events + +```bash +gsuite calendar list +gsuite calendar list --after today --before +7d -n 50 +gsuite calendar list --query "standup" --after today +``` + +### View Event Details + +```bash +gsuite calendar get +gsuite calendar get -f json +``` + +### Create a Meeting + +After confirming with the user: + +```bash +# Simple 1-hour meeting (default duration) +gsuite calendar create --summary "Team Meeting" --start "2026-03-15 09:00" + +# Meeting with explicit duration +gsuite calendar create --summary "Standup" --start "2026-03-15 09:00" --duration 30m + +# All-day event +gsuite calendar create --summary "Company Holiday" --start 2026-12-25 --all-day + +# Meeting with attendees (sends invitations) +gsuite calendar create --summary "Review" --start "2026-03-15 14:00" --duration 1h \ + --attendees "alice@example.com,bob@example.com" --send-updates all + +# Recurring weekly meeting +gsuite calendar create --summary "1:1" --start "2026-03-15 10:00" --duration 30m \ + --rrule "FREQ=WEEKLY;BYDAY=MO" +``` + +### Update an Event + +```bash +# Change title +gsuite calendar update --summary "New Title" + +# Reschedule +gsuite calendar update --start "2026-03-20 10:00" --end "2026-03-20 11:00" + +# Add attendees and notify them +gsuite calendar update --add-attendees "carol@example.com" --send-updates all + +# Update all instances of a recurring event +gsuite calendar update --summary "New Name" --recurring-scope all +``` + +### RSVP to an Event + +```bash +gsuite calendar respond --status accepted +gsuite calendar respond --status declined --comment "Out of office" +gsuite calendar respond --status tentative +``` + +### Delete an Event + +After confirming with the user: + +```bash +gsuite calendar delete + +# Delete all instances of a recurring event (requires --yes) +gsuite calendar delete --recurring-scope all --yes +``` + +### List Available Calendars + +```bash +gsuite calendar calendars +``` + +### Date/Time Input Formats + +Calendar commands accept flexible date/time formats: + +| Format | Example | Description | +|--------|---------|-------------| +| RFC3339 | `2026-03-15T09:00:00-07:00` | Full timestamp with timezone | +| Date + time | `2026-03-15 09:00` | Date and time (local timezone) | +| Date only | `2026-03-15` | Start of day | +| Time only | `09:00` | Today at that time | +| `today` | `today` | Start of today | +| `tomorrow` | `tomorrow` | Start of tomorrow | +| Day name | `monday` | Next occurrence of that day | +| Relative | `+3d` | 3 days from now | + ## Troubleshooting **"no authenticated accounts"** — No accounts logged in. Run `gsuite login`. @@ -285,3 +403,9 @@ the item was already deleted. **"account not found"** — The email passed to `accounts switch` or `accounts remove` doesn't match any authenticated account. Check with `gsuite accounts list`. + +**"calendar permission not granted"** — The OAuth2 token doesn't include calendar +scopes. Run `gsuite login` to re-authenticate with calendar access. + +**"you are not listed as an attendee"** — Trying to RSVP to an event where you +are not in the attendees list. Check the event with `gsuite calendar get `. diff --git a/skills/gsuite-manager/references/commands.md b/skills/gsuite-manager/references/commands.md index 36b369d..a0e189f 100644 --- a/skills/gsuite-manager/references/commands.md +++ b/skills/gsuite-manager/references/commands.md @@ -336,6 +336,174 @@ gsuite send -t "user@example.com" -s "Update" -b "**Bold** and *italic*\n\n- Ite gsuite send -t "user@example.com" -s "Report" -b "See attached.\n\nThanks" --attach report.pdf --attach data.csv ``` +## Calendar + +### `gsuite calendar list` + +List upcoming calendar events. Defaults to events from now through 30 days. + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--calendar-id` | | `primary` | Calendar ID | +| `--max-results` | `-n` | `25` | Maximum number of events | +| `--after` | | now | Show events after this time | +| `--before` | | +30 days | Show events before this time | +| `--query` | `-q` | | Search query | +| `--single-events` | | `true` | Expand recurring events | +| `--order-by` | | `startTime` | Order by: `startTime` or `updated` | +| `--timezone` | | system | IANA timezone (e.g., `America/New_York`) | +| `--show-deleted` | | `false` | Show deleted events | + +```bash +gsuite calendar list +gsuite calendar list --after today --before +7d +gsuite calendar list -q "standup" -n 50 -f json +``` + +### `gsuite calendar get ` + +Get full event details including attendees, recurrence, and links. + +| Flag | Default | Description | +|------|---------|-------------| +| `--calendar-id` | `primary` | Calendar ID | +| `--timezone` | system | IANA timezone | + +```bash +gsuite calendar get abc123def456 +gsuite calendar get abc123def456 -f json +``` + +### `gsuite calendar create` + +Create a new calendar event. + +| Flag | Short | Required | Default | Description | +|------|-------|----------|---------|-------------| +| `--summary` | | Yes | | Event title | +| `--start` | | Yes | | Start time (flexible format) | +| `--end` | | No | | End time (mutually exclusive with `--duration`) | +| `--duration` | `-d` | No | | Duration (e.g., `1h`, `30m`) | +| `--all-day` | | No | `false` | Create all-day event | +| `--description` | | No | | Event description | +| `--location` | `-l` | No | | Event location | +| `--attendees` | | No | | Comma-separated attendee emails | +| `--rrule` | | No | | Recurrence rule (e.g., `FREQ=WEEKLY;BYDAY=MO`) | +| `--send-updates` | | No | `none` | Notifications: `all`, `externalOnly`, `none` | +| `--timezone` | | No | system | IANA timezone | +| `--calendar-id` | | No | `primary` | Calendar ID | + +If neither `--end` nor `--duration` is provided, defaults to 1-hour duration. +For `--all-day`, only the date portion of `--start` is used. + +```bash +gsuite calendar create --summary "Meeting" --start "2026-03-15 09:00" +gsuite calendar create --summary "Standup" --start "2026-03-15 09:00" --duration 30m +gsuite calendar create --summary "Holiday" --start 2026-12-25 --all-day +gsuite calendar create --summary "1:1" --start "2026-03-15 10:00" --duration 30m \ + --rrule "FREQ=WEEKLY;BYDAY=MO" --attendees "alice@example.com" --send-updates all +``` + +### `gsuite calendar update ` + +Update an existing event. Only explicitly provided flags are changed. + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--summary` | | | New event title | +| `--start` | | | New start time | +| `--end` | | | New end time | +| `--description` | | | New description (empty string clears it) | +| `--location` | `-l` | | New location (empty string clears it) | +| `--add-attendees` | | | Comma-separated emails to add | +| `--remove-attendees` | | | Comma-separated emails to remove | +| `--send-updates` | | `none` | Notifications: `all`, `externalOnly`, `none` | +| `--timezone` | | | IANA timezone | +| `--calendar-id` | | `primary` | Calendar ID | +| `--recurring-scope` | | `this` | Scope: `this` or `all` | + +```bash +gsuite calendar update abc123 --summary "New Title" +gsuite calendar update abc123 --start "2026-03-20 10:00" --end "2026-03-20 11:00" +gsuite calendar update abc123 --add-attendees "carol@example.com" --send-updates all +gsuite calendar update abc123 --recurring-scope all --summary "Updated Series" +``` + +### `gsuite calendar delete ` + +Delete a calendar event. + +| Flag | Default | Description | +|------|---------|-------------| +| `--send-updates` | `none` | Notifications: `all`, `externalOnly`, `none` | +| `--recurring-scope` | `this` | Scope: `this` or `all` | +| `--yes` | `false` | Required when `--recurring-scope all` | +| `--calendar-id` | `primary` | Calendar ID | + +```bash +gsuite calendar delete abc123 +gsuite calendar delete abc123 --recurring-scope all --yes +``` + +### `gsuite calendar respond ` + +Set your RSVP status for a calendar event. + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--status` | Yes | | `accepted`, `declined`, or `tentative` | +| `--comment` | No | | RSVP comment | +| `--send-updates` | No | `none` | Notifications: `all`, `externalOnly`, `none` | +| `--calendar-id` | No | `primary` | Calendar ID | + +```bash +gsuite calendar respond abc123 --status accepted +gsuite calendar respond abc123 --status declined --comment "Out of office" +``` + +### `gsuite calendar today` + +Show today's events (shortcut for `calendar list` with today's date range). + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--calendar-id` | | `primary` | Calendar ID | +| `--max-results` | `-n` | `25` | Maximum number of events | +| `--timezone` | | system | IANA timezone | + +```bash +gsuite calendar today +gsuite calendar today -f json +``` + +### `gsuite calendar week` + +Show this week's events (Monday through Sunday). + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--calendar-id` | | `primary` | Calendar ID | +| `--max-results` | `-n` | `25` | Maximum number of events | +| `--timezone` | | system | IANA timezone | + +```bash +gsuite calendar week +gsuite calendar week -f json +``` + +### `gsuite calendar calendars` + +List available calendars. + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--max-results` | `-n` | `100` | Maximum number of calendars | + +```bash +gsuite calendar calendars +gsuite calendar calendars -f json +``` + ## Gmail Search Query Syntax The `search` command and `messages list -q` / `threads list -q` all accept Gmail From e2bc7070dd047f872dac9a6022d76b849dded310 Mon Sep 17 00:00:00 2001 From: Khang Nguyen Date: Tue, 10 Feb 2026 20:33:16 -0500 Subject: [PATCH 2/2] docs: add calendar integration planning document Co-Authored-By: Claude Opus 4.6 --- plans/calendar-integration.md | 469 ++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 plans/calendar-integration.md diff --git a/plans/calendar-integration.md b/plans/calendar-integration.md new file mode 100644 index 0000000..0eb99bb --- /dev/null +++ b/plans/calendar-integration.md @@ -0,0 +1,469 @@ +# feat: Add Google Calendar Integration + +## Enhancement Summary + +**Deepened on:** 2026-02-10 +**Research agents used:** 8 (architecture-strategist, performance-oracle, security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist, go-testing-researcher, date-time-parser-researcher, calendar-api-patterns-researcher) + +### Key Changes from Original Plan +1. **Scope narrowed**: Use `CalendarEventsScope` + `CalendarReadonlyScope` instead of `CalendarScope` (principle of least privilege) +2. **`--send-updates` default changed**: `"none"` instead of `"all"` (prevents accidental email sends in automation) +3. **Flag naming fixed**: `--max-results` instead of `--max`, `--after`/`--before` instead of `--from`/`--to` (pattern consistency) +4. **File count reduced**: 3 files instead of 6 (matches codebase convention of single file per resource) +5. **JSON `omitempty` removed**: Never used in existing codebase -- always include all fields +6. **`-s` shorthand dropped**: Conflicts with `--subject` in drafts/send commands +7. **Delete safety added**: `--yes` flag required for `--recurring-scope all` delete +8. **Attendee email validation added**: `net/mail.ParseAddress()` before API calls +9. **Date parser designed for testability**: Accepts `now` and `loc` parameters, never calls `time.Now()` directly +10. **Proactive scope checking**: Detect missing calendar scope before API call, not via reactive 403 + +### Reviewer Consensus on Conflicts +- **Get+Update vs Patch**: Security says use Patch (avoids TOCTOU), Performance says Get+Update (2 quota units vs 3). **Decision: Use Get+Update for `update`, Patch for `respond`** (respond is a single-field change where Patch is simpler and more atomic). +- **File splitting**: Simplicity says 2 files, Architecture says 3, Pattern says match existing (1 per resource). **Decision: 3 files** (`calendar.go`, `calendar_write.go`, `calendar_time.go`) since the combined size would exceed 500 lines. +- **MVP scope**: Simplicity says drop today/week/calendars/respond. **Decision: Keep all 9 subcommands** -- they are thin wrappers that add significant usability. But `--recurring-scope following` is deferred (requires complex 4-step API dance). + +--- + +## Overview + +Add Google Calendar support to the gsuite CLI, enabling users to list, create, update, delete, and respond to calendar events alongside existing Gmail functionality. This transforms the tool from a Gmail CLI into a Google Workspace CLI. + +Calendar is explicitly listed as a "What's next" area in `.planning/MILESTONES.md` (lines 76, 102, 127) and was previously "Out of Scope" in `.planning/PROJECT.md` (line 40). + +## Problem Statement / Motivation + +Users of the gsuite CLI currently have no way to manage their Google Calendar from the command line. Calendar management is a natural companion to email -- checking today's meetings, creating events, RSVPing to invitations -- all common workflows for power users and automation scripts. Adding calendar support fulfills the project's stated roadmap and makes the CLI significantly more useful for daily workflows. + +## Proposed Solution + +Add a `gsuite calendar` parent command with subcommands following the established Cobra command pattern. Extend the auth layer to support Calendar API scope alongside Gmail. No new Go module dependencies are needed -- `google.golang.org/api/calendar/v3` is already available in the existing `google.golang.org/api v0.265.0` module. + +### Command Structure + +``` +gsuite calendar list # List upcoming events +gsuite calendar get # Get event details +gsuite calendar create # Create an event +gsuite calendar update # Update an event +gsuite calendar delete # Delete an event +gsuite calendar respond # RSVP to an event +gsuite calendar today # Shortcut: today's events +gsuite calendar week # Shortcut: this week's events +gsuite calendar calendars # List available calendars +``` + +## Technical Approach + +### Architecture + +The implementation follows the established patterns exactly: +- Auth: `NewCalendarService()` parallel to `NewGmailService()` in `internal/auth/` +- Commands: New files in `cmd/` using `RunE` + `init()` registration pattern +- Output: Text (default) and JSON via `--format` flag with inline struct types + +### Key Design Decisions + +1. **OAuth2 Scope**: Add `calendar.CalendarEventsScope` + `calendar.CalendarReadonlyScope` to `NewOAuth2Config()` scopes list. This covers all planned operations (CRUD events + list calendars) without granting calendar admin access (ACL, settings). Calendar commands proactively check for missing scope before attempting API calls. + + > **Research Insight (Security):** `calendar.CalendarScope` grants full admin access including ACL modification and calendar deletion. A compromised token with full scope allows an attacker to modify calendar sharing settings and delete entire calendars. The narrower `CalendarEventsScope` + `CalendarReadonlyScope` combination covers all planned commands. + +2. **Shared Auth Logic**: Extract unexported `newAuthenticatedClient(ctx, account) (*http.Client, error)` from `NewGmailService()`. Both `NewGmailService()` and `NewCalendarService()` become thin wrappers that call this shared function then construct their service-specific client. + + > **Research Insight (Architecture):** The extracted function must return `*http.Client` (not token source), because that is what both `gmail.NewService()` and `calendar.NewService()` accept via `option.WithHTTPClient()`. Keep it unexported -- command code should not need to know about HTTP clients. + +3. **`--calendar-id`**: Regular flag added to each subcommand individually (default: `"primary"`). This matches the existing pattern where no parent command has persistent flags -- only `rootCmd` has persistent flags. + + > **Research Insight (Pattern):** The existing codebase never uses persistent flags on parent commands (`labelsCmd`, `messagesCmd`, `draftsCmd` have zero persistent flags). Using a persistent flag on `calendarCmd` would be unprecedented. Adding the flag to each subcommand individually follows the `--max-results` pattern. + +4. **Date/Time Parsing**: Build a custom parser (no external library). Accept flexible input and normalize to RFC3339. All parsing functions accept `now time.Time` and `loc *time.Location` parameters for testability. Use `time.ParseInLocation` for all formats except RFC3339 (which carries its own offset). Default timezone is system local, overridable with `--timezone`. + + > **Research Insight (Date/Time):** `time.Parse` for bare datetimes returns UTC, which is almost never what a CLI user intends. `time.ParseInLocation` is required for everything except RFC3339. Performance cost of the format chain (~750ns worst case) is 5 orders of magnitude smaller than a single API call. Libraries like `araddon/dateparse` support 100+ formats we don't need -- custom parser is simpler and gives better error messages. + +5. **Recurring Events**: Use `SingleEvents(true)` by default for list/today/week. Recurring event update/delete defaults to "this instance only" (pass instance ID to API). Support `--recurring-scope this|all` flag. Defer `--recurring-scope following` (requires complex 4-step process: truncate parent RRULE, create new series). + + > **Research Insight (API):** Instance IDs follow the format `{baseEventId}_{originalStartTime}` (e.g., `abc123_20260315T160000Z`). "This and following" has no single API call -- it requires modifying the parent's RRULE to add an UNTIL clause, then creating a new recurring event from the split point. + +6. **Attendee Management**: Use `--add-attendees` and `--remove-attendees` on update (not `--attendees` which could replace the entire list). Validate email addresses with `net/mail.ParseAddress()` before API calls. + + > **Research Insight (Security):** Invalid email formats could silently create events with malformed attendee entries. Combined with `--send-updates all`, this could attempt to send emails to nonsensical addresses. Validate with Go's `net/mail.ParseAddress()` before making API calls. + +7. **Pagination**: Auto-paginate up to `--max-results` count using `.Pages()` with early termination counter. Set `MaxResults` on the API call to `min(maxResults, 250)` to minimize over-fetching. + + > **Research Insight (Performance):** Without early termination in the `.Pages()` callback, requesting `--max-results 25` on a calendar with 2000 events could fetch all events across multiple pages. Set the API's `MaxResults` to match `--max-results` for single-page responses. The `.Pages()` method in `calendar-gen.go:5760-5776` loops until `NextPageToken` is empty -- return a sentinel error to break early. + +8. **`--send-updates` default**: Default to `"none"` for safety. Users who want to send notifications must opt in with `--send-updates all`. + + > **Research Insight (Security):** For a CLI tool designed for automation, defaulting to `"all"` means a script bug or typo in attendee email could send real calendar invitations to external people. This is irreversible. The Gmail `send` command requires explicit `--to` and `--body` -- calendar notifications should follow the same intentionality. + +9. **Update strategy**: Use Get+Update for `calendar update` (preserves all fields, 2 API quota units). Use Patch for `calendar respond` (single-field change, 1 API call, more atomic). Use `cmd.Flags().Changed("summary")` to determine which fields the user explicitly set. + + > **Research Insight (Performance + Security):** Get+Update introduces a TOCTOU race window but is simpler for multi-field updates. Patch avoids the race but uses 3x quota for updates. For `respond`, Patch is better: it only changes one attendee's status, requires 1 call instead of 2, and avoids sending the entire event back. + +### Implementation Phases + +#### Phase 1: Auth Layer Extension + +**Files:** +- `internal/auth/auth.go` -- Extract `newAuthenticatedClient()`, add `NewCalendarService()` +- `internal/auth/oauth2.go` -- Add calendar scopes, add `NewCalendarService()` method on `OAuth2Config` +- `internal/auth/auth_test.go` -- Tests for scope checking + +**Tasks:** +- [ ] Extract unexported `newAuthenticatedClient(ctx, account) (*http.Client, error)` from `NewGmailService()` (lines 110-147 of `auth.go`). Contains: credential loading, client cred extraction, migration check, account resolution, token loading, OAuth2 client construction. +- [ ] Refactor `NewGmailService()` to call `newAuthenticatedClient()` then `gmail.NewService(ctx, option.WithHTTPClient(client))` +- [ ] Add calendar scopes to `NewOAuth2Config()` in `oauth2.go` line 46: + ```go + Scopes: []string{ + gmail.GmailModifyScope, + calendar.CalendarEventsScope, + calendar.CalendarReadonlyScope, + }, + ``` +- [ ] Add `NewCalendarService()` method to `OAuth2Config`: + ```go + func (c *OAuth2Config) NewCalendarService(ctx context.Context, token *oauth2.Token) (*calendar.Service, error) { + tokenSource := c.config.TokenSource(ctx, token) + client := oauth2.NewClient(ctx, tokenSource) + return calendar.NewService(ctx, option.WithHTTPClient(client)) + } + ``` +- [ ] Add top-level `NewCalendarService(ctx, account)` in `auth.go` -- thin wrapper calling `newAuthenticatedClient()` then `oauthCfg.NewCalendarService()` +- [ ] Add `isInsufficientScopeError(err error) bool` helper using `errors.As` with `*googleapi.Error`: + ```go + var gErr *googleapi.Error + if errors.As(err, &gErr) && gErr.Code == 403 { + for _, item := range gErr.Errors { + if item.Reason == "insufficientPermissions" { + return true + } + } + } + ``` +- [ ] Add `handleCalendarError(err error, context string) error` that wraps API errors with clear messages: 401 -> "Run gsuite login", 403 -> "Run gsuite login to grant calendar access", 404 -> "not found" +- [ ] Table-driven tests for `isInsufficientScopeError` with synthetic `*googleapi.Error` values + +#### Phase 2: Date/Time Parsing Utility + +**Files:** +- `cmd/calendar_time.go` -- Date/time parsing and formatting helpers +- `cmd/calendar_time_test.go` -- Table-driven tests +- `cmd/calendar_time_fuzz_test.go` -- Fuzz tests + +**Tasks:** +- [ ] Implement `parseDateTime(input string, loc *time.Location, now time.Time) (time.Time, error)`: + - Try relative/keyword parsing first (cheap string comparisons) + - Then try format chain ordered by frequency: RFC3339, `"2006-01-02"`, `"2006-01-02 15:04"`, `"2006-01-02T15:04:05"`, `"15:04"` + - Use `time.Parse` for RFC3339 only; `time.ParseInLocation` for all others + - For `"15:04"` format: anchor to today's date using `now` + - Return descriptive error listing accepted formats on failure +- [ ] Implement `parseRelative(input string, loc *time.Location, now time.Time) (time.Time, bool)`: + - Keywords: "today" (start of day), "tomorrow" (start of next day) + - Day names: "monday"-"sunday" (next occurrence, never today) + - Relative: "+Nd" pattern via regex `^\+(\d+)d$` + - Case insensitive via `strings.ToLower()` +- [ ] Implement `parseDuration(input string) (time.Duration, error)` wrapping `time.ParseDuration`, rejecting zero/negative +- [ ] Implement `buildEventDateTime(t time.Time, allDay bool, tz string) *calendar.EventDateTime`: + - All-day: use `Date` field with `"2006-01-02"` format + - Timed: use `DateTime` field with `time.RFC3339` format, set `TimeZone` if provided +- [ ] Implement `formatEventTime(edt *calendar.EventDateTime, displayTz *time.Location) string`: + - All-day: `"Mon Jan 02, 2006 (all day)"` + - Timed: `"Mon Jan 02, 2006 03:04 PM MST"` converted to `displayTz` +- [ ] Implement `startOfDay(t time.Time, loc *time.Location) time.Time` and `nextWeekday(from time.Time, target time.Weekday) time.Time` helpers +- [ ] Table-driven tests (parallel, `t.Fatalf`): + - `TestParseDateTime`: RFC3339, date-only, date+time, ISO without TZ, time-only, today/tomorrow/+Nd, day names, empty, garbage, leap year, midnight boundary, DST spring forward + - `TestParseDuration`: valid durations, zero, negative, overflow, bare number + - `TestBuildEventDateTime`: timed, all-day, with timezone + - `TestFormatEventTime`: datetime, date-only, empty, unparseable +- [ ] Fuzz tests: `FuzzParseDateTime`, `FuzzParseDuration` with seed corpus from unit test values +- [ ] DST edge case tests using `time.LoadLocation("America/Los_Angeles")` and `t.Setenv("TZ", ...)` + +> **Research Insight (Testing):** The codebase convention is `tt` for test table variable (not `tc`), `t.Parallel()` at both levels, `t.Fatalf` for assertions, stdlib only, fuzz seeds from unit test values. `parseRelativeDate` MUST accept a `now` parameter -- calling `time.Now()` internally makes tests non-deterministic. + +#### Phase 3: Core Commands -- List, Get, Today, Week, Calendars + +**Files:** +- `cmd/calendar.go` -- Parent command, list, get, today, week, calendars subcommands, single `init()` +- `cmd/calendar_test.go` -- Tests + +**Tasks:** +- [ ] Define `calendarCmd` parent command (no `RunE`, no persistent flags -- help only) +- [ ] Define flag variables with `calendar` prefix to avoid collisions (matching `drafts.go`/`threads.go` pattern): + ```go + var ( + calendarID string // --calendar-id, default "primary" + calendarMaxResults int64 // --max-results -n, default 25 + calendarAfter string // --after + calendarBefore string // --before + calendarQuery string // --query -q + calendarSingleEvents bool // --single-events, default true + calendarOrderBy string // --order-by, default "startTime" + calendarTimezone string // --timezone + calendarShowDeleted bool // --show-deleted + ) + ``` +- [ ] Single `init()` registering all subcommands and all flags (even from `calendar_write.go`) +- [ ] Implement `calendar list`: + - Flags: `--calendar-id`, `--after`, `--before`, `--max-results -n` (default 25), `--query -q`, `--single-events` (default true), `--order-by` (default "startTime"), `--timezone`, `--show-deleted` + - Default `--after`: current time (now) + - Default `--before`: 30 days from now + - Fields selector (no attendees for list -- reduces payload 40-60%): + ```go + Fields("nextPageToken", "items(id,summary,start,end,status,location,recurrence,recurringEventId)") + ``` + - Auto-paginate with early termination: + ```go + call.MaxResults(min(calendarMaxResults, 250)).Pages(ctx, func(page *calendar.Events) error { + allEvents = append(allEvents, page.Items...) + if int64(len(allEvents)) >= calendarMaxResults { + return errDone + } + return nil + }) + ``` + - Empty results: `outputJSON([]struct{}{})` for JSON, `"No events found."` for text +- [ ] Implement `calendar get ` (`cobra.ExactArgs(1)`): + - Full fields (include attendees): `Fields("id,summary,start,end,status,location,description,recurrence,recurringEventId,attendees(email,responseStatus,self),htmlLink,creator,organizer")` + - Display: summary, ID, status, calendar, time range, timezone, recurrence, location, description, attendees (email + response status), HTML link +- [ ] Implement `calendar today` (thin wrapper calling list logic with `--after` = start of today, `--before` = end of today) +- [ ] Implement `calendar week` (thin wrapper: `--after` = start of this week Monday, `--before` = end of Sunday) +- [ ] Implement `calendar calendars` (`CalendarList.List`): + - Display: ID, summary, access role, primary indicator, timezone + +**Text Output Format (list):** +``` +Events (5): + + Mon Mar 15, 2026 09:00 AM - 10:00 AM Team Standup (recurring) + Mon Mar 15, 2026 02:00 PM - 03:00 PM 1:1 with Manager + Tue Mar 16, 2026 All Day Company Holiday +``` + +**Text Output Format (get):** +``` +Event: Team Standup +ID: abc123def456 +Status: confirmed +Calendar: primary + +When: Mon Mar 15, 2026 09:00 AM - 10:00 AM PST +Timezone: America/Los_Angeles +Recurrence: RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR + +Location: Conference Room B +Description: Daily sync with the engineering team + +Attendees (3): + alice@example.com accepted + bob@example.com tentative + charlie@example.com needsAction + +Link: https://calendar.google.com/calendar/event?eid=... +``` + +**JSON Output Schema (list item) -- no `omitempty`, always include all fields:** +```json +{ + "id": "string", + "summary": "string", + "start": "RFC3339 or date string", + "end": "RFC3339 or date string", + "location": "string", + "status": "string", + "all_day": false, + "recurring": false, + "recurring_event_id": "string" +} +``` + +> **Research Insight (Pattern):** No existing JSON struct in the codebase uses `omitempty`. Always include all fields with empty defaults. This maintains a consistent JSON contract for automation consumers. Include `recurring_event_id` so JSON consumers can correlate expanded instances. + +> **Research Insight (Performance):** Remove `attendees` from the list Fields selector. For an event with 50 attendees, the attendees field alone is 2-3KB. List output doesn't display attendees, so fetching them wastes bandwidth. Keep attendees only in `calendar get`. + +#### Phase 4: Write Commands -- Create, Update, Delete, Respond + +**Files:** +- `cmd/calendar_write.go` -- Create, update, delete, respond commands +- `cmd/calendar_write_test.go` -- Tests + +**`calendar create` flags:** +- `--summary` (required) -- Event title (no `-s` shorthand -- conflicts with `--subject` in drafts/send) +- `--start` (required) -- Start time (flexible format) +- `--end` -- End time (required unless `--duration` or `--all-day`) +- `--duration, -d` -- Alternative to `--end` (e.g., "1h", "30m") +- `--description` -- Event description +- `--location, -l` -- Event location +- `--attendees` -- Comma-separated attendee emails (validated with `net/mail.ParseAddress`) +- `--all-day` -- Create all-day event (auto-sets end = start + 1 day if no `--end`) +- `--rrule` -- Raw RRULE string (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR"), passed through to API +- `--send-updates` -- Default `"none"`, options: `"all"`, `"externalOnly"`, `"none"` +- `--timezone` -- IANA timezone for the event +- `--calendar-id` -- Calendar ID (default: "primary") + +**Tasks:** +- [ ] Implement `calendar create`: + - Validate: `--end` and `--duration` are mutually exclusive + - Validate: `--all-day` with time component in `--start` warns and strips time + - Validate: `--all-day` cannot combine with `--duration` + - For `--all-day` without `--end`: auto-set end = start + 1 day (exclusive end date per Calendar API) + - Validate attendee emails with `net/mail.ParseAddress()` + - Use `SendUpdates()` (not deprecated `SendNotifications()`) + - For recurring events with `--rrule`: set `TimeZone` on `EventDateTime` (required for recurring events) + - Output created event ID and HTML link +- [ ] Implement `calendar update ` (`cobra.ExactArgs(1)`): + - Flags: `--summary`, `--start`, `--end`, `--description`, `--location`, `--add-attendees`, `--remove-attendees`, `--send-updates` (default "none"), `--timezone`, `--calendar-id` + - `--recurring-scope` flag: `"this"` (default), `"all"` (deferred: `"following"`) + - **Get+Update pattern**: Fetch existing event, mutate only fields where `cmd.Flags().Changed("flagname")` is true, send back. This avoids zeroing out unset fields. + - When `--recurring-scope all`: use `event.RecurringEventId` (parent ID) instead of instance ID + - For clearing a field (e.g., empty description): use `event.NullFields = append(event.NullFields, "Description")` + - Clear `event.ServerResponse` before sending update (contains server-managed metadata) +- [ ] Implement `calendar delete ` (`cobra.ExactArgs(1)`): + - `--send-updates` flag (default: `"none"`) + - `--recurring-scope` flag: `"this"` (default), `"all"` + - **Safety: When `--recurring-scope all`, require `--yes` flag**. Without it, print warning and exit non-zero: + ``` + Warning: This will delete ALL instances of this recurring event. + Use --yes to confirm, or --recurring-scope this to delete only this instance. + ``` + - Print confirmation: `"Event deleted: "` +- [ ] Implement `calendar respond ` (`cobra.ExactArgs(1)`): + - `--status` (required): "accepted", "declined", "tentative" + - `--comment` -- Optional RSVP comment + - `--send-updates` (default: "none"), `--calendar-id` + - **Use Patch (not Get+Update)**: Simpler, more atomic, 1 API call + - Fetch event to find self (match `Self: true` in attendees), modify response status, Patch back only the attendees list + - Handle case where user is not in attendees list: `"You are not listed as an attendee of this event"` +- [ ] Extract `validateCalendarCreateFlags(start, end, duration string, allDay bool) error` as a pure testable function +- [ ] Table-driven tests for flag validation (mutually exclusive flags, required flags, email validation) + +> **Research Insight (API):** Use `errors.As(err, &gErr)` (not direct type assertion) for error checking -- the generated `Do()` methods wrap errors via `gensupport.WrapError()`. The `googleapi.Error` type implements `Unwrap()`, so `errors.As` traverses the chain correctly. Use `ForceSendFields` when you need to send a zero-value field, `NullFields` when you need to clear a field to null. + +#### Phase 5: Documentation & Cleanup + +**Files:** +- `cmd/root.go` -- Update root command description +- `CLAUDE.md` -- Add calendar to architecture docs and command list +- `README.md` -- Add calendar commands to documentation +- `skills/gsuite-manager/SKILL.md` -- Add calendar workflows and safety rules +- `.planning/PROJECT.md` -- Move calendar from "Out of Scope" to "Active" + +**Tasks:** +- [ ] Update root command Short/Long descriptions: "Gmail CLI tool" -> "Google Workspace CLI tool" +- [ ] Update `CLAUDE.md`: + - Architecture section: add calendar to command list + - Auth section: mention both Gmail and Calendar scopes, `NewCalendarService()` + - Command pattern: note Calendar follows same pattern +- [ ] Update `README.md` with calendar command documentation and examples +- [ ] Update gsuite-manager skill: + - Add calendar operations to command reference + - Add safety rules: `calendar delete` is destructive, `calendar create --send-updates all` sends emails + - Add common workflows: check today's events, create meeting, RSVP +- [ ] Update `.planning/PROJECT.md`: move Calendar from "Out of Scope" to active feature + +## Acceptance Criteria + +### Functional Requirements +- [ ] `gsuite calendar list` lists upcoming events with date range filters +- [ ] `gsuite calendar get ` shows full event details including attendees +- [ ] `gsuite calendar create` creates timed, all-day, and recurring events +- [ ] `gsuite calendar update ` modifies event fields (only changed fields) +- [ ] `gsuite calendar delete ` deletes events (with `--yes` safety for recurring-all) +- [ ] `gsuite calendar respond ` updates RSVP status via Patch +- [ ] `gsuite calendar today` shows today's events +- [ ] `gsuite calendar week` shows this week's events (Monday-Sunday) +- [ ] `gsuite calendar calendars` lists available calendars +- [ ] All commands support `--format json` and `--format text` +- [ ] All commands support `--calendar-id` flag (default "primary") +- [ ] All commands support `--account` flag for multi-account +- [ ] Flexible date/time input parsing works correctly (RFC3339, date-only, date+time, relative) +- [ ] Existing Gmail commands continue to work unchanged +- [ ] `--send-updates` defaults to `"none"` (opt-in notification sending) + +### Auth Requirements +- [ ] `gsuite login` requests Gmail + CalendarEvents + CalendarReadonly scopes +- [ ] Existing tokens without calendar scope produce proactive error: "Calendar permission not granted. Run 'gsuite login' to re-authenticate with calendar access." +- [ ] `NewCalendarService()` uses shared `newAuthenticatedClient()` -- no code duplication +- [ ] Error detection uses `errors.As` with `*googleapi.Error`, not string matching + +### Quality Gates +- [ ] Table-driven unit tests for all date/time parsing functions (with `t.Parallel()`) +- [ ] Fuzz tests for `parseDateTime` and `parseDuration` +- [ ] DST edge case tests using `time.LoadLocation("America/Los_Angeles")` +- [ ] Unit tests for flag validation (mutually exclusive, required, email format) +- [ ] Unit tests for `isInsufficientScopeError` with synthetic errors +- [ ] `go build ./...` succeeds +- [ ] `go test ./... -race` passes +- [ ] No large files (target ~400 lines per file, split if exceeding 500) + +### Security Requirements +- [ ] OAuth2 scopes use `CalendarEventsScope` + `CalendarReadonlyScope` (not full `CalendarScope`) +- [ ] `--send-updates` defaults to `"none"` +- [ ] `calendar delete --recurring-scope all` requires `--yes` flag +- [ ] Attendee email addresses validated with `net/mail.ParseAddress()` before API calls +- [ ] `--calendar-id` flag validated (non-empty) + +## Dependencies & Prerequisites + +- Google Cloud project must have Calendar API enabled +- OAuth2 client must have Calendar scope authorized in Google Cloud Console +- Existing users must re-authenticate after upgrade (`gsuite login`) +- No new Go module dependencies (`google.golang.org/api/calendar/v3` is part of existing `google.golang.org/api v0.265.0`) + +## Risk Analysis & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| Existing users get 403 on calendar commands | High | Proactive scope check in `NewCalendarService()` with clear error message before any API call | +| Recurring event delete affects entire series | High | Default `--recurring-scope this`, require `--yes` for `--recurring-scope all` | +| Unintended email sends to attendees | High | Default `--send-updates "none"` -- users must opt in | +| Attendee replacement on update | High | Use `--add-attendees`/`--remove-attendees` instead of `--attendees` | +| Get+Update race condition on update | Medium | Document the TOCTOU window; use Patch for `respond` where atomicity matters | +| Timezone confusion | Medium | Default to system local, always display timezone in output, require `TimeZone` on recurring events | +| Auth code duplication | Medium | Extract shared `newAuthenticatedClient()` helper | +| Token path traversal (pre-existing) | Low | Add path sanitization to `TokenPathFor()` -- reject `/`, `\`, `..` in email | + +## Future Considerations (Explicitly Deferred) + +- `--recurring-scope following` (requires complex 4-step API process: truncate parent RRULE + create new series) +- Google Meet link auto-generation (`--meet` flag via `conferenceData`) +- Event reminders (`--reminder` flag) +- Free/busy queries (`gsuite calendar freebusy`) +- Event attachments +- Event colors +- Convenience recurrence flags (`--repeat weekly --until ...`) +- iCalendar (.ics) import/export +- Calendar watch/sync with sync tokens +- QuickAdd command (`gsuite calendar quick "Lunch tomorrow at noon"`) +- Incremental scope strategy (per-service scope request instead of all-at-once) +- Backporting auto-pagination to existing Gmail commands for consistency + +## References & Research + +### Internal References +- Auth pattern: `internal/auth/auth.go:110-147` (NewGmailService) +- OAuth2 scopes: `internal/auth/oauth2.go:46` (current scope list) +- Command pattern: `cmd/labels.go` (cleanest CRUD example, 387 lines, 4 subcommands) +- Output formatting: `cmd/root.go:66-73` (outputJSON helper) +- Flag pattern: `cmd/messages.go:133-155` (init registration) +- Flag naming: `cmd/drafts.go` uses prefixed variables (`draftsMaxResults`) +- Modify pattern: `cmd/messages.go:145-146` (`--add-labels`/`--remove-labels`) +- Testing pattern: `cmd/fuzz_test.go` (existing fuzz tests with seed corpus) +- Testing conventions: `docs/test-best-practices.md` +- Roadmap: `.planning/MILESTONES.md:76,102,127` (calendar mentioned as future) + +### External References +- [Google Calendar API v3 Reference](https://developers.google.com/workspace/calendar/api/v3/reference) +- [Google Calendar API Guides](https://developers.google.com/workspace/calendar/api/guides) +- [Go Calendar v3 Package](https://pkg.go.dev/google.golang.org/api/calendar/v3) +- [Google Calendar API Scopes](https://developers.google.com/workspace/calendar/api/auth) +- [Google Calendar Recurring Events](https://developers.google.com/workspace/calendar/api/guides/recurringevents) +- [Google Calendar API Error Handling](https://developers.google.com/workspace/calendar/api/guides/errors) +- [Google Calendar API Performance Tips](https://developers.google.com/workspace/calendar/api/guides/performance) +- [Go time.ParseInLocation (DST handling)](https://github.com/golang/go/issues/63345) +- [gcalcli (Python CLI reference)](https://github.com/insanum/gcalcli) + +### API Source Code References +- `calendar-gen.go:5760-5776` -- `.Pages()` implementation (pagination loop) +- `calendar-gen.go:1356-1366` -- `Event.ForceSendFields` / `NullFields` +- `calendar-gen.go:1634-1636` -- `EventAttendee.Self` field +- `calendar-gen.go:6296` -- `EventsUpdateCall.SendUpdates()` method +- `googleapi/googleapi.go:66-92` -- `googleapi.Error` struct with `Code`, `Errors[].Reason`