diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index cbde93df..a2fec504 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -7,7 +7,7 @@ type CalendarCmd struct { ACL CalendarAclCmd `cmd:"" name:"acl" aliases:"permissions,perms" help:"List calendar ACL"` Alias CalendarAliasCmd `cmd:"" name:"alias" help:"Manage calendar aliases"` Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"` - Appointments CalendarAppointmentsCmd `cmd:"" name:"appointments" aliases:"appointment-schedules,appt" help:"Report Calendar appointment schedule API limitation"` + Appointments CalendarAppointmentsCmd `cmd:"" name:"appointments" aliases:"appointment-schedules,appt" help:"Manage appointment schedules"` Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` Raw CalendarRawCmd `cmd:"" name:"raw" help:"Dump raw Google Calendar API response as JSON (Events.Get; lossless; for scripting and LLM consumption)"` Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` diff --git a/internal/cmd/calendar_appointments.go b/internal/cmd/calendar_appointments.go index 4708490e..32e4b714 100644 --- a/internal/cmd/calendar_appointments.go +++ b/internal/cmd/calendar_appointments.go @@ -2,13 +2,18 @@ package cmd import ( "context" - "fmt" ) -type CalendarAppointmentsCmd struct{} +const calendarAppointmentScheduleEventType = "appointmentSchedule" -func (c *CalendarAppointmentsCmd) Run(ctx context.Context, flags *RootFlags) error { - return errCalendarAppointmentSchedulesUnsupported +type CalendarAppointmentsCmd struct { + List CalendarAppointmentSchedulesListCmd `cmd:"" name:"list" aliases:"ls" help:"List appointment schedules"` } -var errCalendarAppointmentSchedulesUnsupported = fmt.Errorf("calendar appointment schedules are not exposed by the Google Calendar API; Events.list currently accepts eventTypes birthday, default, focusTime, fromGmail, outOfOffice, and workingLocation only") +type CalendarAppointmentSchedulesListCmd struct { + CalendarEventsCmd `embed:""` +} + +func (c *CalendarAppointmentSchedulesListCmd) Run(ctx context.Context, flags *RootFlags) error { + return c.run(ctx, flags, []string{calendarAppointmentScheduleEventType}) +} diff --git a/internal/cmd/calendar_appointments_test.go b/internal/cmd/calendar_appointments_test.go index 2de74f61..515abf0f 100644 --- a/internal/cmd/calendar_appointments_test.go +++ b/internal/cmd/calendar_appointments_test.go @@ -1,16 +1,84 @@ package cmd import ( - "strings" + "context" + "encoding/json" + "net/http" "testing" + + "google.golang.org/api/calendar/v3" ) -func TestCalendarAppointmentsReportsUnsupportedAPI(t *testing.T) { - err := (&CalendarAppointmentsCmd{}).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@example.com"}) - if err == nil { - t.Fatal("expected unsupported API error") +func TestCalendarAppointmentSchedulesListUsesAppointmentScheduleEventType(t *testing.T) { + svc, closeServer := newCalendarServiceForTest(t, withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/calendars/primary/events" || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if got := r.URL.Query()["eventTypes"]; len(got) != 1 || got[0] != calendarAppointmentScheduleEventType { + t.Fatalf("eventTypes query = %v, want [%s]", got, calendarAppointmentScheduleEventType) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "as1", + "summary": "Office hours", + "eventType": calendarAppointmentScheduleEventType, + "start": map[string]any{"dateTime": "2026-01-01T10:00:00Z"}, + "end": map[string]any{"dateTime": "2026-01-01T11:00:00Z"}, + }, + }, + }) + }))) + defer closeServer() + + ctx := newCalendarJSONContext(t) + out := captureStdout(t, func() { + if err := listCalendarEventsWithEventTypes(ctx, svc, "primary", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, []string{calendarAppointmentScheduleEventType}); err != nil { + t.Fatalf("listCalendarEventsWithEventTypes: %v", err) + } + }) + + var parsed struct { + Events []struct { + ID string `json:"id"` + EventType string `json:"eventType"` + } `json:"events"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.Events) != 1 || parsed.Events[0].ID != "as1" || parsed.Events[0].EventType != calendarAppointmentScheduleEventType { + t.Fatalf("unexpected output: %#v", parsed.Events) } - if !strings.Contains(err.Error(), "appointment schedules are not exposed") { - t.Fatalf("unexpected error: %v", err) +} + +func TestCalendarAppointmentSchedulesListCommandShape(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + svc, closeServer := newCalendarServiceForTest(t, withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/calendars/primary/events" || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("eventTypes"); got != calendarAppointmentScheduleEventType { + t.Fatalf("eventTypes query = %q, want %q", got, calendarAppointmentScheduleEventType) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + }))) + defer closeServer() + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + ctx := newCalendarJSONContext(t) + out := captureStdout(t, func() { + if err := runKong(t, &CalendarAppointmentsCmd{}, []string{"list", "--from", "2026-01-01T00:00:00Z", "--to", "2026-01-02T00:00:00Z"}, ctx, &RootFlags{Account: "a@example.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + if out == "" { + t.Fatal("expected JSON output") } } diff --git a/internal/cmd/calendar_events_cmds.go b/internal/cmd/calendar_events_cmds.go index 3988099b..a8084e7e 100644 --- a/internal/cmd/calendar_events_cmds.go +++ b/internal/cmd/calendar_events_cmds.go @@ -33,6 +33,10 @@ type CalendarEventsCmd struct { } func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { + return c.run(ctx, flags, nil) +} + +func (c *CalendarEventsCmd) run(ctx context.Context, flags *RootFlags, eventTypes []string) error { account, err := requireAccount(flags) if err != nil { return err @@ -80,7 +84,10 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { from, to := timeRange.FormatRFC3339() if c.All { - return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + if len(eventTypes) == 0 { + return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + } + return listAllCalendarsEventsWithEventTypes(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday, eventTypes) } if len(calInputs) > 0 { ids, err := resolveCalendarIDs(ctx, svc, calInputs) @@ -90,9 +97,15 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { if len(ids) == 0 { return usage("no calendars specified") } - return listSelectedCalendarsEvents(ctx, svc, ids, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + if len(eventTypes) == 0 { + return listSelectedCalendarsEvents(ctx, svc, ids, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + } + return listSelectedCalendarsEventsWithEventTypes(ctx, svc, ids, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday, eventTypes) + } + if len(eventTypes) == 0 { + return listCalendarEvents(ctx, svc, calendarID, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) } - return listCalendarEvents(ctx, svc, calendarID, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday) + return listCalendarEventsWithEventTypes(ctx, svc, calendarID, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday, eventTypes) } func normalizeCalendarEventsArgs(args []string) (string, error) { diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index 43fa4db7..f87c08ad 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -15,6 +15,10 @@ import ( ) func calendarEventsListCall(ctx context.Context, svc *calendar.Service, calendarID, from, to string, maxResults int64, query, privatePropFilter, sharedPropFilter, fields, pageToken string) *calendar.EventsListCall { + return calendarEventsListCallWithEventTypes(ctx, svc, calendarID, from, to, maxResults, query, privatePropFilter, sharedPropFilter, fields, nil, pageToken) +} + +func calendarEventsListCallWithEventTypes(ctx context.Context, svc *calendar.Service, calendarID, from, to string, maxResults int64, query, privatePropFilter, sharedPropFilter, fields string, eventTypes []string, pageToken string) *calendar.EventsListCall { call := svc.Events.List(calendarID). TimeMin(from). TimeMax(to). @@ -38,13 +42,20 @@ func calendarEventsListCall(ctx context.Context, svc *calendar.Service, calendar if strings.TrimSpace(fields) != "" { call = call.Fields(gapi.Field(fields)) } + if len(eventTypes) > 0 { + call = call.EventTypes(eventTypes...) + } return call } func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool) error { + return listCalendarEventsWithEventTypes(ctx, svc, calendarID, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, nil) +} + +func listCalendarEventsWithEventTypes(ctx context.Context, svc *calendar.Service, calendarID, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, eventTypes []string) error { calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calendarID, nil) fetch := func(pageToken string) ([]*calendar.Event, string, error) { - resp, err := calendarEventsListCall(ctx, svc, calendarID, from, to, maxResults, query, privatePropFilter, sharedPropFilter, fields, pageToken).Do() + resp, err := calendarEventsListCallWithEventTypes(ctx, svc, calendarID, from, to, maxResults, query, privatePropFilter, sharedPropFilter, fields, eventTypes, pageToken).Do() if err != nil { return nil, "", err } @@ -132,11 +143,46 @@ func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to return listCalendarIDsEvents(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, calendarTimezoneHints(calendars)) } +func listAllCalendarsEventsWithEventTypes(ctx context.Context, svc *calendar.Service, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, eventTypes []string) error { + u := ui.FromContext(ctx) + + calendars, err := listCalendarList(ctx, svc) + if err != nil { + return err + } + + if len(calendars) == 0 { + u.Err().Println("No calendars") + return failEmptyExit(failEmpty) + } + + ids := make([]string, 0, len(calendars)) + for _, cal := range calendars { + if cal == nil || strings.TrimSpace(cal.Id) == "" { + continue + } + ids = append(ids, cal.Id) + } + if len(ids) == 0 { + u.Err().Println("No calendars") + return nil + } + return listCalendarIDsEventsWithEventTypes(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, calendarTimezoneHints(calendars), eventTypes) +} + func listSelectedCalendarsEvents(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool) error { return listCalendarIDsEvents(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, nil) } +func listSelectedCalendarsEventsWithEventTypes(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, eventTypes []string) error { + return listCalendarIDsEventsWithEventTypes(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, nil, eventTypes) +} + func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, timezoneHints map[string]calendarTimezoneHint) error { + return listCalendarIDsEventsWithEventTypes(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, timezoneHints, nil) +} + +func listCalendarIDsEventsWithEventTypes(ctx context.Context, svc *calendar.Service, calendarIDs []string, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, timezoneHints map[string]calendarTimezoneHint, eventTypes []string) error { u := ui.FromContext(ctx) all := []*eventWithCalendar{} for _, calID := range calendarIDs { @@ -146,7 +192,7 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI } calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calID, timezoneHints) fetch := func(pageToken string) ([]*calendar.Event, string, error) { - resp, err := calendarEventsListCall(ctx, svc, calID, from, to, maxResults, query, privatePropFilter, sharedPropFilter, fields, pageToken).Do() + resp, err := calendarEventsListCallWithEventTypes(ctx, svc, calID, from, to, maxResults, query, privatePropFilter, sharedPropFilter, fields, eventTypes, pageToken).Do() if err != nil { return nil, "", err }