diff --git a/cmd/calendar.go b/cmd/calendar.go index 28be1b4..e1182a4 100644 --- a/cmd/calendar.go +++ b/cmd/calendar.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/omriariav/workspace-cli/internal/client" @@ -80,12 +81,240 @@ Examples: RunE: runCalendarRsvp, } +// --- New event commands --- + +var calendarGetCmd = &cobra.Command{ + Use: "get", + Short: "Get event by ID", + Long: `Gets a single calendar event by its ID. + +Examples: + gws calendar get --id abc123 + gws calendar get --id abc123 --calendar-id work@group.calendar.google.com`, + RunE: runCalendarGet, +} + +var calendarQuickAddCmd = &cobra.Command{ + Use: "quick-add", + Short: "Quick add event from text", + Long: `Creates an event from a text string using Google's natural language processing. + +Examples: + gws calendar quick-add --text "Lunch with John tomorrow at noon" + gws calendar quick-add --text "Team meeting Friday 3pm-4pm"`, + RunE: runCalendarQuickAdd, +} + +var calendarInstancesCmd = &cobra.Command{ + Use: "instances", + Short: "List instances of recurring event", + Long: `Lists all instances of a recurring calendar event. + +Examples: + gws calendar instances --id abc123 + gws calendar instances --id abc123 --max 10 --from "2024-03-01" --to "2024-06-01"`, + RunE: runCalendarInstances, +} + +var calendarMoveCmd = &cobra.Command{ + Use: "move", + Short: "Move event to another calendar", + Long: `Moves an event from one calendar to another. + +Examples: + gws calendar move --id abc123 --destination work@group.calendar.google.com`, + RunE: runCalendarMove, +} + +// --- Calendar CRUD commands --- + +var calendarGetCalendarCmd = &cobra.Command{ + Use: "get-calendar", + Short: "Get calendar metadata", + Long: `Gets metadata for a calendar by its ID. + +Examples: + gws calendar get-calendar --id primary + gws calendar get-calendar --id work@group.calendar.google.com`, + RunE: runCalendarGetCalendar, +} + +var calendarCreateCalendarCmd = &cobra.Command{ + Use: "create-calendar", + Short: "Create a secondary calendar", + Long: `Creates a new secondary calendar. + +Examples: + gws calendar create-calendar --summary "Work Projects" + gws calendar create-calendar --summary "Gym" --description "Workout schedule" --timezone "America/New_York"`, + RunE: runCalendarCreateCalendar, +} + +var calendarUpdateCalendarCmd = &cobra.Command{ + Use: "update-calendar", + Short: "Update a calendar", + Long: `Updates an existing calendar's metadata. + +Examples: + gws calendar update-calendar --id cal123 --summary "New Name" + gws calendar update-calendar --id cal123 --description "Updated description" --timezone "Europe/London"`, + RunE: runCalendarUpdateCalendar, +} + +var calendarDeleteCalendarCmd = &cobra.Command{ + Use: "delete-calendar", + Short: "Delete a secondary calendar", + Long: `Deletes a secondary calendar. Cannot delete the primary calendar. + +Examples: + gws calendar delete-calendar --id cal123@group.calendar.google.com`, + RunE: runCalendarDeleteCalendar, +} + +var calendarClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear all events from a calendar", + Long: `Clears all events from a calendar. Use with caution. + +Examples: + gws calendar clear + gws calendar clear --calendar-id work@group.calendar.google.com`, + RunE: runCalendarClear, +} + +// --- Subscription commands --- + +var calendarSubscribeCmd = &cobra.Command{ + Use: "subscribe", + Short: "Subscribe to a public calendar", + Long: `Adds a public calendar to your calendar list. + +Examples: + gws calendar subscribe --id en.usa#holiday@group.v.calendar.google.com`, + RunE: runCalendarSubscribe, +} + +var calendarUnsubscribeCmd = &cobra.Command{ + Use: "unsubscribe", + Short: "Unsubscribe from a calendar", + Long: `Removes a calendar from your calendar list (unsubscribe). + +Examples: + gws calendar unsubscribe --id en.usa#holiday@group.v.calendar.google.com`, + RunE: runCalendarUnsubscribe, +} + +var calendarCalendarInfoCmd = &cobra.Command{ + Use: "calendar-info", + Short: "Get calendar list entry (subscription info)", + Long: `Gets the calendar list entry for a calendar (subscription settings, color, visibility). + +Examples: + gws calendar calendar-info --id primary + gws calendar calendar-info --id work@group.calendar.google.com`, + RunE: runCalendarCalendarInfo, +} + +var calendarUpdateSubscriptionCmd = &cobra.Command{ + Use: "update-subscription", + Short: "Update subscription settings", + Long: `Updates subscription settings for a calendar in your list (color, hidden, summary override). + +Examples: + gws calendar update-subscription --id cal123 --color-id 7 + gws calendar update-subscription --id cal123 --hidden + gws calendar update-subscription --id cal123 --summary-override "My Custom Name"`, + RunE: runCalendarUpdateSubscription, +} + +// --- ACL commands --- + +var calendarAclCmd = &cobra.Command{ + Use: "acl", + Short: "List access control rules", + Long: `Lists access control rules for a calendar. + +Examples: + gws calendar acl + gws calendar acl --calendar-id work@group.calendar.google.com`, + RunE: runCalendarAcl, +} + +var calendarShareCmd = &cobra.Command{ + Use: "share", + Short: "Share calendar with a user", + Long: `Shares a calendar with a user by creating an ACL rule. + +Valid roles: reader, writer, owner, freeBusyReader + +Examples: + gws calendar share --email user@example.com --role reader + gws calendar share --email user@example.com --role writer --calendar-id work@group.calendar.google.com`, + RunE: runCalendarShare, +} + +var calendarUnshareCmd = &cobra.Command{ + Use: "unshare", + Short: "Remove calendar access", + Long: `Removes an access control rule from a calendar. + +Examples: + gws calendar unshare --rule-id "user:user@example.com"`, + RunE: runCalendarUnshare, +} + +var calendarUpdateAclCmd = &cobra.Command{ + Use: "update-acl", + Short: "Update access control rule", + Long: `Updates an existing access control rule for a calendar. + +Valid roles: reader, writer, owner, freeBusyReader + +Examples: + gws calendar update-acl --rule-id "user:user@example.com" --role writer`, + RunE: runCalendarUpdateAcl, +} + +// --- Other commands --- + +var calendarFreebusyCmd = &cobra.Command{ + Use: "freebusy", + Short: "Query free/busy information", + Long: `Queries free/busy information for one or more calendars. + +Examples: + gws calendar freebusy --from "2024-03-01 09:00" --to "2024-03-01 17:00" + gws calendar freebusy --from "2024-03-01 09:00" --to "2024-03-01 17:00" --calendars "primary,user@example.com"`, + RunE: runCalendarFreebusy, +} + +var calendarColorsCmd = &cobra.Command{ + Use: "colors", + Short: "List available calendar colors", + Long: "Lists all available calendar and event colors.", + RunE: runCalendarColors, +} + +var calendarSettingsCmd = &cobra.Command{ + Use: "settings", + Short: "List user calendar settings", + Long: "Lists all user calendar settings.", + RunE: runCalendarSettings, +} + var validRsvpResponses = map[string]bool{ "accepted": true, "declined": true, "tentative": true, } +var validAclRoles = map[string]bool{ + "reader": true, + "writer": true, + "owner": true, + "freeBusyReader": true, +} + func init() { rootCmd.AddCommand(calendarCmd) calendarCmd.AddCommand(calendarListCmd) @@ -94,6 +323,26 @@ func init() { calendarCmd.AddCommand(calendarUpdateCmd) calendarCmd.AddCommand(calendarDeleteCmd) calendarCmd.AddCommand(calendarRsvpCmd) + calendarCmd.AddCommand(calendarGetCmd) + calendarCmd.AddCommand(calendarQuickAddCmd) + calendarCmd.AddCommand(calendarInstancesCmd) + calendarCmd.AddCommand(calendarMoveCmd) + calendarCmd.AddCommand(calendarGetCalendarCmd) + calendarCmd.AddCommand(calendarCreateCalendarCmd) + calendarCmd.AddCommand(calendarUpdateCalendarCmd) + calendarCmd.AddCommand(calendarDeleteCalendarCmd) + calendarCmd.AddCommand(calendarClearCmd) + calendarCmd.AddCommand(calendarSubscribeCmd) + calendarCmd.AddCommand(calendarUnsubscribeCmd) + calendarCmd.AddCommand(calendarCalendarInfoCmd) + calendarCmd.AddCommand(calendarUpdateSubscriptionCmd) + calendarCmd.AddCommand(calendarAclCmd) + calendarCmd.AddCommand(calendarShareCmd) + calendarCmd.AddCommand(calendarUnshareCmd) + calendarCmd.AddCommand(calendarUpdateAclCmd) + calendarCmd.AddCommand(calendarFreebusyCmd) + calendarCmd.AddCommand(calendarColorsCmd) + calendarCmd.AddCommand(calendarSettingsCmd) // Events flags calendarEventsCmd.Flags().Int("days", 7, "Number of days to look ahead") @@ -130,6 +379,103 @@ func init() { calendarRsvpCmd.Flags().String("calendar-id", "primary", "Calendar ID") calendarRsvpCmd.Flags().String("message", "", "Optional message to include with your RSVP (notifies all attendees)") calendarRsvpCmd.MarkFlagRequired("response") + + // Get event flags + calendarGetCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarGetCmd.Flags().String("id", "", "Event ID (required)") + calendarGetCmd.MarkFlagRequired("id") + + // Quick-add flags + calendarQuickAddCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarQuickAddCmd.Flags().String("text", "", "Text describing the event (required)") + calendarQuickAddCmd.MarkFlagRequired("text") + + // Instances flags + calendarInstancesCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarInstancesCmd.Flags().String("id", "", "Recurring event ID (required)") + calendarInstancesCmd.Flags().Int64("max", 50, "Maximum number of instances") + calendarInstancesCmd.Flags().String("from", "", "Start of time range (RFC3339 or YYYY-MM-DD)") + calendarInstancesCmd.Flags().String("to", "", "End of time range (RFC3339 or YYYY-MM-DD)") + calendarInstancesCmd.MarkFlagRequired("id") + + // Move flags + calendarMoveCmd.Flags().String("calendar-id", "primary", "Source calendar ID") + calendarMoveCmd.Flags().String("id", "", "Event ID (required)") + calendarMoveCmd.Flags().String("destination", "", "Destination calendar ID (required)") + calendarMoveCmd.MarkFlagRequired("id") + calendarMoveCmd.MarkFlagRequired("destination") + + // Get-calendar flags + calendarGetCalendarCmd.Flags().String("id", "", "Calendar ID (required)") + calendarGetCalendarCmd.MarkFlagRequired("id") + + // Create-calendar flags + calendarCreateCalendarCmd.Flags().String("summary", "", "Calendar name (required)") + calendarCreateCalendarCmd.Flags().String("description", "", "Calendar description") + calendarCreateCalendarCmd.Flags().String("timezone", "", "Calendar timezone (e.g. America/New_York)") + calendarCreateCalendarCmd.MarkFlagRequired("summary") + + // Update-calendar flags + calendarUpdateCalendarCmd.Flags().String("id", "", "Calendar ID (required)") + calendarUpdateCalendarCmd.Flags().String("summary", "", "New calendar name") + calendarUpdateCalendarCmd.Flags().String("description", "", "New calendar description") + calendarUpdateCalendarCmd.Flags().String("timezone", "", "New calendar timezone") + calendarUpdateCalendarCmd.MarkFlagRequired("id") + + // Delete-calendar flags + calendarDeleteCalendarCmd.Flags().String("id", "", "Calendar ID (required)") + calendarDeleteCalendarCmd.MarkFlagRequired("id") + + // Clear flags + calendarClearCmd.Flags().String("calendar-id", "primary", "Calendar ID") + + // Subscribe flags + calendarSubscribeCmd.Flags().String("id", "", "Calendar ID to subscribe to (required)") + calendarSubscribeCmd.MarkFlagRequired("id") + + // Unsubscribe flags + calendarUnsubscribeCmd.Flags().String("id", "", "Calendar ID to unsubscribe from (required)") + calendarUnsubscribeCmd.MarkFlagRequired("id") + + // Calendar-info flags + calendarCalendarInfoCmd.Flags().String("id", "", "Calendar ID (required)") + calendarCalendarInfoCmd.MarkFlagRequired("id") + + // Update-subscription flags + calendarUpdateSubscriptionCmd.Flags().String("id", "", "Calendar ID (required)") + calendarUpdateSubscriptionCmd.Flags().String("color-id", "", "Color ID (use 'gws calendar colors' to list valid IDs)") + calendarUpdateSubscriptionCmd.Flags().Bool("hidden", false, "Hide calendar from the list") + calendarUpdateSubscriptionCmd.Flags().String("summary-override", "", "Custom display name") + calendarUpdateSubscriptionCmd.MarkFlagRequired("id") + + // ACL flags + calendarAclCmd.Flags().String("calendar-id", "primary", "Calendar ID") + + // Share flags + calendarShareCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarShareCmd.Flags().String("email", "", "Email address to share with (required)") + calendarShareCmd.Flags().String("role", "", "Access role: reader, writer, owner, freeBusyReader (required)") + calendarShareCmd.MarkFlagRequired("email") + calendarShareCmd.MarkFlagRequired("role") + + // Unshare flags + calendarUnshareCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarUnshareCmd.Flags().String("rule-id", "", "ACL rule ID (required)") + calendarUnshareCmd.MarkFlagRequired("rule-id") + + // Update-acl flags + calendarUpdateAclCmd.Flags().String("calendar-id", "primary", "Calendar ID") + calendarUpdateAclCmd.Flags().String("rule-id", "", "ACL rule ID (required)") + calendarUpdateAclCmd.Flags().String("role", "", "New access role: reader, writer, owner, freeBusyReader (required)") + calendarUpdateAclCmd.MarkFlagRequired("rule-id") + calendarUpdateAclCmd.MarkFlagRequired("role") + + // Freebusy flags + calendarFreebusyCmd.Flags().String("from", "", "Start of time range (required)") + calendarFreebusyCmd.Flags().String("to", "", "End of time range (required)") + calendarFreebusyCmd.Flags().String("calendars", "primary", "Comma-separated calendar IDs") + calendarFreebusyCmd.MarkFlagRequired("from") + calendarFreebusyCmd.MarkFlagRequired("to") } func runCalendarList(cmd *cobra.Command, args []string) error { @@ -512,6 +858,831 @@ func runCalendarRsvp(cmd *cobra.Command, args []string) error { return p.Print(result) } +// --- New event command implementations --- + +func runCalendarGet(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + eventID, _ := cmd.Flags().GetString("id") + + event, err := svc.Events.Get(calendarID, eventID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get event: %w", err)) + } + + return p.Print(mapEventToOutput(event)) +} + +func runCalendarQuickAdd(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + text, _ := cmd.Flags().GetString("text") + + event, err := svc.Events.QuickAdd(calendarID, text).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to quick-add event: %w", err)) + } + + return p.Print(mapEventToOutput(event)) +} + +func runCalendarInstances(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + eventID, _ := cmd.Flags().GetString("id") + maxResults, _ := cmd.Flags().GetInt64("max") + + call := svc.Events.Instances(calendarID, eventID).MaxResults(maxResults) + + if cmd.Flags().Changed("from") { + fromStr, _ := cmd.Flags().GetString("from") + fromTime, err := parseTime(fromStr) + if err != nil { + return p.PrintError(fmt.Errorf("invalid --from time: %w", err)) + } + call = call.TimeMin(fromTime.Format(time.RFC3339)) + } + if cmd.Flags().Changed("to") { + toStr, _ := cmd.Flags().GetString("to") + toTime, err := parseTime(toStr) + if err != nil { + return p.PrintError(fmt.Errorf("invalid --to time: %w", err)) + } + call = call.TimeMax(toTime.Format(time.RFC3339)) + } + + resp, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list instances: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.Items)) + for _, event := range resp.Items { + if event == nil { + continue + } + results = append(results, mapEventToOutput(event)) + } + + return p.Print(map[string]interface{}{ + "instances": results, + "count": len(results), + }) +} + +func runCalendarMove(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + eventID, _ := cmd.Flags().GetString("id") + destination, _ := cmd.Flags().GetString("destination") + + event, err := svc.Events.Move(calendarID, eventID, destination).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to move event: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "moved", + "id": event.Id, + "summary": event.Summary, + "destination": destination, + "html_link": event.HtmlLink, + }) +} + +// --- Calendar CRUD implementations --- + +func runCalendarGetCalendar(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + cal, err := svc.Calendars.Get(calID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get calendar: %w", err)) + } + + result := map[string]interface{}{ + "id": cal.Id, + "summary": cal.Summary, + } + if cal.Description != "" { + result["description"] = cal.Description + } + if cal.TimeZone != "" { + result["timezone"] = cal.TimeZone + } + if cal.Location != "" { + result["location"] = cal.Location + } + if cal.Etag != "" { + result["etag"] = cal.Etag + } + + return p.Print(result) +} + +func runCalendarCreateCalendar(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + summary, _ := cmd.Flags().GetString("summary") + description, _ := cmd.Flags().GetString("description") + timezone, _ := cmd.Flags().GetString("timezone") + + cal := &calendar.Calendar{ + Summary: summary, + } + if description != "" { + cal.Description = description + } + if timezone != "" { + cal.TimeZone = timezone + } + + created, err := svc.Calendars.Insert(cal).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to create calendar: %w", err)) + } + + result := map[string]interface{}{ + "status": "created", + "id": created.Id, + "summary": created.Summary, + } + if created.Description != "" { + result["description"] = created.Description + } + if created.TimeZone != "" { + result["timezone"] = created.TimeZone + } + + return p.Print(result) +} + +func runCalendarUpdateCalendar(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + // Fetch existing calendar to patch + cal, err := svc.Calendars.Get(calID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get calendar: %w", err)) + } + + if cmd.Flags().Changed("summary") { + summary, _ := cmd.Flags().GetString("summary") + cal.Summary = summary + } + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + cal.Description = description + } + if cmd.Flags().Changed("timezone") { + timezone, _ := cmd.Flags().GetString("timezone") + cal.TimeZone = timezone + } + + updated, err := svc.Calendars.Update(calID, cal).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update calendar: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "summary": updated.Summary, + } + if updated.Description != "" { + result["description"] = updated.Description + } + if updated.TimeZone != "" { + result["timezone"] = updated.TimeZone + } + + return p.Print(result) +} + +func runCalendarDeleteCalendar(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + err = svc.Calendars.Delete(calID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete calendar: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "id": calID, + }) +} + +func runCalendarClear(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + + err = svc.Calendars.Clear(calendarID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to clear calendar: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "cleared", + "calendar_id": calendarID, + }) +} + +// --- Subscription implementations --- + +func runCalendarSubscribe(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + entry, err := svc.CalendarList.Insert(&calendar.CalendarListEntry{ + Id: calID, + }).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to subscribe: %w", err)) + } + + result := map[string]interface{}{ + "status": "subscribed", + "id": entry.Id, + "summary": entry.Summary, + } + if entry.Description != "" { + result["description"] = entry.Description + } + + return p.Print(result) +} + +func runCalendarUnsubscribe(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + err = svc.CalendarList.Delete(calID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to unsubscribe: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "unsubscribed", + "id": calID, + }) +} + +func runCalendarCalendarInfo(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + entry, err := svc.CalendarList.Get(calID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get calendar info: %w", err)) + } + + result := map[string]interface{}{ + "id": entry.Id, + "summary": entry.Summary, + "primary": entry.Primary, + } + if entry.Description != "" { + result["description"] = entry.Description + } + if entry.TimeZone != "" { + result["timezone"] = entry.TimeZone + } + if entry.ColorId != "" { + result["color_id"] = entry.ColorId + } + if entry.BackgroundColor != "" { + result["background_color"] = entry.BackgroundColor + } + if entry.ForegroundColor != "" { + result["foreground_color"] = entry.ForegroundColor + } + if entry.SummaryOverride != "" { + result["summary_override"] = entry.SummaryOverride + } + if entry.Hidden { + result["hidden"] = true + } + if entry.Selected { + result["selected"] = true + } + if entry.AccessRole != "" { + result["access_role"] = entry.AccessRole + } + + return p.Print(result) +} + +func runCalendarUpdateSubscription(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calID, _ := cmd.Flags().GetString("id") + + patch := &calendar.CalendarListEntry{} + if cmd.Flags().Changed("color-id") { + colorID, _ := cmd.Flags().GetString("color-id") + patch.ColorId = colorID + } + if cmd.Flags().Changed("hidden") { + hidden, _ := cmd.Flags().GetBool("hidden") + patch.Hidden = hidden + if !hidden { + patch.ForceSendFields = append(patch.ForceSendFields, "Hidden") + } + } + if cmd.Flags().Changed("summary-override") { + override, _ := cmd.Flags().GetString("summary-override") + patch.SummaryOverride = override + } + + updated, err := svc.CalendarList.Patch(calID, patch).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update subscription: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "summary": updated.Summary, + } + if updated.SummaryOverride != "" { + result["summary_override"] = updated.SummaryOverride + } + if updated.ColorId != "" { + result["color_id"] = updated.ColorId + } + if updated.Hidden { + result["hidden"] = true + } + + return p.Print(result) +} + +// --- ACL implementations --- + +func runCalendarAcl(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + + resp, err := svc.Acl.List(calendarID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list ACL rules: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.Items)) + for _, rule := range resp.Items { + entry := map[string]interface{}{ + "id": rule.Id, + "role": rule.Role, + } + if rule.Scope != nil { + entry["scope_type"] = rule.Scope.Type + if rule.Scope.Value != "" { + entry["scope_value"] = rule.Scope.Value + } + } + results = append(results, entry) + } + + return p.Print(map[string]interface{}{ + "rules": results, + "count": len(results), + }) +} + +func runCalendarShare(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + calendarID, _ := cmd.Flags().GetString("calendar-id") + email, _ := cmd.Flags().GetString("email") + role, _ := cmd.Flags().GetString("role") + + if !validAclRoles[role] { + return p.PrintError(fmt.Errorf("invalid role '%s': must be reader, writer, owner, or freeBusyReader", role)) + } + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + rule := &calendar.AclRule{ + Role: role, + Scope: &calendar.AclRuleScope{ + Type: "user", + Value: email, + }, + } + + created, err := svc.Acl.Insert(calendarID, rule).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to share calendar: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "shared", + "id": created.Id, + "role": created.Role, + "email": email, + }) +} + +func runCalendarUnshare(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + calendarID, _ := cmd.Flags().GetString("calendar-id") + ruleID, _ := cmd.Flags().GetString("rule-id") + + err = svc.Acl.Delete(calendarID, ruleID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to remove access: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "unshared", + "rule_id": ruleID, + }) +} + +func runCalendarUpdateAcl(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + calendarID, _ := cmd.Flags().GetString("calendar-id") + ruleID, _ := cmd.Flags().GetString("rule-id") + role, _ := cmd.Flags().GetString("role") + + if !validAclRoles[role] { + return p.PrintError(fmt.Errorf("invalid role '%s': must be reader, writer, owner, or freeBusyReader", role)) + } + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + // Get existing rule to preserve scope + existing, err := svc.Acl.Get(calendarID, ruleID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get ACL rule: %w", err)) + } + + existing.Role = role + + updated, err := svc.Acl.Update(calendarID, ruleID, existing).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update ACL rule: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "role": updated.Role, + } + if updated.Scope != nil && updated.Scope.Value != "" { + result["scope_value"] = updated.Scope.Value + } + + return p.Print(result) +} + +// --- Other implementations --- + +func runCalendarFreebusy(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + fromStr, _ := cmd.Flags().GetString("from") + toStr, _ := cmd.Flags().GetString("to") + calendarsStr, _ := cmd.Flags().GetString("calendars") + + fromTime, err := parseTime(fromStr) + if err != nil { + return p.PrintError(fmt.Errorf("invalid --from time: %w", err)) + } + toTime, err := parseTime(toStr) + if err != nil { + return p.PrintError(fmt.Errorf("invalid --to time: %w", err)) + } + + calIDs := strings.Split(calendarsStr, ",") + items := make([]*calendar.FreeBusyRequestItem, len(calIDs)) + for i, id := range calIDs { + items[i] = &calendar.FreeBusyRequestItem{Id: strings.TrimSpace(id)} + } + + req := &calendar.FreeBusyRequest{ + TimeMin: fromTime.Format(time.RFC3339), + TimeMax: toTime.Format(time.RFC3339), + Items: items, + } + + resp, err := svc.Freebusy.Query(req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to query free/busy: %w", err)) + } + + calendars := map[string]interface{}{} + for calID, fb := range resp.Calendars { + busy := make([]map[string]interface{}, 0, len(fb.Busy)) + for _, period := range fb.Busy { + busy = append(busy, map[string]interface{}{ + "start": period.Start, + "end": period.End, + }) + } + entry := map[string]interface{}{ + "busy": busy, + } + if len(fb.Errors) > 0 { + errors := make([]string, 0, len(fb.Errors)) + for _, e := range fb.Errors { + errors = append(errors, e.Reason) + } + entry["errors"] = errors + } + calendars[calID] = entry + } + + return p.Print(map[string]interface{}{ + "time_min": resp.TimeMin, + "time_max": resp.TimeMax, + "calendars": calendars, + }) +} + +func runCalendarColors(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + colors, err := svc.Colors.Get().Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get colors: %w", err)) + } + + calendarColors := map[string]interface{}{} + for id, c := range colors.Calendar { + calendarColors[id] = map[string]interface{}{ + "background": c.Background, + "foreground": c.Foreground, + } + } + + eventColors := map[string]interface{}{} + for id, c := range colors.Event { + eventColors[id] = map[string]interface{}{ + "background": c.Background, + "foreground": c.Foreground, + } + } + + return p.Print(map[string]interface{}{ + "calendar_colors": calendarColors, + "event_colors": eventColors, + }) +} + +func runCalendarSettings(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Calendar() + if err != nil { + return p.PrintError(err) + } + + resp, err := svc.Settings.List().Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list settings: %w", err)) + } + + settings := map[string]interface{}{} + for _, s := range resp.Items { + settings[s.Id] = s.Value + } + + return p.Print(map[string]interface{}{ + "settings": settings, + "count": len(settings), + }) +} + // mapEventToOutput converts a Google Calendar event into a map for JSON output. // Fields are omitted when empty/nil to keep output clean. func mapEventToOutput(event *calendar.Event) map[string]interface{} { diff --git a/cmd/calendar_test.go b/cmd/calendar_test.go index 4c994ed..23802dc 100644 --- a/cmd/calendar_test.go +++ b/cmd/calendar_test.go @@ -1107,3 +1107,758 @@ func TestMapEventToOutput_EmptyMethodOverride(t *testing.T) { t.Errorf("expected method 'popup', got %v", overrides[0]["method"]) } } + +// --- Tests for new calendar commands --- + +func TestCalendarGetCommand_Flags(t *testing.T) { + cmd := calendarGetCmd + flags := []string{"id", "calendar-id"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarQuickAddCommand_Flags(t *testing.T) { + cmd := calendarQuickAddCmd + flags := []string{"text", "calendar-id"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarInstancesCommand_Flags(t *testing.T) { + cmd := calendarInstancesCmd + flags := []string{"id", "calendar-id", "max", "from", "to"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarMoveCommand_Flags(t *testing.T) { + cmd := calendarMoveCmd + flags := []string{"id", "calendar-id", "destination"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarGetCalendarCommand_Flags(t *testing.T) { + if calendarGetCalendarCmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +func TestCalendarCreateCalendarCommand_Flags(t *testing.T) { + cmd := calendarCreateCalendarCmd + flags := []string{"summary", "description", "timezone"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarUpdateCalendarCommand_Flags(t *testing.T) { + cmd := calendarUpdateCalendarCmd + flags := []string{"id", "summary", "description", "timezone"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarDeleteCalendarCommand_Flags(t *testing.T) { + if calendarDeleteCalendarCmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +func TestCalendarClearCommand_Flags(t *testing.T) { + if calendarClearCmd.Flags().Lookup("calendar-id") == nil { + t.Error("expected --calendar-id flag to exist") + } +} + +func TestCalendarSubscribeCommand_Flags(t *testing.T) { + if calendarSubscribeCmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +func TestCalendarUnsubscribeCommand_Flags(t *testing.T) { + if calendarUnsubscribeCmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +func TestCalendarCalendarInfoCommand_Flags(t *testing.T) { + if calendarCalendarInfoCmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +func TestCalendarUpdateSubscriptionCommand_Flags(t *testing.T) { + cmd := calendarUpdateSubscriptionCmd + flags := []string{"id", "color-id", "hidden", "summary-override"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarAclCommand_Flags(t *testing.T) { + if calendarAclCmd.Flags().Lookup("calendar-id") == nil { + t.Error("expected --calendar-id flag to exist") + } +} + +func TestCalendarShareCommand_Flags(t *testing.T) { + cmd := calendarShareCmd + flags := []string{"calendar-id", "email", "role"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarUnshareCommand_Flags(t *testing.T) { + cmd := calendarUnshareCmd + flags := []string{"calendar-id", "rule-id"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarUpdateAclCommand_Flags(t *testing.T) { + cmd := calendarUpdateAclCmd + flags := []string{"calendar-id", "rule-id", "role"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarFreebusyCommand_Flags(t *testing.T) { + cmd := calendarFreebusyCmd + flags := []string{"from", "to", "calendars"} + for _, name := range flags { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to exist", name) + } + } +} + +func TestCalendarAclRoleValidation(t *testing.T) { + tests := []struct { + role string + valid bool + }{ + {"reader", true}, + {"writer", true}, + {"owner", true}, + {"freeBusyReader", true}, + {"admin", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.role, func(t *testing.T) { + result := validAclRoles[tt.role] + if result != tt.valid { + t.Errorf("validAclRoles[%q] = %v, want %v", tt.role, result, tt.valid) + } + }) + } +} + +// TestCalendarGet_MockServer tests get event API +func TestCalendarGet_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/calendars/primary/events/evt-get" && r.Method == "GET" { + resp := &calendar.Event{ + Id: "evt-get", + Summary: "Test Event", + Status: "confirmed", + Start: &calendar.EventDateTime{DateTime: "2026-03-01T09:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2026-03-01T10:00:00Z"}, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + event, err := svc.Events.Get("primary", "evt-get").Do() + if err != nil { + t.Fatalf("failed to get event: %v", err) + } + + if event.Id != "evt-get" { + t.Errorf("expected id 'evt-get', got %s", event.Id) + } + if event.Summary != "Test Event" { + t.Errorf("expected summary 'Test Event', got %s", event.Summary) + } +} + +// TestCalendarQuickAdd_MockServer tests quick-add event API +func TestCalendarQuickAdd_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/calendars/primary/events/quickAdd" && r.Method == "POST" { + text := r.URL.Query().Get("text") + resp := &calendar.Event{ + Id: "evt-quick", + Summary: text, + Status: "confirmed", + Start: &calendar.EventDateTime{DateTime: "2026-03-01T12:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2026-03-01T13:00:00Z"}, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + event, err := svc.Events.QuickAdd("primary", "Lunch tomorrow at noon").Do() + if err != nil { + t.Fatalf("failed to quick-add event: %v", err) + } + + if event.Id != "evt-quick" { + t.Errorf("expected id 'evt-quick', got %s", event.Id) + } +} + +// TestCalendarInstances_MockServer tests instances API +func TestCalendarInstances_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/calendars/primary/events/recurring-123/instances" && r.Method == "GET" { + resp := &calendar.Events{ + Items: []*calendar.Event{ + {Id: "inst-1", Summary: "Weekly Meeting", Status: "confirmed", + Start: &calendar.EventDateTime{DateTime: "2026-03-01T09:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2026-03-01T10:00:00Z"}}, + {Id: "inst-2", Summary: "Weekly Meeting", Status: "confirmed", + Start: &calendar.EventDateTime{DateTime: "2026-03-08T09:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2026-03-08T10:00:00Z"}}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + resp, err := svc.Events.Instances("primary", "recurring-123").Do() + if err != nil { + t.Fatalf("failed to list instances: %v", err) + } + + if len(resp.Items) != 2 { + t.Errorf("expected 2 instances, got %d", len(resp.Items)) + } +} + +// TestCalendarMove_MockServer tests move event API +func TestCalendarMove_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/calendars/primary/events/evt-move/move" && r.Method == "POST" { + dest := r.URL.Query().Get("destination") + resp := &calendar.Event{ + Id: "evt-move", + Summary: "Moved Event", + HtmlLink: "https://calendar.google.com/event?id=evt-move&cal=" + dest, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + event, err := svc.Events.Move("primary", "evt-move", "work@group.calendar.google.com").Do() + if err != nil { + t.Fatalf("failed to move event: %v", err) + } + + if event.Id != "evt-move" { + t.Errorf("expected id 'evt-move', got %s", event.Id) + } +} + +// TestCalendarCRUD_MockServer tests calendar create/get/update/delete +func TestCalendarCRUD_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Create calendar + if r.URL.Path == "/calendars" && r.Method == "POST" { + var cal calendar.Calendar + json.NewDecoder(r.Body).Decode(&cal) + resp := &calendar.Calendar{ + Id: "new-cal-123", + Summary: cal.Summary, + TimeZone: cal.TimeZone, + } + json.NewEncoder(w).Encode(resp) + return + } + + // Get calendar + if r.URL.Path == "/calendars/new-cal-123" && r.Method == "GET" { + resp := &calendar.Calendar{ + Id: "new-cal-123", + Summary: "Work Projects", + TimeZone: "America/New_York", + } + json.NewEncoder(w).Encode(resp) + return + } + + // Update calendar + if r.URL.Path == "/calendars/new-cal-123" && r.Method == "PUT" { + var cal calendar.Calendar + json.NewDecoder(r.Body).Decode(&cal) + resp := &calendar.Calendar{ + Id: "new-cal-123", + Summary: cal.Summary, + TimeZone: cal.TimeZone, + } + json.NewEncoder(w).Encode(resp) + return + } + + // Delete calendar + if r.URL.Path == "/calendars/new-cal-123" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + // Clear calendar + if r.URL.Path == "/calendars/primary/clear" && r.Method == "POST" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + // Create + created, err := svc.Calendars.Insert(&calendar.Calendar{ + Summary: "Work Projects", + TimeZone: "America/New_York", + }).Do() + if err != nil { + t.Fatalf("failed to create calendar: %v", err) + } + if created.Id != "new-cal-123" { + t.Errorf("expected id 'new-cal-123', got %s", created.Id) + } + + // Get + cal, err := svc.Calendars.Get("new-cal-123").Do() + if err != nil { + t.Fatalf("failed to get calendar: %v", err) + } + if cal.Summary != "Work Projects" { + t.Errorf("expected summary 'Work Projects', got %s", cal.Summary) + } + + // Update + cal.Summary = "Updated Projects" + updated, err := svc.Calendars.Update("new-cal-123", cal).Do() + if err != nil { + t.Fatalf("failed to update calendar: %v", err) + } + if updated.Summary != "Updated Projects" { + t.Errorf("expected summary 'Updated Projects', got %s", updated.Summary) + } + + // Delete + err = svc.Calendars.Delete("new-cal-123").Do() + if err != nil { + t.Fatalf("failed to delete calendar: %v", err) + } + + // Clear + err = svc.Calendars.Clear("primary").Do() + if err != nil { + t.Fatalf("failed to clear calendar: %v", err) + } +} + +// TestCalendarACL_MockServer tests ACL list/insert/delete/update +func TestCalendarACL_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // List ACL + if r.URL.Path == "/calendars/primary/acl" && r.Method == "GET" { + resp := &calendar.Acl{ + Items: []*calendar.AclRule{ + {Id: "user:owner@example.com", Role: "owner", Scope: &calendar.AclRuleScope{Type: "user", Value: "owner@example.com"}}, + {Id: "user:reader@example.com", Role: "reader", Scope: &calendar.AclRuleScope{Type: "user", Value: "reader@example.com"}}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + // Insert ACL + if r.URL.Path == "/calendars/primary/acl" && r.Method == "POST" { + var rule calendar.AclRule + json.NewDecoder(r.Body).Decode(&rule) + rule.Id = "user:" + rule.Scope.Value + json.NewEncoder(w).Encode(&rule) + return + } + + // Get ACL rule + if r.URL.Path == "/calendars/primary/acl/user:reader@example.com" && r.Method == "GET" { + resp := &calendar.AclRule{ + Id: "user:reader@example.com", + Role: "reader", + Scope: &calendar.AclRuleScope{Type: "user", Value: "reader@example.com"}, + } + json.NewEncoder(w).Encode(resp) + return + } + + // Update ACL rule + if r.URL.Path == "/calendars/primary/acl/user:reader@example.com" && r.Method == "PUT" { + var rule calendar.AclRule + json.NewDecoder(r.Body).Decode(&rule) + json.NewEncoder(w).Encode(&rule) + return + } + + // Delete ACL rule + if r.URL.Path == "/calendars/primary/acl/user:reader@example.com" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + // List + acl, err := svc.Acl.List("primary").Do() + if err != nil { + t.Fatalf("failed to list ACL: %v", err) + } + if len(acl.Items) != 2 { + t.Errorf("expected 2 ACL rules, got %d", len(acl.Items)) + } + + // Insert (share) + created, err := svc.Acl.Insert("primary", &calendar.AclRule{ + Role: "writer", + Scope: &calendar.AclRuleScope{Type: "user", Value: "new@example.com"}, + }).Do() + if err != nil { + t.Fatalf("failed to insert ACL rule: %v", err) + } + if created.Role != "writer" { + t.Errorf("expected role 'writer', got %s", created.Role) + } + + // Update + existing, err := svc.Acl.Get("primary", "user:reader@example.com").Do() + if err != nil { + t.Fatalf("failed to get ACL rule: %v", err) + } + existing.Role = "writer" + updated, err := svc.Acl.Update("primary", "user:reader@example.com", existing).Do() + if err != nil { + t.Fatalf("failed to update ACL rule: %v", err) + } + if updated.Role != "writer" { + t.Errorf("expected role 'writer', got %s", updated.Role) + } + + // Delete (unshare) + err = svc.Acl.Delete("primary", "user:reader@example.com").Do() + if err != nil { + t.Fatalf("failed to delete ACL rule: %v", err) + } +} + +// TestCalendarSubscription_MockServer tests subscribe/unsubscribe/info/update +func TestCalendarSubscription_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Subscribe (CalendarList.Insert) + if r.URL.Path == "/users/me/calendarList" && r.Method == "POST" { + var entry calendar.CalendarListEntry + json.NewDecoder(r.Body).Decode(&entry) + entry.Summary = "US Holidays" + json.NewEncoder(w).Encode(&entry) + return + } + + // Get calendar info (CalendarList.Get) + if r.URL.Path == "/users/me/calendarList/holidays" && r.Method == "GET" { + resp := &calendar.CalendarListEntry{ + Id: "holidays", + Summary: "US Holidays", + AccessRole: "reader", + BackgroundColor: "#0000ff", + ForegroundColor: "#ffffff", + ColorId: "7", + Selected: true, + } + json.NewEncoder(w).Encode(resp) + return + } + + // Patch subscription (CalendarList.Patch) + if r.URL.Path == "/users/me/calendarList/holidays" && r.Method == "PATCH" { + var entry calendar.CalendarListEntry + json.NewDecoder(r.Body).Decode(&entry) + entry.Id = "holidays" + entry.Summary = "US Holidays" + json.NewEncoder(w).Encode(&entry) + return + } + + // Unsubscribe (CalendarList.Delete) + if r.URL.Path == "/users/me/calendarList/holidays" && r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + // Subscribe + subscribed, err := svc.CalendarList.Insert(&calendar.CalendarListEntry{Id: "holidays"}).Do() + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + if subscribed.Summary != "US Holidays" { + t.Errorf("expected summary 'US Holidays', got %s", subscribed.Summary) + } + + // Get info + info, err := svc.CalendarList.Get("holidays").Do() + if err != nil { + t.Fatalf("failed to get calendar info: %v", err) + } + if info.AccessRole != "reader" { + t.Errorf("expected access_role 'reader', got %s", info.AccessRole) + } + + // Patch subscription + patched, err := svc.CalendarList.Patch("holidays", &calendar.CalendarListEntry{ + SummaryOverride: "My Holidays", + }).Do() + if err != nil { + t.Fatalf("failed to patch subscription: %v", err) + } + if patched.Id != "holidays" { + t.Errorf("expected id 'holidays', got %s", patched.Id) + } + + // Unsubscribe + err = svc.CalendarList.Delete("holidays").Do() + if err != nil { + t.Fatalf("failed to unsubscribe: %v", err) + } +} + +// TestCalendarFreebusy_MockServer tests free/busy query +func TestCalendarFreebusy_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/freeBusy" && r.Method == "POST" { + resp := &calendar.FreeBusyResponse{ + TimeMin: "2026-03-01T09:00:00Z", + TimeMax: "2026-03-01T17:00:00Z", + Calendars: map[string]calendar.FreeBusyCalendar{ + "primary": { + Busy: []*calendar.TimePeriod{ + {Start: "2026-03-01T10:00:00Z", End: "2026-03-01T11:00:00Z"}, + {Start: "2026-03-01T14:00:00Z", End: "2026-03-01T15:00:00Z"}, + }, + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + resp, err := svc.Freebusy.Query(&calendar.FreeBusyRequest{ + TimeMin: "2026-03-01T09:00:00Z", + TimeMax: "2026-03-01T17:00:00Z", + Items: []*calendar.FreeBusyRequestItem{{Id: "primary"}}, + }).Do() + if err != nil { + t.Fatalf("failed to query free/busy: %v", err) + } + + if fb, ok := resp.Calendars["primary"]; ok { + if len(fb.Busy) != 2 { + t.Errorf("expected 2 busy periods, got %d", len(fb.Busy)) + } + } else { + t.Error("expected primary calendar in response") + } +} + +// TestCalendarColors_MockServer tests colors API +func TestCalendarColors_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/colors" && r.Method == "GET" { + resp := &calendar.Colors{ + Calendar: map[string]calendar.ColorDefinition{ + "1": {Background: "#ac725e", Foreground: "#1d1d1d"}, + "2": {Background: "#d06b64", Foreground: "#1d1d1d"}, + }, + Event: map[string]calendar.ColorDefinition{ + "1": {Background: "#a4bdfc", Foreground: "#1d1d1d"}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + colors, err := svc.Colors.Get().Do() + if err != nil { + t.Fatalf("failed to get colors: %v", err) + } + + if len(colors.Calendar) != 2 { + t.Errorf("expected 2 calendar colors, got %d", len(colors.Calendar)) + } + if len(colors.Event) != 1 { + t.Errorf("expected 1 event color, got %d", len(colors.Event)) + } +} + +// TestCalendarSettings_MockServer tests settings API +func TestCalendarSettings_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/users/me/settings" && r.Method == "GET" { + resp := &calendar.Settings{ + Items: []*calendar.Setting{ + {Id: "timezone", Value: "America/New_York"}, + {Id: "locale", Value: "en"}, + {Id: "weekStart", Value: "0"}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := calendar.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create calendar service: %v", err) + } + + resp, err := svc.Settings.List().Do() + if err != nil { + t.Fatalf("failed to list settings: %v", err) + } + + if len(resp.Items) != 3 { + t.Errorf("expected 3 settings, got %d", len(resp.Items)) + } +} diff --git a/cmd/commands_test.go b/cmd/commands_test.go index ecabbb0..33af825 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -202,14 +202,33 @@ func TestGmailSendCommand_Flags(t *testing.T) { func TestCalendarCommands(t *testing.T) { tests := []struct { name string - use string }{ - {"list", "list"}, - {"events", "events"}, - {"create", "create"}, - {"update", "update "}, - {"delete", "delete "}, - {"rsvp", "rsvp "}, + {"list"}, + {"events"}, + {"create"}, + {"update"}, + {"delete"}, + {"rsvp"}, + {"get"}, + {"quick-add"}, + {"instances"}, + {"move"}, + {"get-calendar"}, + {"create-calendar"}, + {"update-calendar"}, + {"delete-calendar"}, + {"clear"}, + {"subscribe"}, + {"unsubscribe"}, + {"calendar-info"}, + {"update-subscription"}, + {"acl"}, + {"share"}, + {"unshare"}, + {"update-acl"}, + {"freebusy"}, + {"colors"}, + {"settings"}, } for _, tt := range tests { diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index 6d48092..5dceaa3 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -1,7 +1,7 @@ --- name: gws-calendar -version: 1.1.0 -description: "Google Calendar CLI operations via gws. Use when users need to list calendars, view events, create/update/delete events, or RSVP to invitations. Triggers: calendar, events, meetings, schedule, rsvp, invite." +version: 2.0.0 +description: "Google Calendar CLI operations via gws. Use when users need to list calendars, view events, create/update/delete events, RSVP to invitations, manage calendar CRUD, subscriptions, ACL, free/busy queries, colors, and settings. Triggers: calendar, events, meetings, schedule, rsvp, invite, share, freebusy." metadata: short-description: Google Calendar CLI operations compatibility: claude-code, codex-cli @@ -30,20 +30,62 @@ For initial setup, see the `gws-auth` skill. ## Quick Command Reference +### Events + | Task | Command | |------|---------| | List calendars | `gws calendar list` | | View upcoming events | `gws calendar events` | | View next 14 days | `gws calendar events --days 14` | | View pending invites | `gws calendar events --days 30 --pending` | +| Get event by ID | `gws calendar get --id ` | | Create an event | `gws calendar create --title "Meeting" --start "2024-02-01 14:00" --end "2024-02-01 15:00"` | +| Quick add from text | `gws calendar quick-add --text "Lunch with John tomorrow at noon"` | | Update an event | `gws calendar update --title "New Title"` | | Delete an event | `gws calendar delete ` | | RSVP to an event | `gws calendar rsvp --response accepted` | +| List recurring instances | `gws calendar instances --id ` | +| Move event to calendar | `gws calendar move --id --destination ` | + +### Calendar Management + +| Task | Command | +|------|---------| +| Get calendar metadata | `gws calendar get-calendar --id ` | +| Create secondary calendar | `gws calendar create-calendar --summary "Projects"` | +| Update calendar | `gws calendar update-calendar --id --summary "New Name"` | +| Delete secondary calendar | `gws calendar delete-calendar --id ` | +| Clear all events | `gws calendar clear` | + +### Subscriptions + +| Task | Command | +|------|---------| +| Subscribe to calendar | `gws calendar subscribe --id ` | +| Unsubscribe | `gws calendar unsubscribe --id ` | +| Get subscription info | `gws calendar calendar-info --id ` | +| Update subscription | `gws calendar update-subscription --id --color-id 7` | + +### Access Control (ACL) + +| Task | Command | +|------|---------| +| List ACL rules | `gws calendar acl` | +| Share with user | `gws calendar share --email user@example.com --role reader` | +| Remove access | `gws calendar unshare --rule-id "user:user@example.com"` | +| Update access | `gws calendar update-acl --rule-id "user:user@example.com" --role writer` | + +### Other + +| Task | Command | +|------|---------| +| Query free/busy | `gws calendar freebusy --from "2024-03-01 09:00" --to "2024-03-01 17:00"` | +| List colors | `gws calendar colors` | +| List settings | `gws calendar settings` | ## Detailed Usage -### list — List calendars +### list -- List calendars ```bash gws calendar list @@ -51,7 +93,7 @@ gws calendar list Lists all calendars you have access to, including shared calendars and subscriptions. -### events — List events +### events -- List events ```bash gws calendar events [flags] @@ -60,10 +102,10 @@ gws calendar events [flags] Lists upcoming events from a calendar. **Flags:** -- `--calendar-id string` — Calendar ID (default: "primary") -- `--days int` — Number of days to look ahead (default 7) -- `--max int` — Maximum number of events (default 50) -- `--pending` — Only show events with pending RSVP (needsAction). Tip: increase `--max` when using `--pending` over long date ranges, since `--max` limits the API fetch before client-side filtering. +- `--calendar-id string` -- Calendar ID (default: "primary") +- `--days int` -- Number of days to look ahead (default 7) +- `--max int` -- Maximum number of events (default 50) +- `--pending` -- Only show events with pending RSVP (needsAction). Tip: increase `--max` when using `--pending` over long date ranges, since `--max` limits the API fetch before client-side filtering. **Output includes** (fields omitted when empty): @@ -73,105 +115,198 @@ Lists upcoming events from a calendar. | **Time** | `start`, `end`, `all_day` | | **Details** | `description`, `location`, `hangout_link`, `html_link`, `created`, `updated`, `color_id`, `visibility`, `transparency`, `event_type` | | **People** | `organizer` (email), `creator` (email), `response_status` (your RSVP) | -| **Attendees** | `attendees[]` — `{ email, response_status, optional, organizer, self }` | -| **Conference** | `conference` — `{ conference_id, solution, entry_points[]: { type, uri } }` | -| **Attachments** | `attachments[]` — `{ file_url, title, mime_type, file_id }` | -| **Recurrence** | `recurrence[]` — RRULE strings | -| **Reminders** | `reminders` — `{ use_default, overrides[]: { method, minutes } }` | +| **Attendees** | `attendees[]` -- `{ email, response_status, optional, organizer, self }` | +| **Conference** | `conference` -- `{ conference_id, solution, entry_points[]: { type, uri } }` | +| **Attachments** | `attachments[]` -- `{ file_url, title, mime_type, file_id }` | +| **Recurrence** | `recurrence[]` -- RRULE strings | +| **Reminders** | `reminders` -- `{ use_default, overrides[]: { method, minutes } }` | + +### get -- Get event by ID -**Examples:** ```bash -gws calendar events -gws calendar events --days 14 --max 20 -gws calendar events --calendar-id work@group.calendar.google.com -gws calendar events --days 30 --pending # Pending invites only +gws calendar get --id [--calendar-id ] ``` -### create — Create an event +Returns full event details using `mapEventToOutput` format (same fields as `events`). + +### create -- Create an event ```bash gws calendar create --title --start <time> --end <time> [flags] ``` **Flags:** -- `--title string` — Event title (required) -- `--start string` — Start time in RFC3339 or `YYYY-MM-DD HH:MM` format (required) -- `--end string` — End time in RFC3339 or `YYYY-MM-DD HH:MM` format (required) -- `--calendar-id string` — Calendar ID (default: "primary") -- `--description string` — Event description -- `--location string` — Event location -- `--attendees strings` — Attendee email addresses +- `--title string` -- Event title (required) +- `--start string` -- Start time in RFC3339 or `YYYY-MM-DD HH:MM` format (required) +- `--end string` -- End time in RFC3339 or `YYYY-MM-DD HH:MM` format (required) +- `--calendar-id string` -- Calendar ID (default: "primary") +- `--description string` -- Event description +- `--location string` -- Event location +- `--attendees strings` -- Attendee email addresses + +### quick-add -- Quick add from text -**Examples:** ```bash -gws calendar create --title "Team Standup" --start "2024-02-01 09:00" --end "2024-02-01 09:30" -gws calendar create --title "Lunch" --start "2024-02-01 12:00" --end "2024-02-01 13:00" --location "Cafe" -gws calendar create --title "Review" --start "2024-02-01 14:00" --end "2024-02-01 15:00" --attendees user1@example.com --attendees user2@example.com +gws calendar quick-add --text "Lunch with John tomorrow at noon" [--calendar-id <cal-id>] ``` -### update — Update an event +Uses Google's natural language processing to create an event from a text string. + +### update -- Update an event ```bash gws calendar update <event-id> [flags] ``` -Updates an existing calendar event. Only specified fields are changed (uses PATCH, not PUT — avoids sending unnecessary notifications). +Updates an existing calendar event. Uses PATCH (only changed fields are sent). **Flags:** -- `--title string` — New event title -- `--start string` — New start time -- `--end string` — New end time -- `--description string` — New event description -- `--location string` — New event location -- `--add-attendees strings` — Attendee emails to add -- `--calendar-id string` — Calendar ID (default: "primary") +- `--title string` -- New event title +- `--start string` -- New start time +- `--end string` -- New end time +- `--description string` -- New event description +- `--location string` -- New event location +- `--add-attendees strings` -- Attendee emails to add +- `--calendar-id string` -- Calendar ID (default: "primary") -At least one update flag is required. +### delete -- Delete an event -**Examples:** ```bash -gws calendar update abc123 --title "New Title" -gws calendar update abc123 --start "2024-02-01 14:00" --end "2024-02-01 15:00" -gws calendar update abc123 --add-attendees user@example.com -gws calendar update abc123 --location "Room 42" --description "Updated agenda" +gws calendar delete <event-id> [--calendar-id <cal-id>] ``` -### delete — Delete an event +### rsvp -- Respond to an event invitation ```bash -gws calendar delete <event-id> [flags] +gws calendar rsvp <event-id> --response <status> [--message <text>] [--calendar-id <cal-id>] ``` -Deletes a calendar event. +Valid responses: `accepted`, `declined`, `tentative`. If `--message` is provided, notifies all attendees. -**Flags:** -- `--calendar-id string` — Calendar ID (default: "primary") +### instances -- List recurring event instances -**Examples:** ```bash -gws calendar delete abc123 -gws calendar delete abc123 --calendar-id work@group.calendar.google.com +gws calendar instances --id <event-id> [--max 50] [--from <time>] [--to <time>] [--calendar-id <cal-id>] ``` -### rsvp — Respond to an event invitation +Lists all instances of a recurring event within an optional time range. + +### move -- Move event to another calendar ```bash -gws calendar rsvp <event-id> --response <status> [flags] +gws calendar move --id <event-id> --destination <cal-id> [--calendar-id <cal-id>] ``` -Sets your RSVP status for a calendar event. +### get-calendar -- Get calendar metadata -**Flags:** -- `--response string` — Response: `accepted`, `declined`, `tentative` (required) -- `--calendar-id string` — Calendar ID (default: "primary") +```bash +gws calendar get-calendar --id <cal-id> +``` + +Returns: `id`, `summary`, `description`, `timezone`, `location`, `etag`. + +### create-calendar -- Create a secondary calendar + +```bash +gws calendar create-calendar --summary <name> [--description <text>] [--timezone <tz>] +``` + +### update-calendar -- Update a calendar + +```bash +gws calendar update-calendar --id <cal-id> [--summary <name>] [--description <text>] [--timezone <tz>] +``` + +### delete-calendar -- Delete a secondary calendar + +```bash +gws calendar delete-calendar --id <cal-id> +``` + +### clear -- Clear all events from a calendar + +```bash +gws calendar clear [--calendar-id <cal-id>] +``` + +### subscribe -- Subscribe to a public calendar + +```bash +gws calendar subscribe --id <cal-id> +``` + +### unsubscribe -- Unsubscribe from a calendar + +```bash +gws calendar unsubscribe --id <cal-id> +``` + +### calendar-info -- Get subscription info + +```bash +gws calendar calendar-info --id <cal-id> +``` + +Returns: `id`, `summary`, `primary`, `description`, `timezone`, `color_id`, `background_color`, `foreground_color`, `summary_override`, `hidden`, `selected`, `access_role`. + +### update-subscription -- Update subscription settings -**Examples:** ```bash -gws calendar rsvp abc123 --response accepted -gws calendar rsvp abc123 --response declined -gws calendar rsvp abc123 --response tentative +gws calendar update-subscription --id <cal-id> [--color-id <id>] [--hidden] [--summary-override <name>] ``` +### acl -- List access control rules + +```bash +gws calendar acl [--calendar-id <cal-id>] +``` + +Returns array of rules with: `id`, `role`, `scope_type`, `scope_value`. + +### share -- Share calendar with a user + +```bash +gws calendar share --email <email> --role <role> [--calendar-id <cal-id>] +``` + +Valid roles: `reader`, `writer`, `owner`, `freeBusyReader`. + +### unshare -- Remove calendar access + +```bash +gws calendar unshare --rule-id <rule-id> [--calendar-id <cal-id>] +``` + +### update-acl -- Update access control rule + +```bash +gws calendar update-acl --rule-id <rule-id> --role <role> [--calendar-id <cal-id>] +``` + +### freebusy -- Query free/busy information + +```bash +gws calendar freebusy --from <time> --to <time> [--calendars "primary,user@example.com"] +``` + +Returns busy periods for each requested calendar. + +### colors -- List available colors + +```bash +gws calendar colors +``` + +Returns `calendar_colors` and `event_colors` maps with `background`/`foreground` hex values. + +### settings -- List user calendar settings + +```bash +gws calendar settings +``` + +Returns all user calendar settings as key-value pairs. + ## Output Modes ```bash @@ -183,8 +318,11 @@ gws calendar events --format text # Human-readable text ## Tips for AI Agents - Always use `--format json` (the default) for programmatic parsing -- Use `gws calendar events` to get event IDs, then use those IDs for update/delete/rsvp +- Use `gws calendar events` to get event IDs, then use those IDs for update/delete/rsvp/get/move - Time format accepts both RFC3339 (`2024-02-01T14:00:00Z`) and human-friendly (`2024-02-01 14:00`) -- The `update` command uses PATCH (not PUT), so only changed fields are sent — this avoids re-sending invitations to attendees +- The `update` command uses PATCH (not PUT), so only changed fields are sent - For non-primary calendars, get the calendar ID from `gws calendar list` first +- Use `quick-add` for natural language event creation (e.g. "Meeting with Bob next Tuesday 2pm") +- Use `freebusy` to check availability before creating events +- ACL roles: `freeBusyReader` < `reader` < `writer` < `owner` - Default event window is 7 days; increase with `--days` for broader views diff --git a/skills/calendar/references/commands.md b/skills/calendar/references/commands.md index 2122c2b..cc95a13 100644 --- a/skills/calendar/references/commands.md +++ b/skills/calendar/references/commands.md @@ -27,10 +27,10 @@ No additional flags. ### Output Fields (JSON) Returns an array of calendars with: -- `id` — Calendar ID (e.g., `primary`, `user@group.calendar.google.com`) -- `summary` — Calendar name -- `primary` — `true` if this is the user's primary calendar -- `description` — Calendar description (if set) +- `id` -- Calendar ID (e.g., `primary`, `user@group.calendar.google.com`) +- `summary` -- Calendar name +- `primary` -- `true` if this is the user's primary calendar +- `description` -- Calendar description (if set) --- @@ -47,53 +47,70 @@ Usage: gws calendar events [flags] | `--calendar-id` | string | `primary` | Calendar ID | | `--days` | int | 7 | Number of days to look ahead | | `--max` | int | 50 | Maximum number of events | -| `--pending` | bool | false | Only show events with pending RSVP (needsAction). Tip: increase `--max` for long date ranges — `--max` limits API fetch before client-side filtering. | +| `--pending` | bool | false | Only show events with pending RSVP (needsAction). Tip: increase `--max` for long date ranges. | ### Output Fields (JSON) Fields are omitted when empty/nil to keep output compact. **Core:** -- `id` — Event ID (used for update/delete/rsvp) -- `summary` — Event title -- `status` — Event status (`confirmed`, `tentative`, `cancelled`) +- `id` -- Event ID (used for update/delete/rsvp) +- `summary` -- Event title +- `status` -- Event status (`confirmed`, `tentative`, `cancelled`) **Time:** -- `start` — Start time (dateTime or date for all-day events) -- `end` — End time -- `all_day` — `true` for all-day events (omitted for timed events) +- `start` -- Start time (dateTime or date for all-day events) +- `end` -- End time +- `all_day` -- `true` for all-day events (omitted for timed events) **Details:** -- `description` — Event description (HTML allowed by Google) -- `location` — Event location -- `hangout_link` — Legacy Hangouts link -- `html_link` — Link to event in Google Calendar web UI -- `created` — Creation timestamp -- `updated` — Last-modified timestamp -- `color_id` — Calendar color ID -- `visibility` — `default`, `public`, `private`, `confidential` -- `transparency` — `opaque` (busy) or `transparent` (free) -- `event_type` — `default`, `outOfOffice`, `focusTime`, `workingLocation` +- `description` -- Event description (HTML allowed by Google) +- `location` -- Event location +- `hangout_link` -- Legacy Hangouts link +- `html_link` -- Link to event in Google Calendar web UI +- `created` -- Creation timestamp +- `updated` -- Last-modified timestamp +- `color_id` -- Calendar color ID +- `visibility` -- `default`, `public`, `private`, `confidential` +- `transparency` -- `opaque` (busy) or `transparent` (free) +- `event_type` -- `default`, `outOfOffice`, `focusTime`, `workingLocation` **People:** -- `organizer` — Organizer email address -- `creator` — Event creator email address -- `response_status` — Current user's RSVP status: `accepted`, `declined`, `tentative`, `needsAction` +- `organizer` -- Organizer email address +- `creator` -- Event creator email address +- `response_status` -- Current user's RSVP status: `accepted`, `declined`, `tentative`, `needsAction` **Attendees:** -- `attendees[]` — Full attendee list, each with: `email`, `response_status`, `optional` (bool), `organizer` (bool), `self` (bool) +- `attendees[]` -- Full attendee list, each with: `email`, `response_status`, `optional` (bool), `organizer` (bool), `self` (bool) **Conference:** -- `conference` — `{ conference_id, solution, entry_points[]: { type, uri } }` +- `conference` -- `{ conference_id, solution, entry_points[]: { type, uri } }` **Attachments:** -- `attachments[]` — `{ file_url, title, mime_type, file_id }` +- `attachments[]` -- `{ file_url, title, mime_type, file_id }` **Recurrence:** -- `recurrence[]` — RRULE/EXRULE/RDATE/EXDATE strings +- `recurrence[]` -- RRULE/EXRULE/RDATE/EXDATE strings **Reminders:** -- `reminders` — `{ use_default (bool), overrides[]: { method, minutes } }` +- `reminders` -- `{ use_default (bool), overrides[]: { method, minutes } }` + +--- + +## gws calendar get + +Gets a single event by its ID. + +``` +Usage: gws calendar get [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Event ID | +| `--calendar-id` | string | `primary` | No | Calendar ID | + +Returns the same output fields as `events`. --- @@ -123,6 +140,21 @@ Both formats are accepted: --- +## gws calendar quick-add + +Creates an event from natural language text. + +``` +Usage: gws calendar quick-add [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--text` | string | | Yes | Text describing the event (e.g. "Lunch with John tomorrow at noon") | +| `--calendar-id` | string | `primary` | No | Calendar ID | + +--- + ## gws calendar update Updates an existing calendar event. Uses PATCH (only changed fields are sent). @@ -141,14 +173,7 @@ Usage: gws calendar update <event-id> [flags] | `--add-attendees` | strings | | Attendee emails to add (repeatable) | | `--calendar-id` | string | `primary` | Calendar ID | -At least one update flag is required (`--title`, `--start`, `--end`, `--description`, `--location`, `--add-attendees`). - -### API Behavior - -The update command uses Google Calendar's **Patch** API (not Update/PUT). This means: -- Only fields you specify are changed -- Unchanged fields are preserved -- Avoids sending unnecessary re-invitation notifications to attendees +At least one update flag is required. --- @@ -178,11 +203,314 @@ Usage: gws calendar rsvp <event-id> [flags] |------|------|---------|----------|-------------| | `--response` | string | | Yes | Response: `accepted`, `declined`, `tentative` | | `--calendar-id` | string | `primary` | No | Calendar ID | +| `--message` | string | | No | Optional message (notifies all attendees) | + +--- + +## gws calendar instances + +Lists instances of a recurring event. + +``` +Usage: gws calendar instances [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Recurring event ID | +| `--calendar-id` | string | `primary` | No | Calendar ID | +| `--max` | int | 50 | No | Maximum number of instances | +| `--from` | string | | No | Start of time range (RFC3339 or YYYY-MM-DD) | +| `--to` | string | | No | End of time range (RFC3339 or YYYY-MM-DD) | + +--- + +## gws calendar move + +Moves an event to another calendar. + +``` +Usage: gws calendar move [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Event ID | +| `--calendar-id` | string | `primary` | No | Source calendar ID | +| `--destination` | string | | Yes | Destination calendar ID | + +--- + +## gws calendar get-calendar + +Gets metadata for a calendar. + +``` +Usage: gws calendar get-calendar [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID | + +### Output Fields + +- `id` -- Calendar ID +- `summary` -- Calendar name +- `description` -- Calendar description (if set) +- `timezone` -- Calendar timezone +- `location` -- Calendar location (if set) +- `etag` -- Calendar etag + +--- + +## gws calendar create-calendar + +Creates a new secondary calendar. + +``` +Usage: gws calendar create-calendar [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--summary` | string | | Yes | Calendar name | +| `--description` | string | | No | Calendar description | +| `--timezone` | string | | No | Calendar timezone (e.g. `America/New_York`) | + +--- + +## gws calendar update-calendar + +Updates an existing calendar's metadata. + +``` +Usage: gws calendar update-calendar [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID | +| `--summary` | string | | No | New calendar name | +| `--description` | string | | No | New calendar description | +| `--timezone` | string | | No | New calendar timezone | + +--- + +## gws calendar delete-calendar + +Deletes a secondary calendar. + +``` +Usage: gws calendar delete-calendar [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID | + +--- + +## gws calendar clear + +Clears all events from a calendar. + +``` +Usage: gws calendar clear [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--calendar-id` | string | `primary` | Calendar ID | + +--- + +## gws calendar subscribe + +Subscribes to a public calendar (adds to your calendar list). + +``` +Usage: gws calendar subscribe [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID to subscribe to | + +--- + +## gws calendar unsubscribe + +Unsubscribes from a calendar (removes from your calendar list). + +``` +Usage: gws calendar unsubscribe [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID to unsubscribe from | + +--- + +## gws calendar calendar-info + +Gets the calendar list entry (subscription settings, color, visibility). + +``` +Usage: gws calendar calendar-info [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID | + +### Output Fields + +- `id`, `summary`, `primary`, `description`, `timezone` +- `color_id`, `background_color`, `foreground_color` +- `summary_override`, `hidden`, `selected`, `access_role` + +--- + +## gws calendar update-subscription + +Updates subscription settings for a calendar in your list. + +``` +Usage: gws calendar update-subscription [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Calendar ID | +| `--color-id` | string | | No | Color ID (use `gws calendar colors` to list valid IDs) | +| `--hidden` | bool | false | No | Hide calendar from the list | +| `--summary-override` | string | | No | Custom display name | + +--- + +## gws calendar acl + +Lists access control rules for a calendar. + +``` +Usage: gws calendar acl [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--calendar-id` | string | `primary` | Calendar ID | + +### Output Fields + +Returns array of rules with: `id`, `role`, `scope_type`, `scope_value`. + +--- + +## gws calendar share + +Shares a calendar with a user. + +``` +Usage: gws calendar share [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--calendar-id` | string | `primary` | No | Calendar ID | +| `--email` | string | | Yes | Email address to share with | +| `--role` | string | | Yes | Access role: `reader`, `writer`, `owner`, `freeBusyReader` | + +--- + +## gws calendar unshare + +Removes an access control rule from a calendar. + +``` +Usage: gws calendar unshare [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--calendar-id` | string | `primary` | No | Calendar ID | +| `--rule-id` | string | | Yes | ACL rule ID (e.g. `user:user@example.com`) | + +--- + +## gws calendar update-acl + +Updates an existing access control rule. + +``` +Usage: gws calendar update-acl [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--calendar-id` | string | `primary` | No | Calendar ID | +| `--rule-id` | string | | Yes | ACL rule ID | +| `--role` | string | | Yes | New role: `reader`, `writer`, `owner`, `freeBusyReader` | + +--- + +## gws calendar freebusy + +Queries free/busy information for one or more calendars. + +``` +Usage: gws calendar freebusy [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--from` | string | | Yes | Start of time range | +| `--to` | string | | Yes | End of time range | +| `--calendars` | string | `primary` | No | Comma-separated calendar IDs | + +### Output + +```json +{ + "time_min": "...", + "time_max": "...", + "calendars": { + "primary": { + "busy": [{"start": "...", "end": "..."}] + } + } +} +``` + +--- + +## gws calendar colors + +Lists all available calendar and event colors. + +``` +Usage: gws calendar colors +``` + +No additional flags. + +### Output + +Returns `calendar_colors` and `event_colors` maps, each keyed by color ID with `background` and `foreground` hex values. + +--- + +## gws calendar settings + +Lists all user calendar settings. + +``` +Usage: gws calendar settings +``` + +No additional flags. -### Valid Responses +### Output -| Value | Meaning | -|-------|---------| -| `accepted` | Accept the invitation | -| `declined` | Decline the invitation | -| `tentative` | Maybe / tentatively accept | +Returns `settings` map of key-value pairs (e.g. `timezone`, `locale`, `weekStart`).