Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cmd/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
15 changes: 10 additions & 5 deletions internal/cmd/calendar_appointments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
82 changes: 75 additions & 7 deletions internal/cmd/calendar_appointments_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
19 changes: 16 additions & 3 deletions internal/cmd/calendar_events_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
50 changes: 48 additions & 2 deletions internal/cmd/calendar_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading